build video support
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Share2, Eye, Layers } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Share2, Eye, Layers, Play } from 'lucide-react';
|
||||
import type { Meme } from '../api/client';
|
||||
import { api } from '../api/client';
|
||||
|
||||
@@ -9,9 +9,38 @@ interface Props {
|
||||
onShare: (meme: Meme) => void;
|
||||
}
|
||||
|
||||
function isVideo(mimeType: string) {
|
||||
return mimeType.startsWith('video/');
|
||||
}
|
||||
|
||||
export function MemeCard({ meme, onOpen, onShare }: Props) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Autoplay video when it enters the viewport; pause when it leaves
|
||||
useEffect(() => {
|
||||
if (!isVideo(meme.mime_type) || !videoRef.current) return;
|
||||
|
||||
const el = videoRef.current;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
el.play().catch(() => {});
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.25 }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [meme.mime_type]);
|
||||
|
||||
const aspectPad = meme.width && meme.height
|
||||
? `${(meme.height / meme.width) * 100}%`
|
||||
: '56.25%'; // 16:9 fallback
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -20,25 +49,46 @@ export function MemeCard({ meme, onOpen, onShare }: Props) {
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => onOpen(meme)}
|
||||
>
|
||||
{/* Image */}
|
||||
<img
|
||||
src={api.imageUrl(meme.file_path)}
|
||||
alt={meme.title}
|
||||
loading="lazy"
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={`w-full block transition-all duration-500 ${
|
||||
loaded ? 'opacity-100' : 'opacity-0'
|
||||
} ${hovered ? 'scale-[1.02]' : 'scale-100'} transition-transform duration-300`}
|
||||
/>
|
||||
|
||||
{/* Skeleton while loading */}
|
||||
{!loaded && (
|
||||
<div
|
||||
className="absolute inset-0 bg-zinc-800 animate-pulse"
|
||||
style={{ paddingTop: `${(meme.height / meme.width) * 100}%` }}
|
||||
className="w-full bg-zinc-800 animate-pulse"
|
||||
style={{ paddingTop: aspectPad }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isVideo(meme.mime_type) ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={api.imageUrl(meme.file_path)}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
onLoadedMetadata={() => setLoaded(true)}
|
||||
className={`w-full block transition-all duration-500 ${
|
||||
loaded ? 'opacity-100' : 'opacity-0 absolute inset-0'
|
||||
} ${hovered ? 'scale-[1.02]' : 'scale-100'} transition-transform duration-300`}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={api.imageUrl(meme.file_path)}
|
||||
alt={meme.title}
|
||||
loading="lazy"
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={`w-full block transition-all duration-500 ${
|
||||
loaded ? 'opacity-100' : 'opacity-0 absolute inset-0'
|
||||
} ${hovered ? 'scale-[1.02]' : 'scale-100'} transition-transform duration-300`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Video badge */}
|
||||
{isVideo(meme.mime_type) && loaded && !hovered && (
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1 px-1.5 py-0.5 rounded bg-zinc-900/80 text-zinc-400">
|
||||
<Play size={10} className="fill-zinc-400" />
|
||||
<span className="text-xs">video</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-t from-zinc-950/90 via-zinc-950/40 to-transparent transition-opacity duration-200 ${
|
||||
@@ -67,20 +117,14 @@ export function MemeCard({ meme, onOpen, onShare }: Props) {
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen(meme);
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); onOpen(meme); }}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<Eye size={12} />
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShare(meme);
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); onShare(meme); }}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg bg-accent/30 hover:bg-accent/50 text-purple-200 transition-colors"
|
||||
>
|
||||
<Share2 size={12} />
|
||||
@@ -90,10 +134,7 @@ export function MemeCard({ meme, onOpen, onShare }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Child indicator */}
|
||||
{/* (shown from parent detail) — not needed on card itself */}
|
||||
|
||||
{/* Dimensions badge */}
|
||||
{/* Dimensions / resolution badge */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-zinc-400">
|
||||
{meme.width}×{meme.height}
|
||||
|
||||
@@ -119,7 +119,7 @@ export function MemeDetail({ memeId, onClose }: Props) {
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{isAdmin && !meme.parent_id && (
|
||||
{isAdmin && !meme.parent_id && !meme.mime_type.startsWith('video/') && (
|
||||
<button
|
||||
onClick={() => setShowRescale(true)}
|
||||
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors"
|
||||
@@ -147,15 +147,28 @@ export function MemeDetail({ memeId, onClose }: Props) {
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col md:flex-row flex-1 overflow-hidden">
|
||||
{/* Image panel */}
|
||||
{/* Image / video panel */}
|
||||
<div className="flex-1 flex items-center justify-center bg-zinc-950 p-4 overflow-hidden">
|
||||
{displayMeme && (
|
||||
<img
|
||||
key={displayMeme.id}
|
||||
src={api.imageUrl(displayMeme.file_path)}
|
||||
alt={displayMeme.title}
|
||||
className="max-w-full max-h-full object-contain rounded-lg animate-fade-in"
|
||||
/>
|
||||
displayMeme.mime_type.startsWith('video/') ? (
|
||||
<video
|
||||
key={displayMeme.id}
|
||||
src={api.imageUrl(displayMeme.file_path)}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="max-w-full max-h-full rounded-lg animate-fade-in"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={displayMeme.id}
|
||||
src={api.imageUrl(displayMeme.file_path)}
|
||||
alt={displayMeme.title}
|
||||
className="max-w-full max-h-full object-contain rounded-lg animate-fade-in"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ interface Props {
|
||||
defaultCollectionId?: number;
|
||||
}
|
||||
|
||||
const ALLOWED = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const ALLOWED = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4', 'video/webm', 'video/quicktime'];
|
||||
|
||||
export function UploadModal({ onClose, defaultCollectionId }: Props) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
@@ -88,11 +88,11 @@ export function UploadModal({ onClose, defaultCollectionId }: Props) {
|
||||
Drag & drop images here, or{' '}
|
||||
<span className="text-accent">browse files</span>
|
||||
</p>
|
||||
<p className="text-xs text-zinc-600 mt-1">JPG, PNG, GIF, WebP — max 100 MB each</p>
|
||||
<p className="text-xs text-zinc-600 mt-1">JPG, PNG, GIF, WebP, MP4, WebM — max 100 MB each</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,video/quicktime"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && addFiles(e.target.files)}
|
||||
|
||||
Reference in New Issue
Block a user