fix: Replace broken media thumbnails with MediaThumbnail component

Old Cloudinary URLs no longer load (account full). The new MediaThumbnail
component handles broken URLs gracefully with a fallback icon, and uses
<video preload="metadata"> for local video files to show the first frame.

- New component: client/src/components/media-thumbnail.tsx
- Updated: new-reel.tsx, new-post.tsx, media-upload.tsx,
           edit-scheduled-post-dialog.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-03-25 08:05:04 +01:00
1 parent 7733acbc22
commit 2744b538e3
5 files changed
+74 -57

No files matched your search

@@ -10,6 +10,7 @@ import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage, Media, Post } from "@shared/schema";
import { format } from "date-fns";
import { Image as ImageIcon, CheckCircle } from "lucide-react";
import { MediaThumbnail } from "@/components/media-thumbnail";
interface EditScheduledPostDialogProps {
open: boolean;
@@ -165,9 +166,10 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
if (!media) return null;
return (
<div key={mediaId} className="relative group">
<img
<MediaThumbnail
src={media.instagramFeedUrl || media.originalUrl}
alt="Selected"
type={media.type === 'video' ? 'video' : 'image'}
className="w-full h-24 object-cover rounded-lg border-2 border-primary"
/>
<div className="absolute top-1 left-1 bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center text-xs font-bold">
@@ -198,9 +200,10 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
className="relative group"
data-testid={`button-select-media-${media.id}`}
>
<img
<MediaThumbnail
src={media.instagramFeedUrl || media.originalUrl}
alt={media.fileName}
type={media.type === 'video' ? 'video' : 'image'}
className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${
isSelected ? 'border-primary' : 'border-border hover:border-primary/50'
}`}
+57
View File
@@ -0,0 +1,57 @@
import { useState } from 'react';
import { ImageIcon, VideoIcon } from 'lucide-react';
interface MediaThumbnailProps {
src: string;
alt: string;
type?: 'image' | 'video';
className?: string;
}
function isVideoUrl(url: string): boolean {
return /\.(mp4|mov|avi|webm|mkv|m4v)(\?.*)?$/i.test(url);
}
/**
* Renders a media thumbnail that handles:
* - Local video files: uses <video preload="metadata"> to show first frame
* - Images: standard <img> with lazy loading
* - Broken/unavailable URLs: shows a placeholder icon
*/
export function MediaThumbnail({ src, alt, type, className = "w-full h-full object-cover" }: MediaThumbnailProps) {
const [error, setError] = useState(false);
const isVideo = type === 'video' || isVideoUrl(src);
if (error || !src) {
return (
<div className={`flex items-center justify-center bg-muted ${className.replace('object-cover', '')}`}>
{isVideo
? <VideoIcon className="w-8 h-8 text-muted-foreground" />
: <ImageIcon className="w-8 h-8 text-muted-foreground" />
}
</div>
);
}
if (isVideo) {
return (
<video
src={src}
className={className}
preload="metadata"
muted
onError={() => setError(true)}
/>
);
}
return (
<img
src={src}
alt={alt}
className={className}
loading="lazy"
onError={() => setError(true)}
/>
);
}
+3 -25
View File
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { getVideoThumbnailUrl } from "@/lib/media-utils";
import { MediaThumbnail } from "@/components/media-thumbnail";
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { SiFacebook, SiInstagram } from "react-icons/si";
@@ -273,33 +273,11 @@ export default function MediaUpload() {
className="relative aspect-square rounded-lg overflow-hidden border-2 border-border/50 cursor-pointer hover:border-primary hover:shadow-lg transition-all group"
data-testid={`media-item-${media.id}`}
>
{media.type === "video" ? (
<>
<img
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
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
className="w-full h-full object-cover"
loading="lazy"
type={media.type === 'video' ? 'video' : 'image'}
/>
)}
<div className="absolute top-2 right-2 flex gap-2">
{media.type === "image" && (
<button
+5 -25
View File
@@ -32,7 +32,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { getVideoThumbnailUrl } from "@/lib/media-utils";
import { MediaThumbnail } from "@/components/media-thumbnail";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal";
import { DateTimePicker } from "@/components/datetime-picker";
@@ -77,21 +77,11 @@ function SortableMediaItem({
className="w-full h-full"
data-testid={`button-select-media-${media.id}`}
>
{isVideo ? (
<img
src={getVideoThumbnailUrl(media.originalUrl)}
alt={media.fileName}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<img
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
className="w-full h-full object-cover"
loading="lazy"
type={isVideo ? 'video' : 'image'}
/>
)}
</button>
{isSelected && (
<>
@@ -543,21 +533,11 @@ export default function NewPost() {
}`}
data-testid={`button-select-media-${media.id}`}
>
{isVideo ? (
<img
src={getVideoThumbnailUrl(media.originalUrl)}
alt={media.fileName}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<img
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
className="w-full h-full object-cover"
loading="lazy"
type={isVideo ? 'video' : 'image'}
/>
)}
{isSelected && (
<div className="absolute inset-0 bg-primary/10 flex items-center justify-center">
<div className="text-xs font-semibold text-primary">Sélectionnée</div>
+4 -5
View File
@@ -27,7 +27,7 @@ import {
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
import { getVideoThumbnailUrl } from "@/lib/media-utils";
import { MediaThumbnail } from "@/components/media-thumbnail";
import { DateTimePicker } from "@/components/datetime-picker";
interface MusicTrack {
@@ -487,11 +487,10 @@ export default function NewReel() {
: 'border-transparent hover:border-muted-foreground'
}`}
>
<img
src={getVideoThumbnailUrl(media.originalUrl)}
<MediaThumbnail
src={media.originalUrl}
alt={media.fileName}
className="w-full h-full object-cover"
loading="lazy"
type="video"
/>
{selectedVideoId === media.id && (
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">