itcloud/frontend/src/components/ViewerModal.tsx

334 lines
8.7 KiB
TypeScript
Raw Normal View History

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>
);
}