mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 15:38:10 +02:00
feat: implement video thumbnail generation from Cloudinary URLs
This commit is contained in:
1 parent
58f513d476
commit
a35b9e3d1c
4 files changed
+59
-26
No files matched your search
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { getVideoThumbnailUrl } from "@/lib/media-utils";
|
||||||
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
|
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { SiFacebook, SiInstagram } from "react-icons/si";
|
import { SiFacebook, SiInstagram } from "react-icons/si";
|
||||||
@@ -273,9 +274,24 @@ export default function MediaUpload() {
|
|||||||
data-testid={`media-item-${media.id}`}
|
data-testid={`media-item-${media.id}`}
|
||||||
>
|
>
|
||||||
{media.type === "video" ? (
|
{media.type === "video" ? (
|
||||||
<div className="w-full h-full bg-muted/30 flex items-center justify-center">
|
<>
|
||||||
<Video className="w-12 h-12 text-secondary" />
|
<img
|
||||||
</div>
|
src={getVideoThumbnailUrl(media.originalUrl)}
|
||||||
|
alt={media.fileName}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback: replace broken img with Video icon
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.display = 'none';
|
||||||
|
const fallback = target.parentElement?.querySelector('.video-fallback');
|
||||||
|
if (fallback) (fallback as HTMLElement).style.display = 'flex';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="video-fallback w-full h-full bg-muted/30 flex items-center justify-center absolute inset-0" style={{ display: 'none' }}>
|
||||||
|
<Video className="w-12 h-12 text-secondary" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Derives a Cloudinary video thumbnail URL from the original video URL.
|
||||||
|
*
|
||||||
|
* Cloudinary automatically generates image frames for uploaded videos.
|
||||||
|
* By inserting transformation parameters into the URL, we can extract
|
||||||
|
* a static JPEG thumbnail without any backend change or DB migration.
|
||||||
|
*
|
||||||
|
* @param originalUrl - The Cloudinary video URL (e.g. .../video/upload/v123/file.mp4)
|
||||||
|
* @returns A JPEG thumbnail URL with fill-crop at 400x400
|
||||||
|
*/
|
||||||
|
export function getVideoThumbnailUrl(originalUrl: string): string {
|
||||||
|
// Only transform Cloudinary video URLs
|
||||||
|
if (!originalUrl.includes('/video/upload/')) {
|
||||||
|
return originalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalUrl
|
||||||
|
.replace('/video/upload/', '/video/upload/so_0,w_400,h_400,c_fill/')
|
||||||
|
.replace(/\.[^.]+$/, '.jpg');
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { getVideoThumbnailUrl } from "@/lib/media-utils";
|
||||||
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
|
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
|
||||||
import { PreviewModal } from "@/components/preview-modal";
|
import { PreviewModal } from "@/components/preview-modal";
|
||||||
import { DateTimePicker } from "@/components/datetime-picker";
|
import { DateTimePicker } from "@/components/datetime-picker";
|
||||||
@@ -66,11 +67,10 @@ function SortableMediaItem({
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
|
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${isSelected
|
||||||
isSelected
|
|
||||||
? 'border-primary ring-2 ring-primary'
|
? 'border-primary ring-2 ring-primary'
|
||||||
: 'border-transparent hover:border-muted-foreground'
|
: 'border-transparent hover:border-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
@@ -78,12 +78,11 @@ function SortableMediaItem({
|
|||||||
data-testid={`button-select-media-${media.id}`}
|
data-testid={`button-select-media-${media.id}`}
|
||||||
>
|
>
|
||||||
{isVideo ? (
|
{isVideo ? (
|
||||||
<video
|
<img
|
||||||
src={media.originalUrl}
|
src={getVideoThumbnailUrl(media.originalUrl)}
|
||||||
|
alt={media.fileName}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
muted
|
loading="lazy"
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
@@ -212,8 +211,8 @@ export default function NewPost() {
|
|||||||
mutationFn: async (productInfoText: string) => {
|
mutationFn: async (productInfoText: string) => {
|
||||||
const productInfo = {
|
const productInfo = {
|
||||||
name: productInfoText.match(/Produit:\s*(.+?)(?:\n|$)/i)?.[1] ||
|
name: productInfoText.match(/Produit:\s*(.+?)(?:\n|$)/i)?.[1] ||
|
||||||
productInfoText.match(/Nom:\s*(.+?)(?:\n|$)/i)?.[1] ||
|
productInfoText.match(/Nom:\s*(.+?)(?:\n|$)/i)?.[1] ||
|
||||||
productInfoText,
|
productInfoText,
|
||||||
price: productInfoText.match(/Prix:\s*(.+?)(?:\n|$)/i)?.[1] || "",
|
price: productInfoText.match(/Prix:\s*(.+?)(?:\n|$)/i)?.[1] || "",
|
||||||
description: productInfoText.match(/Description:\s*(.+?)(?:\n|$)/i)?.[1] || "",
|
description: productInfoText.match(/Description:\s*(.+?)(?:\n|$)/i)?.[1] || "",
|
||||||
features: productInfoText.match(/Caractéristiques:\s*(.+?)(?:\n|$)/i)?.[1]?.split(",") || [],
|
features: productInfoText.match(/Caractéristiques:\s*(.+?)(?:\n|$)/i)?.[1]?.split(",") || [],
|
||||||
@@ -537,20 +536,18 @@ export default function NewPost() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
|
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${isSelected
|
||||||
isSelected
|
|
||||||
? 'border-primary ring-2 ring-primary opacity-50'
|
? 'border-primary ring-2 ring-primary opacity-50'
|
||||||
: 'border-transparent hover:border-muted-foreground'
|
: 'border-transparent hover:border-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
data-testid={`button-select-media-${media.id}`}
|
data-testid={`button-select-media-${media.id}`}
|
||||||
>
|
>
|
||||||
{isVideo ? (
|
{isVideo ? (
|
||||||
<video
|
<img
|
||||||
src={media.originalUrl}
|
src={getVideoThumbnailUrl(media.originalUrl)}
|
||||||
|
alt={media.fileName}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
muted
|
loading="lazy"
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import type { SocialPage, Media } from "@shared/schema";
|
import type { SocialPage, Media } from "@shared/schema";
|
||||||
|
import { getVideoThumbnailUrl } from "@/lib/media-utils";
|
||||||
import { DateTimePicker } from "@/components/datetime-picker";
|
import { DateTimePicker } from "@/components/datetime-picker";
|
||||||
|
|
||||||
interface MusicTrack {
|
interface MusicTrack {
|
||||||
@@ -485,12 +486,11 @@ export default function NewReel() {
|
|||||||
: 'border-transparent hover:border-muted-foreground'
|
: 'border-transparent hover:border-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<video
|
<img
|
||||||
src={media.originalUrl}
|
src={getVideoThumbnailUrl(media.originalUrl)}
|
||||||
|
alt={media.fileName}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
muted
|
loading="lazy"
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
/>
|
/>
|
||||||
{selectedVideoId === media.id && (
|
{selectedVideoId === media.id && (
|
||||||
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
|
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
|
||||||
|
|||||||
Reference in new issue
Block a user