2025-12-30 13:35:19 +01:00
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
Box,
|
|
|
|
|
|
IconButton,
|
|
|
|
|
|
CircularProgress,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
} from '@mui/material';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Close as CloseIcon,
|
|
|
|
|
|
NavigateBefore as PrevIcon,
|
|
|
|
|
|
NavigateNext as NextIcon,
|
|
|
|
|
|
Download as DownloadIcon,
|
|
|
|
|
|
Share as ShareIcon,
|
|
|
|
|
|
Delete as DeleteIcon,
|
|
|
|
|
|
} from '@mui/icons-material';
|
|
|
|
|
|
import type { Asset } from '../types';
|
|
|
|
|
|
import api from '../services/api';
|
|
|
|
|
|
|
|
|
|
|
|
interface ViewerModalProps {
|
|
|
|
|
|
asset: Asset | null;
|
|
|
|
|
|
assets: Asset[];
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onDelete?: (assetId: string) => void;
|
|
|
|
|
|
onShare?: (assetId: string) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function ViewerModal({
|
|
|
|
|
|
asset,
|
|
|
|
|
|
assets,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onDelete,
|
|
|
|
|
|
onShare,
|
|
|
|
|
|
}: ViewerModalProps) {
|
|
|
|
|
|
const [currentUrl, setCurrentUrl] = useState<string>('');
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [currentIndex, setCurrentIndex] = useState(-1);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (asset) {
|
|
|
|
|
|
const index = assets.findIndex((a) => a.id === asset.id);
|
|
|
|
|
|
setCurrentIndex(index);
|
|
|
|
|
|
loadMedia(asset);
|
|
|
|
|
|
}
|
2025-12-30 15:53:16 +01:00
|
|
|
|
|
|
|
|
|
|
// Cleanup blob URL on unmount or asset change
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (currentUrl) {
|
|
|
|
|
|
URL.revokeObjectURL(currentUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-12-30 13:35:19 +01:00
|
|
|
|
}, [asset]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const handleKeyPress = (e: KeyboardEvent) => {
|
|
|
|
|
|
if (!asset) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
} else if (e.key === 'ArrowLeft') {
|
2025-12-30 14:00:44 +01:00
|
|
|
|
if (currentIndex > 0) {
|
|
|
|
|
|
const prevAsset = assets[currentIndex - 1];
|
|
|
|
|
|
loadMedia(prevAsset);
|
|
|
|
|
|
setCurrentIndex(currentIndex - 1);
|
|
|
|
|
|
}
|
2025-12-30 13:35:19 +01:00
|
|
|
|
} else if (e.key === 'ArrowRight') {
|
2025-12-30 14:00:44 +01:00
|
|
|
|
if (currentIndex < assets.length - 1) {
|
|
|
|
|
|
const nextAsset = assets[currentIndex + 1];
|
|
|
|
|
|
loadMedia(nextAsset);
|
|
|
|
|
|
setCurrentIndex(currentIndex + 1);
|
|
|
|
|
|
}
|
2025-12-30 13:35:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('keydown', handleKeyPress);
|
|
|
|
|
|
return () => window.removeEventListener('keydown', handleKeyPress);
|
2025-12-30 14:00:44 +01:00
|
|
|
|
}, [asset, currentIndex, assets, onClose]);
|
2025-12-30 13:35:19 +01:00
|
|
|
|
|
|
|
|
|
|
const loadMedia = async (asset: Asset) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
2025-12-30 15:53:16 +01:00
|
|
|
|
// Revoke previous blob URL to prevent memory leaks
|
|
|
|
|
|
if (currentUrl) {
|
|
|
|
|
|
URL.revokeObjectURL(currentUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Load media through backend proxy with auth
|
|
|
|
|
|
const blob = await api.getMediaBlob(asset.id, 'original');
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
2025-12-30 13:35:19 +01:00
|
|
|
|
setCurrentUrl(url);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load media:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handlePrev = () => {
|
|
|
|
|
|
if (currentIndex > 0) {
|
|
|
|
|
|
const prevAsset = assets[currentIndex - 1];
|
|
|
|
|
|
loadMedia(prevAsset);
|
|
|
|
|
|
setCurrentIndex(currentIndex - 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleNext = () => {
|
|
|
|
|
|
if (currentIndex < assets.length - 1) {
|
|
|
|
|
|
const nextAsset = assets[currentIndex + 1];
|
|
|
|
|
|
loadMedia(nextAsset);
|
|
|
|
|
|
setCurrentIndex(currentIndex + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDownload = () => {
|
|
|
|
|
|
if (currentUrl && asset) {
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
|
link.href = currentUrl;
|
|
|
|
|
|
link.download = asset.original_filename;
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDelete = () => {
|
|
|
|
|
|
if (asset && onDelete) {
|
|
|
|
|
|
onDelete(asset.id);
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleShare = () => {
|
|
|
|
|
|
if (asset && onShare) {
|
|
|
|
|
|
onShare(asset.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (!asset) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
open={!!asset}
|
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
|
maxWidth={false}
|
|
|
|
|
|
fullWidth
|
|
|
|
|
|
PaperProps={{
|
|
|
|
|
|
sx: {
|
|
|
|
|
|
bgcolor: 'black',
|
|
|
|
|
|
m: 0,
|
|
|
|
|
|
maxWidth: '100vw',
|
|
|
|
|
|
maxHeight: '100vh',
|
|
|
|
|
|
height: '100vh',
|
|
|
|
|
|
},
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Box
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* Top bar */}
|
|
|
|
|
|
<Box
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
right: 0,
|
|
|
|
|
|
p: 2,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
bgcolor: 'rgba(0,0,0,0.5)',
|
|
|
|
|
|
zIndex: 1,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Typography variant="h6" color="white" noWrap sx={{ flex: 1, mr: 2 }}>
|
|
|
|
|
|
{asset.original_filename}
|
|
|
|
|
|
</Typography>
|
|
|
|
|
|
|
|
|
|
|
|
<Box>
|
|
|
|
|
|
<IconButton color="inherit" onClick={handleDownload} sx={{ color: 'white' }}>
|
|
|
|
|
|
<DownloadIcon />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
{onShare && (
|
|
|
|
|
|
<IconButton color="inherit" onClick={handleShare} sx={{ color: 'white' }}>
|
|
|
|
|
|
<ShareIcon />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{onDelete && (
|
|
|
|
|
|
<IconButton color="inherit" onClick={handleDelete} sx={{ color: 'white' }}>
|
|
|
|
|
|
<DeleteIcon />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<IconButton onClick={onClose} sx={{ color: 'white' }}>
|
|
|
|
|
|
<CloseIcon />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Navigation buttons */}
|
|
|
|
|
|
{currentIndex > 0 && (
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
onClick={handlePrev}
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
left: 16,
|
|
|
|
|
|
color: 'white',
|
|
|
|
|
|
bgcolor: 'rgba(0,0,0,0.5)',
|
|
|
|
|
|
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<PrevIcon fontSize="large" />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{currentIndex < assets.length - 1 && (
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
onClick={handleNext}
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
right: 16,
|
|
|
|
|
|
color: 'white',
|
|
|
|
|
|
bgcolor: 'rgba(0,0,0,0.5)',
|
|
|
|
|
|
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<NextIcon fontSize="large" />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Content */}
|
|
|
|
|
|
{loading && (
|
|
|
|
|
|
<CircularProgress sx={{ color: 'white' }} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && asset.type === 'photo' && (
|
|
|
|
|
|
<Box
|
|
|
|
|
|
component="img"
|
|
|
|
|
|
src={currentUrl}
|
|
|
|
|
|
alt={asset.original_filename}
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
maxWidth: '95%',
|
|
|
|
|
|
maxHeight: '85%',
|
|
|
|
|
|
objectFit: 'contain',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && asset.type === 'video' && (
|
|
|
|
|
|
<Box
|
|
|
|
|
|
component="video"
|
|
|
|
|
|
src={currentUrl}
|
|
|
|
|
|
controls
|
|
|
|
|
|
autoPlay
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
maxWidth: '95%',
|
|
|
|
|
|
maxHeight: '85%',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Bottom info */}
|
|
|
|
|
|
<Box
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
bottom: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
right: 0,
|
|
|
|
|
|
p: 2,
|
|
|
|
|
|
bgcolor: 'rgba(0,0,0,0.5)',
|
|
|
|
|
|
color: 'white',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
gap: 2,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Typography variant="body2">
|
|
|
|
|
|
{currentIndex + 1} / {assets.length}
|
|
|
|
|
|
</Typography>
|
|
|
|
|
|
<Typography variant="body2">
|
|
|
|
|
|
{(asset.size_bytes / 1024 / 1024).toFixed(2)} MB
|
|
|
|
|
|
</Typography>
|
|
|
|
|
|
{asset.width && asset.height && (
|
|
|
|
|
|
<Typography variant="body2">
|
|
|
|
|
|
{asset.width} × {asset.height}
|
|
|
|
|
|
</Typography>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|