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:
@@ -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],
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
const increment = () => {
|
||||||
if (!hasIncremented.current) {
|
if (!hasIncremented.current) {
|
||||||
ContentService.incrementViews(contentId).catch((err) => {
|
ContentService.incrementViews(contentId).catch((err) => {
|
||||||
console.error("Failed to increment views:", 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" />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user