37 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
Mathis HERRIOT
f4a1a2f4df chore: bump version to 1.1.1
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 59s
CI/CD Pipeline / Valider frontend (push) Successful in 1m41s
CI/CD Pipeline / Valider documentation (push) Successful in 1m44s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-21 10:45:30 +01:00
Mathis HERRIOT
0548c418c7 feat(auth): implement role seeding on application bootstrap
- Added `onApplicationBootstrap` to seed default roles if none exist.
- Introduced `seedRoles` method to handle role creation with logging.
- Updated `RbacRepository` with `countRoles` and `createRole` methods.
- Added unit tests to ensure role seeding logic functions correctly.
2026-01-21 10:45:25 +01:00
Mathis HERRIOT
dd0a9e620b feat(docs): enhance API reference documentation with detailed responses and constraints
- Added missing response details and validation constraints for multiple endpoints.
- Improved parameter descriptions and structured examples for better clarity and consistency.
2026-01-21 10:45:06 +01:00
Mathis HERRIOT
7e7b19fe9f chore(ci): add --remove-orphans flag to Docker Compose deployment script
All checks were successful
CI/CD Pipeline / Valider frontend (push) Successful in 1m40s
CI/CD Pipeline / Valider documentation (push) Successful in 1m43s
CI/CD Pipeline / Valider backend (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m21s
2026-01-21 10:08:25 +01:00
Mathis HERRIOT
57bc51290b feat(docs): update and reorganize API reference structure
- Refactored API endpoint documentation using individual accordions for better clarity.
- Added detailed descriptions for `/contents`, `/categories`, `/favorites`, `/reports`, `/api-keys`, `/tags`, `/media`, and `/admin` endpoints.
- Improved consistency in query parameters and usage examples.
2026-01-21 10:08:09 +01:00
40 changed files with 2290 additions and 432 deletions

View File

@@ -83,7 +83,7 @@ jobs:
- name: Déployer avec Docker Compose - name: Déployer avec Docker Compose
run: | run: |
docker compose -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
env: env:
BACKEND_PORT: ${{ secrets.BACKEND_PORT }} BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }} FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}

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.0", "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

@@ -9,6 +9,8 @@ describe("RbacService", () => {
const mockRbacRepository = { const mockRbacRepository = {
findRolesByUserId: jest.fn(), findRolesByUserId: jest.fn(),
findPermissionsByUserId: jest.fn(), findPermissionsByUserId: jest.fn(),
countRoles: jest.fn(),
createRole: jest.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -58,4 +60,35 @@ describe("RbacService", () => {
expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId); expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId);
}); });
}); });
describe("seedRoles", () => {
it("should be called on application bootstrap", async () => {
const seedRolesSpy = jest.spyOn(service, "seedRoles");
await service.onApplicationBootstrap();
expect(seedRolesSpy).toHaveBeenCalled();
});
it("should seed roles if none exist", async () => {
mockRbacRepository.countRoles.mockResolvedValue(0);
await service.seedRoles();
expect(repository.countRoles).toHaveBeenCalled();
expect(repository.createRole).toHaveBeenCalledTimes(3);
expect(repository.createRole).toHaveBeenCalledWith(
"Administrator",
"admin",
"Full system access",
);
});
it("should not seed roles if some already exist", async () => {
mockRbacRepository.countRoles.mockResolvedValue(3);
await service.seedRoles();
expect(repository.countRoles).toHaveBeenCalled();
expect(repository.createRole).not.toHaveBeenCalled();
});
});
}); });

View File

