From 29b1db4aed73a7dbdcb26abc1fb59ef9889a42a1 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:57:44 +0100 Subject: [PATCH] feat: add ViewCounter enhancements and file upload progress tracking - Improved `ViewCounter` with visibility-based view increment using `IntersectionObserver` and 50% video progress tracking. - Added real-time file upload progress updates via Socket.io, including status and percentage feedback. - Integrated `ViewCounter` dynamically into `ContentCard` and removed redundant instances from static pages. - Updated backend upload logic to emit progress updates at different stages via the `EventsGateway`. --- backend/src/contents/contents.module.ts | 3 +- backend/src/contents/contents.service.ts | 58 ++++++++++++++- .../@modal/(.)meme/[slug]/page.tsx | 2 - .../src/app/(dashboard)/meme/[slug]/page.tsx | 2 - frontend/src/app/(dashboard)/upload/page.tsx | 41 +++++++++-- frontend/src/components/content-card.tsx | 4 ++ frontend/src/components/view-counter.tsx | 71 ++++++++++++++++--- 7 files changed, 159 insertions(+), 22 deletions(-) diff --git a/backend/src/contents/contents.module.ts b/backend/src/contents/contents.module.ts index 89b1c43..8fd2b73 100644 --- a/backend/src/contents/contents.module.ts +++ b/backend/src/contents/contents.module.ts @@ -1,13 +1,14 @@ import { Module } from "@nestjs/common"; import { AuthModule } from "../auth/auth.module"; import { MediaModule } from "../media/media.module"; +import { RealtimeModule } from "../realtime/realtime.module"; import { S3Module } from "../s3/s3.module"; import { ContentsController } from "./contents.controller"; import { ContentsService } from "./contents.service"; import { ContentsRepository } from "./repositories/contents.repository"; @Module({ - imports: [S3Module, AuthModule, MediaModule], + imports: [S3Module, AuthModule, MediaModule, RealtimeModule], controllers: [ContentsController], providers: [ContentsService, ContentsRepository], exports: [ContentsRepository], diff --git a/backend/src/contents/contents.service.ts b/backend/src/contents/contents.service.ts index c0e2dd6..80e63ce 100644 --- a/backend/src/contents/contents.service.ts +++ b/backend/src/contents/contents.service.ts @@ -14,6 +14,7 @@ import type { } from "../common/interfaces/media.interface"; import type { IStorageService } from "../common/interfaces/storage.interface"; import { MediaService } from "../media/media.service"; +import { EventsGateway } from "../realtime/events.gateway"; import { S3Service } from "../s3/s3.service"; import { CreateContentDto } from "./dto/create-content.dto"; import { UploadContentDto } from "./dto/upload-content.dto"; @@ -29,6 +30,7 @@ export class ContentsService { @Inject(MediaService) private readonly mediaService: IMediaService, private readonly configService: ConfigService, @Inject(CACHE_MANAGER) private cacheManager: Cache, + private readonly eventsGateway: EventsGateway, ) {} private async clearContentsCache() { @@ -48,6 +50,11 @@ export class ContentsService { data: UploadContentDto, ) { this.logger.log(`Uploading and processing file for user ${userId}`); + this.eventsGateway.sendToUser(userId, "upload_progress", { + status: "starting", + progress: 0, + }); + // 0. Validation du format et de la taille const allowedMimeTypes = [ "image/png", @@ -60,13 +67,25 @@ export class ContentsService { ]; if (!allowedMimeTypes.includes(file.mimetype)) { + this.eventsGateway.sendToUser(userId, "upload_progress", { + status: "error", + message: "Format de fichier non supporté", + }); throw new BadRequestException( "Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.", ); } - const isGif = file.mimetype === "image/gif"; - const isVideo = file.mimetype.startsWith("video/"); + // Autodétermination du type si non fourni ou pour valider + let contentType: "meme" | "gif" | "video" = "meme"; + if (file.mimetype === "image/gif") { + contentType = "gif"; + } else if (file.mimetype.startsWith("video/")) { + contentType = "video"; + } + + const isGif = contentType === "gif"; + const isVideo = contentType === "video"; let maxSizeKb: number; if (isGif) { @@ -78,23 +97,39 @@ export class ContentsService { } if (file.size > maxSizeKb * 1024) { + this.eventsGateway.sendToUser(userId, "upload_progress", { + status: "error", + message: "Fichier trop volumineux", + }); throw new BadRequestException( `Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "image"}: ${maxSizeKb} Ko.`, ); } // 1. Scan Antivirus + this.eventsGateway.sendToUser(userId, "upload_progress", { + status: "scanning", + progress: 20, + }); const scanResult = await this.mediaService.scanFile( file.buffer, file.originalname, ); if (scanResult.isInfected) { + this.eventsGateway.sendToUser(userId, "upload_progress", { + status: "error", + message: "Fichier infecté", + }); throw new BadRequestException( `Le fichier est infecté par ${scanResult.virusName}`, ); } // 2. Transcodage + this.eventsGateway.sendToUser(userId, "upload_progress", { + status: "processing", + progress: 40, + }); let processed: MediaProcessingResult; if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") { // Image -> WebP (format moderne, bien supporté) @@ -110,17 +145,34 @@ export class ContentsService { } // 3. Upload vers S3 + this.eventsGateway.sendToUser(userId, "upload_progress", { + status: "uploading_s3", + progress: 70, + }); const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`; await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType); this.logger.log(`File uploaded successfully to S3: ${key}`); // 4. Création en base de données - return await this.create(userId, { + this.eventsGateway.sendToUser(userId, "upload_progress", { + status: "saving", + progress: 90, + }); + const content = await this.create(userId, { ...data, + type: contentType, // Utiliser le type autodéterminé storageKey: key, mimeType: processed.mimeType, fileSize: processed.size, }); + + this.eventsGateway.sendToUser(userId, "upload_progress", { + status: "completed", + progress: 100, + contentId: content.id, + }); + + return content; } async findAll(options: { diff --git a/frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx b/frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx index 2f3d59f..6645116 100644 --- a/frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx +++ b/frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx @@ -10,7 +10,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; -import { ViewCounter } from "@/components/view-counter"; import { ContentService } from "@/services/content.service"; import type { Content } from "@/types/content"; @@ -46,7 +45,6 @@ export default function MemeModal({ ) : content ? (
-
) : ( diff --git a/frontend/src/app/(dashboard)/meme/[slug]/page.tsx b/frontend/src/app/(dashboard)/meme/[slug]/page.tsx index 44a03b1..8950813 100644 --- a/frontend/src/app/(dashboard)/meme/[slug]/page.tsx +++ b/frontend/src/app/(dashboard)/meme/[slug]/page.tsx @@ -5,7 +5,6 @@ import { notFound } from "next/navigation"; import { CommentSection } from "@/components/comment-section"; import { ContentCard } from "@/components/content-card"; import { Button } from "@/components/ui/button"; -import { ViewCounter } from "@/components/view-counter"; import { ContentService } from "@/services/content.service"; export const revalidate = 3600; // ISR: Revalider toutes les heures @@ -42,7 +41,6 @@ export default async function MemePage({ return (
- ; export default function UploadPage() { const router = useRouter(); const { isAuthenticated, isLoading } = useAuth(); + const { socket } = useSocket(); const [categories, setCategories] = React.useState([]); const [file, setFile] = React.useState(null); const [preview, setPreview] = React.useState(null); const [isUploading, setIsUploading] = React.useState(false); + const [uploadStatus, setUploadStatus] = React.useState(""); + const [uploadProgress, setUploadProgress] = React.useState(0); + + React.useEffect(() => { + if (socket) { + socket.on( + "upload_progress", + (data: { status: string; progress: number; message?: string }) => { + setUploadStatus(data.status); + setUploadProgress(data.progress); + if (data.status === "error" && data.message) { + toast.error(data.message); + } + }, + ); + + return () => { + socket.off("upload_progress"); + }; + } + }, [socket]); const form = useForm({ resolver: zodResolver(uploadSchema), @@ -327,10 +350,20 @@ export default function UploadPage() {