64 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
Mathis HERRIOT
d613a89e63 chore: bump version to 1.1.0
All checks were successful
CI/CD Pipeline / Valider frontend (push) Successful in 1m40s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Valider backend (push) Successful in 1m54s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m38s
2026-01-21 09:54:34 +01:00
Mathis HERRIOT
67a10ad7d8 feat(layout): add metadata base URL configuration for dynamic app URL
- Configured `metadataBase` in `RootLayout` using `NEXT_PUBLIC_APP_URL` with a fallback to the default URL.
2026-01-21 09:47:49 +01:00
Mathis HERRIOT
82e98f4fce feat(config): add support for remote image domains in Next.js config
- Enabled `images.remotePatterns` to allow loading images from `memegoat.fr` and `api.memegoat.fr`.
2026-01-21 09:47:19 +01:00
Mathis HERRIOT
70a4249e41 fix(content): update conditional checks to use mimeType for content rendering
- Replaced `type` field checks with `mimeType.startsWith("image/")` for improved accuracy in `content-card` and admin content page components.
- Adjusted `CardContent` background color for better visual consistency.
2026-01-21 09:41:11 +01:00
Mathis HERRIOT
de7d41f4a1 fix(auth): prevent login redirect loop and concurrent refresh calls
- Added check to avoid redirecting to `/login` if already on the login page.
- Prevented multiple simultaneous `refreshUser` calls by adding an `isLoading` guard.
- Improved `useEffect` cleanup in `auth-provider` to handle components unmounting.
2026-01-21 09:40:47 +01:00
Mathis HERRIOT
2da1142866 chore(docker): restrict Postgres port exposure to localhost in production configuration
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m17s
2026-01-20 22:35:42 +01:00
Mathis HERRIOT
4e8e441d98 chore: bump version to 1.0.8
Some checks failed
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Valider backend (push) Successful in 1m19s
CI/CD Pipeline / Déploiement en Production (push) Failing after 1m9s
2026-01-20 22:25:12 +01:00
Mathis HERRIOT
0e83de70e3 chore(build): automate version commit during release process
- Added function to stage and commit version changes automatically for `package.json` files.
- Integrated automated commit step into the release workflow.
2026-01-20 22:25:03 +01:00
Mathis HERRIOT
8169ef719a fix(content): update conditional rendering for type field to use meme instead of image 2026-01-20 22:18:14 +01:00
Mathis HERRIOT
7637499a97 build(release): bump package versions to 1.0.7
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m30s
CI/CD Pipeline / Valider documentation (push) Successful in 1m34s
CI/CD Pipeline / Valider frontend (push) Failing after 1m12s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 22:05:06 +01:00
Mathis HERRIOT
c03ad8c221 fix(content): update type field and conditional rendering for content handling
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m26s
CI/CD Pipeline / Valider frontend (push) Failing after 1m10s
CI/CD Pipeline / Valider documentation (push) Successful in 1m34s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
- Changed `type` field values from `image | video` to `meme | gif`.
- Updated conditional rendering in `content-card` component to match new `type` values.
2026-01-20 22:04:25 +01:00
Mathis HERRIOT
8483927823 fix(tests): replace any with Record<string, unknown> in repository tests
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m32s
- Updated type assertions in repository test files to use `Record<string, unknown>` instead of `any`.
2026-01-20 21:42:16 +01:00
Mathis HERRIOT
e7b79013fd build(release): bump package versions to 1.0.6
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m1s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m45s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 21:28:47 +01:00
Mathis HERRIOT
b6b37ebc6b docs(api): update /media endpoint documentation to use path query parameter
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 56s
CI/CD Pipeline / Valider frontend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m41s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 21:28:23 +01:00
Mathis HERRIOT
d647a585c8 fix(media): handle missing path parameter and improve error logging
- Updated `getFile` method to validate `path` query parameter.
- Added improved logging for file retrieval errors.
- Updated test cases to cover scenarios with missing `path`.
2026-01-20 21:28:10 +01:00
Mathis HERRIOT
6a2abf115f fix(s3): update public URL to include path query parameter
- Updated `getPublicUrl` to use `?path=<key>` format in public URLs.
- Adjusted corresponding test cases to reflect the new URL structure.
2026-01-20 21:27:49 +01:00
Mathis HERRIOT
ded2d3220d build(release): bump package versions to 1.0.5
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m46s
CI/CD Pipeline / Valider documentation (push) Successful in 1m50s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m57s
2026-01-20 20:00:36 +01:00
Mathis HERRIOT
162d53630d refactor(theme): organize imports and format components for consistency
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 2m5s
- Reorganized imports in multiple files for better readability.
- Fixed formatting issues in `mode-toggle` and `settings` components to improve maintainability.
2026-01-20 19:59:16 +01:00
Mathis HERRIOT
0e8a2e3986 build(release): bump package versions to 1.0.4
Some checks failed
CI/CD Pipeline / Valider frontend (push) Failing after 58s
CI/CD Pipeline / Valider backend (push) Successful in 1m33s
CI/CD Pipeline / Valider documentation (push) Successful in 1m38s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 16:28:37 +01:00
Mathis HERRIOT
5cc77ae5b0 feat(theme): add appearance settings and theme provider integration
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m26s
CI/CD Pipeline / Valider frontend (push) Failing after 53s
CI/CD Pipeline / Valider documentation (push) Successful in 1m32s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
- Added theme selection in settings page for light, dark, and system modes.
- Integrated ThemeProvider into application layout.
- Updated dashboard layout to include theme toggle in the header.
2026-01-20 16:27:59 +01:00
Mathis HERRIOT
3b9b73bc4b feat(theme): add theme toggle component and integrate into sidebar 2026-01-20 16:27:24 +01:00
Mathis HERRIOT
a6e34c511e build(release): bump package versions to 1.0.3
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Déploiement en Production (push) Failing after 2m6s
2026-01-20 16:19:18 +01:00
Mathis HERRIOT
13650b6a39 build(docker): add nw_caddy network to production compose file
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider documentation (push) Successful in 1m43s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Déploiement en Production (push) Failing after 1m49s
2026-01-20 16:18:32 +01:00
Mathis HERRIOT
dbe90ae47b build(release): bump package versions to 1.0.2
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m35s
CI/CD Pipeline / Valider documentation (push) Successful in 1m42s
CI/CD Pipeline / Valider frontend (push) Successful in 1m46s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m57s
2026-01-20 15:46:05 +01:00
Mathis HERRIOT
d0c78cb206 refactor(health): use type import for Cache from cache-manager
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 2m2s
2026-01-20 15:45:40 +01:00
Mathis HERRIOT
1c38434b6e build(tsconfig): enable isolatedModules in TypeScript config 2026-01-20 15:43:34 +01:00
Mathis HERRIOT
1666aaadf2 build(release): bump package versions to 1.0.1
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m35s
CI/CD Pipeline / Valider frontend (push) Successful in 1m55s
CI/CD Pipeline / Valider documentation (push) Successful in 1m59s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 15:37:43 +01:00
59 changed files with 2509 additions and 419 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.0.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

