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`.
This commit is contained in:
Mathis HERRIOT
2026-01-29 14:57:44 +01:00
parent 9db3067721
commit 29b1db4aed
7 changed files with 159 additions and 22 deletions

View File

@@ -1,13 +1,14 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module"; import { AuthModule } from "../auth/auth.module";
import { MediaModule } from "../media/media.module"; import { MediaModule } from "../media/media.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module"; import { S3Module } from "../s3/s3.module";
import { ContentsController } from "./contents.controller"; import { ContentsController } from "./contents.controller";
import { ContentsService } from "./contents.service"; import { ContentsService } from "./contents.service";
import { ContentsRepository } from "./repositories/contents.repository"; import { ContentsRepository } from "./repositories/contents.repository";
@Module({ @Module({
imports: [S3Module, AuthModule, MediaModule], imports: [S3Module, AuthModule, MediaModule, RealtimeModule],
controllers: [ContentsController], controllers: [ContentsController],
providers: [ContentsService, ContentsRepository], providers: [ContentsService, ContentsRepository],
exports: [ContentsRepository], exports: [ContentsRepository],

View File

@@ -14,6 +14,7 @@ import type {
} from "../common/interfaces/media.interface"; } from "../common/interfaces/media.interface";
import type { IStorageService } from "../common/interfaces/storage.interface"; import type { IStorageService } from "../common/interfaces/storage.interface";
import { MediaService } from "../media/media.service"; import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
import { CreateContentDto } from "./dto/create-content.dto"; import { CreateContentDto } from "./dto/create-content.dto";
import { UploadContentDto } from "./dto/upload-content.dto"; import { UploadContentDto } from "./dto/upload-content.dto";
@@ -29,6 +30,7 @@ export class ContentsService {
@Inject(MediaService) private readonly mediaService: IMediaService, @Inject(MediaService) private readonly mediaService: IMediaService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject(CACHE_MANAGER) private cacheManager: Cache, @Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly eventsGateway: EventsGateway,
) {} ) {}
private async clearContentsCache() { private async clearContentsCache() {
@@ -48,6 +50,11 @@ export class ContentsService {
data: UploadContentDto, data: UploadContentDto,
) { ) {
this.logger.log(`Uploading and processing file for user ${userId}`); 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 // 0. Validation du format et de la taille
const allowedMimeTypes = [ const allowedMimeTypes = [
"image/png", "image/png",
@@ -60,13 +67,25 @@ export class ContentsService {
]; ];
if (!allowedMimeTypes.includes(file.mimetype)) { if (!allowedMimeTypes.includes(file.mimetype)) {
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "error",
message: "Format de fichier non supporté",
});
throw new BadRequestException( throw new BadRequestException(
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.", "Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.",
); );
} }
const isGif = file.mimetype === "image/gif"; // Autodétermination du type si non fourni ou pour valider
const isVideo = file.mimetype.startsWith("video/"); 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; let maxSizeKb: number;
if (isGif) { if (isGif) {
@@ -78,23 +97,39 @@ export class ContentsService {
} }
if (file.size > maxSizeKb * 1024) { if (file.size > maxSizeKb * 1024) {
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "error",
message: "Fichier trop volumineux",
});
throw new BadRequestException( throw new BadRequestException(
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "image"}: ${maxSizeKb} Ko.`, `Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "image"}: ${maxSizeKb} Ko.`,
); );
} }
// 1. Scan Antivirus // 1. Scan Antivirus
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "scanning",
progress: 20,
});
const scanResult = await this.mediaService.scanFile( const scanResult = await this.mediaService.scanFile(
file.buffer, file.buffer,
file.originalname, file.originalname,
); );
if (scanResult.isInfected) { if (scanResult.isInfected) {
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "error",
message: "Fichier infecté",
});
throw new BadRequestException( throw new BadRequestException(
`Le fichier est infecté par ${scanResult.virusName}`, `Le fichier est infecté par ${scanResult.virusName}`,
); );
} }
// 2. Transcodage // 2. Transcodage
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "processing",
progress: 40,
});
let processed: MediaProcessingResult; let processed: MediaProcessingResult;
if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") { if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") {
// Image -> WebP (format moderne, bien supporté) // Image -> WebP (format moderne, bien supporté)
@@ -110,17 +145,34 @@ export class ContentsService {
} }
// 3. Upload vers S3 // 3. Upload vers S3
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "uploading_s3",
progress: 70,
});
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`; const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType); await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
this.logger.log(`File uploaded successfully to S3: ${key}`); this.logger.log(`File uploaded successfully to S3: ${key}`);
// 4. Création en base de données // 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, ...data,
type: contentType, // Utiliser le type autodéterminé
storageKey: key, storageKey: key,
mimeType: processed.mimeType, mimeType: processed.mimeType,
fileSize: processed.size, fileSize: processed.size,
}); });
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "completed",
progress: 100,
contentId: content.id,
});
return content;
} }
async findAll(options: { async findAll(options: {

View File

@@ -10,7 +10,6 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { ViewCounter } from "@/components/view-counter";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import type { Content } from "@/types/content"; import type { Content } from "@/types/content";
@@ -46,7 +45,6 @@ export default function MemeModal({
</div> </div>
) : content ? ( ) : content ? (
<div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden"> <div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
<ViewCounter contentId={content.id} />
<ContentCard content={content} /> <ContentCard content={content} />
</div> </div>
) : ( ) : (

View File

@@ -5,7 +5,6 @@ import { notFound } from "next/navigation";
import { CommentSection } from "@/components/comment-section"; import { CommentSection } from "@/components/comment-section";
import { ContentCard } from "@/components/content-card"; import { ContentCard } from "@/components/content-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ViewCounter } from "@/components/view-counter";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
export const revalidate = 3600; // ISR: Revalider toutes les heures export const revalidate = 3600; // ISR: Revalider toutes les heures
@@ -42,7 +41,6 @@ export default async function MemePage({
return ( return (
<div className="max-w-4xl mx-auto py-8 px-4"> <div className="max-w-4xl mx-auto py-8 px-4">
<ViewCounter contentId={content.id} />
<Link <Link
href="/" href="/"
className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors" className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors"

View File

@@ -36,6 +36,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { useSocket } from "@/providers/socket-provider";
import { CategoryService } from "@/services/category.service"; import { CategoryService } from "@/services/category.service";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import type { Category } from "@/types/content"; import type { Category } from "@/types/content";
@@ -52,10 +53,32 @@ type UploadFormValues = z.infer<typeof uploadSchema>;
export default function UploadPage() { export default function UploadPage() {
const router = useRouter(); const router = useRouter();
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
const { socket } = useSocket();
const [categories, setCategories] = React.useState<Category[]>([]); const [categories, setCategories] = React.useState<Category[]>([]);
const [file, setFile] = React.useState<File | null>(null); const [file, setFile] = React.useState<File | null>(null);
const [preview, setPreview] = React.useState<string | null>(null); const [preview, setPreview] = React.useState<string | null>(null);
const [isUploading, setIsUploading] = React.useState(false); const [isUploading, setIsUploading] = React.useState(false);
const [uploadStatus, setUploadStatus] = React.useState<string>("");
const [uploadProgress, setUploadProgress] = React.useState<number>(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<UploadFormValues>({ const form = useForm<UploadFormValues>({
resolver: zodResolver(uploadSchema), resolver: zodResolver(uploadSchema),
@@ -327,10 +350,20 @@ export default function UploadPage() {
<Button type="submit" className="w-full" disabled={isUploading}> <Button type="submit" className="w-full" disabled={isUploading}>
{isUploading ? ( {isUploading ? (
<> <div className="flex flex-col items-center gap-1">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <div className="flex items-center gap-2">
Upload en cours... <Loader2 className="h-4 w-4 animate-spin" />
</> <span>{uploadProgress}%</span>
</div>
<span className="text-[10px] uppercase tracking-wider opacity-70">
{uploadStatus === "starting" && "Initialisation..."}
{uploadStatus === "scanning" && "Scan Antivirus..."}
{uploadStatus === "processing" && "Optimisation..."}
{uploadStatus === "uploading_s3" && "Envoi au cloud..."}
{uploadStatus === "saving" && "Finalisation..."}
{uploadStatus === "completed" && "Terminé !"}
</span>
</div>
) : ( ) : (
"Publier le mème" "Publier le mème"
)} )}

View File

@@ -36,6 +36,7 @@ import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service"; import { FavoriteService } from "@/services/favorite.service";
import type { Content } from "@/types/content"; import type { Content } from "@/types/content";
import { UserContentEditDialog } from "./user-content-edit-dialog"; import { UserContentEditDialog } from "./user-content-edit-dialog";
import { ViewCounter } from "./view-counter";
interface ContentCardProps { interface ContentCardProps {
content: Content; content: Content;
@@ -98,6 +99,8 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
await FavoriteService.add(content.id); await FavoriteService.add(content.id);
setIsLiked(true); setIsLiked(true);
setLikesCount((prev) => prev + 1); setLikesCount((prev) => prev + 1);
// Considérer un like comme une vue
ContentService.incrementViews(content.id).catch(() => {});
} }
} catch (_error) { } catch (_error) {
toast.error("Une erreur est survenue"); toast.error("Une erreur est survenue");
@@ -146,6 +149,7 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
return ( return (
<> <>
<ViewCounter contentId={content.id} videoRef={videoRef} />
<Card className="overflow-hidden border-none gap-0 shadow-none bg-transparent"> <Card className="overflow-hidden border-none gap-0 shadow-none bg-transparent">
<CardHeader className="p-3 flex flex-row items-center space-y-0 gap-3"> <CardHeader className="p-3 flex flex-row items-center space-y-0 gap-3">
<Avatar className="h-8 w-8 border"> <Avatar className="h-8 w-8 border">

View File

@@ -1,23 +1,74 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { type RefObject, useEffect, useRef } from "react";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
interface ViewCounterProps { interface ViewCounterProps {
contentId: string; contentId: string;
videoRef?: RefObject<HTMLVideoElement | null>;
} }
export function ViewCounter({ contentId }: ViewCounterProps) { export function ViewCounter({ contentId, videoRef }: ViewCounterProps) {
const hasIncremented = useRef(false); const hasIncremented = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!hasIncremented.current) { const increment = () => {
ContentService.incrementViews(contentId).catch((err) => { if (!hasIncremented.current) {
console.error("Failed to increment views:", err); ContentService.incrementViews(contentId).catch((err) => {
}); console.error("Failed to increment views:", err);
hasIncremented.current = true; });
} hasIncremented.current = true;
}, [contentId]); }
};
return null; // 1. Observer pour la visibilité (IntersectionObserver)
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
// Si c'est une image (pas de videoRef), on attend 3 secondes
if (!videoRef) {
const timer = setTimeout(() => {
increment();
}, 3000);
return () => clearTimeout(timer);
}
}
},
{ threshold: 0.5 },
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
// 2. Logique pour la vidéo (> 50%)
let videoElement: HTMLVideoElement | null = null;
const handleTimeUpdate = () => {
if (videoElement && videoElement.duration > 0) {
const progress = videoElement.currentTime / videoElement.duration;
if (progress >= 0.5) {
increment();
videoElement.removeEventListener("timeupdate", handleTimeUpdate);
}
}
};
if (videoRef?.current) {
videoElement = videoRef.current;
videoElement.addEventListener("timeupdate", handleTimeUpdate);
}
return () => {
observer.disconnect();
if (videoElement) {
videoElement.removeEventListener("timeupdate", handleTimeUpdate);
}
};
}, [contentId, videoRef]);
return (
<div ref={containerRef} className="absolute inset-0 pointer-events-none" />
);
} }