Compare commits
13 Commits
7cb5ff487d
...
77ac960411
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77ac960411
|
||
|
|
8425ffe4fc
|
||
|
|
b81835661c
|
||
|
|
fbc231dc9a
|
||
|
|
37a23390d5
|
||
|
|
bd9dd140ab
|
||
|
|
5b6e0143b6
|
||
|
|
214bf077e5
|
||
|
|
bb9ae058db
|
||
|
|
0b07320974
|
||
|
|
0c045e8d3c
|
||
|
|
8ffeaeba05
|
||
|
|
9e37272bff
|
42
.env.example
42
.env.example
@@ -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
|
||||
|
||||
73
.gitea/workflows/deploy.yml
Normal file
73
.gitea/workflows/deploy.yml
Normal 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
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
.pnpm-store
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
177
backend/.migrations/0000_right_sally_floyd.sql
Normal file
177
backend/.migrations/0000_right_sally_floyd.sql
Normal 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");
|
||||
30
backend/.migrations/0001_purple_goliath.sql
Normal file
30
backend/.migrations/0001_purple_goliath.sql
Normal 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");
|
||||
1
backend/.migrations/0002_redundant_skin.sql
Normal file
1
backend/.migrations/0002_redundant_skin.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "avatar_url" varchar(255);
|
||||
1316
backend/.migrations/meta/0000_snapshot.json
Normal file
1316
backend/.migrations/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1513
backend/.migrations/meta/0001_snapshot.json
Normal file
1513
backend/.migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1519
backend/.migrations/meta/0002_snapshot.json
Normal file
1519
backend/.migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
27
backend/.migrations/meta/_journal.json
Normal file
27
backend/.migrations/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" ]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { SQL, sql } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
createCuid: () => () => 'mocked-cuid',
|
||||
};
|
||||
@@ -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: {} }),
|
||||
};
|
||||
@@ -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),
|
||||
}
|
||||
};
|
||||
@@ -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) }) }),
|
||||
};
|
||||
@@ -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!");
|
||||
});
|
||||
});
|
||||
@@ -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
141
docker-compose.prod.yml
Normal 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
129
docker-compose.yml
Normal 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:
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
1025
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
124
frontend/src/app/(auth)/login/page.tsx
Normal file
124
frontend/src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
frontend/src/app/(auth)/register/page.tsx
Normal file
153
frontend/src/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx
Normal file
45
frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
frontend/src/app/(dashboard)/@modal/default.tsx
Normal file
3
frontend/src/app/(dashboard)/@modal/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
42
frontend/src/app/(dashboard)/_hooks/use-infinite-scroll.ts
Normal file
42
frontend/src/app/(dashboard)/_hooks/use-infinite-scroll.ts
Normal 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 };
|
||||
}
|
||||
31
frontend/src/app/(dashboard)/category/[slug]/page.tsx
Normal file
31
frontend/src/app/(dashboard)/category/[slug]/page.tsx
Normal 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} />;
|
||||
}
|
||||
54
frontend/src/app/(dashboard)/category/page.tsx
Normal file
54
frontend/src/app/(dashboard)/category/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/app/(dashboard)/layout.tsx
Normal file
37
frontend/src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/src/app/(dashboard)/loading.tsx
Normal file
13
frontend/src/app/(dashboard)/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/app/(dashboard)/meme/[slug]/page.tsx
Normal file
90
frontend/src/app/(dashboard)/meme/[slug]/page.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
21
frontend/src/app/(dashboard)/page.tsx
Normal file
21
frontend/src/app/(dashboard)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/src/app/(dashboard)/profile/page.tsx
Normal file
82
frontend/src/app/(dashboard)/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/src/app/(dashboard)/recent/page.tsx
Normal file
13
frontend/src/app/(dashboard)/recent/page.tsx
Normal 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" />;
|
||||
}
|
||||
13
frontend/src/app/(dashboard)/trends/page.tsx
Normal file
13
frontend/src/app/(dashboard)/trends/page.tsx
Normal 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" />;
|
||||
}
|
||||
257
frontend/src/app/(dashboard)/upload/page.tsx
Normal file
257
frontend/src/app/(dashboard)/upload/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/app/error.tsx
Normal file
46
frontend/src/app/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
34
frontend/src/app/not-found.tsx
Normal file
34
frontend/src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
14
frontend/src/app/robots.ts
Normal file
14
frontend/src/app/robots.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
45
frontend/src/app/sitemap.ts
Normal file
45
frontend/src/app/sitemap.ts
Normal 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;
|
||||
}
|
||||
243
frontend/src/components/app-sidebar.tsx
Normal file
243
frontend/src/components/app-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/category-content.tsx
Normal file
13
frontend/src/components/category-content.tsx
Normal 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}`} />;
|
||||
}
|
||||
131
frontend/src/components/content-card.tsx
Normal file
131
frontend/src/components/content-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/content-list.tsx
Normal file
89
frontend/src/components/content-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/content-skeleton.tsx
Normal file
35
frontend/src/components/content-skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/home-content.tsx
Normal file
35
frontend/src/components/home-content.tsx
Normal 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} />;
|
||||
}
|
||||
146
frontend/src/components/mobile-filters.tsx
Normal file
146
frontend/src/components/mobile-filters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/search-sidebar.tsx
Normal file
132
frontend/src/components/search-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user