@@ -23,7 +23,7 @@ describe("ApiKeysRepository", () => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder // biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", { Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) { value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as any).execute(); const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled); return Promise.resolve(result).then(onFulfilled);
}, },
configurable: true, configurable: true,

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

@@ -24,7 +24,7 @@ describe("CategoriesRepository", () => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder // biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", { Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) { value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as any).execute(); const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled); return Promise.resolve(result).then(onFulfilled);
}, },
configurable: true, configurable: true,

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

@@ -21,10 +21,9 @@ describe("FavoritesRepository", () => {
const wrapWithThen = (obj: unknown) => { const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder // biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
// biome-ignore lint/suspicious/noExplicitAny: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", { Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) { value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as any).execute(); const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled); return Promise.resolve(result).then(onFulfilled);
}, },
configurable: true, configurable: true,

View File

@@ -1,6 +1,6 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Controller, Get, Inject } from "@nestjs/common"; import { Controller, Get, Inject } from "@nestjs/common";
import { Cache } from "cache-manager"; import type { Cache } from "cache-manager";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { DatabaseService } from "./database/database.service"; import { DatabaseService } from "./database/database.service";

View File

@@ -49,6 +49,11 @@ describe("MediaController", () => {
expect(stream.pipe).toHaveBeenCalledWith(res); expect(stream.pipe).toHaveBeenCalledWith(res);
}); });
it("should throw NotFoundException if path is missing", async () => {
const res = {} as unknown as Response;
await expect(controller.getFile("", res)).rejects.toThrow(NotFoundException);
});
it("should throw NotFoundException if file is not found", async () => { it("should throw NotFoundException if file is not found", async () => {
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found")); mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
const res = {} as unknown as Response; const res = {} as unknown as Response;

View File

@@ -1,27 +1,47 @@
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common"; import {
Controller,
Get,
Logger,
NotFoundException,
Query,
Res,
} from "@nestjs/common";
import type { Response } from "express"; import type { Response } from "express";
import type { BucketItemStat } from "minio"; import type { BucketItemStat } from "minio";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
@Controller("media") @Controller("media")
export class MediaController { export class MediaController {
private readonly logger = new Logger(MediaController.name);
constructor(private readonly s3Service: S3Service) {} constructor(private readonly s3Service: S3Service) {}
@Get("*key") @Get()
async getFile(@Param("key") key: string, @Res() res: Response) { async getFile(@Query("path") path: string, @Res() res: Response) {
try { if (!path) {
const stats = (await this.s3Service.getFileInfo(key)) as BucketItemStat; this.logger.warn("Tentative d'accès à un média sans paramètre 'path'");
const stream = await this.s3Service.getFile(key); throw new NotFoundException("Paramètre 'path' manquant");
}
const contentType = try {
stats.metaData?.["content-type"] || "application/octet-stream"; this.logger.log(`Récupération du fichier : ${path}`);
const stats = (await this.s3Service.getFileInfo(path)) as BucketItemStat;
const stream = await this.s3Service.getFile(path);
const contentType: string =
stats.metaData?.["content-type"] ||
stats.metaData?.["Content-Type"] ||
"application/octet-stream";
res.setHeader("Content-Type", contentType); res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", stats.size); res.setHeader("Content-Length", stats.size);
res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
stream.pipe(res); stream.pipe(res);
} catch (_error) { } catch (error) {
this.logger.error(
`Erreur lors de la récupération du fichier ${path} : ${error.message}`,
);
throw new NotFoundException("Fichier non trouvé"); throw new NotFoundException("Fichier non trouvé");
} }
} }

View File

@@ -23,10 +23,9 @@ describe("ReportsRepository", () => {
const wrapWithThen = (obj: unknown) => { const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder // biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
// biome-ignore lint/suspicious/noExplicitAny: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", { Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) { value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as any).execute(); const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled); return Promise.resolve(result).then(onFulfilled);
}, },
configurable: true, configurable: true,

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 () => {
@@ -192,7 +191,7 @@ describe("S3Service", () => {
return null; return null;
}); });
const url = service.getPublicUrl("test.webp"); const url = service.getPublicUrl("test.webp");
expect(url).toBe("https://api.test.com/media/test.webp"); expect(url).toBe("https://api.test.com/media?path=test.webp");
}); });
it("should use DOMAIN_NAME and PORT for localhost", () => { it("should use DOMAIN_NAME and PORT for localhost", () => {
@@ -205,7 +204,7 @@ describe("S3Service", () => {
}, },
); );
const url = service.getPublicUrl("test.webp"); const url = service.getPublicUrl("test.webp");
expect(url).toBe("http://localhost:3000/media/test.webp"); expect(url).toBe("http://localhost:3000/media?path=test.webp");
}); });
it("should use api.DOMAIN_NAME for production", () => { it("should use api.DOMAIN_NAME for production", () => {
@@ -217,7 +216,7 @@ describe("S3Service", () => {
}, },
); );
const url = service.getPublicUrl("test.webp"); const url = service.getPublicUrl("test.webp");
expect(url).toBe("https://api.memegoat.fr/media/test.webp"); expect(url).toBe("https://api.memegoat.fr/media?path=test.webp");
}); });
}); });
}); });

