334 lines
8.7 KiB
TypeScript
334 lines
8.7 KiB
TypeScript
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);
|
||
}
|
||
|
||
// Cleanup blob URL on unmount or asset change
|
||
return () => {
|
||
if (currentUrl) {
|
||
URL.revokeObjectURL(currentUrl);
|
||
}
|
||
};
|
||
}, [asset]);
|
||
|
||
useEffect(() => {
|
||
const handleKeyPress = (e: KeyboardEvent) => {
|
||
if (!asset) return;
|
||
|
||
if (e.key === 'Escape') {
|
||
onClose();
|
||
} else if (e.key === 'ArrowLeft') {
|
||
if (currentIndex > 0) {
|
||
const prevAsset = assets[currentIndex - 1];
|
||
loadMedia(prevAsset);
|
||
setCurrentIndex(currentIndex - 1);
|
||
}
|
||
} else if (e.key === 'ArrowRight') {
|
||
if (currentIndex < assets.length - 1) {
|
||
const nextAsset = assets[currentIndex + 1];
|
||
loadMedia(nextAsset);
|
||
setCurrentIndex(currentIndex + 1);
|
||
}
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyPress);
|
||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||
}, [asset, currentIndex, assets, onClose]);
|
||
|
||
const loadMedia = async (asset: Asset) => {
|
||
try {
|
||
setLoading(true);
|
||
// 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);
|
||
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,
|
||
pt: 'max(env(safe-area-inset-top), 8px)',
|
||
px: { xs: 1, sm: 2 },
|
||
pb: { xs: 1, sm: 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: { xs: 1, sm: 2 },
|
||
fontSize: { xs: '0.875rem', sm: '1.25rem' },
|
||
}}
|
||
>
|
||
{asset.original_filename}
|
||
</Typography>
|
||
|
||
<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" />
|
||
</IconButton>
|
||
{onShare && (
|
||
<IconButton
|
||
color="inherit"
|
||
onClick={handleShare}
|
||
sx={{ color: 'white', p: { xs: 0.5, sm: 1 } }}
|
||
size="small"
|
||
>
|
||
<ShareIcon fontSize="small" />
|
||
</IconButton>
|
||
)}
|
||
{onDelete && (
|
||
<IconButton
|
||
color="inherit"
|
||
onClick={handleDelete}
|
||
sx={{ color: 'white', p: { xs: 0.5, sm: 1 } }}
|
||
size="small"
|
||
>
|
||
<DeleteIcon fontSize="small" />
|
||
</IconButton>
|
||
)}
|
||
<IconButton
|
||
onClick={onClose}
|
||
sx={{ color: 'white', p: { xs: 0.5, sm: 1 } }}
|
||
size="small"
|
||
>
|
||
<CloseIcon fontSize="small" />
|
||
</IconButton>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Navigation buttons */}
|
||
{currentIndex > 0 && (
|
||
<IconButton
|
||
onClick={handlePrev}
|
||
size="small"
|
||
sx={{
|
||
position: 'absolute',
|
||
left: { xs: 4, sm: 16 },
|
||
color: 'white',
|
||
bgcolor: 'rgba(0,0,0,0.5)',
|
||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
||
p: { xs: 0.5, sm: 1 },
|
||
}}
|
||
>
|
||
<PrevIcon fontSize="medium" />
|
||
</IconButton>
|
||
)}
|
||
|
||
{currentIndex < assets.length - 1 && (
|
||
<IconButton
|
||
onClick={handleNext}
|
||
size="small"
|
||
sx={{
|
||
position: 'absolute',
|
||
right: { xs: 4, sm: 16 },
|
||
color: 'white',
|
||
bgcolor: 'rgba(0,0,0,0.5)',
|
||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
||
p: { xs: 0.5, sm: 1 },
|
||
}}
|
||
>
|
||
<NextIcon fontSize="medium" />
|
||
</IconButton>
|
||
)}
|
||
|
||
{/* Content */}
|
||
{loading && (
|
||
<CircularProgress sx={{ color: 'white' }} />
|
||
)}
|
||
|
||
{!loading && asset.type === 'photo' && (
|
||
<Box
|
||
component="img"
|
||
src={currentUrl}
|
||
alt={asset.original_filename}
|
||
sx={{
|
||
maxWidth: { xs: 'calc(100% - 16px)', sm: '95%' },
|
||
maxHeight: { xs: 'calc(100vh - 160px)', sm: '85%' },
|
||
objectFit: 'contain',
|
||
px: { xs: 1, sm: 0 },
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{!loading && asset.type === 'video' && (
|
||
<Box
|
||
component="video"
|
||
src={currentUrl}
|
||
controls
|
||
autoPlay
|
||
sx={{
|
||
maxWidth: { xs: 'calc(100% - 16px)', sm: '95%' },
|
||
maxHeight: { xs: 'calc(100vh - 160px)', sm: '85%' },
|
||
px: { xs: 1, sm: 0 },
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Bottom info */}
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
px: { xs: 1, sm: 2 },
|
||
pt: { xs: 1, sm: 2 },
|
||
pb: 'max(env(safe-area-inset-bottom), 8px)',
|
||
bgcolor: 'rgba(0,0,0,0.5)',
|
||
color: 'white',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
gap: { xs: 1, sm: 2 },
|
||
flexWrap: 'wrap',
|
||
}}
|
||
>
|
||
<Typography variant="body2" sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
|
||
{currentIndex + 1} / {assets.length}
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
|
||
{(asset.size_bytes / 1024 / 1024).toFixed(2)} MB
|
||
</Typography>
|
||
{asset.width && asset.height && (
|
||
<Typography variant="body2" sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
|
||
{asset.width} × {asset.height}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
</Dialog>
|
||
);
|
||
}
|