32 Commits

Author SHA1 Message Date
Mathis HERRIOT
906f615428 chore: bump version to 1.4.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m45s
CI/CD Pipeline / Valider frontend (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 26s
2026-01-21 15:44:17 +01:00
Mathis HERRIOT
fc4efd1e24 feat(user): add "removeMe" API method
- Introduced a new `removeMe` method in `user.service` for account deletion.
2026-01-21 15:43:58 +01:00
Mathis HERRIOT
6bc6a8f68c feat(profile): add "Share Profile" button and improve page responsiveness
- Added "Share Profile" button with clipboard copy and success notification.
- Enhanced responsiveness by adjusting avatar sizes, typography, and spacing.
- Refined button styling for consistency and usability.
2026-01-21 15:43:48 +01:00
Mathis HERRIOT
e69156407e feat(settings): add account deletion feature and improve UI
- Introduced "Delete Account" functionality with confirmation dialog and success/error notifications.
- Enhanced general settings page UI, including updated card layouts and improved form elements.
- Added support for theme selection with a more user-friendly design.
- Refined typography and button styling for better visual consistency.
2026-01-21 15:43:43 +01:00
Mathis HERRIOT
7dce7ec286 feat(profile): add profile sharing feature and enhance UI responsiveness
- Implemented the "Share Profile" button with clipboard copy functionality and success notification.
- Improved profile page responsiveness by adjusting avatar, text sizes, and spacing.
- Added consistent button styling and updated tabs for better usability and design.
2026-01-21 15:43:34 +01:00
Mathis HERRIOT
029bbe9bb9 feat(users): enhance table responsiveness and replace action buttons with dropdown menu
- Improved table layout by hiding specific columns on smaller screens.
- Replaced action buttons with `DropdownMenu` for a cleaner and more accessible UI.
- Updated skeleton loaders to align with the revised table structure.
2026-01-21 15:43:19 +01:00
Mathis HERRIOT
c3f57db1e5 feat(contents): improve table responsiveness and replace action buttons with dropdown menu
- Enhanced table layout by hiding columns on smaller screens for better responsiveness.
- Replaced action buttons with `DropdownMenu` for improved accessibility and cleaner UI.
- Adjusted skeleton loaders to align with the updated table structure.
2026-01-21 15:43:09 +01:00
Mathis HERRIOT
939448d15c feat(categories): enhance table UI and add dropdown menu for actions
- Improved table responsiveness by hiding columns on smaller screens.
- Replaced action buttons with a `DropdownMenu` for better accessibility.
- Updated skeleton loaders to match the new table layout.
2026-01-21 15:43:00 +01:00
Mathis HERRIOT
4e61b0de9a feat(dashboard): add toaster notifications and update header styling
- Integrated `Toaster` component for notifications in the dashboard layout.
- Updated header typography with better font size and tracking improvements.
2026-01-21 15:42:52 +01:00
Mathis HERRIOT
73556894f8 feat(content-card): improve UI and add tooltips for interaction elements
- Introduced tooltips for like, views, and share buttons for better user guidance.
- Added support for category display and improved tag styling.
- Adjusted `CardContent` aspect ratios for better responsiveness.
- Enhanced share button functionality with copy-to-clipboard feedback.
2026-01-21 15:42:35 +01:00
Mathis HERRIOT
96a9d6e7a7 chore: bump version to 1.4.0
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider frontend (push) Successful in 1m41s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m27s
2026-01-21 13:49:15 +01:00
Mathis HERRIOT
058830bb60 refactor(content-card): fix indentation and improve readability
- Fixed inconsistent indentation in `content-card` component.
- Enhanced code readability by restructuring JSX elements properly.
2026-01-21 13:48:29 +01:00
Mathis HERRIOT
02d612e026 feat(contents): add UserContentEditDialog component for editing user content
- Introduced `UserContentEditDialog` to enable inline editing of user-generated content.
- Integrated form handling with `react-hook-form` for validation and form state management.
- Added category selection support and updated content saving functionality.
- Included success and error feedback with loading states for better user experience.
2026-01-21 13:44:10 +01:00
Mathis HERRIOT
498f85d24e feat(contents): add update method with user ownership validation
- Introduced `update` method in contents service to allow partial updates.
- Implemented user ownership validation to ensure secure modifications.
- Added cache clearing logic after successful updates.
2026-01-21 13:42:56 +01:00
Mathis HERRIOT
10cc5a6d8d feat(contents): add update endpoint to contents controller
- Introduced a `PATCH /:id` endpoint to enable partial content updates.
- Integrated AuthGuard for securing the endpoint.
2026-01-21 13:42:24 +01:00
Mathis HERRIOT
7503707ef1 refactor(contents): extract and memoize fetchInitial logic
- Refactored `fetchInitial` function to make it reusable using `useCallback`.
- Updated `ContentCard` to call `fetchInitial` via `onUpdate` prop for better reusability.
- Removed duplicate logic from `useEffect` for improved code readability and maintainability.
2026-01-21 13:42:17 +01:00
Mathis HERRIOT
8778508ced feat(contents): add author actions to content card
- Added dropdown menu for authors to edit or delete their content.
- Integrated `UserContentEditDialog` for inline editing.
- Enabled content deletion with confirmation and success/error feedback.
- Improved UI with `DropdownMenu` for better action accessibility.
2026-01-21 13:42:11 +01:00
Mathis HERRIOT
b968d1e6f8 feat(contents): add update and remove methods to contents service
- Introduced `update` method for partial content updates.
- Added `remove` method to handle content deletion.
2026-01-21 13:41:52 +01:00
Mathis HERRIOT
0382b21a65 chore: bump version to 1.3.0
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m43s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m18s
2026-01-21 13:22:19 +01:00
Mathis HERRIOT
764c4c07c8 feat(users, contents): extend admin update functionality and role management
- Added `moderator` role to `User` type for improved role assignment flexibility.
- Introduced `updateAdmin` method in user and content services to handle partial admin-specific updates.
- Enhanced `Content` type with `categoryId` and `iconUrl` to support richer categorization.
2026-01-21 13:22:07 +01:00
Mathis HERRIOT
68b5071f6d feat(admin): implement dialogs for editing users, categories, and contents
- Added `UserEditDialog`, `CategoryDialog`, and `ContentEditDialog` components.
- Enabled role, status, and other attributes updates for users.
- Implemented create and update functionality for categories.
- Facilitated content management with category assignment and updates.
2026-01-21 13:21:51 +01:00
Mathis HERRIOT
f5c90b0ae4 feat(users): add updateAdmin endpoint and enhance role assignment
- Introduced `PATCH /admin/:uuid` endpoint for admin-specific user updates.
- Updated `update` logic to handle role assignment via `rbacService`.
- Refactored `findAll` method in repository for improved readability.
2026-01-21 13:20:32 +01:00
Mathis HERRIOT
c8820a71b6 feat(categories): add create, update, and delete methods to category service
- Introduced `create`, `update`, and `remove` methods for managing categories via the service.
- Enables API integration for category CRUD functionality.
2026-01-21 13:20:16 +01:00
Mathis HERRIOT
9b714716f6 feat(admin): add edit dialogs for users, contents, and categories
- Implemented edit functionality for users, contents, and categories, including modals for updating records.
- Enhanced table actions with edit buttons alongside delete.
- Improved user, content, and category fetching with `useCallback` to optimize re-renders.
- Added skeleton loaders and UI updates for better user experience.
2026-01-21 13:19:29 +01:00
Mathis HERRIOT
3a5550d6eb feat(contents): add updateAdmin method to contents service
- Introduced `updateAdmin` logic to handle admin-specific content updates.
- Included cache clearing upon successful update for data consistency.
2026-01-21 13:19:08 +01:00
Mathis HERRIOT
07cdb741b3 feat(contents): add updateAdmin endpoint and repository method
- Introduced `PATCH /:id/admin` endpoint to update admin-specific content.
- Added `update` method to `ContentsRepository` for data updates with timestamp.
2026-01-21 13:18:41 +01:00
Mathis HERRIOT
02796e4e1f chore: bump version to 1.2.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m35s
CI/CD Pipeline / Valider documentation (push) Successful in 2m7s
CI/CD Pipeline / Valider frontend (push) Successful in 2m4s
CI/CD Pipeline / Déploiement en Production (push) Successful in 17s
2026-01-21 11:38:47 +01:00
Mathis HERRIOT
951b38db67 chore: update lint scripts and improve formatting consistency
- Added `lint:fix` scripts for backend, frontend, and documentation.
- Enabled `biome check --write` for unsafe fixes in backend scripts.
- Fixed imports, formatting, and logging for improved code clarity.
- Adjusted service unit tests for better readability and maintainability.
2026-01-21 11:38:25 +01:00
Mathis HERRIOT
a90aba2748 chore: bump version to 1.2.0
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 52s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-21 11:07:08 +01:00
Mathis HERRIOT
3f0b1e5119 feat(auth): add bootstrap token flow for initial admin creation
- Introduced `BootstrapService` to handle admin creation when no admins exist.
- Added `/auth/bootstrap-admin` endpoint to consume bootstrap tokens.
- Updated `RbacRepository` to support counting admins and assigning roles.
- Included unit tests for `BootstrapService` to ensure token behavior and admin assignment.
2026-01-21 11:07:02 +01:00
Mathis HERRIOT
aff8acebf8 fix(config): correct transformIgnorePatterns regex in Jest config 2026-01-21 11:06:46 +01:00
Mathis HERRIOT
a721b4041c feat(docs): add /auth/bootstrap-admin endpoint details to API reference
- Documented usage, parameters, and responses for the new endpoint.
- Included constraints and warnings for better API clarity.
2026-01-21 11:06:20 +01:00
38 changed files with 1807 additions and 340 deletions

View File

@@ -24,7 +24,8 @@
"rules": { "rules": {
"recommended": true, "recommended": true,
"suspicious": { "suspicious": {
"noUnknownAtRules": "off" "noUnknownAtRules": "off",
"noExplicitAny": "off"
}, },
"style": { "style": {
"useImportType": "off" "useImportType": "off"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/backend", "name": "@memegoat/backend",
"version": "1.1.1", "version": "1.4.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -13,7 +13,7 @@
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"lint": "biome check", "lint": "biome check",
"lint:write": "biome check --write", "lint:write": "biome check --write --unsafe",
"format": "biome format --write", "format": "biome format --write",
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
@@ -107,7 +107,7 @@
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node", "testEnvironment": "node",
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)" "node_modules/(?!(.pnpm/)?(jose|@noble|uuid))"
], ],
"transform": { "transform": {
"^.+\\.(t|j)sx?$": "ts-jest" "^.+\\.(t|j)sx?$": "ts-jest"

View File

@@ -24,6 +24,7 @@ import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
jest.mock("iron-session", () => ({ jest.mock("iron-session", () => ({
getIronSession: jest.fn().mockResolvedValue({ getIronSession: jest.fn().mockResolvedValue({
@@ -44,6 +45,10 @@ describe("AuthController", () => {
refresh: jest.fn(), refresh: jest.fn(),
}; };
const mockBootstrapService = {
consumeToken: jest.fn(),
};
const mockConfigService = { const mockConfigService = {
get: jest get: jest
.fn() .fn()
@@ -55,6 +60,7 @@ describe("AuthController", () => {
controllers: [AuthController], controllers: [AuthController],
providers: [ providers: [
{ provide: AuthService, useValue: mockAuthService }, { provide: AuthService, useValue: mockAuthService },
{ provide: BootstrapService, useValue: mockBootstrapService },
{ provide: ConfigService, useValue: mockConfigService }, { provide: ConfigService, useValue: mockConfigService },
], ],
}).compile(); }).compile();
@@ -75,7 +81,6 @@ describe("AuthController", () => {
password: "password", password: "password",
username: "test", username: "test",
}; };
// biome-ignore lint/suspicious/noExplicitAny: Necessary to avoid defining full DTO in test
await controller.register(dto as any); await controller.register(dto as any);
expect(authService.register).toHaveBeenCalledWith(dto); expect(authService.register).toHaveBeenCalledWith(dto);
}); });

View File

@@ -1,9 +1,19 @@
import { Body, Controller, Headers, Post, Req, Res } from "@nestjs/common"; import {
Body,
Controller,
Get,
Headers,
Post,
Query,
Req,
Res,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Throttle } from "@nestjs/throttler"; import { Throttle } from "@nestjs/throttler";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { getIronSession } from "iron-session"; import { getIronSession } from "iron-session";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
import { LoginDto } from "./dto/login.dto"; import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto"; import { RegisterDto } from "./dto/register.dto";
import { Verify2faDto } from "./dto/verify-2fa.dto"; import { Verify2faDto } from "./dto/verify-2fa.dto";
@@ -13,6 +23,7 @@ import { getSessionOptions, SessionData } from "./session.config";
export class AuthController { export class AuthController {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly bootstrapService: BootstrapService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {}
@@ -120,4 +131,12 @@ export class AuthController {
session.destroy(); session.destroy();
return res.json({ message: "User logged out" }); return res.json({ message: "User logged out" });
} }
@Get("bootstrap-admin")
async bootstrapAdmin(
@Query("token") token: string,
@Query("username") username: string,
) {
return this.bootstrapService.consumeToken(token, username);
}
} }

View File

@@ -3,6 +3,7 @@ import { SessionsModule } from "../sessions/sessions.module";
import { UsersModule } from "../users/users.module"; import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
import { AuthGuard } from "./guards/auth.guard"; import { AuthGuard } from "./guards/auth.guard";
import { OptionalAuthGuard } from "./guards/optional-auth.guard"; import { OptionalAuthGuard } from "./guards/optional-auth.guard";
import { RolesGuard } from "./guards/roles.guard"; import { RolesGuard } from "./guards/roles.guard";
@@ -15,6 +16,7 @@ import { RbacRepository } from "./repositories/rbac.repository";
providers: [ providers: [
AuthService, AuthService,
RbacService, RbacService,
BootstrapService,
RbacRepository, RbacRepository,
AuthGuard, AuthGuard,
OptionalAuthGuard, OptionalAuthGuard,

View File

@@ -0,0 +1,114 @@
import { UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { UsersService } from "../users/users.service";
import { BootstrapService } from "./bootstrap.service";
import { RbacService } from "./rbac.service";
describe("BootstrapService", () => {
let service: BootstrapService;
let rbacService: RbacService;
let _usersService: UsersService;
const mockRbacService = {
countAdmins: jest.fn(),
assignRoleToUser: jest.fn(),
};
const mockUsersService = {
findPublicProfile: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
BootstrapService,
{ provide: RbacService, useValue: mockRbacService },
{ provide: UsersService, useValue: mockUsersService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<BootstrapService>(BootstrapService);
rbacService = module.get<RbacService>(RbacService);
_usersService = module.get<UsersService>(UsersService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("onApplicationBootstrap", () => {
it("should generate a token if no admin exists", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
const generateTokenSpy = jest.spyOn(
service as any,
"generateBootstrapToken",
);
await service.onApplicationBootstrap();
expect(rbacService.countAdmins).toHaveBeenCalled();
expect(generateTokenSpy).toHaveBeenCalled();
});
it("should not generate a token if admin exists", async () => {
mockRbacService.countAdmins.mockResolvedValue(1);
const generateTokenSpy = jest.spyOn(
service as any,
"generateBootstrapToken",
);
await service.onApplicationBootstrap();
expect(rbacService.countAdmins).toHaveBeenCalled();
expect(generateTokenSpy).not.toHaveBeenCalled();
});
});
describe("consumeToken", () => {
it("should throw UnauthorizedException if token is invalid", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
await service.onApplicationBootstrap();
await expect(service.consumeToken("wrong-token", "user1")).rejects.toThrow(
UnauthorizedException,
);
});
it("should throw UnauthorizedException if user not found", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
await service.onApplicationBootstrap();
const token = (service as any).bootstrapToken;
mockUsersService.findPublicProfile.mockResolvedValue(null);
await expect(service.consumeToken(token, "user1")).rejects.toThrow(
UnauthorizedException,
);
});
it("should assign admin role and invalidate token on success", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
await service.onApplicationBootstrap();
const token = (service as any).bootstrapToken;
const mockUser = { uuid: "user-uuid", username: "user1" };
mockUsersService.findPublicProfile.mockResolvedValue(mockUser);
const result = await service.consumeToken(token, "user1");
expect(rbacService.assignRoleToUser).toHaveBeenCalledWith(
"user-uuid",
"admin",
);
expect((service as any).bootstrapToken).toBeNull();
expect(result.message).toContain("user1 is now an administrator");
});
});
});

View File

@@ -0,0 +1,67 @@
import * as crypto from "node:crypto";
import {
Injectable,
Logger,
OnApplicationBootstrap,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { UsersService } from "../users/users.service";
import { RbacService } from "./rbac.service";
@Injectable()
export class BootstrapService implements OnApplicationBootstrap {
private readonly logger = new Logger(BootstrapService.name);
private bootstrapToken: string | null = null;
constructor(
private readonly rbacService: RbacService,
private readonly usersService: UsersService,
private readonly configService: ConfigService,
) {}
async onApplicationBootstrap() {
const adminCount = await this.rbacService.countAdmins();
if (adminCount === 0) {
this.generateBootstrapToken();
}
}
private generateBootstrapToken() {
this.bootstrapToken = crypto.randomBytes(32).toString("hex");
const domain = this.configService.get("DOMAIN_NAME") || "localhost";
const protocol = domain.includes("localhost") ? "http" : "https";
const url = `${protocol}://${domain}/auth/bootstrap-admin`;
this.logger.warn("SECURITY ALERT: No administrator found in database.");
this.logger.warn(
"To create the first administrator, use the following endpoint:",
);
this.logger.warn(
`Endpoint: GET ${url}?token=${this.bootstrapToken}&username=votre_nom_utilisateur`,
);
this.logger.warn(
'Exemple: curl -X GET "http://localhost/auth/bootstrap-admin?token=...&username=..."',
);
this.logger.warn("This token is one-time use only.");
}
async consumeToken(token: string, username: string) {
if (!this.bootstrapToken || token !== this.bootstrapToken) {
throw new UnauthorizedException("Invalid or expired bootstrap token");
}
const user = await this.usersService.findPublicProfile(username);
if (!user) {
throw new UnauthorizedException(`User ${username} not found`);
}
await this.rbacService.assignRoleToUser(user.uuid, "admin");
this.bootstrapToken = null; // One-time use
this.logger.log(
`User ${username} has been promoted to administrator via bootstrap token.`,
);
return { message: `User ${username} is now an administrator` };
}
}

View File

@@ -18,7 +18,11 @@ export class RbacService implements OnApplicationBootstrap {
if (count === 0) { if (count === 0) {
this.logger.log("No roles found, seeding default roles..."); this.logger.log("No roles found, seeding default roles...");
const defaultRoles = [ const defaultRoles = [
{ name: "Administrator", slug: "admin", description: "Full system access" }, {
name: "Administrator",
slug: "admin",
description: "Full system access",
},
{ {
name: "Moderator", name: "Moderator",
slug: "moderator", slug: "moderator",
@@ -51,4 +55,12 @@ export class RbacService implements OnApplicationBootstrap {
async getUserPermissions(userId: string) { async getUserPermissions(userId: string) {
return this.rbacRepository.findPermissionsByUserId(userId); return this.rbacRepository.findPermissionsByUserId(userId);
} }
async countAdmins() {
return this.rbacRepository.countAdmins();
}
async assignRoleToUser(userId: string, roleSlug: string) {
return this.rbacRepository.assignRole(userId, roleSlug);
}
} }

View File

@@ -47,6 +47,15 @@ export class RbacRepository {
return result.length; return result.length;
} }
async countAdmins(): Promise<number> {
const result = await this.databaseService.db
.select({ count: usersToRoles.userId })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(roles.slug, "admin"));
return result.length;
}
async createRole(name: string, slug: string, description?: string) { async createRole(name: string, slug: string, description?: string) {
return this.databaseService.db return this.databaseService.db
.insert(roles) .insert(roles)
@@ -57,4 +66,25 @@ export class RbacRepository {
}) })
.returning(); .returning();
} }
async assignRole(userId: string, roleSlug: string) {
const role = await this.databaseService.db
.select()
.from(roles)
.where(eq(roles.slug, roleSlug))
.limit(1);
if (!role[0]) {
throw new Error(`Role with slug ${roleSlug} not found`);
}
return this.databaseService.db
.insert(usersToRoles)
.values({
userId,
roleId: role[0].id,
})
.onConflictDoNothing()
.returning();
}
} }

View File

@@ -10,6 +10,7 @@ import {
Param, Param,
ParseBoolPipe, ParseBoolPipe,
ParseIntPipe, ParseIntPipe,
Patch,
Post, Post,
Query, Query,
Req, Req,
@@ -173,6 +174,16 @@ export class ContentsController {
return this.contentsService.incrementUsage(id); return this.contentsService.incrementUsage(id);
} }
@Patch(":id")
@UseGuards(AuthGuard)
update(
@Param("id") id: string,
@Req() req: AuthenticatedRequest,
@Body() updateContentDto: any,
) {
return this.contentsService.update(id, req.user.sub, updateContentDto);
}
@Delete(":id") @Delete(":id")
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) { remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
@@ -185,4 +196,11 @@ export class ContentsController {
removeAdmin(@Param("id") id: string) { removeAdmin(@Param("id") id: string) {
return this.contentsService.removeAdmin(id); return this.contentsService.removeAdmin(id);
} }
@Patch(":id/admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
updateAdmin(@Param("id") id: string, @Body() updateContentDto: any) {
return this.contentsService.updateAdmin(id, updateContentDto);
}
} }

View File

@@ -184,6 +184,35 @@ export class ContentsService {
return deleted; return deleted;
} }
async updateAdmin(id: string, data: any) {
this.logger.log(`Updating content ${id} by admin`);
const updated = await this.contentsRepository.update(id, data);
if (updated) {
await this.clearContentsCache();
}
return updated;
}
async update(id: string, userId: string, data: any) {
this.logger.log(`Updating content ${id} for user ${userId}`);
// Vérifier que le contenu appartient à l'utilisateur
const existing = await this.contentsRepository.findOne(id, userId);
if (!existing || existing.userId !== userId) {
throw new BadRequestException(
"Contenu non trouvé ou vous n'avez pas la permission de le modifier.",
);
}
const updated = await this.contentsRepository.update(id, data);
if (updated) {
await this.clearContentsCache();
}
return updated;
}
async findOne(idOrSlug: string, userId?: string) { async findOne(idOrSlug: string, userId?: string) {
const content = await this.contentsRepository.findOne(idOrSlug, userId); const content = await this.contentsRepository.findOne(idOrSlug, userId);
if (!content) return null; if (!content) return null;

View File

@@ -404,6 +404,15 @@ export class ContentsRepository {
return deleted; return deleted;
} }
async update(id: string, data: Partial<typeof contents.$inferInsert>) {
const [updated] = await this.databaseService.db
.update(contents)
.set({ ...data, updatedAt: new Date() })
.where(eq(contents.id, id))
.returning();
return updated;
}
async findBySlug(slug: string) { async findBySlug(slug: string) {
const [result] = await this.databaseService.db const [result] = await this.databaseService.db
.select() .select()

View File

@@ -8,7 +8,6 @@ jest.mock("minio");
describe("S3Service", () => { describe("S3Service", () => {
let service: S3Service; let service: S3Service;
let configService: ConfigService; let configService: ConfigService;
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
let minioClient: any; let minioClient: any;
beforeEach(async () => { beforeEach(async () => {

View File

@@ -14,4 +14,12 @@ export class UpdateUserDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
avatarUrl?: string; avatarUrl?: string;
@IsOptional()
@IsString()
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
@IsOptional()
@IsString()
role?: string;
} }

View File

@@ -64,7 +64,7 @@ export class UsersRepository {
} }
async findAll(limit: number, offset: number) { async findAll(limit: number, offset: number) {
return await this.databaseService.db const result = await this.databaseService.db
.select({ .select({
uuid: users.uuid, uuid: users.uuid,
username: users.username, username: users.username,
@@ -77,6 +77,8 @@ export class UsersRepository {
.from(users) .from(users)
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
return result;
} }
async findByUsername(username: string) { async findByUsername(username: string) {

View File

@@ -112,6 +112,16 @@ export class UsersController {
return this.usersService.remove(uuid); return this.usersService.remove(uuid);
} }
@Patch("admin/:uuid")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
updateAdmin(
@Param("uuid") uuid: string,
@Body() updateUserDto: UpdateUserDto,
) {
return this.usersService.update(uuid, updateUserDto);
}
// Double Authentification (2FA) // Double Authentification (2FA)
@Post("me/2fa/setup") @Post("me/2fa/setup")
@UseGuards(AuthGuard) @UseGuards(AuthGuard)

View File

@@ -100,7 +100,14 @@ export class UsersService {
async update(uuid: string, data: UpdateUserDto) { async update(uuid: string, data: UpdateUserDto) {
this.logger.log(`Updating user profile for ${uuid}`); this.logger.log(`Updating user profile for ${uuid}`);
const result = await this.usersRepository.update(uuid, data);
const { role, ...userData } = data;
const result = await this.usersRepository.update(uuid, userData);
if (role) {
await this.rbacService.assignRoleToUser(uuid, role);
}
if (result[0]) { if (result[0]) {
await this.clearUserCache(result[0].username); await this.clearUserCache(result[0].username);

View File

@@ -92,6 +92,21 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
**Réponses :** **Réponses :**
- `200 OK` : Déconnexion réussie. - `200 OK` : Déconnexion réussie.
</Accordion> </Accordion>
<Accordion title="GET /auth/bootstrap-admin">
Élève les privilèges d'un utilisateur au rang d'administrateur.
<Callout type="warn">
Cette route n'est active que si aucun administrateur n'existe en base de données. Le token est affiché dans les logs de la console au démarrage.
</Callout>
**Query Params :**
- `token` (string) : Token à usage unique généré par le système.
- `username` (string) : Nom de l'utilisateur à promouvoir.
**Réponses :**
- `200 OK` : Utilisateur promu.
- `401 Unauthorized` : Token invalide ou utilisateur non trouvé.
</Accordion>
</Accordions> </Accordions>
### 👤 Utilisateurs (`/users`) ### 👤 Utilisateurs (`/users`)

View File

@@ -1,12 +1,13 @@
{ {
"name": "@memegoat/frontend", "name": "@memegoat/frontend",
"version": "1.1.1", "version": "1.4.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "biome check", "lint": "biome check",
"lint:write": "biome check --write",
"format": "biome format --write" "format": "biome format --write"
}, },
"dependencies": { "dependencies": {

View File

@@ -0,0 +1,149 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
interface CategoryDialogProps {
category?: Category | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function CategoryDialog({
category,
open,
onOpenChange,
onSuccess,
}: CategoryDialogProps) {
const [loading, setLoading] = useState(false);
const form = useForm<Partial<Category>>({
defaultValues: {
name: "",
description: "",
iconUrl: "",
},
});
useEffect(() => {
if (category) {
form.reset({
name: category.name,
description: category.description || "",
iconUrl: category.iconUrl || "",
});
} else {
form.reset({
name: "",
description: "",
iconUrl: "",
});
}
}, [category, form]);
const onSubmit = async (values: Partial<Category>) => {
setLoading(true);
try {
if (category) {
await CategoryService.update(category.id, values);
} else {
await CategoryService.create(values);
}
onSuccess();
onOpenChange(false);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{category ? "Modifier la catégorie" : "Créer une catégorie"}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
rules={{ required: "Le nom est requis" }}
render={({ field }) => (
<FormItem>
<FormLabel>Nom</FormLabel>
<FormControl>
<Input {...field} placeholder="Nom de la catégorie" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="Description (optionnel)" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="iconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>URL de l'icône</FormLabel>
<FormControl>
<Input {...field} placeholder="https://..." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Annuler
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,6 +1,17 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { Edit, MoreHorizontal, Plus, Trash2 } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
Table, Table,
@@ -12,32 +23,66 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { CategoryService } from "@/services/category.service"; import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content"; import type { Category } from "@/types/content";
import { CategoryDialog } from "./category-dialog";
export default function AdminCategoriesPage() { export default function AdminCategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<Category | null>(
null,
);
useEffect(() => { const fetchCategories = useCallback(() => {
setLoading(true);
CategoryService.getAll() CategoryService.getAll()
.then(setCategories) .then(setCategories)
.catch((err) => console.error(err)) .catch((err) => console.error(err))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
const handleDelete = async (id: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer cette catégorie ?")) return;
try {
await CategoryService.remove(id);
setCategories(categories.filter((c) => c.id !== id));
} catch (error) {
console.error(error);
}
};
const handleEdit = (category: Category) => {
setSelectedCategory(category);
setDialogOpen(true);
};
const handleCreate = () => {
setSelectedCategory(null);
setDialogOpen(true);
};
return ( return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8"> <div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight"> <h2 className="text-3xl font-bold tracking-tight">
Catégories ({categories.length}) Catégories ({categories.length})
</h2> </h2>
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" /> Ajouter une catégorie
</Button>
</div> </div>
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Nom</TableHead> <TableHead>Nom</TableHead>
<TableHead>Slug</TableHead> <TableHead className="hidden sm:table-cell">Slug</TableHead>
<TableHead>Description</TableHead> <TableHead className="hidden md:table-cell">Description</TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -48,17 +93,20 @@ export default function AdminCategoriesPage() {
<TableCell> <TableCell>
<Skeleton className="h-4 w-[150px]" /> <Skeleton className="h-4 w-[150px]" />
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden sm:table-cell">
<Skeleton className="h-4 w-[150px]" /> <Skeleton className="h-4 w-[150px]" />
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden md:table-cell">
<Skeleton className="h-4 w-[250px]" /> <Skeleton className="h-4 w-[250px]" />
</TableCell> </TableCell>
<TableCell>
<Skeleton className="h-8 w-8 rounded-full" />
</TableCell>
</TableRow> </TableRow>
)) ))
) : categories.length === 0 ? ( ) : categories.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center h-24"> <TableCell colSpan={4} className="text-center h-24">
Aucune catégorie trouvée. Aucune catégorie trouvée.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -66,18 +114,61 @@ export default function AdminCategoriesPage() {
categories.map((category) => ( categories.map((category) => (
<TableRow key={category.id}> <TableRow key={category.id}>
<TableCell className="font-medium whitespace-nowrap"> <TableCell className="font-medium whitespace-nowrap">
{category.name} <div className="flex items-center gap-2">
{category.iconUrl && (
<div className="relative h-6 w-6">
<Image
src={category.iconUrl}
alt=""
fill
className="rounded object-cover"
/>
</div>
)}
{category.name}
</div>
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap">{category.slug}</TableCell> <TableCell className="whitespace-nowrap hidden sm:table-cell">
<TableCell className="text-muted-foreground"> {category.slug}
</TableCell>
<TableCell className="text-muted-foreground hidden md:table-cell">
{category.description || "Aucune description"} {category.description || "Aucune description"}
</TableCell> </TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleEdit(category)}>
<Edit className="mr-2 h-4 w-4" /> Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(category.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<CategoryDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
category={selectedCategory}
onSuccess={fetchCategories}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,155 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CategoryService } from "@/services/category.service";
import { ContentService } from "@/services/content.service";
import type { Category, Content } from "@/types/content";
interface ContentEditDialogProps {
content: Content | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function ContentEditDialog({
content,
open,
onOpenChange,
onSuccess,
}: ContentEditDialogProps) {
const [loading, setLoading] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const form = useForm<{ title: string; categoryId: string }>({
defaultValues: {
title: "",
categoryId: "",
},
});
useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
useEffect(() => {
if (content) {
form.reset({
title: content.title,
categoryId: content.categoryId || "none",
});
}
}, [content, form]);
const onSubmit = async (values: { title: string; categoryId: string }) => {
if (!content) return;
setLoading(true);
try {
const data = {
...values,
categoryId: values.categoryId === "none" ? null : values.categoryId,
};
await ContentService.updateAdmin(content.id, data);
onSuccess();
onOpenChange(false);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier le contenu</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
rules={{ required: "Le titre est requis" }}
render={({ field }) => (
<FormItem>
<FormLabel>Titre</FormLabel>
<FormControl>
<Input {...field} placeholder="Titre du contenu" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Catégorie</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une catégorie" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">Sans catégorie</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Annuler
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,10 +2,26 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Download, Eye, Image as ImageIcon, Trash2, Video } from "lucide-react"; import {
import { useEffect, useState } from "react"; Download,
Edit,
Eye,
Image as ImageIcon,
MoreHorizontal,
Trash2,
Video,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
Table, Table,
@@ -17,13 +33,17 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import type { Content } from "@/types/content"; import type { Content } from "@/types/content";
import { ContentEditDialog } from "./content-edit-dialog";
export default function AdminContentsPage() { export default function AdminContentsPage() {
const [contents, setContents] = useState<Content[]>([]); const [contents, setContents] = useState<Content[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [selectedContent, setSelectedContent] = useState<Content | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => { const fetchContents = useCallback(() => {
setLoading(true);
ContentService.getExplore({ limit: 20 }) ContentService.getExplore({ limit: 20 })
.then((res) => { .then((res) => {
setContents(res.data); setContents(res.data);
@@ -33,6 +53,10 @@ export default function AdminContentsPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
fetchContents();
}, [fetchContents]);
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return; if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return;
@@ -45,6 +69,11 @@ export default function AdminContentsPage() {
} }
}; };
const handleEdit = (content: Content) => {
setSelectedContent(content);
setDialogOpen(true);
};
return ( return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8"> <div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -57,11 +86,11 @@ export default function AdminContentsPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Contenu</TableHead> <TableHead>Contenu</TableHead>
<TableHead>Catégorie</TableHead> <TableHead className="hidden sm:table-cell">Catégorie</TableHead>
<TableHead>Auteur</TableHead> <TableHead className="hidden md:table-cell">Auteur</TableHead>
<TableHead>Stats</TableHead> <TableHead className="hidden lg:table-cell">Stats</TableHead>
<TableHead>Date</TableHead> <TableHead className="hidden xl:table-cell">Date</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[100px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -72,23 +101,26 @@ export default function AdminContentsPage() {
<TableCell> <TableCell>
<Skeleton className="h-10 w-[200px]" /> <Skeleton className="h-10 w-[200px]" />
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden sm:table-cell">
<Skeleton className="h-4 w-[100px]" /> <Skeleton className="h-4 w-[100px]" />
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden md:table-cell">
<Skeleton className="h-4 w-[100px]" /> <Skeleton className="h-4 w-[100px]" />
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden lg:table-cell">
<Skeleton className="h-4 w-[80px]" /> <Skeleton className="h-4 w-[80px]" />
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden xl:table-cell">
<Skeleton className="h-4 w-[100px]" /> <Skeleton className="h-4 w-[100px]" />
</TableCell> </TableCell>
<TableCell>
<Skeleton className="h-8 w-8 rounded-full" />
</TableCell>
</TableRow> </TableRow>
)) ))
) : contents.length === 0 ? ( ) : contents.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center h-24"> <TableCell colSpan={6} className="text-center h-24">
Aucun contenu trouvé. Aucun contenu trouvé.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -112,13 +144,15 @@ export default function AdminContentsPage() {
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden sm:table-cell">
<Badge variant="outline"> <Badge variant="outline">
{content.category?.name || "Sans catégorie"} {content.category?.name || "Sans catégorie"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>@{content.author.username}</TableCell> <TableCell className="hidden md:table-cell">
<TableCell> @{content.author.username}
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex flex-col gap-1 text-xs"> <div className="flex flex-col gap-1 text-xs">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Eye className="h-3 w-3" /> {content.views} <Eye className="h-3 w-3" /> {content.views}
@@ -128,18 +162,31 @@ export default function AdminContentsPage() {
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap"> <TableCell className="hidden xl:table-cell whitespace-nowrap">
{format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })} {format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <DropdownMenu>
variant="ghost" <DropdownMenuTrigger asChild>
size="icon" <Button variant="ghost" size="icon">
onClick={() => handleDelete(content.id)} <MoreHorizontal className="h-4 w-4" />
className="text-destructive hover:text-destructive hover:bg-destructive/10" <span className="sr-only">Actions</span>
> </Button>
<Trash2 className="h-4 w-4" /> </DropdownMenuTrigger>
</Button> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleEdit(content)}>
<Edit className="mr-2 h-4 w-4" /> Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(content.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -147,6 +194,12 @@ export default function AdminContentsPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<ContentEditDialog
content={selectedContent}
open={dialogOpen}
onOpenChange={setDialogOpen}
onSuccess={fetchContents}
/>
</div> </div>
); );
} }

View File

@@ -2,10 +2,18 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Trash2 } from "lucide-react"; import { Edit, MoreHorizontal, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
Table, Table,
@@ -17,13 +25,17 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { UserService } from "@/services/user.service"; import { UserService } from "@/services/user.service";
import type { User } from "@/types/user"; import type { User } from "@/types/user";
import { UserEditDialog } from "./user-edit-dialog";
export default function AdminUsersPage() { export default function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => { const fetchUsers = useCallback(() => {
setLoading(true);
UserService.getUsersAdmin() UserService.getUsersAdmin()
.then((res) => { .then((res) => {
setUsers(res.data); setUsers(res.data);
@@ -35,6 +47,10 @@ export default function AdminUsersPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleDelete = async (uuid: string) => { const handleDelete = async (uuid: string) => {
if ( if (
!confirm( !confirm(
@@ -52,6 +68,11 @@ export default function AdminUsersPage() {
} }
}; };
const handleEdit = (user: User) => {
setSelectedUser(user);
setDialogOpen(true);
};
return ( return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8"> <div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -64,11 +85,13 @@ export default function AdminUsersPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Utilisateur</TableHead> <TableHead>Utilisateur</TableHead>
<TableHead>Email</TableHead> <TableHead className="hidden md:table-cell">Email</TableHead>
<TableHead>Rôle</TableHead> <TableHead>Rôle</TableHead>
<TableHead>Status</TableHead> <TableHead className="hidden sm:table-cell">Status</TableHead>
<TableHead>Date d'inscription</TableHead> <TableHead className="hidden lg:table-cell">
<TableHead className="w-[50px]"></TableHead> Date d'inscription
</TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -79,23 +102,26 @@ export default function AdminUsersPage() {
<TableCell> <TableCell>
<Skeleton className="h-4 w-[150px]" /> <Skeleton className="h-4 w-[150px]" />
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden md:table-cell">
<Skeleton className="h-4 w-[200px]" /> <Skeleton className="h-4 w-[200px]" />
</TableCell> </TableCell>
<TableCell> <TableCell>
<Skeleton className="h-4 w-[50px]" /> <Skeleton className="h-4 w-[50px]" />
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden sm:table-cell">
<Skeleton className="h-4 w-[80px]" /> <Skeleton className="h-4 w-[80px]" />
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden lg:table-cell">
<Skeleton className="h-4 w-[100px]" /> <Skeleton className="h-4 w-[100px]" />
</TableCell> </TableCell>
<TableCell>
<Skeleton className="h-8 w-8 rounded-full" />
</TableCell>
</TableRow> </TableRow>
)) ))
) : users.length === 0 ? ( ) : users.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center h-24"> <TableCell colSpan={6} className="text-center h-24">
Aucun utilisateur trouvé. Aucun utilisateur trouvé.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -106,29 +132,50 @@ export default function AdminUsersPage() {
{user.displayName || user.username} {user.displayName || user.username}
<div className="text-xs text-muted-foreground">@{user.username}</div> <div className="text-xs text-muted-foreground">@{user.username}</div>
</TableCell> </TableCell>
<TableCell>{user.email}</TableCell> <TableCell className="hidden md:table-cell">{user.email}</TableCell>
<TableCell> <TableCell>
<Badge variant={user.role === "admin" ? "default" : "secondary"}> <Badge variant={user.role === "admin" ? "default" : "secondary"}>
{user.role} {user.role}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden sm:table-cell">
<Badge variant={user.status === "active" ? "success" : "destructive"}> <Badge
variant={
user.status === "active"
? "success"
: user.status === "suspended"
? "destructive"
: "secondary"
}
>
{user.status} {user.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap"> <TableCell className="hidden lg:table-cell whitespace-nowrap">
{format(new Date(user.createdAt), "PPP", { locale: fr })} {format(new Date(user.createdAt), "PPP", { locale: fr })}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <DropdownMenu>
variant="ghost" <DropdownMenuTrigger asChild>
size="icon" <Button variant="ghost" size="icon">
onClick={() => handleDelete(user.uuid)} <MoreHorizontal className="h-4 w-4" />
className="text-destructive hover:text-destructive hover:bg-destructive/10" <span className="sr-only">Actions</span>
> </Button>
<Trash2 className="h-4 w-4" /> </DropdownMenuTrigger>
</Button> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleEdit(user)}>
<Edit className="mr-2 h-4 w-4" /> Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(user.uuid)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -136,6 +183,12 @@ export default function AdminUsersPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<UserEditDialog
user={selectedUser}
open={dialogOpen}
onOpenChange={setDialogOpen}
onSuccess={fetchUsers}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,153 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
interface UserEditDialogProps {
user: User | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function UserEditDialog({
user,
open,
onOpenChange,
onSuccess,
}: UserEditDialogProps) {
const [loading, setLoading] = useState(false);
const form = useForm<Partial<User>>({
defaultValues: {
role: "user",
status: "active",
},
});
useEffect(() => {
if (user) {
form.reset({
role: user.role || "user",
status: user.status || "active",
});
}
}, [user, form]);
const onSubmit = async (values: Partial<User>) => {
if (!user) return;
setLoading(true);
try {
await UserService.updateAdmin(user.uuid, values);
onSuccess();
onOpenChange(false);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier l'utilisateur @{user?.username}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Rôle</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner un rôle" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user">Utilisateur</SelectItem>
<SelectItem value="moderator">Modérateur</SelectItem>
<SelectItem value="admin">Administrateur</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Statut</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner un statut" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="active">Actif</SelectItem>
<SelectItem value="suspended">Suspendu</SelectItem>
<SelectItem value="pending">En attente</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Annuler
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -8,6 +8,7 @@ import {
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { Toaster } from "@/components/ui/sonner";
import { UserNavMobile } from "@/components/user-nav-mobile"; import { UserNavMobile } from "@/components/user-nav-mobile";
export default function DashboardLayout({ export default function DashboardLayout({
@@ -26,7 +27,9 @@ export default function DashboardLayout({
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden sticky top-0 bg-background z-40"> <header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden sticky top-0 bg-background z-40">
<SidebarTrigger /> <SidebarTrigger />
<div className="flex-1 flex justify-center"> <div className="flex-1 flex justify-center">
<span className="font-bold text-primary text-lg">MemeGoat</span> <span className="font-bold text-primary text-xl tracking-tight">
MemeGoat
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ModeToggle /> <ModeToggle />
@@ -46,6 +49,7 @@ export default function DashboardLayout({
</React.Suspense> </React.Suspense>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
<Toaster />
</React.Suspense> </React.Suspense>
); );
} }

View File

@@ -1,6 +1,13 @@
"use client"; "use client";
import { Calendar, Camera, LogIn, LogOut, Settings } from "lucide-react"; import {
Calendar,
Camera,
LogIn,
LogOut,
Settings,
Share2,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
@@ -59,6 +66,12 @@ export default function ProfilePage() {
[], [],
); );
const handleShareProfile = () => {
const url = `${window.location.origin}/user/${user?.username}`;
navigator.clipboard.writeText(url);
toast.success("Lien du profil copié !");
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-[400px] items-center justify-center"> <div className="flex h-[400px] items-center justify-center">
@@ -93,12 +106,12 @@ export default function ProfilePage() {
return ( return (
<div className="max-w-4xl mx-auto py-8 px-4"> <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="bg-white dark:bg-zinc-900 rounded-2xl p-6 md:p-8 border shadow-sm mb-8">
<div className="flex flex-col md:flex-row items-center gap-8"> <div className="flex flex-col md:flex-row items-center md:items-start gap-6 md:gap-8">
<div className="relative group"> <div className="relative group shrink-0">
<Avatar className="h-32 w-32 border-4 border-primary/10"> <Avatar className="h-24 w-24 md:h-32 md:w-32 border-4 border-primary/10">
<AvatarImage src={user.avatarUrl} alt={user.username} /> <AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback className="text-4xl"> <AvatarFallback className="text-3xl md:text-4xl">
{user.username.slice(0, 2).toUpperCase()} {user.username.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@@ -106,8 +119,9 @@ export default function ProfilePage() {
type="button" type="button"
onClick={handleAvatarClick} 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" className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
title="Changer l'avatar"
> >
<Camera className="h-8 w-8" /> <Camera className="h-6 w-6 md:h-8 md:w-8" />
</button> </button>
<input <input
type="file" type="file"
@@ -118,17 +132,21 @@ export default function ProfilePage() {
/> />
</div> </div>
<div className="flex-1 text-center md:text-left space-y-4"> <div className="flex-1 text-center md:text-left space-y-4">
<div> <div className="space-y-1">
<h1 className="text-3xl font-bold"> <h1 className="text-2xl md:text-3xl font-bold tracking-tight">
{user.displayName || user.username} {user.displayName || user.username}
</h1> </h1>
<p className="text-muted-foreground">@{user.username}</p> <p className="text-muted-foreground font-medium">@{user.username}</p>
</div> </div>
{user.bio && ( {user.bio && (
<p className="max-w-md text-sm leading-relaxed">{user.bio}</p> <p className="max-w-md text-sm md:text-base leading-relaxed text-balance">
{user.bio}
</p>
)} )}
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground"> <div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
Membre depuis{" "} Membre depuis{" "}
{new Date(user.createdAt).toLocaleDateString("fr-FR", { {new Date(user.createdAt).toLocaleDateString("fr-FR", {
@@ -137,18 +155,28 @@ export default function ProfilePage() {
})} })}
</span> </span>
</div> </div>
<div className="flex flex-wrap justify-center md:justify-start gap-2">
<Button asChild variant="outline" size="sm"> <div className="flex flex-wrap justify-center md:justify-start gap-2 pt-2">
<Button asChild variant="outline" size="sm" className="h-9 px-4">
<Link href="/settings"> <Link href="/settings">
<Settings className="h-4 w-4 mr-2" /> <Settings className="h-4 w-4 mr-2" />
Paramètres Paramètres
</Link> </Link>
</Button> </Button>
<Button
variant="outline"
size="sm"
className="h-9 px-4"
onClick={handleShareProfile}
>
<Share2 className="h-4 w-4 mr-2" />
Partager
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => logout()} onClick={() => logout()}
className="text-red-500 hover:text-red-600 hover:bg-red-50" className="h-9 px-4 text-destructive hover:text-destructive hover:bg-destructive/10"
> >
<LogOut className="h-4 w-4 mr-2" /> <LogOut className="h-4 w-4 mr-2" />
Déconnexion Déconnexion
@@ -159,18 +187,18 @@ export default function ProfilePage() {
</div> </div>
<Tabs value={tab} className="w-full"> <Tabs value={tab} className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-8"> <TabsList className="grid w-full grid-cols-2 mb-8 h-11">
<TabsTrigger value="memes" asChild> <TabsTrigger value="memes" asChild className="text-sm font-semibold">
<Link href="/profile?tab=memes">Mes Mèmes</Link> <Link href="/profile?tab=memes">Mes Mèmes</Link>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="favorites" asChild> <TabsTrigger value="favorites" asChild className="text-sm font-semibold">
<Link href="/profile?tab=favorites">Mes Favoris</Link> <Link href="/profile?tab=favorites">Mes Favoris</Link>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="memes"> <TabsContent value="memes" className="mt-0 outline-none">
<ContentList fetchFn={fetchMyMemes} /> <ContentList fetchFn={fetchMyMemes} />
</TabsContent> </TabsContent>
<TabsContent value="favorites"> <TabsContent value="favorites" className="mt-0 outline-none">
<ContentList fetchFn={fetchMyFavorites} /> <ContentList fetchFn={fetchMyFavorites} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@@ -2,19 +2,34 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { import {
AlertTriangle,
Laptop, Laptop,
Loader2, Loader2,
Moon, Moon,
Palette, Palette,
Save, Save,
Settings,
Sun, Sun,
Trash2,
User as UserIcon, User as UserIcon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import * as React from "react"; import * as React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import * as z from "zod"; import * as z from "zod";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -49,8 +64,10 @@ type SettingsFormValues = z.infer<typeof settingsSchema>;
export default function SettingsPage() { export default function SettingsPage() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { user, isLoading, refreshUser } = useAuth(); const { user, isLoading, refreshUser, logout } = useAuth();
const router = useRouter();
const [isSaving, setIsSaving] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const [mounted, setMounted] = React.useState(false); const [mounted, setMounted] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
@@ -111,146 +128,225 @@ export default function SettingsPage() {
} }
}; };
const handleDeleteAccount = async () => {
setIsDeleting(true);
try {
await UserService.removeMe();
toast.success("Votre compte a été supprimé.");
logout();
router.push("/");
} catch (error) {
console.error(error);
toast.error("Erreur lors de la suppression du compte.");
} finally {
setIsDeleting(false);
}
};
return ( return (
<div className="max-w-2xl mx-auto py-12 px-4"> <div className="max-w-2xl mx-auto py-12 px-4">
<div className="flex items-center gap-3 mb-8"> <div className="flex items-center gap-3 mb-8">
<div className="bg-primary/10 p-3 rounded-xl"> <div className="bg-primary/10 p-3 rounded-xl">
<UserIcon className="h-6 w-6 text-primary" /> <Settings className="h-6 w-6 text-primary" />
</div> </div>
<h1 className="text-3xl font-bold">Paramètres du profil</h1> <h1 className="text-3xl font-bold tracking-tight">Paramètres</h1>
</div> </div>
<Card> <div className="space-y-8">
<CardHeader> <Card className="border-none shadow-sm">
<CardTitle>Informations personnelles</CardTitle> <CardHeader className="pb-4">
<CardDescription> <div className="flex items-center gap-2 mb-1">
Mettez à jour vos informations publiques. Ces données seront visibles par <UserIcon className="h-5 w-5 text-primary" />
les autres utilisateurs. <CardTitle>Informations personnelles</CardTitle>
</CardDescription> </div>
</CardHeader> <CardDescription>
<CardContent> Mettez à jour vos informations publiques. Ces données seront visibles par
<Form {...form}> les autres utilisateurs.
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> </CardDescription>
<div className="grid gap-4"> </CardHeader>
<FormItem> <CardContent>
<FormLabel>Nom d'utilisateur</FormLabel> <Form {...form}>
<FormControl> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Input <div className="grid gap-6">
value={user.username} <div className="grid sm:grid-cols-2 gap-4">
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> <FormItem>
<FormLabel>Nom d'affichage</FormLabel> <FormLabel>Nom d'utilisateur</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Votre nom" {...field} /> <Input
</FormControl> value={user.username}
<FormDescription> disabled
Le nom qui sera affiché sur votre profil et vos mèmes. className="bg-muted cursor-not-allowed"
</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> </FormControl>
<FormDescription> <FormDescription>Identifiant unique non modifiable.</FormDescription>
Une courte description de vous (max 255 caractères).
</FormDescription>
<FormMessage />
</FormItem> </FormItem>
)}
/> <FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Nom d'affichage</FormLabel>
<FormControl>
<Input placeholder="Votre nom" {...field} />
</FormControl>
<FormDescription>Nom visible sur votre profil.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Racontez-nous quelque chose sur vous..."
className="resize-none min-h-[100px]"
{...field}
/>
</FormControl>
<FormDescription>
Une courte description de vous (max 255 caractères).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end border-t pt-6">
<Button type="submit" disabled={isSaving} className="min-w-[150px]">
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
<Card className="border-none shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-center gap-2 mb-1">
<Palette className="h-5 w-5 text-primary" />
<CardTitle>Apparence</CardTitle>
</div>
<CardDescription>
Personnalisez l'apparence de l'application selon vos préférences.
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup
value={mounted ? theme : "system"}
onValueChange={(value) => setTheme(value)}
className="grid grid-cols-1 sm:grid-cols-3 gap-4"
>
<div className="relative">
<RadioGroupItem value="light" id="light" className="peer sr-only" />
<Label
htmlFor="light"
className="flex flex-col items-center justify-between rounded-xl border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer transition-all"
>
<Sun className="mb-3 h-6 w-6" />
<span className="text-sm font-semibold">Clair</span>
</Label>
</div> </div>
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto"> <div className="relative">
{isSaving ? ( <RadioGroupItem value="dark" id="dark" className="peer sr-only" />
<> <Label
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> htmlFor="dark"
Enregistrement... className="flex flex-col items-center justify-between rounded-xl border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer transition-all"
</> >
) : ( <Moon className="mb-3 h-6 w-6" />
<> <span className="text-sm font-semibold">Sombre</span>
<Save className="mr-2 h-4 w-4" /> </Label>
Enregistrer les modifications </div>
</>
)} <div className="relative">
</Button> <RadioGroupItem value="system" id="system" className="peer sr-only" />
</form> <Label
</Form> htmlFor="system"
</CardContent> className="flex flex-col items-center justify-between rounded-xl border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer transition-all"
</Card> >
<Card className="mt-8"> <Laptop className="mb-3 h-6 w-6" />
<CardHeader> <span className="text-sm font-semibold">Système</span>
<div className="flex items-center gap-2"> </Label>
<Palette className="h-5 w-5 text-primary" /> </div>
<CardTitle>Apparence</CardTitle> </RadioGroup>
</div> </CardContent>
<CardDescription> </Card>
Personnalisez l'apparence de l'application selon vos préférences.
</CardDescription> <Card className="border-destructive/20 shadow-sm bg-destructive/5">
</CardHeader> <CardHeader className="pb-4">
<CardContent> <div className="flex items-center gap-2 mb-1">
<RadioGroup <AlertTriangle className="h-5 w-5 text-destructive" />
value={mounted ? theme : "system"} <CardTitle className="text-destructive">Zone de danger</CardTitle>
onValueChange={(value) => setTheme(value)}
className="grid grid-cols-1 sm:grid-cols-3 gap-4"
>
<div>
<RadioGroupItem value="light" id="light" className="peer sr-only" />
<Label
htmlFor="light"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
>
<Sun className="mb-3 h-6 w-6" />
<span>Clair</span>
</Label>
</div> </div>
<div> <CardDescription className="text-destructive/80 font-medium">
<RadioGroupItem value="dark" id="dark" className="peer sr-only" /> Actions irréversibles concernant votre compte.
<Label </CardDescription>
htmlFor="dark" </CardHeader>
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer" <CardContent>
> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 rounded-lg bg-white dark:bg-zinc-900 border border-destructive/20">
<Moon className="mb-3 h-6 w-6" /> <div className="space-y-1">
<span>Sombre</span> <p className="font-bold">Supprimer mon compte</p>
</Label> <p className="text-sm text-muted-foreground">
Toutes vos données seront supprimées définitivement.
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" className="font-semibold">
<Trash2 className="h-4 w-4 mr-2" />
Supprimer le compte
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous absolument sûr ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action est irréversible. Votre compte sera supprimé
définitivement ainsi que tous vos mèmes et vos favoris.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAccount}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Suppression...
</>
) : (
"Confirmer la suppression"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
<div> </CardContent>
<RadioGroupItem value="system" id="system" className="peer sr-only" /> </Card>
<Label </div>
htmlFor="system"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
>
<Laptop className="mb-3 h-6 w-6" />
<span>Système</span>
</Label>
</div>
</RadioGroup>
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -1,9 +1,11 @@
"use client"; "use client";
import { Calendar, User as UserIcon } from "lucide-react"; import { Calendar, Share2, User as UserIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner";
import { ContentList } from "@/components/content-list"; import { ContentList } from "@/components/content-list";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import { UserService } from "@/services/user.service"; import { UserService } from "@/services/user.service";
@@ -31,6 +33,12 @@ export default function PublicProfilePage({
[username], [username],
); );
const handleShareProfile = () => {
const url = `${window.location.origin}/user/${username}`;
navigator.clipboard.writeText(url);
toast.success("Lien du profil copié !");
};
if (loading) { if (loading) {
return ( return (
<div className="flex h-[400px] items-center justify-center"> <div className="flex h-[400px] items-center justify-center">
@@ -55,28 +63,28 @@ export default function PublicProfilePage({
return ( return (
<div className="max-w-4xl mx-auto py-8 px-4"> <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="bg-white dark:bg-zinc-900 rounded-2xl p-6 md:p-8 border shadow-sm mb-8">
<div className="flex flex-col md:flex-row items-center gap-8"> <div className="flex flex-col md:flex-row items-center md:items-start gap-6 md:gap-8">
<Avatar className="h-32 w-32 border-4 border-primary/10"> <Avatar className="h-24 w-24 md:h-32 md:w-32 border-4 border-primary/10">
<AvatarImage src={user.avatarUrl} alt={user.username} /> <AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback className="text-4xl"> <AvatarFallback className="text-3xl md:text-4xl">
{user.username.slice(0, 2).toUpperCase()} {user.username.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex-1 text-center md:text-left space-y-4"> <div className="flex-1 text-center md:text-left space-y-4">
<div> <div className="space-y-1">
<h1 className="text-3xl font-bold"> <h1 className="text-2xl md:text-3xl font-bold tracking-tight">
{user.displayName || user.username} {user.displayName || user.username}
</h1> </h1>
<p className="text-muted-foreground">@{user.username}</p> <p className="text-muted-foreground font-medium">@{user.username}</p>
</div> </div>
{user.bio && ( {user.bio && (
<p className="max-w-md text-sm leading-relaxed mx-auto md:mx-0"> <p className="max-w-md text-sm md:text-base leading-relaxed mx-auto md:mx-0 text-balance">
{user.bio} {user.bio}
</p> </p>
)} )}
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground"> <div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
Membre depuis{" "} Membre depuis{" "}
{new Date(user.createdAt).toLocaleDateString("fr-FR", { {new Date(user.createdAt).toLocaleDateString("fr-FR", {
@@ -85,12 +93,23 @@ export default function PublicProfilePage({
})} })}
</span> </span>
</div> </div>
<div className="flex justify-center md:justify-start pt-2">
<Button
variant="outline"
size="sm"
className="h-9 px-4"
onClick={handleShareProfile}
>
<Share2 className="h-4 w-4 mr-2" />
Partager le profil
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-8"> <div className="space-y-8">
<h2 className="text-xl font-bold border-b pb-4">Ses mèmes</h2> <h2 className="text-xl md:text-2xl font-bold border-b pb-4">Ses mèmes</h2>
<ContentList fetchFn={fetchUserMemes} /> <ContentList fetchFn={fetchUserMemes} />
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Eye, Heart, MoreHorizontal, Share2 } from "lucide-react"; import { Edit, Eye, Heart, MoreHorizontal, Share2, Trash2 } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -15,20 +15,37 @@ import {
CardFooter, CardFooter,
CardHeader, CardHeader,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service"; import { FavoriteService } from "@/services/favorite.service";
import type { Content } from "@/types/content"; import type { Content } from "@/types/content";
import { UserContentEditDialog } from "./user-content-edit-dialog";
interface ContentCardProps { interface ContentCardProps {
content: Content; content: Content;
onUpdate?: () => void;
} }
export function ContentCard({ content }: ContentCardProps) { export function ContentCard({ content, onUpdate }: ContentCardProps) {
const { isAuthenticated } = useAuth(); const { isAuthenticated, user } = useAuth();
const router = useRouter(); const router = useRouter();
const [isLiked, setIsLiked] = React.useState(content.isLiked || false); const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
const [likesCount, setLikesCount] = React.useState(content.favoritesCount); const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
const [editDialogOpen, setEditDialogOpen] = React.useState(false);
const isAuthor = user?.uuid === content.authorId;
React.useEffect(() => { React.useEffect(() => {
setIsLiked(content.isLiked || false); setIsLiked(content.isLiked || false);
@@ -71,95 +88,194 @@ export function ContentCard({ content }: ContentCardProps) {
} }
}; };
return ( const handleDelete = async () => {
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow"> if (!confirm("Êtes-vous sûr de vouloir supprimer ce mème ?")) return;
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={content.author.avatarUrl} />
<AvatarFallback>{content.author.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<Link
href={`/user/${content.author.username}`}
className="text-sm font-semibold hover:underline"
>
{content.author.displayName || content.author.username}
</Link>
<span className="text-xs text-muted-foreground">
{new Date(content.createdAt).toLocaleDateString("fr-FR")}
</span>
</div>
<Button variant="ghost" size="icon" className="ml-auto h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 aspect-square flex items-center justify-center">
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
{content.mimeType.startsWith("image/") ? (
<Image
src={content.url}
alt={content.title}
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
) : (
<video
src={content.url}
controls={false}
autoPlay
muted
loop
className="w-full h-full object-contain"
/>
)}
</Link>
</CardContent>
<CardFooter className="p-4 flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className={`gap-1.5 h-8 ${isLiked ? "text-red-500 hover:text-red-600" : ""}`}
onClick={handleLike}
>
<Heart className={`h-4 w-4 ${isLiked ? "fill-current" : ""}`} />
<span className="text-xs">{likesCount}</span>
</Button>
<Button variant="ghost" size="sm" className="gap-1.5 h-8">
<Eye className="h-4 w-4" />
<span className="text-xs">{content.views}</span>
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Share2 className="h-4 w-4" />
</Button>
</div>
<Button
size="sm"
variant="secondary"
className="text-xs h-8"
onClick={handleUse}
>
Utiliser
</Button>
</div>
<div className="w-full space-y-2"> try {
<h3 className="font-medium text-sm line-clamp-2">{content.title}</h3> await ContentService.remove(content.id);
<div className="flex flex-wrap gap-1"> toast.success("Mème supprimé !");
{content.tags.slice(0, 3).map((tag, _i) => ( if (onUpdate) {
<Badge onUpdate();
key={typeof tag === "string" ? tag : tag.id} } else {
variant="secondary" // Si pas de onUpdate, on est probablement sur la page de détail
className="text-[10px] py-0 px-1.5" router.push("/");
> }
#{typeof tag === "string" ? tag : tag.name} } catch (_error) {
</Badge> toast.error("Erreur lors de la suppression.");
))} }
};
return (
<>
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={content.author.avatarUrl} />
<AvatarFallback>
{content.author.username[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<Link
href={`/user/${content.author.username}`}
className="text-sm font-semibold hover:underline"
>
{content.author.displayName || content.author.username}
</Link>
<span className="text-xs text-muted-foreground">
{new Date(content.createdAt).toLocaleDateString("fr-FR")}
</span>
</div> </div>
</div>
</CardFooter> <div className="ml-auto flex items-center gap-1">
</Card> <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isAuthor && (
<>
<DropdownMenuItem onClick={() => setEditDialogOpen(true)}>
<Edit className="h-4 w-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={() => toast.success("Lien copié !")}>
<Share2 className="h-4 w-4 mr-2" />
Partager
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 aspect-square sm:aspect-video md:aspect-square flex items-center justify-center">
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
{content.mimeType.startsWith("image/") ? (
<Image
src={content.url}
alt={content.title}
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={false}
/>
) : (
<video
src={content.url}
controls={false}
autoPlay
muted
loop
playsInline
className="w-full h-full object-contain"
/>
)}
</Link>
</CardContent>
<CardFooter className="p-4 flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={`gap-1.5 h-8 px-2 ${isLiked ? "text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20" : ""}`}
onClick={handleLike}
>
<Heart className={`h-4 w-4 ${isLiked ? "fill-current" : ""}`} />
<span className="text-xs font-medium">{likesCount}</span>
</Button>
</TooltipTrigger>
<TooltipContent>Liker</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2 cursor-default"
>
<Eye className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">{content.views}</span>
</Button>
</TooltipTrigger>
<TooltipContent>Vues</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}/meme/${content.slug}`,
);
toast.success("Lien copié !");
}}
>
<Share2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Partager</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Button
size="sm"
variant="secondary"
className="text-xs h-8 font-semibold"
onClick={handleUse}
>
Utiliser
</Button>
</div>
<div className="w-full space-y-2">
<Link href={`/meme/${content.slug}`}>
<h3 className="font-semibold text-base line-clamp-2 hover:text-primary transition-colors">
{content.title}
</h3>
</Link>
<div className="flex flex-wrap gap-1.5">
{content.category && (
<Badge variant="outline" className="text-[10px] py-0 px-2 bg-muted/50">
{content.category.name}
</Badge>
)}
{content.tags.slice(0, 3).map((tag, _i) => (
<Badge
key={typeof tag === "string" ? tag : tag.id}
variant="secondary"
className="text-[10px] py-0 px-2"
>
#{typeof tag === "string" ? tag : tag.name}
</Badge>
))}
</div>
</div>
</CardFooter>
</Card>
<UserContentEditDialog
content={content}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
onSuccess={() => onUpdate?.()}
/>
</>
); );
} }

View File

@@ -20,6 +20,27 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
const [offset, setOffset] = React.useState(0); const [offset, setOffset] = React.useState(0);
const [hasMore, setHasMore] = React.useState(true); const [hasMore, setHasMore] = React.useState(true);
const fetchInitial = React.useCallback(async () => {
setLoading(true);
try {
const response = await fetchFn({
limit: 10,
offset: 0,
});
setContents(response.data);
setOffset(0);
setHasMore(response.data.length === 10);
} catch (error) {
console.error("Failed to fetch contents:", error);
} finally {
setLoading(false);
}
}, [fetchFn]);
React.useEffect(() => {
fetchInitial();
}, [fetchInitial]);
const loadMore = React.useCallback(async () => { const loadMore = React.useCallback(async () => {
if (!hasMore || loading) return; if (!hasMore || loading) return;
@@ -46,32 +67,12 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
onLoadMore: loadMore, onLoadMore: loadMore,
}); });
React.useEffect(() => {
const fetchInitial = async () => {
setLoading(true);
try {
const response = await fetchFn({
limit: 10,
offset: 0,
});
setContents(response.data);
setHasMore(response.data.length === 10);
} catch (error) {
console.error("Failed to fetch contents:", error);
} finally {
setLoading(false);
}
};
fetchInitial();
}, [fetchFn]);
return ( return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8"> <div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
{title && <h1 className="text-2xl font-bold">{title}</h1>} {title && <h1 className="text-2xl font-bold">{title}</h1>}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{contents.map((content) => ( {contents.map((content) => (
<ContentCard key={content.id} content={content} /> <ContentCard key={content.id} content={content} onUpdate={fetchInitial} />
))} ))}
</div> </div>

View File

@@ -0,0 +1,158 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CategoryService } from "@/services/category.service";
import { ContentService } from "@/services/content.service";
import type { Category, Content } from "@/types/content";
interface UserContentEditDialogProps {
content: Content | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function UserContentEditDialog({
content,
open,
onOpenChange,
onSuccess,
}: UserContentEditDialogProps) {
const [loading, setLoading] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const form = useForm<{ title: string; categoryId: string }>({
defaultValues: {
title: "",
categoryId: "",
},
});
useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
useEffect(() => {
if (content) {
form.reset({
title: content.title,
categoryId: content.categoryId || "none",
});
}
}, [content, form]);
const onSubmit = async (values: { title: string; categoryId: string }) => {
if (!content) return;
setLoading(true);
try {
const data = {
...values,
categoryId: values.categoryId === "none" ? null : values.categoryId,
};
await ContentService.update(content.id, data);
toast.success("Mème mis à jour !");
onSuccess();
onOpenChange(false);
} catch (error) {
console.error(error);
toast.error("Erreur lors de la mise à jour.");
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier mon mème</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
rules={{ required: "Le titre est requis" }}
render={({ field }) => (
<FormItem>
<FormLabel>Titre</FormLabel>
<FormControl>
<Input {...field} placeholder="Titre du mème" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Catégorie</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une catégorie" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">Sans catégorie</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Annuler
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,4 +11,18 @@ export const CategoryService = {
const { data } = await api.get<Category>(`/categories/${id}`); const { data } = await api.get<Category>(`/categories/${id}`);
return data; return data;
}, },
async create(category: Partial<Category>): Promise<Category> {
const { data } = await api.post<Category>("/categories", category);
return data;
},
async update(id: string, category: Partial<Category>): Promise<Category> {
const { data } = await api.patch<Category>(`/categories/${id}`, category);
return data;
},
async remove(id: string): Promise<void> {
await api.delete(`/categories/${id}`);
},
}; };

View File

@@ -65,4 +65,18 @@ export const ContentService = {
async removeAdmin(id: string): Promise<void> { async removeAdmin(id: string): Promise<void> {
await api.delete(`/contents/${id}/admin`); await api.delete(`/contents/${id}/admin`);
}, },
async update(id: string, update: Partial<Content>): Promise<Content> {
const { data } = await api.patch<Content>(`/contents/${id}`, update);
return data;
},
async remove(id: string): Promise<void> {
await api.delete(`/contents/${id}`);
},
async updateAdmin(id: string, update: Partial<Content>): Promise<Content> {
const { data } = await api.patch<Content>(`/contents/${id}/admin`, update);
return data;
},
}; };

View File

@@ -17,6 +17,10 @@ export const UserService = {
return data; return data;
}, },
async removeMe(): Promise<void> {
await api.delete("/users/me");
},
async getUsersAdmin( async getUsersAdmin(
limit = 10, limit = 10,
offset = 0, offset = 0,
@@ -34,6 +38,11 @@ export const UserService = {
await api.delete(`/users/${uuid}`); await api.delete(`/users/${uuid}`);
}, },
async updateAdmin(uuid: string, update: Partial<User>): Promise<User> {
const { data } = await api.patch<User>(`/users/admin/${uuid}`, update);
return data;
},
async updateAvatar(file: File): Promise<User> { async updateAvatar(file: File): Promise<User> {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);

View File

@@ -18,6 +18,7 @@ export interface Content {
favoritesCount: number; favoritesCount: number;
isLiked?: boolean; isLiked?: boolean;
tags: (string | Tag)[]; tags: (string | Tag)[];
categoryId?: string | null;
category?: Category; category?: Category;
authorId: string; authorId: string;
author: User; author: User;
@@ -36,6 +37,7 @@ export interface Category {
name: string; name: string;
slug: string; slug: string;
description?: string; description?: string;
iconUrl?: string;
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {

View File

@@ -6,7 +6,7 @@ export interface User {
displayName?: string; displayName?: string;
avatarUrl?: string; avatarUrl?: string;
bio?: string; bio?: string;
role?: "user" | "admin"; role?: "user" | "admin" | "moderator";
status?: "active" | "verification" | "suspended" | "pending" | "deleted"; status?: "active" | "verification" | "suspended" | "pending" | "deleted";
createdAt: string; createdAt: string;
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/source", "name": "@memegoat/source",
"version": "1.1.1", "version": "1.4.1",
"description": "", "description": "",
"scripts": { "scripts": {
"version:get": "cmake -P version.cmake GET", "version:get": "cmake -P version.cmake GET",
@@ -13,9 +13,13 @@
"build:back": "pnpm run -F @memegoat/backend build", "build:back": "pnpm run -F @memegoat/backend build",
"build:docs": "pnpm run -F @memegoat/documentation build", "build:docs": "pnpm run -F @memegoat/documentation build",
"lint": "pnpm run lint:back && pnpm run lint:front && pnpm run lint:docs", "lint": "pnpm run lint:back && pnpm run lint:front && pnpm run lint:docs",
"lint:fix": "pnpm run lint:back:fix && pnpm run lint:front:fix && pnpm run lint:docs:fix",
"lint:back": "pnpm run -F @memegoat/backend lint", "lint:back": "pnpm run -F @memegoat/backend lint",
"lint:back:fix": "pnpm run -F @memegoat/backend lint:write",
"lint:front": "pnpm run -F @memegoat/frontend lint", "lint:front": "pnpm run -F @memegoat/frontend lint",
"lint:front:fix": "pnpm run -F @memegoat/frontend lint:write",
"lint:docs": "pnpm run -F @memegoat/documentation lint", "lint:docs": "pnpm run -F @memegoat/documentation lint",
"lint:docs:fix": "pnpm run -F @memegoat/documentation lint:write",
"test": "pnpm run test:back && pnpm run test:front", "test": "pnpm run test:back && pnpm run test:front",
"test:back": "pnpm run -F @memegoat/backend test", "test:back": "pnpm run -F @memegoat/backend test",
"test:front": "pnpm run -F @memegoat/frontend test", "test:front": "pnpm run -F @memegoat/frontend test",