Compare commits

..

13 Commits

Author SHA1 Message Date
Mathis HERRIOT
77ac960411 feat(ci): add GitHub Actions workflow for production deployment
Some checks failed
Backend Tests / test (push) Failing after 4m47s
Lint / lint (push) Failing after 4m48s
Introduce `deploy.yml` to automate deployment to production on `prod` branch push. Includes setup for Node.js, pnpm caching, linting, building, and Docker Compose deployment. Update `docker-compose.prod.yml` to use environment variables for enhanced configurability.
2026-01-14 16:44:03 +01:00
Mathis HERRIOT
8425ffe4fc refactor!: remove pnpm-lock.yaml file from the repository to eliminate redundant dependency tracking and streamline package management. 2026-01-14 16:38:26 +01:00
Mathis HERRIOT
b81835661c feat(docker): add production-ready docker-compose configuration with health checks
Introduce a new `docker-compose.prod.yml` file tailored for production setups. Add health checks for PostgreSQL, Redis, and ClamAV services. Update existing `docker-compose.yml` with health checks, environment refinements, service dependencies, and production-specific optimizations.
2026-01-14 16:38:07 +01:00
Mathis HERRIOT
fbc231dc9a refactor!: remove unused resizable components and enhance efficiency in critical areas
Remove outdated `ResizablePanelGroup`, `ResizablePanel`, and `ResizableHandle` components from the codebase. Optimize API error handling with anti-spam protection for repetitive errors. Update Dockerfile for streamlined builds, improve sitemap generation in `app`, and refactor lazy loading using `React.Suspense`. Refine services to support enhanced query parameters like `author`.
2026-01-14 16:37:55 +01:00
Mathis HERRIOT
37a23390d5 refactor: enhance module exports and imports across services
Refactor multiple modules to improve dependency management by adding missing imports (e.g., `AuthModule`, `CryptoModule`) and ensuring essential services and repositories are exported. Update Dockerfile for better build and runtime efficiency, improve CORS handling, and enhance validation with updates to DTOs. Include package.json refinements for dependency organization.
2026-01-14 16:36:59 +01:00
Mathis HERRIOT
bd9dd140ab **feat(docker): add docker-compose for multi-service setup**
Introduce `docker-compose.yml` to orchestrate services for local development. Include configurations for PostgreSQL, Redis, MinIO (S3), Mailpit, backend, and frontend. Simplify setup with predefined environment variables, volumes, and dependencies, ensuring streamlined service management and integration.
2026-01-14 13:53:02 +01:00
Mathis HERRIOT
5b6e0143b6 feat: add comprehensive database migration snapshot
Introduce a detailed database schema migration snapshot, including tables such as `users`, `categories`, `contents`, `tags`, `favorites`, `roles`, `permissions`, and more. Adds relationships, indexes, unique constraints, and primary keys to ensure optimal structure and query efficiency.
2026-01-14 13:52:53 +01:00
Mathis HERRIOT
214bf077e5 feat: add axios and related dependencies to pnpm lockfile
Includes `axios@1.13.2` and its dependencies (`follow-redirects` and `proxy-from-env`) for enhanced HTTP request handling.
2026-01-14 13:52:43 +01:00
Mathis HERRIOT
bb9ae058db refactor(docker): update Dockerfile for optimized multi-stage builds and dependency management
Switched to pnpm-based alpine image, added dependency and build stages, and improved caching. Streamlined Next.js production setup with reduced image size and improved runtime performance.
2026-01-14 13:52:20 +01:00
Mathis HERRIOT
0b07320974 feat: introduce new app routes with modular structure and enhanced features
Added modular app routes including `login`, `dashboard`, `categories`, `trends`, and `upload`. Introduced reusable components such as `ContentList`, `ContentSkeleton`, and `AppSidebar` for improved UI consistency. Enhanced authentication with `AuthProvider` and implemented lazy loading, dynamic layouts, and infinite scrolling for better performance.
2026-01-14 13:52:08 +01:00
Mathis HERRIOT
0c045e8d3c refactor: remove unused tests, mocks, and outdated e2e configurations
Deleted unused e2e tests, mocks (`cuid2`, `jose`, `ml-kem`, `sha3`), and their associated jest configurations. Simplified services by ensuring proper dependency imports, resolving circular references, and improving TypeScript type usage for enhanced maintainability and testability. Upgraded Dockerfile base image to match new development standards.
2026-01-14 13:51:32 +01:00
Mathis HERRIOT
8ffeaeba05 feat: update .env.example with refined defaults for services
Revise `.env.example` file to include updated configurations for database, Redis, S3 storage, and mail services. Add missing variables for enhanced security and better local environment setup.
2026-01-14 13:50:43 +01:00
Mathis HERRIOT
9e37272bff feat: add initial database schema with migrations
Introduce foundational database schema with tables for `users`, `categories`, `contents`, `tags`, and `favorites`. Add foreign key relationships, constraints, and indexes for efficient querying.
2026-01-14 13:04:27 +01:00
111 changed files with 8010 additions and 2692 deletions

View File

@@ -8,32 +8,40 @@ BACKEND_PORT=3001
FRONTEND_PORT=3000
# Database (PostgreSQL)
POSTGRES_HOST=localhost
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_DB=memegoat
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=app
# Storage (S3/MinIO) - À configurer lors de l'implémentation
# S3_ENDPOINT=localhost
# S3_PORT=9000
# S3_ACCESS_KEY=
# S3_SECRET_KEY=
# S3_BUCKET=memegoat
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
# Security (PGP & Auth) - À configurer lors de l'implémentation
# PGP_PASSPHRASE=
JWT_SECRET=super-secret-key-change-me-in-production
ENCRYPTION_KEY=another-super-secret-key-32-chars
# Storage (S3/MinIO)
S3_ENDPOINT=s3
S3_PORT=9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET_NAME=memegoat
# Security
JWT_SECRET=super-secret-jwt-key-change-me-in-prod
ENCRYPTION_KEY=01234567890123456789012345678901
PGP_ENCRYPTION_KEY=super-secret-pgp-key
SESSION_PASSWORD=super-secret-session-password-32-chars
# Mail
MAIL_HOST=localhost
MAIL_HOST=mail
MAIL_PORT=1025
MAIL_SECURE=false
MAIL_USER=user
MAIL_PASS=password
MAIL_FROM=noreply@memegoat.fr
DOMAIN_NAME=memegoat.fr
MAIL_USER=
MAIL_PASS=
MAIL_FROM=noreply@memegoat.local
DOMAIN_NAME=localhost
ENABLE_CORS=false
CORS_DOMAIN_NAME=localhost
# Media Limits (in KB)
MAX_IMAGE_SIZE_KB=512

View File