View File

@@ -173,6 +173,6 @@ export class S3Service implements OnModuleInit, IStorageService {
baseUrl = `https://api.${domain}`; baseUrl = `https://api.${domain}`;
} }
return `${baseUrl}/media/${storageKey}`; return `${baseUrl}/media?path=${storageKey}`;
} }
} }

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

@@ -4,7 +4,7 @@
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"resolvePackageJsonExports": true, "resolvePackageJsonExports": true,
"esModuleInterop": true, "esModuleInterop": true,
"isolatedModules": false, "isolatedModules": true,
"declaration": true, "declaration": true,
"removeComments": true, "removeComments": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,

View File

@@ -9,6 +9,8 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-app} POSTGRES_DB: ${POSTGRES_DB:-app}
networks: networks:
- nw_memegoat - nw_memegoat
ports:
- "127.0.0.1:5432:5432" # not exposed to WAN, LAN only for administration checkup
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -35,6 +37,7 @@ services:
restart: always restart: always
networks: networks:
- nw_memegoat - nw_memegoat
- nw_caddy
#ports: #ports:
# - "9000:9000" # - "9000:9000"
# - "9001:9001" # - "9001:9001"

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,16 +44,15 @@ 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 :**
- `200 OK` : Connexion réussie.
```json ```json
{ {
"message": "User logged in successfully", "message": "User logged in successfully",
"userId": "uuid-v4" "userId": "uuid-v4"
} }
``` ```
*Note: L'access_token et le refresh_token sont stockés dans un cookie HttpOnly chiffré.* - `200 OK` (2FA requise) :
**Réponse (2FA requise) :**
```json ```json
{ {
"message": "2FA required", "message": "2FA required",
@@ -55,6 +60,9 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
"userId": "uuid-v4" "userId": "uuid-v4"
} }
``` ```
- `401 Unauthorized` : Identifiants invalides.
*Note: L'access_token et le refresh_token sont stockés dans un cookie HttpOnly chiffré.*
</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/*key` : Accès direct aux fichiers stockés sur S3. 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

@@ -24,7 +24,7 @@ Le système utilise plusieurs méthodes d'authentification sécurisées pour ré
Memegoat utilise une architecture de stockage d'objets compatible S3 (MinIO). Les interactions se font de deux manières : Memegoat utilise une architecture de stockage d'objets compatible S3 (MinIO). Les interactions se font de deux manières :
1. **Proxification Backend** : Pour l'accès public via `/media/*`. 1. **Proxification Backend** : Pour l'accès public via `/media?path=...`.
2. **URLs Présignées** : Pour l'upload sécurisé direct depuis le client (via `/contents/upload-url`). 2. **URLs Présignées** : Pour l'upload sécurisé direct depuis le client (via `/contents/upload-url`).
### Notifications (Mail) ### Notifications (Mail)

View File

@@ -3,6 +3,18 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
reactCompiler: true, reactCompiler: true,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "memegoat.fr",
},
{
protocol: "https",
hostname: "api.memegoat.fr",
},
],
},
output: "standalone", output: "standalone",
}; };

View File

@@ -1,12 +1,13 @@
{ {
"name": "@memegoat/frontend", "name": "@memegoat/frontend",
"version": "1.0.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">
<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} {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>
@@ -98,7 +130,7 @@ export default function AdminContentsPage() {
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted"> <div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
{content.type === "image" ? ( {content.mimeType.startsWith("image/") ? (
<ImageIcon className="h-5 w-5 text-muted-foreground" /> <ImageIcon className="h-5 w-5 text-muted-foreground" />
) : ( ) : (
<Video className="h-5 w-5 text-muted-foreground" /> <Video className="h-5 w-5 text-muted-foreground" />
@@ -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>
>
<Trash2 className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger>
<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>
>
<Trash2 className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger>
<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

@@ -1,12 +1,14 @@
import * as React from "react"; import * as React from "react";
import { AppSidebar } from "@/components/app-sidebar"; import { AppSidebar } from "@/components/app-sidebar";
import { MobileFilters } from "@/components/mobile-filters"; import { MobileFilters } from "@/components/mobile-filters";
import { ModeToggle } from "@/components/mode-toggle";
import { SearchSidebar } from "@/components/search-sidebar"; import { SearchSidebar } from "@/components/search-sidebar";
import { import {
SidebarInset, SidebarInset,
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({
@@ -25,9 +27,14 @@ 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">
<ModeToggle />
<UserNavMobile /> <UserNavMobile />
</div>
</header> </header>
<main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950"> <main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950">
{children} {children}
@@ -42,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

@@ -1,11 +1,35 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Save, User as UserIcon } from "lucide-react"; import {
AlertTriangle,
Laptop,
Loader2,
Moon,
Palette,
Save,
Settings,
Sun,
Trash2,
User as UserIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
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,
@@ -24,6 +48,8 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
@@ -37,8 +63,16 @@ const settingsSchema = z.object({
type SettingsFormValues = z.infer<typeof settingsSchema>; type SettingsFormValues = z.infer<typeof settingsSchema>;
export default function SettingsPage() { export default function SettingsPage() {
const { user, isLoading, refreshUser } = useAuth(); const { theme, setTheme } = useTheme();
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);
React.useEffect(() => {
setMounted(true);
}, []);
const form = useForm<SettingsFormValues>({ const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema), resolver: zodResolver(settingsSchema),
@@ -94,18 +128,37 @@ 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">
<CardHeader className="pb-4">
<div className="flex items-center gap-2 mb-1">
<UserIcon className="h-5 w-5 text-primary" />
<CardTitle>Informations personnelles</CardTitle> <CardTitle>Informations personnelles</CardTitle>
</div>
<CardDescription> <CardDescription>
Mettez à jour vos informations publiques. Ces données seront visibles par Mettez à jour vos informations publiques. Ces données seront visibles par
les autres utilisateurs. les autres utilisateurs.
@@ -114,19 +167,18 @@ export default function SettingsPage() {
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-4"> <div className="grid gap-6">
<div className="grid sm:grid-cols-2 gap-4">
<FormItem> <FormItem>
<FormLabel>Nom d'utilisateur</FormLabel> <FormLabel>Nom d'utilisateur</FormLabel>
<FormControl> <FormControl>
<Input <Input
value={user.username} value={user.username}
disabled disabled
className="bg-zinc-50 dark:bg-zinc-900" className="bg-muted cursor-not-allowed"
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>Identifiant unique non modifiable.</FormDescription>
Le nom d'utilisateur ne peut pas être modifié.
</FormDescription>
</FormItem> </FormItem>
<FormField <FormField
@@ -138,13 +190,12 @@ export default function SettingsPage() {
<FormControl> <FormControl>
<Input placeholder="Votre nom" {...field} /> <Input placeholder="Votre nom" {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>Nom visible sur votre profil.</FormDescription>
Le nom qui sera affiché sur votre profil et vos mèmes.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div>
<FormField <FormField
control={form.control} control={form.control}
@@ -155,7 +206,7 @@ export default function SettingsPage() {
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="Racontez-nous quelque chose sur vous..." placeholder="Racontez-nous quelque chose sur vous..."
className="resize-none" className="resize-none min-h-[100px]"
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -168,7 +219,8 @@ export default function SettingsPage() {
/> />
</div> </div>
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto"> <div className="flex justify-end border-t pt-6">
<Button type="submit" disabled={isSaving} className="min-w-[150px]">
{isSaving ? ( {isSaving ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -177,14 +229,124 @@ export default function SettingsPage() {
) : ( ) : (
<> <>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
Enregistrer les modifications Enregistrer
</> </>
)} )}
</Button> </Button>
</div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </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 className="relative">
<RadioGroupItem value="dark" id="dark" className="peer sr-only" />
<Label
htmlFor="dark"
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>
</Label>
</div>
<div className="relative">
<RadioGroupItem value="system" id="system" className="peer sr-only" />
<Label
htmlFor="system"
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"
>
<Laptop className="mb-3 h-6 w-6" />
<span className="text-sm font-semibold">Système</span>
</Label>
</div>
</RadioGroup>
</CardContent>
</Card>
<Card className="border-destructive/20 shadow-sm bg-destructive/5">
<CardHeader className="pb-4">
<div className="flex items-center gap-2 mb-1">
<AlertTriangle className="h-5 w-5 text-destructive" />
<CardTitle className="text-destructive">Zone de danger</CardTitle>
</div>
<CardDescription className="text-destructive/80 font-medium">
Actions irréversibles concernant votre compte.
</CardDescription>
</CardHeader>
<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">
<div className="space-y-1">
<p className="font-bold">Supprimer mon compte</p>
<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>
</CardContent>
</Card>
</div>
</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

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google"; import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { AuthProvider } from "@/providers/auth-provider"; import { AuthProvider } from "@/providers/auth-provider";
import { ThemeProvider } from "@/providers/theme-provider";
import "./globals.css"; import "./globals.css";
const ubuntuSans = Ubuntu_Sans({ const ubuntuSans = Ubuntu_Sans({
@@ -48,6 +49,9 @@ export const metadata: Metadata = {
images: ["/memegoat-og.png"], images: ["/memegoat-og.png"],
}, },
icons: "/memegoat-color.svg", icons: "/memegoat-color.svg",
metadataBase: new URL(
process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.fr",
),
}; };
export default function RootLayout({ export default function RootLayout({
@@ -59,11 +63,18 @@ export default function RootLayout({
<html lang="fr" suppressHydrationWarning> <html lang="fr" suppressHydrationWarning>
<body <body
className={`${ubuntuSans.variable} ${ubuntuMono.variable} antialiased`} className={`${ubuntuSans.variable} ${ubuntuMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
> >
<AuthProvider> <AuthProvider>
{children} {children}
<Toaster /> <Toaster />
</AuthProvider> </AuthProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@@ -19,6 +19,7 @@ import {
import Link from "next/link"; import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { ModeToggle } from "@/components/mode-toggle";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
Collapsible, Collapsible,
@@ -286,6 +287,14 @@ export function AppSidebar() {
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
)} )}
<SidebarMenuItem>
<div className="flex items-center justify-between px-2 py-2">
<span className="text-xs font-medium text-muted-foreground group-data-[collapsible=icon]:hidden">
Thème
</span>
<ModeToggle />
</div>
</SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Aide"> <SidebarMenuButton asChild tooltip="Aide">
<Link href="/help"> <Link href="/help">

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,12 +88,32 @@ export function ContentCard({ content }: ContentCardProps) {
} }
}; };
const handleDelete = async () => {
if (!confirm("Êtes-vous sûr de vouloir supprimer ce mème ?")) return;
try {
await ContentService.remove(content.id);
toast.success("Mème supprimé !");
if (onUpdate) {
onUpdate();
} else {
// Si pas de onUpdate, on est probablement sur la page de détail
router.push("/");
}
} catch (_error) {
toast.error("Erreur lors de la suppression.");
}
};
return ( return (
<>
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow"> <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"> <CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage src={content.author.avatarUrl} /> <AvatarImage src={content.author.avatarUrl} />
<AvatarFallback>{content.author.username[0].toUpperCase()}</AvatarFallback> <AvatarFallback>
{content.author.username[0].toUpperCase()}
</AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col"> <div className="flex flex-col">
<Link <Link
@@ -89,19 +126,48 @@ export function ContentCard({ content }: ContentCardProps) {
{new Date(content.createdAt).toLocaleDateString("fr-FR")} {new Date(content.createdAt).toLocaleDateString("fr-FR")}
</span> </span>
</div> </div>
<Button variant="ghost" size="icon" className="ml-auto h-8 w-8">
<div className="ml-auto flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </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> </CardHeader>
<CardContent className="p-0 relative bg-zinc-100 dark:bg-zinc-900 aspect-square flex items-center justify-center"> <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"> <Link href={`/meme/${content.slug}`} className="w-full h-full relative">
{content.type === "image" ? ( {content.mimeType.startsWith("image/") ? (
<Image <Image
src={content.url} src={content.url}
alt={content.title} alt={content.title}
fill fill
className="object-contain" className="object-contain"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={false}
/> />
) : ( ) : (
<video <video
@@ -110,6 +176,7 @@ export function ContentCard({ content }: ContentCardProps) {
autoPlay autoPlay
muted muted
loop loop
playsInline
className="w-full h-full object-contain" className="w-full h-full object-contain"
/> />
)} )}
@@ -118,27 +185,60 @@ export function ContentCard({ content }: ContentCardProps) {
<CardFooter className="p-4 flex flex-col gap-4"> <CardFooter className="p-4 flex flex-col gap-4">
<div className="w-full flex items-center justify-between"> <div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className={`gap-1.5 h-8 ${isLiked ? "text-red-500 hover:text-red-600" : ""}`} 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} onClick={handleLike}
> >
<Heart className={`h-4 w-4 ${isLiked ? "fill-current" : ""}`} /> <Heart className={`h-4 w-4 ${isLiked ? "fill-current" : ""}`} />
<span className="text-xs">{likesCount}</span> <span className="text-xs font-medium">{likesCount}</span>
</Button> </Button>
<Button variant="ghost" size="sm" className="gap-1.5 h-8"> </TooltipTrigger>
<Eye className="h-4 w-4" /> <TooltipContent>Liker</TooltipContent>
<span className="text-xs">{content.views}</span> </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> </Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> </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" /> <Share2 className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent>Partager</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
className="text-xs h-8" className="text-xs h-8 font-semibold"
onClick={handleUse} onClick={handleUse}
> >
Utiliser Utiliser
@@ -146,13 +246,22 @@ export function ContentCard({ content }: ContentCardProps) {
</div> </div>
<div className="w-full space-y-2"> <div className="w-full space-y-2">
<h3 className="font-medium text-sm line-clamp-2">{content.title}</h3> <Link href={`/meme/${content.slug}`}>
<div className="flex flex-wrap gap-1"> <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) => ( {content.tags.slice(0, 3).map((tag, _i) => (
<Badge <Badge
key={typeof tag === "string" ? tag : tag.id} key={typeof tag === "string" ? tag : tag.id}
variant="secondary" variant="secondary"
className="text-[10px] py-0 px-1.5" className="text-[10px] py-0 px-2"
> >
#{typeof tag === "string" ? tag : tag.name} #{typeof tag === "string" ? tag : tag.name}
</Badge> </Badge>
@@ -161,5 +270,12 @@ export function ContentCard({ content }: ContentCardProps) {
</div> </div>
</CardFooter> </CardFooter>
</Card> </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,34 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Changer le thème</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Clair</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Sombre</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
Système
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

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

@@ -53,8 +53,11 @@ api.interceptors.response.use(
} catch (refreshError) { } catch (refreshError) {
// If refresh fails, we might want to redirect to login on the client // If refresh fails, we might want to redirect to login on the client
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
// On évite de rediriger vers login si on y est déjà pour éviter les boucles
if (!window.location.pathname.includes("/login")) {
window.location.href = "/login"; window.location.href = "/login";
} }
}
return Promise.reject(refreshError); return Promise.reject(refreshError);
} }
} }

View File

@@ -26,6 +26,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
const refreshUser = React.useCallback(async () => { const refreshUser = React.useCallback(async () => {
// Éviter de lancer plusieurs refresh en même temps
if (!isLoading) setIsLoading(true);
try { try {
const userData = await UserService.getMe(); const userData = await UserService.getMe();
setUser(userData); setUser(userData);
@@ -34,11 +36,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, [isLoading]);
React.useEffect(() => { React.useEffect(() => {
refreshUser(); let isMounted = true;
}, [refreshUser]); const initAuth = async () => {
try {
const userData = await UserService.getMe();
if (isMounted) setUser(userData);
} catch (_error) {
if (isMounted) setUser(null);
} finally {
if (isMounted) setIsLoading(false);
}
};
initAuth();
return () => {
isMounted = false;
};
}, []);
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
try { try {

View File

@@ -0,0 +1,11 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type * as React from "react";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

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

@@ -7,7 +7,7 @@ export interface Content {
description?: string; description?: string;
url: string; url: string;
thumbnailUrl?: string; thumbnailUrl?: string;
type: "image" | "video"; type: "meme" | "gif";
mimeType: string; mimeType: string;
size: number; size: number;
width?: number; width?: number;
@@ -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.0.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",

View File

@@ -39,6 +39,42 @@ function(increment_version CURRENT_VERSION TYPE OUT_VAR)
set(${OUT_VAR} "${MAJOR}.${MINOR}.${PATCH}" PARENT_SCOPE) set(${OUT_VAR} "${MAJOR}.${MINOR}.${PATCH}" PARENT_SCOPE)
endfunction() endfunction()
# Fonction pour créer un commit git pour les changements de version
function(commit_version_changes VERSION)
find_package(Git QUIET)
if(GIT_FOUND)
# On n'ajoute que les fichiers package.json modifiés
set(ADDED_ANY FALSE)
foreach(JSON_FILE ${PACKAGE_JSON_FILES})
if(EXISTS "${JSON_FILE}")
execute_process(
COMMAND ${GIT_EXECUTABLE} add "${JSON_FILE}"
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
)
set(ADDED_ANY TRUE)
endif()
endforeach()
if(ADDED_ANY)
# On commit uniquement les fichiers qui ont été ajoutés (staged)
# L'utilisation de --only ou spécifier les fichiers à nouveau assure qu'on ne prend pas d'autres changements
execute_process(
COMMAND ${GIT_EXECUTABLE} commit -m "chore: bump version to ${VERSION}" -- ${PACKAGE_JSON_FILES}
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
RESULT_VARIABLE COMMIT_RESULT
)
if(COMMIT_RESULT EQUAL 0)
message(STATUS "Changements commités avec succès pour la version ${VERSION}")
else()
message(WARNING "Échec du commit des changements. Il n'y a peut-être rien à commiter ou aucun changement sur les fichiers JSON.")
endif()
endif()
else()
message(WARNING "Git non trouvé, impossible de commiter les changements.")
endif()
endfunction()
# Fonction pour créer un tag git # Fonction pour créer un tag git
function(create_git_tag VERSION) function(create_git_tag VERSION)
find_package(Git QUIET) find_package(Git QUIET)
@@ -73,6 +109,9 @@ function(set_new_version NEW_VERSION)
endif() endif()
endforeach() endforeach()
# Commiter les changements
commit_version_changes(${NEW_VERSION})
# Créer le tag git # Créer le tag git
create_git_tag(${NEW_VERSION}) create_git_tag(${NEW_VERSION})
endfunction() endfunction()