@@ -1,10 +1,53 @@
import { Injectable } from "@nestjs/common"; import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common";
import { RbacRepository } from "./repositories/rbac.repository"; import { RbacRepository } from "./repositories/rbac.repository";
@Injectable() @Injectable()
export class RbacService { export class RbacService implements OnApplicationBootstrap {
private readonly logger = new Logger(RbacService.name);
constructor(private readonly rbacRepository: RbacRepository) {} constructor(private readonly rbacRepository: RbacRepository) {}
async onApplicationBootstrap() {
this.logger.log("RbacService initialized, checking roles...");
await this.seedRoles();
}
async seedRoles() {
try {
const count = await this.rbacRepository.countRoles();
if (count === 0) {
this.logger.log("No roles found, seeding default roles...");
const defaultRoles = [
{
name: "Administrator",
slug: "admin",
description: "Full system access",
},
{
name: "Moderator",
slug: "moderator",
description: "Access to moderation tools",
},
{ name: "User", slug: "user", description: "Standard user access" },
];
for (const role of defaultRoles) {
await this.rbacRepository.createRole(
role.name,
role.slug,
role.description,
);
this.logger.log(`Created role: ${role.slug}`);
}
this.logger.log("Default roles seeded successfully.");
} else {
this.logger.log(`${count} roles already exist, skipping seeding.`);
}
} catch (error) {
this.logger.error("Error during roles seeding:", error);
}
}
async getUserRoles(userId: string) { async getUserRoles(userId: string) {
return this.rbacRepository.findRolesByUserId(userId); return this.rbacRepository.findRolesByUserId(userId);
} }
@@ -12,4 +55,12 @@ export class RbacService {
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

@@ -39,4 +39,52 @@ export class RbacRepository {
return Array.from(new Set(result.map((p) => p.slug))); return Array.from(new Set(result.map((p) => p.slug)));
} }
async countRoles(): Promise<number> {
const result = await this.databaseService.db
.select({ count: roles.id })
.from(roles);
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) {
return this.databaseService.db
.insert(roles)
.values({
name,
slug,
description,
})
.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

@@ -18,15 +18,21 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
Inscrit un nouvel utilisateur. Inscrit un nouvel utilisateur.
**Corps de la requête (JSON) :** **Corps de la requête (JSON) :**
- `username` (string) : Nom d'utilisateur unique. - `username` (string, max: 32) : Nom d'utilisateur unique.
- `email` (string) : Adresse email valide. - `email` (string) : Adresse email valide.
- `password` (string) : Mot de passe (min. 8 caractères). - `password` (string, min: 8) : Mot de passe.
- `displayName` (string, optional, max: 32) : Nom d'affichage.
**Réponses :**
- `201 Created` : Utilisateur créé.
- `400 Bad Request` : Validation échouée ou utilisateur déjà existant.
```json ```json
{ {
"username": "goat_user", "username": "goat_user",
"email": "user@memegoat.fr", "email": "user@memegoat.fr",
"password": "strong-password" "password": "strong-password",
"displayName": "Le Bouc"
} }
``` ```
</Accordion> </Accordion>
@@ -38,23 +44,25 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
- `email` (string) - `email` (string)
- `password` (string) - `password` (string)
**Réponse (Succès) :** **Réponses :**
```json - `200 OK` : Connexion réussie.
{ ```json
"message": "User logged in successfully", {
"userId": "uuid-v4" "message": "User logged in successfully",
} "userId": "uuid-v4"
``` }
*Note: L'access_token et le refresh_token sont stockés dans un cookie HttpOnly chiffré.* ```
- `200 OK` (2FA requise) :
```json
{
"message": "2FA required",
"requires2FA": true,
"userId": "uuid-v4"
}
```
- `401 Unauthorized` : Identifiants invalides.
**Réponse (2FA requise) :** *Note: L'access_token et le refresh_token sont stockés dans un cookie HttpOnly chiffré.*
```json
{
"message": "2FA required",
"requires2FA": true,
"userId": "uuid-v4"
}
```
</Accordion> </Accordion>
<Accordion title="POST /auth/verify-2fa"> <Accordion title="POST /auth/verify-2fa">
@@ -63,15 +71,41 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
**Corps de la requête :** **Corps de la requête :**
- `userId` (uuid) : ID de l'utilisateur. - `userId` (uuid) : ID de l'utilisateur.
- `token` (string) : Code TOTP à 6 chiffres. - `token` (string) : Code TOTP à 6 chiffres.
**Réponses :**
- `200 OK` : Vérification réussie, session établie.
- `401 Unauthorized` : Token invalide ou utilisateur non autorisé.
</Accordion> </Accordion>
<Accordion title="POST /auth/refresh"> <Accordion title="POST /auth/refresh">
Obtient un nouvel `access_token` à partir du `refresh_token` stocké dans la session. Obtient un nouvel `access_token` à partir du `refresh_token` stocké dans la session.
Met à jour automatiquement le cookie de session. Met à jour automatiquement le cookie de session.
**Réponses :**
- `200 OK` : Token rafraîchi.
- `401 Unauthorized` : Refresh token absent ou invalide.
</Accordion> </Accordion>
<Accordion title="POST /auth/logout"> <Accordion title="POST /auth/logout">
Invalide la session actuelle. Invalide la session actuelle en détruisant le cookie de session.
**Réponses :**
- `200 OK` : Déconnexion réussie.
</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> </Accordion>
</Accordions> </Accordions>
@@ -80,36 +114,62 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
<Accordions> <Accordions>
<Accordion title="GET /users/me"> <Accordion title="GET /users/me">
Récupère les informations détaillées de l'utilisateur connecté. Requiert l'authentification. Récupère les informations détaillées de l'utilisateur connecté. Requiert l'authentification.
**Réponses :**
- `200 OK` : Retourne l'objet utilisateur complet (incluant données privées).
- `401 Unauthorized` : Session invalide.
</Accordion> </Accordion>
<Accordion title="GET /users/public/:username"> <Accordion title="GET /users/public/:username">
Récupère le profil public d'un utilisateur par son nom d'utilisateur. Récupère le profil public d'un utilisateur par son nom d'utilisateur. Mise en cache pendant 1 minute.
**Réponse :** `id`, `username`, `displayName`, `avatarUrl`, `createdAt`.
**Réponses :**
- `200 OK` : Profil public (id, username, displayName, bio, avatarUrl, createdAt).
- `404 Not Found` : Utilisateur non trouvé.
</Accordion> </Accordion>
<Accordion title="GET /users/me/export"> <Accordion title="GET /users/me/export">
Extrait l'intégralité des données de l'utilisateur au format JSON (Conformité RGPD). Extrait l'intégralité des données de l'utilisateur au format JSON (Conformité RGPD).
Contient le profil, les contenus et les favoris. Contient le profil, les contenus créés et les favoris.
**Réponses :**
- `200 OK` : Archive JSON des données.
- `401 Unauthorized` : Non authentifié.
</Accordion> </Accordion>
<Accordion title="PATCH /users/me"> <Accordion title="PATCH /users/me">
Met à jour les informations du profil. Met à jour les informations du profil.
**Corps :**
- `displayName` (string) **Corps de la requête :**
- `bio` (string) - `displayName` (string, optional, max: 32)
- `bio` (string, optional, max: 255)
- `avatarUrl` (string, optional) : URL directe de l'avatar.
**Réponses :**
- `200 OK` : Profil mis à jour.
- `400 Bad Request` : Validation échouée.
</Accordion> </Accordion>
<Accordion title="POST /users/me/avatar"> <Accordion title="POST /users/me/avatar">
Met à jour l'avatar de l'utilisateur. Met à jour l'avatar de l'utilisateur via upload de fichier.
**Type :** `multipart/form-data` **Type :** `multipart/form-data`
**Champ :** `file` (Image) **Champ :** `file` (Image: png, jpeg, webp)
**Réponses :**
- `201 Created` : Avatar téléchargé et mis à jour.
- `400 Bad Request` : Fichier invalide ou trop volumineux.
</Accordion> </Accordion>
<Accordion title="PATCH /users/me/consent"> <Accordion title="PATCH /users/me/consent">
Met à jour les consentements légaux de l'utilisateur. Met à jour les consentements légaux de l'utilisateur (CGU/RGPD).
**Corps :**
- `termsVersion` (string) **Corps de la requête :**
- `privacyVersion` (string) - `termsVersion` (string, max: 16)
- `privacyVersion` (string, max: 16)
**Réponses :**
- `200 OK` : Consentements enregistrés.
</Accordion> </Accordion>
<Accordion title="DELETE /users/me"> <Accordion title="DELETE /users/me">
@@ -117,132 +177,388 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
<Callout type="warn"> <Callout type="warn">
Les données sont définitivement purgées après un délai légal de 30 jours. Les données sont définitivement purgées après un délai légal de 30 jours.
</Callout> </Callout>
**Réponses :**
- `200 OK` : Suppression planifiée.
</Accordion> </Accordion>
<Accordion title="Gestion 2FA"> <Accordion title="POST /users/me/2fa/setup">
- `POST /users/me/2fa/setup` : Génère un secret et QR Code. Génère un secret et un QR Code pour la configuration de la 2FA.
- `POST /users/me/2fa/enable` : Active après vérification du jeton.
- `POST /users/me/2fa/disable` : Désactive avec jeton. **Réponses :**
- `201 Created` :
```json
{
"secret": "JBSWY3DPEHPK3PXP",
"qrCodeDataUrl": "data:image/png;base64,..."
}
```
</Accordion> </Accordion>
<Accordion title="Administration (Admin uniquement)"> <Accordion title="POST /users/me/2fa/enable">
- `GET /users/admin` : Liste tous les utilisateurs (avec pagination `limit`, `offset`). Active la 2FA après vérification du jeton TOTP.
- `DELETE /users/:uuid` : Supprime définitivement un utilisateur par son UUID.
**Corps de la requête :**
- `token` (string) : Code TOTP généré par l'app.
**Réponses :**
- `200 OK` : 2FA activée.
- `400 Bad Request` : Token invalide ou 2FA non initiée.
</Accordion>
<Accordion title="POST /users/me/2fa/disable">
Désactive la 2FA en utilisant un jeton TOTP valide.
**Corps de la requête :**
- `token` (string) : Code TOTP.
**Réponses :**
- `200 OK` : 2FA désactivée.
</Accordion>
<Accordion title="GET /users/admin">
Liste tous les utilisateurs. **Réservé aux administrateurs.**
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
**Réponses :**
- `200 OK` : Liste paginée des utilisateurs.
- `403 Forbidden` : Droits insuffisants.
</Accordion>
<Accordion title="DELETE /users/:uuid">
Supprime définitivement un utilisateur par son UUID. **Réservé aux administrateurs.**
**Réponses :**
- `200 OK` : Utilisateur supprimé.
</Accordion> </Accordion>
</Accordions> </Accordions>
### 🖼️ Contenus (`/contents`) ### 🖼️ Contenus (`/contents`)
<Accordions> <Accordions>
<Accordion title="GET /contents/explore | /trends | /recent"> <Accordion title="GET /contents/explore">
Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur). Recherche et filtre les contenus. Cet endpoint est mis en cache pendant 1 minute.
**Query Params :** **Query Params :**
- `limit` (number) : Défaut 10. - `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0. - `offset` (number) : Défaut 0.
- `sort` : `trend` | `recent` (uniquement sur `/explore`) - `sort` : `trend` | `recent`
- `tag` (string) : Filtrer par tag. - `tag` (string) : Filtrer par tag (nom).
- `category` (slug ou id) : Filtrer par catégorie. - `category` (slug ou uuid) : Filtrer par catégorie.
- `author` (username) : Filtrer par auteur. - `author` (username) : Filtrer par auteur.
- `query` (titre) : Recherche textuelle. - `query` (string) : Recherche textuelle dans le titre.
- `favoritesOnly` (bool) : Ne montrer que les favoris de l'utilisateur connecté. - `favoritesOnly` (boolean) : Ne montrer que les favoris de l'utilisateur (nécessite auth).
- `userId` (uuid) : Filtrer les contenus d'un utilisateur spécifique. - `userId` (uuid) : Filtrer par ID utilisateur.
**Réponses :**
- `200 OK` : Liste paginée des contenus.
</Accordion>
<Accordion title="GET /contents/trends">
Récupère les contenus les plus populaires du moment. Cache de 5 minutes.
**Query Params :** `limit`, `offset`.
**Réponses :**
- `200 OK` : Liste des tendances.
</Accordion>
<Accordion title="GET /contents/recent">
Récupère les contenus les plus récents. Cache de 1 minute.
**Query Params :** `limit`, `offset`.
**Réponses :**
- `200 OK` : Liste des contenus récents.
</Accordion> </Accordion>
<Accordion title="GET /contents/:idOrSlug"> <Accordion title="GET /contents/:idOrSlug">
Récupère un contenu par son ID ou son Slug. Récupère un contenu par son ID ou son Slug. Cache de 1 heure.
**Détection de Bots (SEO) :** **Détection de Bots (SEO) :**
Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards** pour un partage optimal. Pour les autres clients, les données sont retournées en JSON. Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards**.
**Réponses :**
- `200 OK` : Objet Contenu ou Rendu HTML (Bots).
- `404 Not Found` : Contenu inexistant.
</Accordion> </Accordion>
<Accordion title="POST /contents"> <Accordion title="POST /contents">
Crée une entrée de contenu (sans upload de fichier direct). Utile pour référencer des URLs externes. Crée une entrée de contenu à partir d'une ressource déjà uploadée ou externe.
**Corps :** `title`, `description`, `url`, `type`, `categoryId`, `tags`.
**Corps de la requête :**
- `type` : `meme` | `gif`
- `title` (string, max: 255)
- `storageKey` (string, max: 512) : Clé du fichier sur S3.
- `mimeType` (string, max: 128)
- `fileSize` (number)
- `categoryId` (uuid, optional)
- `tags` (string[], optional)
**Réponses :**
- `201 Created` : Contenu référencé.
- `401 Unauthorized` : Non authentifié.
</Accordion> </Accordion>
<Accordion title="POST /contents/upload"> <Accordion title="POST /contents/upload">
Upload un fichier avec traitement automatique par le serveur. Upload un fichier et crée le contenu associé en une seule étape.
**Type :** `multipart/form-data`
**Type :** `multipart/form-data`
**Champs :** **Champs :**
- `file` (binary) : png, jpeg, webp, webm, gif. - `file` (binary) : png, jpeg, webp, webm, gif.
- `type` : `meme` | `gif` - `type` : `meme` | `gif`
- `title` : string - `title` (string)
- `categoryId`? : uuid - `categoryId` (uuid, optional)
- `tags`? : string[] - `tags` (string[], optional)
**Réponses :**
- `201 Created` : Upload réussi et contenu créé.
- `400 Bad Request` : Fichier non supporté ou données invalides.
</Accordion> </Accordion>
<Accordion title="POST /contents/upload-url"> <Accordion title="POST /contents/upload-url">
Génère une URL présignée pour un upload direct vers S3. Génère une URL présignée pour un upload direct vers S3.
**Query Param :** `fileName` (string).
**Query Param :**
- `fileName` (string) : Nom du fichier avec extension.
**Réponses :**
- `201 Created` : Retourne l'URL présignée et les champs requis.
</Accordion> </Accordion>
<Accordion title="POST /contents/:id/view | /use"> <Accordion title="POST /contents/:id/view">
Incrémente les statistiques de vue ou d'utilisation. Incrémente le compteur de vues d'un contenu.
**Réponses :**
- `201 Created` : Compteur incrémenté.
</Accordion>
<Accordion title="POST /contents/:id/use">
Incrémente le compteur d'utilisation (clic sur "Utiliser").
**Réponses :**
- `201 Created` : Compteur incrémenté.
</Accordion> </Accordion>
<Accordion title="DELETE /contents/:id"> <Accordion title="DELETE /contents/:id">
Supprime un contenu (Soft Delete). Doit être l'auteur. Supprime un contenu (Soft Delete). Doit être l'auteur.
**Réponses :**
- `200 OK` : Contenu supprimé.
- `403 Forbidden` : Tentative de supprimer le contenu d'autrui.
</Accordion> </Accordion>
<Accordion title="DELETE /contents/:id/admin"> <Accordion title="DELETE /contents/:id/admin">
Supprime définitivement un contenu. **Réservé aux administrateurs.** Supprime définitivement un contenu. **Réservé aux administrateurs.**
**Réponses :**
- `200 OK` : Contenu supprimé définitivement.
</Accordion> </Accordion>
</Accordions> </Accordions>
### 📂 Catégories, ⭐ Favoris, 🚩 Signalements ### 📂 Catégories (`/categories`)
<Accordions> <Accordions>
<Accordion title="Catégories (/categories)"> <Accordion title="GET /categories">
- `GET /categories` : Liste toutes les catégories. Liste toutes les catégories de mèmes disponibles. Cache de 1 heure.
- `GET /categories/:id` : Détails d'une catégorie.
- `POST /categories` : Création (Admin uniquement). **Réponses :**
- `PATCH /categories/:id` : Mise à jour (Admin uniquement). - `200 OK` : Liste d'objets catégorie.
- `DELETE /categories/:id` : Suppression (Admin uniquement).
</Accordion> </Accordion>
<Accordion title="Favoris (/favorites)"> <Accordion title="GET /categories/:id">
Requiert l'authentification. Récupère les détails d'une catégorie spécifique.
- `GET /favorites` : Liste les favoris de l'utilisateur (avec pagination `limit`, `offset`).
- `POST /favorites/:contentId` : Ajoute un favori. **Réponses :**
- `DELETE /favorites/:contentId` : Retire un favori. - `200 OK` : Objet catégorie.
- `404 Not Found` : Catégorie non trouvée.
</Accordion> </Accordion>
<Accordion title="Signalements (/reports)"> <Accordion title="POST /categories">
- `POST /reports` : Signale un contenu ou un tag. Crée une nouvelle catégorie. **Admin uniquement.**
- `GET /reports` : Liste des signalements (Pagination `limit`, `offset`). **Admin/Modérateurs**.
- `PATCH /reports/:id/status` : Change le statut (`pending`, `resolved`, `dismissed`). **Admin/Modérateurs**. **Corps de la requête :**
- `name` (string, max: 64)
- `description` (string, optional, max: 255)
- `iconUrl` (string, optional, max: 512)
**Réponses :**
- `201 Created` : Catégorie créée.
</Accordion>
<Accordion title="PATCH /categories/:id">
Met à jour une catégorie existante. **Admin uniquement.**
**Corps de la requête :** (Tous optionnels) `name`, `description`, `iconUrl`.
**Réponses :**
- `200 OK` : Catégorie mise à jour.
</Accordion>
<Accordion title="DELETE /categories/:id">
Supprime une catégorie. **Admin uniquement.**
**Réponses :**
- `200 OK` : Catégorie supprimée.
</Accordion> </Accordion>
</Accordions> </Accordions>
### 🔑 Clés API & 🏷️ Tags ### ⭐ Favoris (`/favorites`)
<Accordions> <Accordions>
<Accordion title="Clés API (/api-keys)"> <Accordion title="GET /favorites">
- `POST /api-keys` : Génère une clé `{ name, expiresAt? }`. Liste les favoris de l'utilisateur connecté.
- `GET /api-keys` : Liste les clés actives.
- `DELETE /api-keys/:id` : Révoque une clé. **Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
**Réponses :**
- `200 OK` : Liste paginée des favoris.
- `401 Unauthorized` : Non authentifié.
</Accordion> </Accordion>
<Accordion title="Tags (/tags)"> <Accordion title="POST /favorites/:contentId">
- `GET /tags` : Recherche de tags. Ajoute un contenu aux favoris de l'utilisateur.
- **Params :** `query` (recherche), `sort` (`popular` | `recent`), `limit`, `offset`.
**Réponses :**
- `201 Created` : Favori ajouté.
</Accordion>
<Accordion title="DELETE /favorites/:contentId">
Retire un contenu des favoris de l'utilisateur.
**Réponses :**
- `200 OK` : Favori supprimé.
</Accordion> </Accordion>
</Accordions> </Accordions>
### 🛠️ Système & Médias ### 🚩 Signalements (`/reports`)
<Accordions> <Accordions>
<Accordion title="Santé (/health)"> <Accordion title="POST /reports">
- `GET /health` : Vérifie l'état de l'API et de la connexion à la base de données. Signale un contenu ou un tag pour modération.
**Corps de la requête :**
- `contentId` (uuid, optional) : ID du contenu à signaler.
- `tagId` (uuid, optional) : ID du tag à signaler.
- `reason` : `inappropriate` | `spam` | `copyright` | `other`
- `description` (string, optional, max: 1000)
**Réponses :**
- `201 Created` : Signalement enregistré.
</Accordion> </Accordion>
<Accordion title="Médias (/media)"> <Accordion title="GET /reports">
- `GET /media?path=key` : Accès direct aux fichiers stockés sur S3 via le paramètre `path`. Supporte la mise en cache agressive. Liste les signalements. **Réservé aux administrateurs et modérateurs.**
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
**Réponses :**
- `200 OK` : Liste des signalements.
</Accordion> </Accordion>
<Accordion title="Administration (/admin)"> <Accordion title="PATCH /reports/:id/status">
- `GET /admin/stats` : Récupère les statistiques globales de la plateforme. **Admin uniquement**. Met à jour le statut d'un signalement. **Réservé aux administrateurs et modérateurs.**
**Corps de la requête :**
- `status` : `pending` | `reviewed` | `resolved` | `dismissed`
**Réponses :**
- `200 OK` : Statut mis à jour.
</Accordion>
</Accordions>
### 🔑 Clés API (`/api-keys`)
<Accordions>
<Accordion title="POST /api-keys">
Génère une nouvelle clé API pour l'utilisateur.
**Corps de la requête :**
- `name` (string, max: 128) : Nom descriptif de la clé.
- `expiresAt` (date-string, optional) : Date d'expiration.
**Réponses :**
- `201 Created` : Clé générée. Retourne le token (à conserver précieusement, ne sera plus affiché).
</Accordion>
<Accordion title="GET /api-keys">
Liste toutes les clés API actives de l'utilisateur.
**Réponses :**
- `200 OK` : Liste des métadonnées des clés (nom, date de création, expiration).
</Accordion>
<Accordion title="DELETE /api-keys/:id">
Révoque une clé API spécifique.
**Réponses :**
- `200 OK` : Clé révoquée.
</Accordion>
</Accordions>
### 🏷️ Tags (`/tags`)
<Accordions>
<Accordion title="GET /tags">
Liste les tags populaires ou recherchés. Cache de 5 minutes.
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
- `query` (string, optional) : Recherche par nom.
- `sort` : `popular` | `recent`
**Réponses :**
- `200 OK` : Liste paginée des tags.
</Accordion>
</Accordions>
### 🛠️ Système (`/health`)
<Accordions>
<Accordion title="GET /health">
Vérifie l'état de santé de l'API et de ses dépendances (DB, Redis).
**Réponses :**
- `200 OK` : Système opérationnel.
```json
{
"status": "ok",
"timestamp": "2024-01-21T10:00:00.000Z",
"database": "connected",
"redis": "connected"
}
```
- `503 Service Unavailable` : Problème sur l'un des composants.
</Accordion>
</Accordions>
### 📁 Médias (`/media`)
<Accordions>
<Accordion title="GET /media">
Sert un fichier média stocké sur S3 avec une gestion optimisée du cache.
**Query Params :**
- `path` (string) : Chemin relatif du fichier sur le bucket.
**Réponses :**
- `200 OK` : Flux binaire du fichier. Headers `Content-Type` et `Cache-Control` inclus.
- `404 Not Found` : Fichier introuvable.
</Accordion>
</Accordions>
### 📊 Administration (`/admin`)
<Accordions>
<Accordion title="GET /admin/stats">
Récupère les statistiques globales d'utilisation de la plateforme (**Admin uniquement**).
</Accordion> </Accordion>
</Accordions> </Accordions>

View File

@@ -1,12 +1,13 @@
{ {
"name": "@memegoat/frontend", "name": "@memegoat/frontend",
"version": "1.1.0", "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.0", "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",