@@ -0,0 +1,73 @@
name: Deploy to Production
on:
push:
branches:
- prod
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITEA_ENV
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Lint all projects
run: pnpm run lint
- name: Build all projects
run: pnpm run build
- name: Deploy with Docker Compose
run: |
docker compose -f docker-compose.prod.yml up -d --build
env:
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PORT: ${{ secrets.REDIS_PORT }}
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_PORT: ${{ secrets.S3_PORT }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
PGP_ENCRYPTION_KEY: ${{ secrets.PGP_ENCRYPTION_KEY }}
SESSION_PASSWORD: ${{ secrets.SESSION_PASSWORD }}
MAIL_HOST: ${{ secrets.MAIL_HOST }}
MAIL_PASS: ${{ secrets.MAIL_PASS }}
MAIL_USER: ${{ secrets.MAIL_USER }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Dependencies
node_modules/
jspm_packages/
.pnpm-store
# Environment variables
.env

View File

@@ -0,0 +1,177 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TYPE "public"."user_status" AS ENUM('active', 'verification', 'suspended', 'pending', 'deleted');--> statement-breakpoint
CREATE TYPE "public"."content_type" AS ENUM('meme', 'gif');--> statement-breakpoint
CREATE TYPE "public"."report_reason" AS ENUM('inappropriate', 'spam', 'copyright', 'other');--> statement-breakpoint
CREATE TYPE "public"."report_status" AS ENUM('pending', 'reviewed', 'resolved', 'dismissed');--> statement-breakpoint
CREATE TABLE "users" (
"uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"status" "user_status" DEFAULT 'pending' NOT NULL,
"email" "bytea" NOT NULL,
"email_hash" varchar(64) NOT NULL,
"display_name" varchar(32),
"username" varchar(32) NOT NULL,
"password_hash" varchar(72) NOT NULL,
"two_factor_secret" "bytea",
"is_two_factor_enabled" boolean DEFAULT false NOT NULL,
"terms_version" varchar(16),
"privacy_version" varchar(16),
"gdpr_accepted_at" timestamp with time zone,
"last_login_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
CONSTRAINT "users_email_hash_unique" UNIQUE("email_hash"),
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
CREATE TABLE "permissions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(64) NOT NULL,
"slug" varchar(64) NOT NULL,
"description" varchar(128),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "permissions_name_unique" UNIQUE("name"),
CONSTRAINT "permissions_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "roles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(64) NOT NULL,
"slug" varchar(64) NOT NULL,
"description" varchar(128),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "roles_name_unique" UNIQUE("name"),
CONSTRAINT "roles_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "roles_to_permissions" (
"role_id" uuid NOT NULL,
"permission_id" uuid NOT NULL,
CONSTRAINT "roles_to_permissions_role_id_permission_id_pk" PRIMARY KEY("role_id","permission_id")
);
--> statement-breakpoint
CREATE TABLE "users_to_roles" (
"user_id" uuid NOT NULL,
"role_id" uuid NOT NULL,
CONSTRAINT "users_to_roles_user_id_role_id_pk" PRIMARY KEY("user_id","role_id")
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"refresh_token" varchar(512) NOT NULL,
"user_agent" varchar(255),
"ip_hash" varchar(64),
"is_valid" boolean DEFAULT true NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sessions_refresh_token_unique" UNIQUE("refresh_token")
);
--> statement-breakpoint
CREATE TABLE "api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"key_hash" varchar(128) NOT NULL,
"name" varchar(128) NOT NULL,
"prefix" varchar(8) NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"last_used_at" timestamp with time zone,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash")
);
--> statement-breakpoint
CREATE TABLE "tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(64) NOT NULL,
"slug" varchar(64) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tags_name_unique" UNIQUE("name"),
CONSTRAINT "tags_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "contents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"type" "content_type" NOT NULL,
"title" varchar(255) NOT NULL,
"storage_key" varchar(512) NOT NULL,
"mime_type" varchar(128) NOT NULL,
"file_size" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
CONSTRAINT "contents_storage_key_unique" UNIQUE("storage_key")
);
--> statement-breakpoint
CREATE TABLE "contents_to_tags" (
"content_id" uuid NOT NULL,
"tag_id" uuid NOT NULL,
CONSTRAINT "contents_to_tags_content_id_tag_id_pk" PRIMARY KEY("content_id","tag_id")
);
--> statement-breakpoint
CREATE TABLE "reports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"reporter_id" uuid NOT NULL,
"content_id" uuid,
"tag_id" uuid,
"reason" "report_reason" NOT NULL,
"description" text,
"status" "report_status" DEFAULT 'pending' NOT NULL,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audit_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid,
"action" varchar(64) NOT NULL,
"entity_type" varchar(64) NOT NULL,
"entity_id" uuid,
"details" jsonb,
"ip_hash" varchar(64),
"user_agent" varchar(255),
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "roles_to_permissions" ADD CONSTRAINT "roles_to_permissions_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "roles_to_permissions" ADD CONSTRAINT "roles_to_permissions_permission_id_permissions_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."permissions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users_to_roles" ADD CONSTRAINT "users_to_roles_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users_to_roles" ADD CONSTRAINT "users_to_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contents" ADD CONSTRAINT "contents_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contents_to_tags" ADD CONSTRAINT "contents_to_tags_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contents_to_tags" ADD CONSTRAINT "contents_to_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reports" ADD CONSTRAINT "reports_reporter_id_users_uuid_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reports" ADD CONSTRAINT "reports_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reports" ADD CONSTRAINT "reports_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "users_uuid_idx" ON "users" USING btree ("uuid");--> statement-breakpoint
CREATE INDEX "users_email_hash_idx" ON "users" USING btree ("email_hash");--> statement-breakpoint
CREATE INDEX "users_username_idx" ON "users" USING btree ("username");--> statement-breakpoint
CREATE INDEX "users_status_idx" ON "users" USING btree ("status");--> statement-breakpoint
CREATE INDEX "permissions_slug_idx" ON "permissions" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "roles_slug_idx" ON "roles" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "sessions_refresh_token_idx" ON "sessions" USING btree ("refresh_token");--> statement-breakpoint
CREATE INDEX "sessions_expires_at_idx" ON "sessions" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "api_keys_user_id_idx" ON "api_keys" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "api_keys_key_hash_idx" ON "api_keys" USING btree ("key_hash");--> statement-breakpoint
CREATE INDEX "tags_slug_idx" ON "tags" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "contents_user_id_idx" ON "contents" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "contents_storage_key_idx" ON "contents" USING btree ("storage_key");--> statement-breakpoint
CREATE INDEX "contents_deleted_at_idx" ON "contents" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "reports_reporter_id_idx" ON "reports" USING btree ("reporter_id");--> statement-breakpoint
CREATE INDEX "reports_content_id_idx" ON "reports" USING btree ("content_id");--> statement-breakpoint
CREATE INDEX "reports_tag_id_idx" ON "reports" USING btree ("tag_id");--> statement-breakpoint
CREATE INDEX "reports_status_idx" ON "reports" USING btree ("status");--> statement-breakpoint
CREATE INDEX "reports_expires_at_idx" ON "reports" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "audit_logs_action_idx" ON "audit_logs" USING btree ("action");--> statement-breakpoint
CREATE INDEX "audit_logs_entity_idx" ON "audit_logs" USING btree ("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs" USING btree ("created_at");

View File

@@ -0,0 +1,30 @@
CREATE TABLE "categories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(64) NOT NULL,
"slug" varchar(64) NOT NULL,
"description" varchar(255),
"icon_url" varchar(512),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "categories_name_unique" UNIQUE("name"),
CONSTRAINT "categories_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "favorites" (
"user_id" uuid NOT NULL,
"content_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "favorites_user_id_content_id_pk" PRIMARY KEY("user_id","content_id")
);
--> statement-breakpoint
ALTER TABLE "tags" ADD COLUMN "user_id" uuid;--> statement-breakpoint
ALTER TABLE "contents" ADD COLUMN "category_id" uuid;--> statement-breakpoint
ALTER TABLE "contents" ADD COLUMN "slug" varchar(255) NOT NULL;--> statement-breakpoint
ALTER TABLE "contents" ADD COLUMN "views" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "contents" ADD COLUMN "usage_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "categories_slug_idx" ON "categories" USING btree ("slug");--> statement-breakpoint
ALTER TABLE "tags" ADD CONSTRAINT "tags_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contents" ADD CONSTRAINT "contents_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contents" ADD CONSTRAINT "contents_slug_unique" UNIQUE("slug");

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "avatar_url" varchar(255);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1767618753676,
"tag": "0000_right_sally_floyd",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1768392191169,
"tag": "0001_purple_goliath",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1768393637823,
"tag": "0002_redundant_skin",
"breakpoints": true
}
]
}

View File

@@ -1,17 +1,26 @@
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM base AS build
WORKDIR /usr/src/app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/
RUN pnpm install --no-frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
RUN pnpm install --no-frozen-lockfile
RUN pnpm run --filter @memegoat/backend build
RUN pnpm deploy --filter=@memegoat/backend --prod /app
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
RUN cp -r backend/dist /app/dist
RUN cp -r backend/.migrations /app/.migrations
FROM base AS runtime
WORKDIR /app
COPY --from=build /app .
EXPOSE 3000
CMD [ "node", "dist/main" ]
ENV NODE_ENV=production
CMD [ "node", "dist/src/main" ]

View File

@@ -6,7 +6,9 @@
"private": true,
"license": "UNLICENSED",
"files": [
"dist"
"dist",
".migrations",
"drizzle.config.ts"
],
"scripts": {
"build": "nest build",
@@ -60,24 +62,11 @@
"rxjs": "^7.8.1",
"sharp": "^0.34.5",
"uuid": "^13.0.0",
"zod": "^4.3.5"
"zod": "^4.3.5",
"drizzle-kit": "^0.31.8"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/jest": "^30.0.0",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.6",
"@types/sharp": "^0.32.0",
"@types/supertest": "^6.0.2",
"@types/uuid": "^11.0.0",
"drizzle-kit": "^0.31.8",
"globals": "^16.0.0",
"jest": "^30.0.0",
"source-map-support": "^0.5.21",
@@ -87,6 +76,12 @@
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},

View File

@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
@@ -7,9 +7,9 @@ import { ApiKeysService } from "./api-keys.service";
import { ApiKeysRepository } from "./repositories/api-keys.repository";
@Module({
imports: [DatabaseModule, AuthModule, CryptoModule],
imports: [DatabaseModule, forwardRef(() => AuthModule), CryptoModule],
controllers: [ApiKeysController],
providers: [ApiKeysService, ApiKeysRepository],
exports: [ApiKeysService],
exports: [ApiKeysService, ApiKeysRepository],
})
export class ApiKeysModule {}

View File

@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { SessionsModule } from "../sessions/sessions.module";
@@ -9,9 +9,14 @@ import { RbacService } from "./rbac.service";
import { RbacRepository } from "./repositories/rbac.repository";
@Module({
imports: [UsersModule, CryptoModule, SessionsModule, DatabaseModule],
imports: [
forwardRef(() => UsersModule),
CryptoModule,
SessionsModule,
DatabaseModule,
],
controllers: [AuthController],
providers: [AuthService, RbacService, RbacRepository],
exports: [AuthService, RbacService],
exports: [AuthService, RbacService, RbacRepository],
})
export class AuthModule {}

View File

@@ -1,5 +1,7 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
UnauthorizedException,
@@ -19,6 +21,7 @@ export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
private readonly hashingService: HashingService,
private readonly jwtService: JwtService,

View File

