itcloud/frontend/src/components/ViewerModal.tsx

334 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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