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,
|
2025-12-31 01:04:36 +01:00
|
|
|
|
pt: 'max(env(safe-area-inset-top), 8px)',
|
2026-01-06 00:10:15 +01:00
|
|
|
|
px: { xs: 1, sm: 2 },
|
|
|
|
|
|
pb: { xs: 1, sm: 2 },
|
2025-12-30 13:35:19 +01:00
|
|
|
|
display: 'flex',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
bgcolor: 'rgba(0,0,0,0.5)',
|
|
|
|
|
|
zIndex: 1,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-01-06 00:10:15 +01:00
|
|
|
|
<Typography
|
|
|
|
|
|
variant="h6"
|
|
|
|
|
|
color="white"
|
|
|
|
|
|
noWrap
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
mr: { xs: 1, sm: 2 },
|
|
|
|
|
|
fontSize: { xs: '0.875rem', sm: '1.25rem' },
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2025-12-30 13:35:19 +01:00
|
|
|
|
{asset.original_filename}
|
|
|
|
|
|
</Typography>
|
|
|
|
|
|
|
2026-01-06 00:10:15 +01:00
|
|
|
|
<Box sx={{ display: 'flex', gap: { xs: 0, sm: 0.5 } }}>
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
color="inherit"
|
|
|
|
|
|
onClick={handleDownload}
|
|
|
|
|
|
sx={{ color: 'white', p: { xs: 0.5, sm: 1 } }}
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
>
|
|
|
|
|
|
<DownloadIcon fontSize="small" />
|
2025-12-30 13:35:19 +01:00
|
|
|
|
</IconButton>
|
|
|
|
|
|
{onShare && (
|
2026-01-06 00:10:15 +01:00
|
|
|
|
<IconButton
|
|
|
|
|
|
color="inherit"
|
|
|
|
|
|
onClick={handleShare}
|
|
|
|
|
|
sx={{ color: 'white', p: { xs: 0.5, sm: 1 } }}
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ShareIcon fontSize="small" />
|
2025-12-30 13:35:19 +01:00
|
|
|
|
</IconButton>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{onDelete && (
|
2026-01-06 00:10:15 +01:00
|
|
|
|
<IconButton
|
|
|
|
|
|
color="inherit"
|
|
|
|
|
|
onClick={handleDelete}
|
|
|
|
|
|
sx={{ color: 'white', p: { xs: 0.5, sm: 1 } }}
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
>
|
|
|
|
|
|
<DeleteIcon fontSize="small" />
|
2025-12-30 13:35:19 +01:00
|
|
|
|
</IconButton>
|
|
|
|
|
|
)}
|
2026-01-06 00:10:15 +01:00
|
|
|
|
<IconButton
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
sx={{ color: 'white', p: { xs: 0.5, sm: 1 } }}
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CloseIcon fontSize="small" />
|
2025-12-30 13:35:19 +01:00
|
|
|
|
</IconButton>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Navigation buttons */}
|
|
|
|
|
|
{currentIndex > 0 && (
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
onClick={handlePrev}
|
2026-01-06 00:10:15 +01:00
|
|
|
|
size="small"
|
2025-12-30 13:35:19 +01:00
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'absolute',
|
2026-01-06 00:10:15 +01:00
|
|
|
|
left: { xs: 4, sm: 16 },
|
2025-12-30 13:35:19 +01:00
|
|
|
|
color: 'white',
|
|
|
|
|
|
bgcolor: 'rgba(0,0,0,0.5)',
|
|
|
|
|
|
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
2026-01-06 00:10:15 +01:00
|
|
|
|
p: { xs: 0.5, sm: 1 },
|
2025-12-30 13:35:19 +01:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-01-06 00:10:15 +01:00
|
|
|
|
<PrevIcon fontSize="medium" />
|
2025-12-30 13:35:19 +01:00
|
|
|
|
</IconButton>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{currentIndex < assets.length - 1 && (
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
onClick={handleNext}
|
2026-01-06 00:10:15 +01:00
|
|
|
|
size="small"
|
2025-12-30 13:35:19 +01:00
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'absolute',
|
2026-01-06 00:10:15 +01:00
|
|
|
|
right: { xs: 4, sm: 16 },
|
2025-12-30 13:35:19 +01:00
|
|
|
|
color: 'white',
|
|
|
|
|
|
bgcolor: 'rgba(0,0,0,0.5)',
|
|
|
|
|
|
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
2026-01-06 00:10:15 +01:00
|
|
|
|
p: { xs: 0.5, sm: 1 },
|
2025-12-30 13:35:19 +01:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-01-06 00:10:15 +01:00
|
|
|
|
<NextIcon fontSize="medium" />
|
2025-12-30 13:35:19 +01:00
|
|
|
|
</IconButton>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Content */}
|
|
|
|
|
|
{loading && (
|
|
|
|
|
|
<CircularProgress sx={{ color: 'white' }} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && asset.type === 'photo' && (
|
|
|
|
|
|
<Box
|
|
|
|
|
|
component="img"
|
|
|
|
|
|
src={currentUrl}
|
|
|
|
|
|
alt={asset.original_filename}
|
|
|
|
|
|
sx={{
|
2026-01-06 00:10:15 +01:00
|
|
|
|
maxWidth: { xs: 'calc(100% - 16px)', sm: '95%' },
|
|
|
|
|
|
maxHeight: { xs: 'calc(100vh - 160px)', sm: '85%' },
|
2025-12-30 13:35:19 +01:00
|
|
|
|
objectFit: 'contain',
|
2026-01-06 00:10:15 +01:00
|
|
|
|
px: { xs: 1, sm: 0 },
|
2025-12-30 13:35:19 +01:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && asset.type === 'video' && (
|
|
|
|
|
|
<Box
|
|
|
|
|
|
component="video"
|
|
|
|
|
|
src={currentUrl}
|
|
|
|
|
|
controls
|
|
|
|
|
|
autoPlay
|
|
|
|
|
|
sx={{
|
2026-01-06 00:10:15 +01:00
|
|
|
|
maxWidth: { xs: 'calc(100% - 16px)', sm: '95%' },
|
|
|
|
|
|
maxHeight: { xs: 'calc(100vh - 160px)', sm: '85%' },
|
|
|
|
|
|
px: { xs: 1, sm: 0 },
|
2025-12-30 13:35:19 +01:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Bottom info */}
|
|
|
|
|
|
<Box
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
bottom: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
right: 0,
|
2026-01-06 00:10:15 +01:00
|
|
|
|
px: { xs: 1, sm: 2 },
|
|
|
|
|
|
pt: { xs: 1, sm: 2 },
|
2025-12-31 01:04:36 +01:00
|
|
|
|
pb: 'max(env(safe-area-inset-bottom), 8px)',
|
2025-12-30 13:35:19 +01:00
|
|
|
|
bgcolor: 'rgba(0,0,0,0.5)',
|
|
|
|
|
|
color: 'white',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
justifyContent: 'center',
|
2026-01-06 00:10:15 +01:00
|
|
|
|
gap: { xs: 1, sm: 2 },
|
|
|
|
|
|
flexWrap: 'wrap',
|
2025-12-30 13:35:19 +01:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-01-06 00:10:15 +01:00
|
|
|
|
<Typography variant="body2" sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
|
2025-12-30 13:35:19 +01:00
|
|
|
|
{currentIndex + 1} / {assets.length}
|
|
|
|
|
|
</Typography>
|
2026-01-06 00:10:15 +01:00
|
|
|
|
<Typography variant="body2" sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
|
2025-12-30 13:35:19 +01:00
|
|
|
|
{(asset.size_bytes / 1024 / 1024).toFixed(2)} MB
|
|
|
|
|
|
</Typography>
|
|
|
|
|
|
{asset.width && asset.height && (
|
2026-01-06 00:10:15 +01:00
|
|
|
|
<Typography variant="body2" sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
|
2025-12-30 13:35:19 +01:00
|
|
|
|
{asset.width} × {asset.height}
|
|
|
|
|
|
</Typography>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|