@@ -1,10 +1,21 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from "class-validator";
import {
IsEmail,
IsNotEmpty,
IsString,
MaxLength,
MinLength,
} from "class-validator";
export class RegisterDto {
@IsString()
@IsNotEmpty()
@MaxLength(32)
username!: string;
@IsString()
@MaxLength(32)
displayName?: string;
@IsEmail()
email!: string;

View File

@@ -48,7 +48,9 @@ describe("RbacService", () => {
it("should return user permissions", async () => {
const userId = "user-id";
const mockPermissions = ["read", "write"];
mockRbacRepository.findPermissionsByUserId.mockResolvedValue(mockPermissions);
mockRbacRepository.findPermissionsByUserId.mockResolvedValue(
mockPermissions,
);
const result = await service.getUserPermissions(userId);

View File

@@ -1,3 +1,4 @@
import { CacheInterceptor, CacheKey, CacheTTL } from "@nestjs/cache-manager";
import {
Body,
Controller,
@@ -9,7 +10,6 @@ import {
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { CacheInterceptor, CacheKey, CacheTTL } from "@nestjs/cache-manager";
import { Roles } from "../auth/decorators/roles.decorator";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";

View File

@@ -1,13 +1,15 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
import { CategoriesRepository } from "./repositories/categories.repository";
@Module({
imports: [DatabaseModule],
imports: [DatabaseModule, AuthModule, CryptoModule],
controllers: [CategoriesController],
providers: [CategoriesService, CategoriesRepository],
exports: [CategoriesService],
exports: [CategoriesService, CategoriesRepository],
})
export class CategoriesModule {}

View File

@@ -1,9 +1,9 @@
import { Test, TestingModule } from "@nestjs/testing";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { CategoriesService } from "./categories.service";
import { CategoriesRepository } from "./repositories/categories.repository";
import { CreateCategoryDto } from "./dto/create-category.dto";
import { UpdateCategoryDto } from "./dto/update-category.dto";
import { CategoriesRepository } from "./repositories/categories.repository";
describe("CategoriesService", () => {
let service: CategoriesService;
@@ -75,7 +75,9 @@ describe("CategoriesService", () => {
describe("create", () => {
it("should create a category and generate slug", async () => {
const dto: CreateCategoryDto = { name: "Test Category" };
mockCategoriesRepository.create.mockResolvedValue([{ ...dto, slug: "test-category" }]);
mockCategoriesRepository.create.mockResolvedValue([
{ ...dto, slug: "test-category" },
]);
const result = await service.create(dto);
@@ -91,7 +93,9 @@ describe("CategoriesService", () => {
it("should update a category and regenerate slug", async () => {
const id = "1";
const dto: UpdateCategoryDto = { name: "New Name" };
mockCategoriesRepository.update.mockResolvedValue([{ id, ...dto, slug: "new-name" }]);
mockCategoriesRepository.update.mockResolvedValue([
{ id, ...dto, slug: "new-name" },
]);
const result = await service.update(id, dto);

View File

@@ -1,9 +1,9 @@
import { Injectable, Logger, Inject } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";
import { CategoriesRepository } from "./repositories/categories.repository";
import { Inject, Injectable, Logger } from "@nestjs/common";
import type { Cache } from "cache-manager";
import { CreateCategoryDto } from "./dto/create-category.dto";
import { UpdateCategoryDto } from "./dto/update-category.dto";
import { CategoriesRepository } from "./repositories/categories.repository";
@Injectable()
export class CategoriesService {

View File

@@ -33,7 +33,10 @@ export class CategoriesRepository {
.returning();
}
async update(id: string, data: UpdateCategoryDto & { slug?: string; updatedAt: Date }) {
async update(
id: string,
data: UpdateCategoryDto & { slug?: string; updatedAt: Date },
) {
return await this.databaseService.db
.update(categories)
.set(data)

View File

@@ -1,10 +1,20 @@
import { Global, Module } from "@nestjs/common";
import { forwardRef, Global, Module } from "@nestjs/common";
import { ContentsModule } from "../contents/contents.module";
import { DatabaseModule } from "../database/database.module";
import { ReportsModule } from "../reports/reports.module";
import { SessionsModule } from "../sessions/sessions.module";
import { UsersModule } from "../users/users.module";
import { PurgeService } from "./services/purge.service";
@Global()
@Module({
imports: [DatabaseModule],
imports: [
DatabaseModule,
forwardRef(() => SessionsModule),
forwardRef(() => ReportsModule),
forwardRef(() => UsersModule),
forwardRef(() => ContentsModule),
],
providers: [PurgeService],
exports: [PurgeService],
})

View File

@@ -9,10 +9,7 @@ export interface IStorageService {
bucketName?: string,
): Promise<string>;
getFile(
fileName: string,
bucketName?: string,
): Promise<Readable>;
getFile(fileName: string, bucketName?: string): Promise<Readable>;
getFileUrl(
fileName: string,
@@ -28,7 +25,7 @@ export interface IStorageService {
deleteFile(fileName: string, bucketName?: string): Promise<void>;
getFileInfo(fileName: string, bucketName?: string): Promise<any>;
getFileInfo(fileName: string, bucketName?: string): Promise<unknown>;
moveFile(
sourceFileName: string,

View File

@@ -9,10 +9,16 @@ import { PurgeService } from "./purge.service";
describe("PurgeService", () => {
let service: PurgeService;
const mockSessionsRepository = { purgeExpired: jest.fn().mockResolvedValue([]) };
const mockReportsRepository = { purgeObsolete: jest.fn().mockResolvedValue([]) };
const mockSessionsRepository = {
purgeExpired: jest.fn().mockResolvedValue([]),
};
const mockReportsRepository = {
purgeObsolete: jest.fn().mockResolvedValue([]),
};
const mockUsersRepository = { purgeDeleted: jest.fn().mockResolvedValue([]) };
const mockContentsRepository = { purgeSoftDeleted: jest.fn().mockResolvedValue([]) };
const mockContentsRepository = {
purgeSoftDeleted: jest.fn().mockResolvedValue([]),
};
beforeEach(async () => {
jest.clearAllMocks();

View File

@@ -42,9 +42,8 @@ export class PurgeService {
);
// 4. Purge des contenus supprimés (Soft Delete > 30 jours)
const deletedContents = await this.contentsRepository.purgeSoftDeleted(
thirtyDaysAgo,
);
const deletedContents =
await this.contentsRepository.purgeSoftDeleted(thirtyDaysAgo);
this.logger.log(
`Purged ${deletedContents.length} contents marked for deletion more than 30 days ago.`,
);

View File

@@ -12,5 +12,6 @@ import { ContentsRepository } from "./repositories/contents.repository";
imports: [DatabaseModule, S3Module, AuthModule, CryptoModule, MediaModule],
controllers: [ContentsController],
providers: [ContentsService, ContentsRepository],
exports: [ContentsRepository],
})
export class ContentsModule {}

View File

@@ -2,11 +2,10 @@ jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../database/database.service";
import { MediaService } from "../media/media.service";
import { S3Service } from "../s3/s3.service";
import { ContentsService } from "./contents.service";
@@ -44,9 +43,7 @@ describe("ContentsService", () => {
};
const mockCacheManager = {
store: {
keys: jest.fn().mockResolvedValue([]),
},
clear: jest.fn(),
del: jest.fn(),
};
@@ -141,7 +138,9 @@ describe("ContentsService", () => {
describe("incrementViews", () => {
it("should increment views", async () => {
mockContentsRepository.incrementViews.mockResolvedValue([{ id: "1", views: 1 }]);
mockContentsRepository.incrementViews.mockResolvedValue([
{ id: "1", views: 1 },
]);
const result = await service.incrementViews("1");
expect(mockContentsRepository.incrementViews).toHaveBeenCalledWith("1");
expect(result[0].views).toBe(1);

View File

@@ -1,13 +1,22 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";
import { Inject } from "@nestjs/common";
import {
BadRequestException,
Inject,
Injectable,
Logger,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Cache } from "cache-manager";
import { v4 as uuidv4 } from "uuid";
import type { IMediaService } from "../common/interfaces/media.interface";
import type {
IMediaService,
MediaProcessingResult,
} from "../common/interfaces/media.interface";
import type { IStorageService } from "../common/interfaces/storage.interface";
import { MediaService } from "../media/media.service";
import { S3Service } from "../s3/s3.service";
import { CreateContentDto } from "./dto/create-content.dto";
import { UploadContentDto } from "./dto/upload-content.dto";
import { ContentsRepository } from "./repositories/contents.repository";
@Injectable()
@@ -24,11 +33,7 @@ export class ContentsService {
private async clearContentsCache() {
this.logger.log("Clearing contents cache");
const keys = await this.cacheManager.store.keys();
const contentsKeys = keys.filter((key) => key.startsWith("contents/"));
for (const key of contentsKeys) {
await this.cacheManager.del(key);
}
await this.cacheManager.clear();
}
async getUploadUrl(userId: string, fileName: string) {
@@ -40,12 +45,7 @@ export class ContentsService {
async uploadAndProcess(
userId: string,
file: Express.Multer.File,
data: {
title: string;
type: "meme" | "gif";
categoryId?: string;
tags?: string[];
},
data: UploadContentDto,
) {
this.logger.log(`Uploading and processing file for user ${userId}`);
// 0. Validation du format et de la taille
@@ -86,7 +86,7 @@ export class ContentsService {
}
// 2. Transcodage
let processed;
let processed: MediaProcessingResult;
if (file.mimetype.startsWith("image/")) {
// Image ou GIF -> WebP (format moderne, bien supporté)
processed = await this.mediaService.processImage(file.buffer, "webp");
@@ -129,7 +129,7 @@ export class ContentsService {
return { data, totalCount };
}
async create(userId: string, data: any) {
async create(userId: string, data: CreateContentDto) {
this.logger.log(`Creating content for user ${userId}: ${data.title}`);
const { tags: tagNames, ...contentData } = data;
@@ -166,7 +166,7 @@ export class ContentsService {
return this.contentsRepository.findOne(idOrSlug);
}
generateBotHtml(content: any): string {
generateBotHtml(content: { title: string; storageKey: string }): string {
const imageUrl = this.getFileUrl(content.storageKey);
return `<!DOCTYPE html>
<html>

View File

@@ -6,9 +6,9 @@ import {
exists,
ilike,
isNull,
sql,
lte,
type SQL,
sql,
} from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import {
@@ -113,10 +113,7 @@ export class ContentsRepository {
.select()
.from(favorites)
.where(
and(
eq(favorites.contentId, contents.id),
eq(favorites.userId, userId),
),
and(eq(favorites.contentId, contents.id), eq(favorites.userId, userId)),
),
),
);
@@ -143,7 +140,6 @@ export class ContentsRepository {
author: {
id: users.uuid,
username: users.username,
avatarUrl: users.avatarUrl,
},
category: {
id: categories.id,
@@ -173,18 +169,13 @@ export class ContentsRepository {
return results.map((r) => ({
...r,
tags: tagsForContents
.filter((t) => t.contentId === r.id)
.map((t) => t.name),
tags: tagsForContents.filter((t) => t.contentId === r.id).map((t) => t.name),
}));
}
async create(data: NewContentInDb & { userId: string }, tagNames?: string[]) {
return await this.databaseService.db.transaction(async (tx) => {
const [newContent] = await tx
.insert(contents)
.values(data)
.returning();
const [newContent] = await tx.insert(contents).values(data).returning();
if (tagNames && tagNames.length > 0) {
for (const tagName of tagNames) {
@@ -325,10 +316,7 @@ export class ContentsRepository {
.select()
.from(favorites)
.where(
and(
eq(favorites.contentId, contents.id),
eq(favorites.userId, userId),
),
and(eq(favorites.contentId, contents.id), eq(favorites.userId, userId)),
),
),
);
@@ -377,7 +365,12 @@ export class ContentsRepository {
async purgeSoftDeleted(before: Date) {
return await this.databaseService.db
.delete(contents)
.where(and(sql`${contents.deletedAt} IS NOT NULL`, lte(contents.deletedAt, before)))
.where(
and(
sql`${contents.deletedAt} IS NOT NULL`,
lte(contents.deletedAt, before),
),
)
.returning();
}
}

View File

@@ -1,8 +1,8 @@
import { Module } from "@nestjs/common";
import { CryptoService } from "./crypto.service";
import { EncryptionService } from "./services/encryption.service";
import { HashingService } from "./services/hashing.service";
import { JwtService } from "./services/jwt.service";
import { EncryptionService } from "./services/encryption.service";
import { PostQuantumService } from "./services/post-quantum.service";
@Module({

View File

@@ -64,9 +64,9 @@ jest.mock("jose", () => ({
}));
import { CryptoService } from "./crypto.service";
import { EncryptionService } from "./services/encryption.service";
import { HashingService } from "./services/hashing.service";
import { JwtService } from "./services/jwt.service";
import { EncryptionService } from "./services/encryption.service";
import { PostQuantumService } from "./services/post-quantum.service";
describe("CryptoService", () => {

View File

@@ -3,9 +3,9 @@ export * from "./audit_logs";
export * from "./categories";
export * from "./content";
export * from "./favorites";
export * from "./pgp";
export * from "./rbac";
export * from "./reports";
export * from "./sessions";
export * from "./tags";
export * from "./users";
export * from "./pgp";

View File

@@ -55,6 +55,9 @@ export function pgpSymEncrypt(value: string | SQL, key: string | SQL) {
/**
* @deprecated Utiliser directement les colonnes de type pgpEncrypted qui gèrent maintenant le déchiffrement automatiquement.
*/
export function pgpSymDecrypt(column: AnyPgColumn, key: string | SQL): SQL<string> {
export function pgpSymDecrypt(
column: AnyPgColumn,
key: string | SQL,
): SQL<string> {
return sql`pgp_sym_decrypt(${column}, ${key})`.mapWith(column) as SQL<string>;
}

View File

@@ -1,4 +1,3 @@
import { SQL, sql } from "drizzle-orm";
import {
boolean,
index,

View File

@@ -1,13 +1,15 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { FavoritesController } from "./favorites.controller";
import { FavoritesService } from "./favorites.service";
import { FavoritesRepository } from "./repositories/favorites.repository";
@Module({
imports: [DatabaseModule],
imports: [DatabaseModule, AuthModule, CryptoModule],
controllers: [FavoritesController],
providers: [FavoritesService, FavoritesRepository],
exports: [FavoritesService],
exports: [FavoritesService, FavoritesRepository],
})
export class FavoritesModule {}

View File

@@ -34,7 +34,9 @@ describe("FavoritesService", () => {
describe("addFavorite", () => {
it("should add a favorite", async () => {
mockFavoritesRepository.findContentById.mockResolvedValue({ id: "content1" });
mockFavoritesRepository.findContentById.mockResolvedValue({
id: "content1",
});
mockFavoritesRepository.add.mockResolvedValue([
{ userId: "u1", contentId: "content1" },
]);
@@ -53,7 +55,9 @@ describe("FavoritesService", () => {
});
it("should throw ConflictException on duplicate favorite", async () => {
mockFavoritesRepository.findContentById.mockResolvedValue({ id: "content1" });
mockFavoritesRepository.findContentById.mockResolvedValue({
id: "content1",
});
mockFavoritesRepository.add.mockRejectedValue(new Error("Duplicate"));
await expect(service.addFavorite("u1", "content1")).rejects.toThrow(
ConflictException,
@@ -63,7 +67,9 @@ describe("FavoritesService", () => {
describe("removeFavorite", () => {
it("should remove a favorite", async () => {
mockFavoritesRepository.remove.mockResolvedValue([{ userId: "u1", contentId: "c1" }]);
mockFavoritesRepository.remove.mockResolvedValue([
{ userId: "u1", contentId: "c1" },
]);
const result = await service.removeFavorite("u1", "c1");
expect(result).toEqual({ userId: "u1", contentId: "c1" });
expect(repository.remove).toHaveBeenCalledWith("u1", "c1");

View File

@@ -14,7 +14,7 @@ export class FavoritesService {
async addFavorite(userId: string, contentId: string) {
this.logger.log(`Adding favorite: user ${userId}, content ${contentId}`);
const content = await this.favoritesRepository.findContentById(contentId);
if (!content) {
throw new NotFoundException("Content not found");

View File

@@ -24,14 +24,31 @@ async function bootstrap() {
}
// Sécurité
app.use(helmet());
app.enableCors({
origin:
configService.get("NODE_ENV") === "production"
? [configService.get("DOMAIN_NAME") as string]
: true,
credentials: true,
});
app.use(
helmet({
crossOriginResourcePolicy: { policy: "cross-origin" },
}),
);
const corsEnabled = Boolean(configService.get<boolean>("ENABLE_CORS"));
if (corsEnabled) {
const domainName = configService.get<string>("CORS_DOMAIN_NAME");
app.enableCors({
origin: (origin, callback) => {
if (!origin || domainName === "*") {
callback(null, true);
return;
}
const allowedOrigins = domainName?.split(",").map((o) => o.trim()) || [];
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(null, false);
}
},
credentials: true,
});
}
// Validation Globale
app.useGlobalPipes(
@@ -49,4 +66,4 @@ async function bootstrap() {
await app.listen(port);
logger.log(`Application is running on: http://localhost:${port}`);
}
bootstrap();
bootstrap().then();

View File

@@ -5,7 +5,7 @@ import {
Logger,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as NodeClam from "clamscan";
import NodeClam from "clamscan";
import type {
IMediaService,
MediaProcessingResult,

View File

@@ -2,5 +2,8 @@ import type { MediaProcessingResult } from "../../common/interfaces/media.interf
export interface IMediaProcessorStrategy {
canHandle(mimeType: string): boolean;
process(buffer: Buffer, options?: any): Promise<MediaProcessingResult>;
process(
buffer: Buffer,
options?: Record<string, unknown>,
): Promise<MediaProcessingResult>;
}

View File

@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
@@ -7,8 +7,9 @@ import { ReportsService } from "./reports.service";
import { ReportsRepository } from "./repositories/reports.repository";
@Module({
imports: [DatabaseModule, AuthModule, CryptoModule],
imports: [DatabaseModule, forwardRef(() => AuthModule), CryptoModule],
controllers: [ReportsController],
providers: [ReportsService, ReportsRepository],
exports: [ReportsRepository, ReportsService],
})
export class ReportsModule {}

View File

@@ -34,7 +34,11 @@ describe("ReportsService", () => {
it("should create a report", async () => {
const reporterId = "u1";
const data = { contentId: "c1", reason: "spam" };
mockReportsRepository.create.mockResolvedValue({ id: "r1", ...data, reporterId });
mockReportsRepository.create.mockResolvedValue({
id: "r1",
...data,
reporterId,
});
const result = await service.create(reporterId, data);
@@ -54,7 +58,9 @@ describe("ReportsService", () => {
describe("updateStatus", () => {
it("should update report status", async () => {
mockReportsRepository.updateStatus.mockResolvedValue([{ id: "r1", status: "resolved" }]);
mockReportsRepository.updateStatus.mockResolvedValue([
{ id: "r1", status: "resolved" },
]);
const result = await service.updateStatus("r1", "resolved");
expect(result[0].status).toBe("resolved");
expect(repository.updateStatus).toHaveBeenCalledWith("r1", "resolved");

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { ReportsRepository } from "./repositories/reports.repository";
import { CreateReportDto } from "./dto/create-report.dto";
import { ReportsRepository } from "./repositories/reports.repository";
@Injectable()
export class ReportsService {

View File

@@ -11,7 +11,7 @@ export class ReportsRepository {
reporterId: string;
contentId?: string;
tagId?: string;
reason: string;
reason: "inappropriate" | "spam" | "copyright" | "other";
description?: string;
}) {
const [newReport] = await this.databaseService.db

View File

@@ -32,7 +32,7 @@ export class SessionsRepository {
return result[0] || null;
}
async update(sessionId: string, data: any) {
async update(sessionId: string, data: Record<string, unknown>) {
const [updatedSession] = await this.databaseService.db
.update(sessions)
.set({ ...data, updatedAt: new Date() })

View File

@@ -1,12 +1,12 @@
import { Module } from "@nestjs/common";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { SessionsService } from "./sessions.service";
import { SessionsRepository } from "./repositories/sessions.repository";
import { SessionsService } from "./sessions.service";
@Module({
imports: [DatabaseModule, CryptoModule],
providers: [SessionsService, SessionsRepository],
exports: [SessionsService],
exports: [SessionsService, SessionsRepository],
})
export class SessionsModule {}

View File

@@ -15,8 +15,8 @@ import { UnauthorizedException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { HashingService } from "../crypto/services/hashing.service";
import { JwtService } from "../crypto/services/jwt.service";
import { SessionsService } from "./sessions.service";
import { SessionsRepository } from "./repositories/sessions.repository";
import { SessionsService } from "./sessions.service";
describe("SessionsService", () => {
let service: SessionsService;
@@ -76,7 +76,10 @@ describe("SessionsService", () => {
userId: "u1",
expiresAt,
});
mockSessionsRepository.update.mockResolvedValue({ id: "s1", refreshToken: "new-token" });
mockSessionsRepository.update.mockResolvedValue({
id: "s1",
refreshToken: "new-token",
});
const result = await service.refreshSession("old-token");

View File

@@ -30,7 +30,8 @@ export class SessionsService {
}
async refreshSession(oldRefreshToken: string) {
const session = await this.sessionsRepository.findValidByRefreshToken(oldRefreshToken);
const session =
await this.sessionsRepository.findValidByRefreshToken(oldRefreshToken);
if (!session || session.expiresAt < new Date()) {
if (session) {

View File

@@ -1,3 +1,4 @@
import { CacheInterceptor, CacheTTL } from "@nestjs/cache-manager";
import {
Controller,
DefaultValuePipe,
@@ -6,7 +7,6 @@ import {
Query,
UseInterceptors,
} from "@nestjs/common";
import { CacheInterceptor, CacheTTL } from "@nestjs/cache-manager";
import { TagsService } from "./tags.service";
@Controller("tags")

View File

@@ -1,13 +1,15 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { TagsRepository } from "./repositories/tags.repository";
import { TagsController } from "./tags.controller";
import { TagsService } from "./tags.service";
import { TagsRepository } from "./repositories/tags.repository";
@Module({
imports: [DatabaseModule],
imports: [DatabaseModule, AuthModule, CryptoModule],
controllers: [TagsController],
providers: [TagsService, TagsRepository],
exports: [TagsService],
exports: [TagsService, TagsRepository],
})
export class TagsModule {}

View File

@@ -1,6 +1,6 @@
import { Test, TestingModule } from "@nestjs/testing";
import { TagsService } from "./tags.service";
import { TagsRepository } from "./repositories/tags.repository";
import { TagsService } from "./tags.service";
describe("TagsService", () => {
let service: TagsService;

View File

@@ -1,8 +1,7 @@
import { Injectable } from "@nestjs/common";
import { and, eq, lte, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { users, contents, favorites } from "../../database/schemas";
import type { UpdateUserDto } from "../dto/update-user.dto";
import { contents, favorites, users } from "../../database/schemas";
@Injectable()
export class UsersRepository {
@@ -99,7 +98,7 @@ export class UsersRepository {
return result[0] || null;
}
async update(uuid: string, data: any) {
async update(uuid: string, data: Partial<typeof users.$inferInsert>) {
return await this.databaseService.db
.update(users)
.set({ ...data, updatedAt: new Date() })

View File

@@ -1,9 +1,12 @@
import { CacheInterceptor, CacheTTL } from "@nestjs/cache-manager";
import {
Body,
Controller,
DefaultValuePipe,
Delete,
forwardRef,
Get,
Inject,
Param,
ParseIntPipe,
Patch,
@@ -13,7 +16,6 @@ import {
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { CacheInterceptor, CacheKey, CacheTTL } from "@nestjs/cache-manager";
import { AuthService } from "../auth/auth.service";
import { Roles } from "../auth/decorators/roles.decorator";
import { AuthGuard } from "../auth/guards/auth.guard";
@@ -27,6 +29,7 @@ import { UsersService } from "./users.service";
export class UsersController {
constructor(
private readonly usersService: UsersService,
@Inject(forwardRef(() => AuthService))
private readonly authService: AuthService,
) {}

View File

@@ -1,15 +1,15 @@
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { UsersRepository } from "./repositories/users.repository";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
import { UsersRepository } from "./repositories/users.repository";
@Module({
imports: [DatabaseModule, CryptoModule, AuthModule],
imports: [DatabaseModule, CryptoModule, forwardRef(() => AuthModule)],
controllers: [UsersController],
providers: [UsersService, UsersRepository],
exports: [UsersService],
exports: [UsersService, UsersRepository],
})
export class UsersModule {}

View File

@@ -11,10 +11,10 @@ jest.mock("jose", () => ({
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { UsersService } from "./users.service";
import { Test, TestingModule } from "@nestjs/testing";
import { UsersRepository } from "./repositories/users.repository";
import { UsersService } from "./users.service";
describe("UsersService", () => {
let service: UsersService;
@@ -91,7 +91,9 @@ describe("UsersService", () => {
describe("update", () => {
it("should update a user", async () => {
mockUsersRepository.update.mockResolvedValue([{ uuid: "uuid1", displayName: "New" }]);
mockUsersRepository.update.mockResolvedValue([
{ uuid: "uuid1", displayName: "New" },
]);
const result = await service.update("uuid1", { displayName: "New" });
expect(result[0].displayName).toBe("New");
});

View File

@@ -1,8 +1,8 @@
import { Injectable, Logger, Inject } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";
import { UsersRepository } from "./repositories/users.repository";
import { Inject, Injectable, Logger } from "@nestjs/common";
import type { Cache } from "cache-manager";
import { UpdateUserDto } from "./dto/update-user.dto";
import { UsersRepository } from "./repositories/users.repository";
@Injectable()
export class UsersService {

View File

@@ -1,3 +0,0 @@
module.exports = {
createCuid: () => () => 'mocked-cuid',
};

View File

@@ -1,13 +0,0 @@
module.exports = {
SignJWT: class {
constructor() { return this; }
setProtectedHeader() { return this; }
setIssuedAt() { return this; }
setExpirationTime() { return this; }
sign() { return Promise.resolve('mocked-token'); }
},
jwtVerify: () => Promise.resolve({ payload: { sub: 'mocked-user' } }),
importJWK: () => Promise.resolve({}),
exportJWK: () => Promise.resolve({}),
generateKeyPair: () => Promise.resolve({ publicKey: {}, privateKey: {} }),
};

View File

@@ -1,7 +0,0 @@
module.exports = {
ml_kem768: {
keygen: () => ({ publicKey: Buffer.alloc(1184), secretKey: Buffer.alloc(2400) }),
encapsulate: () => ({ cipherText: Buffer.alloc(1088), sharedSecret: Buffer.alloc(32) }),
decapsulate: () => Buffer.alloc(32),
}
};

View File

@@ -1,5 +0,0 @@
module.exports = {
sha3_256: () => ({ update: () => ({ digest: () => Buffer.alloc(32) }) }),
sha3_512: () => ({ update: () => ({ digest: () => Buffer.alloc(64) }) }),
shake256: () => ({ update: () => ({ digest: () => Buffer.alloc(32) }) }),
};

View File

@@ -1,25 +0,0 @@
import { INestApplication } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import request from "supertest";
import { App } from "supertest/types";
import { AppModule } from "../src/app.module";
describe("AppController (e2e)", () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it("/ (GET)", () => {
return request(app.getHttpServer())
.get("/")
.expect(200)
.expect("Hello World!");
});
});

View File

@@ -1,9 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

141
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,141 @@
services:
db:
image: postgres:17-alpine
container_name: memegoat-db
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
POSTGRES_DB: ${POSTGRES_DB:-app}
networks:
- nw_memegoat
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: memegoat-redis
restart: always
networks:
- nw_memegoat
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
s3:
image: minio/minio:RELEASE.2025-04-08T15-41-24Z
container_name: memegoat-s3
restart: always
networks:
- nw_memegoat
#ports:
# - "9000:9000"
# - "9001:9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-minioadmin}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
#mail:
# image: axllent/mailpit
# container_name: memegoat-mail
# restart: always
# ports:
# - "1025:1025" # smtp
# - "8025:8025" # web ui
backend:
build:
context: .
dockerfile: backend/Dockerfile
target: runtime
container_name: memegoat-backend
networks:
- nw_memegoat
- nw_caddy
command: >
node dist/src/main
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
s3:
condition: service_started
clamav:
condition: service_started
environment:
NODE_ENV: production
POSTGRES_HOST: ${POSTGRES_HOST:-memegoat-db}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
REDIS_HOST: ${REDIS_HOST:-memegoat-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
S3_ENDPOINT: ${S3_ENDPOINT:-memegoat-s3}
S3_PORT: ${S3_PORT:-9000}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-minioadmin}
S3_SECRET_KEY: ${S3_SECRET_KEY:-minioadmin}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-memegoat}
MAIL_HOST: ${MAIL_HOST:-smtp.mail.ovh.net}
MAIL_PORT: ${MAIL_PORT:-465}
MAIL_USER: ${MAIL_USER}
MAIL_PASS: ${MAIL_PASS}
MAIL_FROM: ${MAIL_FROM:-noreply@memegoat.fr}
DOMAIN_NAME: ${DOMAIN_NAME:-localhost}
JWT_SECRET: ${JWT_SECRET:-super-secret-jwt-key-change-me-in-prod}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-01234567890123456789012345678901}
PGP_ENCRYPTION_KEY: ${PGP_ENCRYPTION_KEY:-super-secret-pgp-key}
SESSION_PASSWORD: ${SESSION_PASSWORD:-super-secret-session-password-32-chars}
CORS_DOMAIN_NAME: ${CORS_DOMAIN_NAME:-*}
ENABLE_CORS: ${ENABLE_CORS:-true}
CLAMAV_HOST: memegoat-clamav
CLAMAV_PORT: 3310
MAX_IMAGE_SIZE_KB: 512
MAX_GIF_SIZE_KB: 1024
clamav:
image: clamav/clamav:latest
container_name: memegoat-clamav
restart: always
networks:
- nw_memegoat
healthcheck:
test: ["CMD", "clamdscan", "--version"]
interval: 20s
timeout: 10s
retries: 5
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
target: runner
container_name: memegoat-frontend
networks:
- nw_caddy
restart: always
environment:
NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000}
depends_on:
- backend
volumes:
postgres_data:
minio_data:
networks:
nw_memegoat:
internal: true
nw_caddy:
external: true

129
docker-compose.yml Normal file
View File

@@ -0,0 +1,129 @@
services:
db:
image: postgres:17-alpine
container_name: memegoat-db
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
POSTGRES_DB: ${POSTGRES_DB:-app}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: memegoat-redis
restart: always
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
s3:
image: minio/minio:RELEASE.2025-04-08T15-41-24Z
container_name: memegoat-s3
restart: always
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-minioadmin}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
mail:
image: axllent/mailpit
container_name: memegoat-mail
restart: always
ports:
- "1025:1025" # smtp
- "8025:8025" # web ui
backend:
build:
context: .
dockerfile: backend/Dockerfile
target: runtime
container_name: memegoat-backend
command: >
node dist/src/main
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
s3:
condition: service_started
clamav:
condition: service_started
ports:
- "3000:3000"
environment:
NODE_ENV: production
POSTGRES_HOST: db
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
REDIS_HOST: redis
REDIS_PORT: 6379
S3_ENDPOINT: s3
S3_PORT: 9000
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-minioadmin}
S3_SECRET_KEY: ${S3_SECRET_KEY:-minioadmin}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-memegoat}
MAIL_HOST: mail
MAIL_PORT: 1025
MAIL_USER: ""
MAIL_PASS: ""
MAIL_FROM: ${MAIL_FROM:-noreply@memegoat.local}
DOMAIN_NAME: ${DOMAIN_NAME:-localhost}
JWT_SECRET: ${JWT_SECRET:-super-secret-jwt-key-change-me-in-prod}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-01234567890123456789012345678901}
PGP_ENCRYPTION_KEY: ${PGP_ENCRYPTION_KEY:-super-secret-pgp-key}
SESSION_PASSWORD: ${SESSION_PASSWORD:-super-secret-session-password-32-chars}
CORS_DOMAIN_NAME: ${CORS_DOMAIN_NAME:-*}
ENABLE_CORS: ${ENABLE_CORS:-true}
CLAMAV_HOST: clamav
CLAMAV_PORT: 3310
clamav:
image: clamav/clamav:1.4
container_name: memegoat-clamav
restart: always
healthcheck:
test: ["CMD", "clamdscan", "--version"]
interval: 20s
timeout: 10s
retries: 5
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
target: runner
container_name: memegoat-frontend
restart: always
ports:
- "3001:3000"
environment:
NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000}
depends_on:
- backend
volumes:
postgres_data:
minio_data:

View File

@@ -1,22 +1,66 @@
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# syntax=docker.io/docker/dockerfile:1
FROM base AS build
WORKDIR /usr/src/app
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run --filter @memegoat/documentation build
FROM pnpm/pnpm:20-alpine AS base
FROM base AS runtime
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY --from=build /usr/src/app/documentation/public ./documentation/public
COPY --from=build /usr/src/app/documentation/.next/standalone ./
COPY --from=build /usr/src/app/documentation/.next/static ./documentation/.next/static
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* source.config.ts* next.config.* ./
RUN \
if [ -f pnpm-lock.yaml ]; then pnpm i --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f pnpm-lock.yaml ]; then pnpm run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f yarn.lock ]; then yarn run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "documentation/server.js"]
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -7,7 +7,7 @@
"dev": "next dev",
"start": "next start",
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
"postinstall": "fumadocs-mdx",
"postinstall_disabled": "fumadocs-mdx",
"lint": "biome check",
"lint:write": "biome check --write",
"format": "biome format --write"
@@ -32,6 +32,7 @@
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vite": "^6.2.1"
}
}

View File

@@ -1,22 +1,45 @@
FROM node:22-slim AS base
# syntax=docker.io/docker/dockerfile:1
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM base AS build
FROM base AS builder
WORKDIR /usr/src/app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/
RUN pnpm install --no-frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
RUN pnpm install --no-frozen-lockfile
RUN pnpm run --filter @memegoat/frontend build
FROM base AS runtime
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=build /usr/src/app/frontend/public ./frontend/public
COPY --from=build /usr/src/app/frontend/.next/standalone ./
COPY --from=build /usr/src/app/frontend/.next/static ./frontend/.next/static
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /usr/src/app/frontend/public ./frontend/public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /usr/src/app/frontend/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /usr/src/app/frontend/.next/static ./frontend/.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Note: server.js is created in the standalone output.
# In a monorepo, it's often inside a subdirectory matching the package name.
CMD ["node", "frontend/server.js"]

View File

@@ -37,6 +37,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",

1025
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useAuth } from "@/providers/auth-provider";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { ArrowLeft } from "lucide-react";
const loginSchema = z.object({
email: z.string().email({ message: "Email invalide" }),
password: z.string().min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export default function LoginPage() {
const { login } = useAuth();
const [loading, setLoading] = React.useState(false);
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
});
async function onSubmit(values: LoginFormValues) {
setLoading(true);
try {
await login(values.email, values.password);
} catch (error) {
// Error is handled in useAuth via toast
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
<div className="w-full max-w-md space-y-4">
<Link
href="/"
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour à l'accueil
</Link>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Connexion</CardTitle>
<CardDescription>
Entrez vos identifiants pour accéder à votre compte MemeGoat.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="goat@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Mot de passe</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Connexion en cours..." : "Se connecter"}
</Button>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col space-y-2">
<p className="text-sm text-center text-muted-foreground">
Vous n'avez pas de compte ?{" "}
<Link href="/register" className="text-primary hover:underline font-medium">
S'inscrire
</Link>
</p>
</CardFooter>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useAuth } from "@/providers/auth-provider";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { ArrowLeft } from "lucide-react";
const registerSchema = z.object({
username: z.string().min(3, { message: "Le pseudo doit faire au moins 3 caractères" }),
email: z.string().email({ message: "Email invalide" }),
password: z.string().min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
displayName: z.string().optional(),
});
type RegisterFormValues = z.infer<typeof registerSchema>;
export default function RegisterPage() {
const { register } = useAuth();
const [loading, setLoading] = React.useState(false);
const form = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
username: "",
email: "",
password: "",
displayName: "",
},
});
async function onSubmit(values: RegisterFormValues) {
setLoading(true);
try {
await register(values);
} catch (error) {
// Error handled in useAuth
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
<div className="w-full max-w-md space-y-4">
<Link
href="/"
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour à l'accueil
</Link>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Inscription</CardTitle>
<CardDescription>
Rejoignez la communauté MemeGoat dès aujourd'hui.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Pseudo</FormLabel>
<FormControl>
<Input placeholder="supergoat" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="goat@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Nom d'affichage (Optionnel)</FormLabel>
<FormControl>
<Input placeholder="Le Roi des Chèvres" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Mot de passe</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Création du compte..." : "S'inscrire"}
</Button>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col space-y-2">
<p className="text-sm text-center text-muted-foreground">
Vous avez déjà un compte ?{" "}
<Link href="/login" className="text-primary hover:underline font-medium">
Se connecter
</Link>
</p>
</CardFooter>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { ContentService } from "@/services/content.service";
import { ContentCard } from "@/components/content-card";
import type { Content } from "@/types/content";
import { Spinner } from "@/components/ui/spinner";
export default function MemeModal({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = React.use(params);
const router = useRouter();
const [content, setContent] = React.useState<Content | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
ContentService.getOne(slug)
.then(setContent)
.catch(console.error)
.finally(() => setLoading(false));
}, [slug]);
return (
<Dialog open onOpenChange={(open) => !open && router.back()}>
<DialogContent className="max-w-3xl p-0 overflow-hidden bg-transparent border-none">
<DialogTitle className="sr-only">{content?.title || "Détail du mème"}</DialogTitle>
<DialogDescription className="sr-only">Affiche le mème en grand avec ses détails</DialogDescription>
{loading ? (
<div className="h-[500px] flex items-center justify-center bg-zinc-950/50 rounded-lg">
<Spinner className="h-10 w-10 text-white" />
</div>
) : content ? (
<div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
<ContentCard content={content} />
</div>
) : (
<div className="p-8 bg-white dark:bg-zinc-900 rounded-lg text-center">
<p>Impossible de charger ce mème.</p>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,42 @@
import * as React from "react";
interface UseInfiniteScrollOptions {
threshold?: number;
hasMore: boolean;
loading: boolean;
onLoadMore: () => void;
}
export function useInfiniteScroll({
threshold = 1.0,
hasMore,
loading,
onLoadMore,
}: UseInfiniteScrollOptions) {
const loaderRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
onLoadMore();
}
},
{ threshold }
);
const currentLoader = loaderRef.current;
if (currentLoader) {
observer.observe(currentLoader);
}
return () => {
if (currentLoader) {
observer.unobserve(currentLoader);
}
observer.disconnect();
};
}, [onLoadMore, hasMore, loading, threshold]);
return { loaderRef };
}

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import type { Metadata } from "next";
import { CategoryContent } from "@/components/category-content";
import { CategoryService } from "@/services/category.service";
export async function generateMetadata({
params
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params;
try {
const categories = await CategoryService.getAll();
const category = categories.find(c => c.slug === slug);
return {
title: `${category?.name || slug} | MemeGoat`,
description: `Découvrez tous les mèmes de la catégorie ${category?.name || slug} sur MemeGoat.`,
};
} catch (error) {
return { title: `Catégorie : ${slug} | MemeGoat` };
}
}
export default async function CategoryPage({
params
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params;
return <CategoryContent slug={slug} />;
}

View File

@@ -0,0 +1,54 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid } from "lucide-react";
export default function CategoriesPage() {
const [categories, setCategories] = React.useState<Category[]>([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
CategoryService.getAll()
.then(setCategories)
.finally(() => setLoading(false));
}, []);
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="flex items-center gap-2 mb-8">
<LayoutGrid className="h-6 w-6" />
<h1 className="text-3xl font-bold">Catégories</h1>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="animate-pulse">
<CardHeader className="h-24 bg-zinc-100 dark:bg-zinc-800 rounded-t-lg" />
<CardContent className="h-12" />
</Card>
))
) : (
categories.map((category) => (
<Link key={category.id} href={`/category/${category.slug}`}>
<Card className="hover:border-primary transition-colors cursor-pointer group h-full">
<CardHeader className="bg-zinc-50 dark:bg-zinc-900 group-hover:bg-primary/5 transition-colors">
<CardTitle className="text-lg">{category.name}</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<p className="text-sm text-muted-foreground">
{category.description || `Découvrez tous les mèmes de la catégorie ${category.name}.`}
</p>
</CardContent>
</Card>
</Link>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/app-sidebar";
import { SearchSidebar } from "@/components/search-sidebar";
import { MobileFilters } from "@/components/mobile-filters";
export default function DashboardLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset className="flex flex-row overflow-hidden">
<div className="flex-1 flex flex-col min-w-0">
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden">
<SidebarTrigger />
<div className="flex-1" />
</header>
<main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950">
{children}
{modal}
</main>
<React.Suspense fallback={null}>
<MobileFilters />
</React.Suspense>
</div>
<React.Suspense fallback={null}>
<SearchSidebar />
</React.Suspense>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,13 @@
import { ContentSkeleton } from "@/components/content-skeleton";
export default function Loading() {
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
<div className="flex flex-col gap-6">
{[...Array(3)].map((_, i) => (
<ContentSkeleton key={i} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import * as React from "react";
import type { Metadata } from "next";
import { ContentService } from "@/services/content.service";
import { ContentCard } from "@/components/content-card";
import { Button } from "@/components/ui/button";
import { ChevronLeft } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
export const revalidate = 3600; // ISR: Revalider toutes les heures
export async function generateMetadata({
params
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params;
try {
const content = await ContentService.getOne(slug);
return {
title: `${content.title} | MemeGoat`,
description: content.description || `Regardez ce mème : ${content.title}`,
openGraph: {
images: [content.thumbnailUrl || content.url],
},
};
} catch (error) {
return { title: "Mème non trouvé | MemeGoat" };
}
}
export default async function MemePage({
params
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params;
try {
const content = await ContentService.getOne(slug);
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<Link href="/" className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors">
<ChevronLeft className="h-4 w-4 mr-1" />
Retour au flux
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<ContentCard content={content} />
</div>
<div className="space-y-6">
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border">
<h2 className="font-bold text-lg mb-4">À propos de ce mème</h2>
<div className="space-y-4 text-sm">
<div>
<p className="text-muted-foreground">Publié par</p>
<p className="font-medium">{content.author.displayName || content.author.username}</p>
</div>
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-medium">{new Date(content.createdAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}</p>
</div>
{content.description && (
<div>
<p className="text-muted-foreground">Description</p>
<p>{content.description}</p>
</div>
)}
</div>
</div>
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border text-center">
<p className="text-sm text-muted-foreground mb-4">Envie de créer votre propre mème ?</p>
<Button className="w-full">Utiliser ce template</Button>
</div>
</div>
</div>
</div>
);
} catch (error) {
notFound();
}
}

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import type { Metadata } from "next";
import { HomeContent } from "@/components/home-content";
import { Spinner } from "@/components/ui/spinner";
export const metadata: Metadata = {
title: "MemeGoat | La meilleure plateforme de mèmes pour les chèvres",
description: "Explorez, créez et partagez les meilleurs mèmes de la communauté. Rejoignez le troupeau sur MemeGoat.",
};
export default function HomePage() {
return (
<React.Suspense fallback={
<div className="flex items-center justify-center p-12">
<Spinner className="h-8 w-8 text-primary" />
</div>
}>
<HomeContent />
</React.Suspense>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import * as React from "react";
import { useAuth } from "@/providers/auth-provider";
import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Settings, LogOut, Calendar } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
export default function ProfilePage() {
const { user, isAuthenticated, isLoading, logout } = useAuth();
if (isLoading) return null;
if (!isAuthenticated || !user) {
redirect("/login");
}
const fetchMyMemes = React.useCallback((params: { limit: number; offset: number }) =>
ContentService.getExplore({ ...params, author: user.username }),
[user.username]);
const fetchMyFavorites = React.useCallback((params: { limit: number; offset: number }) =>
FavoriteService.list(params),
[]);
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
<div className="flex flex-col md:flex-row items-center gap-8">
<Avatar className="h-32 w-32 border-4 border-primary/10">
<AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback className="text-4xl">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 text-center md:text-left space-y-4">
<div>
<h1 className="text-3xl font-bold">{user.displayName || user.username}</h1>
<p className="text-muted-foreground">@{user.username}</p>
</div>
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Membre depuis {new Date(user.createdAt).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' })}
</span>
</div>
<div className="flex flex-wrap justify-center md:justify-start gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/settings">
<Settings className="h-4 w-4 mr-2" />
Paramètres
</Link>
</Button>
<Button variant="ghost" size="sm" onClick={() => logout()} className="text-red-500 hover:text-red-600 hover:bg-red-50">
<LogOut className="h-4 w-4 mr-2" />
Déconnexion
</Button>
</div>
</div>
</div>
</div>
<Tabs defaultValue="memes" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-8">
<TabsTrigger value="memes">Mes Mèmes</TabsTrigger>
<TabsTrigger value="favorites">Mes Favoris</TabsTrigger>
</TabsList>
<TabsContent value="memes">
<ContentList fetchFn={fetchMyMemes} />
</TabsContent>
<TabsContent value="favorites">
<ContentList fetchFn={fetchMyFavorites} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import * as React from "react";
import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
export default function RecentPage() {
const fetchFn = React.useCallback((params: { limit: number; offset: number }) =>
ContentService.getRecent(params.limit, params.offset),
[]);
return <ContentList fetchFn={fetchFn} title="Nouveaux Mèmes" />;
}

View File

@@ -0,0 +1,13 @@
"use client";
import * as React from "react";
import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
export default function TrendsPage() {
const fetchFn = React.useCallback((params: { limit: number; offset: number }) =>
ContentService.getTrends(params.limit, params.offset),
[]);
return <ContentList fetchFn={fetchFn} title="Top Tendances" />;
}

View File

@@ -0,0 +1,257 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Upload, Image as ImageIcon, Film, X, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CategoryService } from "@/services/category.service";
import { ContentService } from "@/services/content.service";
import type { Category } from "@/types/content";
const uploadSchema = z.object({
title: z.string().min(3, "Le titre doit faire au moins 3 caractères"),
type: z.enum(["meme", "gif"]),
categoryId: z.string().optional(),
tags: z.string().optional(),
});
type UploadFormValues = z.infer<typeof uploadSchema>;
export default function UploadPage() {
const router = useRouter();
const [categories, setCategories] = React.useState<Category[]>([]);
const [file, setFile] = React.useState<File | null>(null);
const [preview, setPreview] = React.useState<string | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const form = useForm<UploadFormValues>({
resolver: zodResolver(uploadSchema),
defaultValues: {
title: "",
type: "meme",
tags: "",
},
});
React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
if (selectedFile.size > 10 * 1024 * 1024) {
toast.error("Le fichier est trop volumineux (max 10Mo)");
return;
}
setFile(selectedFile);
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(selectedFile);
}
};
const onSubmit = async (values: UploadFormValues) => {
if (!file) {
toast.error("Veuillez sélectionner un fichier");
return;
}
setIsUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("title", values.title);
formData.append("type", values.type);
if (values.categoryId) formData.append("categoryId", values.categoryId);
if (values.tags) {
const tagsArray = values.tags.split(",").map(t => t.trim()).filter(t => t !== "");
tagsArray.forEach(tag => formData.append("tags[]", tag));
}
await ContentService.upload(formData);
toast.success("Mème uploadé avec succès !");
router.push("/");
} catch (error: any) {
console.error("Upload failed:", error);
toast.error(error.response?.data?.message || "Échec de l'upload. Êtes-vous connecté ?");
} finally {
setIsUploading(false);
}
};
return (
<div className="max-w-2xl mx-auto py-8 px-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Partager un mème
</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<FormLabel>Fichier (Image ou GIF)</FormLabel>
{!preview ? (
<div
className="border-2 border-dashed rounded-lg p-12 flex flex-col items-center justify-center bg-zinc-50 dark:bg-zinc-900 cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={() => document.getElementById("file-upload")?.click()}
>
<div className="bg-primary/10 p-4 rounded-full mb-4">
<ImageIcon className="h-8 w-8 text-primary" />
</div>
<p className="font-medium">Cliquez pour choisir un fichier</p>
<p className="text-xs text-muted-foreground mt-1">PNG, JPG ou GIF jusqu'à 10Mo</p>
<input
id="file-upload"
type="file"
className="hidden"
accept="image/*,.gif"
onChange={handleFileChange}
/>
</div>
) : (
<div className="relative rounded-lg overflow-hidden border bg-zinc-100 dark:bg-zinc-800">
<img
src={preview}
alt="Preview"
className="max-h-[400px] mx-auto object-contain"
/>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-2 right-2 rounded-full"
onClick={() => {
setFile(null);
setPreview(null);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Titre</FormLabel>
<FormControl>
<Input placeholder="Un titre génial pour votre mème..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Format</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez un format" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="meme">Image fixe</SelectItem>
<SelectItem value="gif">GIF Animé</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Catégorie</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez une catégorie" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<Input placeholder="funny, coding, goat..." {...field} />
</FormControl>
<FormDescription>
Séparez les tags par des virgules.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Upload en cours...
</>
) : (
"Publier le mème"
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { AlertTriangle, RefreshCw, Home } from "lucide-react";
import Link from "next/link";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
<div className="text-center space-y-6 max-w-md">
<div className="flex justify-center">
<div className="bg-orange-100 dark:bg-orange-900/30 p-4 rounded-full">
<AlertTriangle className="h-12 w-12 text-orange-600 dark:text-orange-400" />
</div>
</div>
<h1 className="text-4xl font-bold tracking-tight">Oups ! Une erreur est survenue</h1>
<p className="text-muted-foreground text-lg">
La chèvre a glissé sur une peau de banane. Nous essayons de la remettre sur pied.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button onClick={reset} size="lg" className="gap-2">
<RefreshCw className="h-4 w-4" />
Réessayer
</Button>
<Button asChild variant="outline" size="lg" className="gap-2">
<Link href="/">
<Home className="h-4 w-4" />
Retourner à l'accueil
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,35 +1,40 @@
import type { Metadata } from "next";
import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { AuthProvider } from "@/providers/auth-provider";
import "./globals.css";
const ubuntuSans = Ubuntu_Sans({
variable: "--font-ubuntu-sans",
subsets: ["latin"],
variable: "--font-ubuntu-sans",
subsets: ["latin"],
});
const ubuntuMono = Ubuntu_Mono({
variable: "--font-geist-mono",
weight: ["400", "700"],
subsets: ["latin"],
variable: "--font-geist-mono",
weight: ["400", "700"],
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "MemeGoat",
icons: "/memegoat-color.svg",
title: "MemeGoat",
icons: "/memegoat-color.svg",
};
export default function RootLayout({
children,
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${ubuntuSans.variable} ${ubuntuMono.variable} antialiased`}
>
{children}
</body>
</html>
);
return (
<html lang="fr" suppressHydrationWarning>
<body
className={`${ubuntuSans.variable} ${ubuntuMono.variable} antialiased`}
>
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Home, AlertCircle } from "lucide-react";
export default function NotFound() {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
<div className="text-center space-y-6 max-w-md">
<div className="flex justify-center">
<div className="bg-red-100 dark:bg-red-900/30 p-4 rounded-full">
<AlertCircle className="h-12 w-12 text-red-600 dark:text-red-400" />
</div>
</div>
<h1 className="text-4xl font-bold tracking-tight">404 - Perdu dans le troupeau ?</h1>
<p className="text-muted-foreground text-lg">
On dirait que ce mème s'est enfui. La chèvre ne l'a pas trouvé.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button asChild size="lg" className="gap-2">
<Link href="/">
<Home className="h-4 w-4" />
Retourner à l'accueil
</Link>
</Button>
</div>
</div>
<div className="mt-12 text-8xl grayscale opacity-20 select-none">
🐐
</div>
</div>
);
}

View File

@@ -1,9 +0,0 @@
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<p>Hello world !</p>
</main>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.local";
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/settings/", "/upload/", "/api/"],
},
sitemap: `${baseUrl}/sitemap.xml`,
};
}

View File

@@ -0,0 +1,45 @@
import type { MetadataRoute } from "next";
import { ContentService } from "@/services/content.service";
import { CategoryService } from "@/services/category.service";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.local";
// Pages statiques
const routes: MetadataRoute.Sitemap = ["", "/trends", "/recent"].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: "daily" as const,
priority: route === "" ? 1 : 0.8,
}));
// Catégories
try {
const categories = await CategoryService.getAll();
const categoryRoutes = categories.map((category) => ({
url: `${baseUrl}/category/${category.slug}`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 0.6,
}));
routes.push(...categoryRoutes);
} catch (error) {
console.error("Sitemap: Failed to fetch categories");
}
// Mèmes (limité aux 100 derniers pour éviter un sitemap trop gros d'un coup)
try {
const contents = await ContentService.getRecent(100, 0);
const memeRoutes = contents.data.map((meme) => ({
url: `${baseUrl}/meme/${meme.slug}`,
lastModified: new Date(meme.updatedAt),
changeFrequency: "monthly" as const,
priority: 0.5,
}));
routes.push(...memeRoutes);
} catch (error) {
console.error("Sitemap: Failed to fetch memes");
}
return routes;
}

View File

@@ -0,0 +1,243 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Home,
TrendingUp,
Clock,
LayoutGrid,
PlusCircle,
Settings,
HelpCircle,
ChevronRight,
LogOut,
User as UserIcon,
LogIn,
} from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
import { useAuth } from "@/providers/auth-provider";
const mainNav = [
{
title: "Accueil",
url: "/",
icon: Home,
},
{
title: "Tendances",
url: "/trends",
icon: TrendingUp,
},
{
title: "Nouveautés",
url: "/recent",
icon: Clock,
},
];
export function AppSidebar() {
const pathname = usePathname();
const { user, logout, isAuthenticated, isLoading } = useAuth();
const [categories, setCategories] = React.useState<Category[]>([]);
React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
return (
<Sidebar collapsible="icon">
<SidebarHeader className="flex items-center justify-center py-4">
<Link href="/" className="flex items-center gap-2 font-bold text-xl">
<div className="bg-primary text-primary-foreground p-1 rounded">
🐐
</div>
<span className="group-data-[collapsible=icon]:hidden">MemeGoat</span>
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
{mainNav.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={pathname === item.url}
tooltip={item.title}
>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Explorer</SidebarGroupLabel>
<SidebarMenu>
<Collapsible asChild className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip="Catégories">
<LayoutGrid />
<span>Catégories</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{categories.map((category) => (
<SidebarMenuSubItem key={category.id}>
<SidebarMenuSubButton asChild isActive={pathname === `/category/${category.slug}`}>
<Link href={`/category/${category.slug}`}>
<span>{category.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Communauté</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Publier">
<Link href="/upload">
<PlusCircle />
<span>Publier</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
{isAuthenticated && user ? (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback className="rounded-lg">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold">
{user.displayName || user.username}
</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronRight className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="right"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback className="rounded-lg">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{user.displayName || user.username}
</span>
<span className="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile" className="flex items-center gap-2">
<UserIcon className="size-4" />
<span>Profil</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2">
<Settings className="size-4" />
<span>Paramètres</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
<LogOut className="size-4 mr-2" />
<span>Déconnexion</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
) : (
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Se connecter">
<Link href="/login">
<LogIn className="size-4" />
<span>Se connecter</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Aide">
<Link href="/help">
<HelpCircle />
<span>Aide</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import * as React from "react";
import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
export function CategoryContent({ slug }: { slug: string }) {
const fetchFn = React.useCallback((p: { limit: number; offset: number }) =>
ContentService.getExplore({ ...p, category: slug }),
[slug]);
return <ContentList fetchFn={fetchFn} title={`Catégorie : ${slug}`} />;
}

View File

@@ -0,0 +1,131 @@
"use client";
import * as React from "react";
import Image from "next/image";
import Link from "next/link";
import { Heart, MessageSquare, Share2, MoreHorizontal, Eye } from "lucide-react";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import type { Content } from "@/types/content";
import { useAuth } from "@/providers/auth-provider";
import { FavoriteService } from "@/services/favorite.service";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
interface ContentCardProps {
content: Content;
}
export function ContentCard({ content }: ContentCardProps) {
const { isAuthenticated, user } = useAuth();
const router = useRouter();
const [isLiked, setIsLiked] = React.useState(false);
const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
const handleLike = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isAuthenticated) {
toast.error("Vous devez être connecté pour liker un mème");
router.push("/login");
return;
}
try {
if (isLiked) {
await FavoriteService.remove(content.id);
setIsLiked(false);
setLikesCount(prev => prev - 1);
} else {
await FavoriteService.add(content.id);
setIsLiked(true);
setLikesCount(prev => prev + 1);
}
} catch (error) {
toast.error("Une erreur est survenue");
}
};
return (
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={content.author.avatarUrl} />
<AvatarFallback>{content.author.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<Link href={`/user/${content.author.username}`} className="text-sm font-semibold hover:underline">
{content.author.displayName || content.author.username}
</Link>
<span className="text-xs text-muted-foreground">
{new Date(content.createdAt).toLocaleDateString('fr-FR')}
</span>
</div>
<Button variant="ghost" size="icon" className="ml-auto h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="p-0 relative bg-zinc-100 dark:bg-zinc-900 aspect-square flex items-center justify-center">
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
{content.type === "image" ? (
<Image
src={content.url}
alt={content.title}
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
) : (
<video
src={content.url}
controls={false}
autoPlay
muted
loop
className="w-full h-full object-contain"
/>
)}
</Link>
</CardContent>
<CardFooter className="p-4 flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className={`gap-1.5 h-8 ${isLiked ? 'text-red-500 hover:text-red-600' : ''}`}
onClick={handleLike}
>
<Heart className={`h-4 w-4 ${isLiked ? 'fill-current' : ''}`} />
<span className="text-xs">{likesCount}</span>
</Button>
<Button variant="ghost" size="sm" className="gap-1.5 h-8">
<Eye className="h-4 w-4" />
<span className="text-xs">{content.views}</span>
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Share2 className="h-4 w-4" />
</Button>
</div>
<Button size="sm" variant="secondary" className="text-xs h-8">
Utiliser
</Button>
</div>
<div className="w-full space-y-2">
<h3 className="font-medium text-sm line-clamp-2">{content.title}</h3>
<div className="flex flex-wrap gap-1">
{content.tags.slice(0, 3).map((tag, i) => (
<Badge key={typeof tag === 'string' ? tag : tag.id} variant="secondary" className="text-[10px] py-0 px-1.5">
#{typeof tag === 'string' ? tag : tag.name}
</Badge>
))}
</div>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import * as React from "react";
import { ContentCard } from "@/components/content-card";
import { ContentService } from "@/services/content.service";
import type { Content, PaginatedResponse } from "@/types/content";
import { Spinner } from "@/components/ui/spinner";
import { useInfiniteScroll } from "@/app/(dashboard)/_hooks/use-infinite-scroll";
interface ContentListProps {
fetchFn: (params: { limit: number; offset: number }) => Promise<PaginatedResponse<Content>>;
title?: string;
}
export function ContentList({ fetchFn, title }: ContentListProps) {
const [contents, setContents] = React.useState<Content[]>([]);
const [loading, setLoading] = React.useState(true);
const [offset, setOffset] = React.useState(0);
const [hasMore, setHasMore] = React.useState(true);
const loadMore = React.useCallback(async () => {
if (!hasMore || loading) return;
setLoading(true);
try {
const response = await fetchFn({
limit: 10,
offset: offset + 10,
});
setContents(prev => [...prev, ...response.data]);
setOffset(prev => prev + 10);
setHasMore(response.data.length === 10);
} catch (error) {
console.error("Failed to load more contents:", error);
} finally {
setLoading(false);
}
}, [offset, hasMore, loading, fetchFn]);
const { loaderRef } = useInfiniteScroll({
hasMore,
loading,
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 (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
{title && <h1 className="text-2xl font-bold">{title}</h1>}
<div className="flex flex-col gap-6">
{contents.map((content) => (
<ContentCard key={content.id} content={content} />
))}
</div>
<div ref={loaderRef} className="py-8 flex justify-center">
{loading && <Spinner className="h-8 w-8 text-primary" />}
{!hasMore && contents.length > 0 && (
<p className="text-muted-foreground text-sm italic">Vous avez atteint la fin ! 🐐</p>
)}
{!loading && contents.length === 0 && (
<p className="text-muted-foreground text-sm italic">Aucun mème trouvé ici... pour l'instant !</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export function ContentSkeleton() {
return (
<Card className="overflow-hidden border-none shadow-sm">
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-2 w-16" />
</div>
</CardHeader>
<CardContent className="p-0 aspect-square">
<Skeleton className="h-full w-full" />
</CardContent>
<CardFooter className="p-4 flex flex-col gap-4">
<div className="w-full flex justify-between">
<div className="flex gap-2">
<Skeleton className="h-8 w-12 rounded-md" />
<Skeleton className="h-8 w-12 rounded-md" />
</div>
<Skeleton className="h-8 w-20 rounded-md" />
</div>
<div className="w-full space-y-2">
<Skeleton className="h-4 w-full" />
<div className="flex gap-1">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-12" />
</div>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
import { useSearchParams } from "next/navigation";
export function HomeContent() {
const searchParams = useSearchParams();
const sort = (searchParams.get("sort") as "trend" | "recent") || "trend";
const category = searchParams.get("category") || undefined;
const tag = searchParams.get("tag") || undefined;
const query = searchParams.get("query") || undefined;
const fetchFn = React.useCallback((params: { limit: number; offset: number }) =>
ContentService.getExplore({
...params,
sort,
category,
tag,
query
}),
[sort, category, tag, query]);
const title = query
? `Résultats pour "${query}"`
: category
? `Catégorie : ${category}`
: sort === "trend"
? "Tendances du moment"
: "Nouveautés";
return <ContentList fetchFn={fetchFn} title={title} />;
}

View File

@@ -0,0 +1,146 @@
"use client";
import * as React from "react";
import { Filter, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
export function MobileFilters() {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const [categories, setCategories] = React.useState<Category[]>([]);
const [query, setQuery] = React.useState(searchParams.get("query") || "");
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (open) {
CategoryService.getAll().then(setCategories).catch(console.error);
}
}, [open]);
const updateSearch = React.useCallback((name: string, value: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
router.push(`${pathname}?${params.toString()}`);
}, [router, pathname, searchParams]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
updateSearch("query", query);
setOpen(false);
};
const currentSort = searchParams.get("sort") || "trend";
const currentCategory = searchParams.get("category");
return (
<div className="lg:hidden fixed top-4 right-4 z-50">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="icon" className="rounded-full shadow-lg h-12 w-12">
<Filter className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] sm:w-[400px]">
<SheetHeader>
<SheetTitle>Recherche & Filtres</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-6">
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des mèmes..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</form>
<ScrollArea className="h-[calc(100vh-200px)]">
<div className="space-y-6 pr-4">
<div>
<h3 className="text-sm font-medium mb-3">Trier par</h3>
<div className="flex flex-wrap gap-2">
<Badge
variant={currentSort === "trend" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "trend")}
>
Tendances
</Badge>
<Badge
variant={currentSort === "recent" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "recent")}
>
Récent
</Badge>
</div>
</div>
<Separator />
<div>
<h3 className="text-sm font-medium mb-3">Catégories</h3>
<div className="flex flex-wrap gap-2">
<Badge
variant={!currentCategory ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", null)}
>
Tout
</Badge>
{categories.map((cat) => (
<Badge
key={cat.id}
variant={currentCategory === cat.slug ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", cat.slug)}
>
{cat.name}
</Badge>
))}
</div>
</div>
<Separator />
<div>
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
<div className="flex flex-wrap gap-2">
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map(tag => (
<Badge
key={tag}
variant={searchParams.get("tag") === tag ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("tag", searchParams.get("tag") === tag ? null : tag)}
>
#{tag}
</Badge>
))}
</div>
</div>
</div>
</ScrollArea>
</div>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,132 @@
"use client";
import * as React from "react";
import { Search, Filter } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
export function SearchSidebar() {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const [categories, setCategories] = React.useState<Category[]>([]);
const [query, setQuery] = React.useState(searchParams.get("query") || "");
React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
const updateSearch = React.useCallback((name: string, value: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
// If we are not on explore/trends/recent, maybe we should redirect to home?
// For now, let's just update the URL.
router.push(`${pathname}?${params.toString()}`);
}, [router, pathname, searchParams]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
updateSearch("query", query);
};
const currentSort = searchParams.get("sort") || "trend";
const currentCategory = searchParams.get("category");
return (
<aside className="hidden lg:flex flex-col w-80 border-l bg-background">
<div className="p-4 border-b">
<h2 className="font-semibold mb-4">Rechercher</h2>
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des mèmes..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</form>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<Filter className="h-4 w-4" />
Filtres
</h3>
<div className="space-y-4">
<div>
<p className="text-xs text-muted-foreground mb-2">Trier par</p>
<div className="flex flex-wrap gap-2">
<Badge
variant={currentSort === "trend" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "trend")}
>
Tendances
</Badge>
<Badge
variant={currentSort === "recent" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "recent")}
>
Récent
</Badge>
</div>
</div>
<Separator />
<div>
<p className="text-xs text-muted-foreground mb-2">Catégories</p>
<div className="flex flex-wrap gap-2">
<Badge
variant={!currentCategory ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", null)}
>
Tout
</Badge>
{categories.map((cat) => (
<Badge
key={cat.id}
variant={currentCategory === cat.slug ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", cat.slug)}
>
{cat.name}
</Badge>
))}
</div>
</div>
</div>
</div>
<div>
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
<div className="flex flex-wrap gap-2">
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map(tag => (
<Badge
key={tag}
variant={searchParams.get("tag") === tag ? "default" : "outline"}
className="cursor-pointer hover:bg-secondary"
onClick={() => updateSearch("tag", searchParams.get("tag") === tag ? null : tag)}
>
#{tag}
</Badge>
))}
</div>
</div>
</div>
</ScrollArea>
</aside>
);
}

View File

@@ -1,56 +0,0 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

Some files were not shown because too many files have changed in this diff Show More