2025-12-30 13:35:19 +01:00
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Card,
|
|
|
|
|
|
CardMedia,
|
|
|
|
|
|
CardActionArea,
|
|
|
|
|
|
Box,
|
|
|
|
|
|
IconButton,
|
|
|
|
|
|
CircularProgress,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
Checkbox,
|
|
|
|
|
|
} from '@mui/material';
|
|
|
|
|
|
import {
|
|
|
|
|
|
PlayCircleOutline as VideoIcon,
|
|
|
|
|
|
CheckCircle as CheckedIcon,
|
|
|
|
|
|
} from '@mui/icons-material';
|
|
|
|
|
|
import type { Asset } from '../types';
|
|
|
|
|
|
import api from '../services/api';
|
|
|
|
|
|
|
|
|
|
|
|
interface MediaCardProps {
|
|
|
|
|
|
asset: Asset;
|
|
|
|
|
|
selected?: boolean;
|
|
|
|
|
|
onSelect?: (assetId: string, selected: boolean) => void;
|
|
|
|
|
|
onClick?: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function MediaCard({ asset, selected, onSelect, onClick }: MediaCardProps) {
|
|
|
|
|
|
const [thumbnailUrl, setThumbnailUrl] = useState<string>('');
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadThumbnail();
|
|
|
|
|
|
}, [asset.id]);
|
|
|
|
|
|
|
|
|
|
|
|
const loadThumbnail = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError(false);
|
2025-12-30 15:53:16 +01:00
|
|
|
|
// Load media through backend proxy with auth
|
|
|
|
|
|
const kind = asset.storage_key_thumb ? 'thumb' : 'original';
|
|
|
|
|
|
if (asset.type === 'photo' || asset.storage_key_thumb) {
|
|
|
|
|
|
const blob = await api.getMediaBlob(asset.id, kind);
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
setThumbnailUrl(url);
|
|
|
|
|
|
}
|
2025-12-30 13:35:19 +01:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to load thumbnail:', err);
|
|
|
|
|
|
setError(true);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatFileSize = (bytes: number): string => {
|
|
|
|
|
|
if (bytes < 1024) return bytes + ' B';
|
|
|
|
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
|
|
|
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
|
|
|
|
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatDate = (dateString: string): string => {
|
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
|
return date.toLocaleDateString('ru-RU', {
|
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
|
month: 'short',
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSelect = (e: React.MouseEvent) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
if (onSelect) {
|
|
|
|
|
|
onSelect(asset.id, !selected);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Card
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
aspectRatio: '1',
|
|
|
|
|
|
borderRadius: 2,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
transition: 'transform 0.2s, box-shadow 0.2s',
|
|
|
|
|
|
'&:hover': {
|
|
|
|
|
|
transform: 'translateY(-4px)',
|
|
|
|
|
|
boxShadow: 4,
|
|
|
|
|
|
},
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
|
|
|
|
|
|
{loading && (
|
|
|
|
|
|
<Box
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
bgcolor: 'grey.200',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<CircularProgress />
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
|
<Box
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
bgcolor: 'grey.300',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Typography color="error">Ошибка загрузки</Typography>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && !error && thumbnailUrl && (
|
|
|
|
|
|
<CardMedia
|
|
|
|
|
|
component="img"
|
|
|
|
|
|
image={thumbnailUrl}
|
|
|
|
|
|
alt={asset.original_filename}
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
objectFit: 'cover',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{asset.type === 'video' && (
|
|
|
|
|
|
<Box
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: 8,
|
|
|
|
|
|
right: 8,
|
|
|
|
|
|
color: 'white',
|
|
|
|
|
|
bgcolor: 'rgba(0,0,0,0.5)',
|
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<VideoIcon fontSize="large" />
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<Box
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
bottom: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
right: 0,
|
|
|
|
|
|
bgcolor: 'rgba(0,0,0,0.6)',
|
|
|
|
|
|
color: 'white',
|
|
|
|
|
|
p: 1,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Typography variant="caption" display="block" noWrap>
|
|
|
|
|
|
{asset.original_filename}
|
|
|
|
|
|
</Typography>
|
|
|
|
|
|
<Typography variant="caption" display="block">
|
|
|
|
|
|
{formatFileSize(asset.size_bytes)} • {formatDate(asset.created_at)}
|
|
|
|
|
|
</Typography>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
|
|
{onSelect && (
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={selected}
|
|
|
|
|
|
onClick={handleSelect}
|
|
|
|
|
|
icon={
|
|
|
|
|
|
<Box
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
width: 24,
|
|
|
|
|
|
height: 24,
|
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
|
border: '2px solid white',
|
|
|
|
|
|
bgcolor: 'rgba(0,0,0,0.3)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
checkedIcon={<CheckedIcon sx={{ color: 'primary.main' }} />}
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: 8,
|
|
|
|
|
|
left: 8,
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardActionArea>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|