309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
import {
|
|
Delete,
|
|
Folder,
|
|
Movie,
|
|
OndemandVideo,
|
|
YouTube
|
|
} from '@mui/icons-material';
|
|
import {
|
|
Box,
|
|
Card,
|
|
CardActionArea,
|
|
CardContent,
|
|
CardMedia,
|
|
Chip,
|
|
IconButton,
|
|
Typography,
|
|
useMediaQuery,
|
|
useTheme
|
|
} from '@mui/material';
|
|
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import { Collection, Video } from '../types';
|
|
import ConfirmationModal from './ConfirmationModal';
|
|
|
|
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
|
|
|
interface VideoCardProps {
|
|
video: Video;
|
|
collections?: Collection[];
|
|
onDeleteVideo?: (id: string) => Promise<any>;
|
|
showDeleteButton?: boolean;
|
|
disableCollectionGrouping?: boolean;
|
|
}
|
|
|
|
const VideoCard: React.FC<VideoCardProps> = ({
|
|
video,
|
|
collections = [],
|
|
onDeleteVideo,
|
|
showDeleteButton = false,
|
|
disableCollectionGrouping = false
|
|
}) => {
|
|
const { t } = useLanguage();
|
|
const navigate = useNavigate();
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
|
|
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
|
const formatDate = (dateString: string) => {
|
|
if (!dateString || dateString.length !== 8) {
|
|
return t('unknownDate');
|
|
}
|
|
|
|
const year = dateString.substring(0, 4);
|
|
const month = dateString.substring(4, 6);
|
|
const day = dateString.substring(6, 8);
|
|
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
// Format duration (seconds or MM:SS)
|
|
const formatDuration = (duration: string | number | undefined) => {
|
|
if (!duration) return null;
|
|
|
|
// If it's already a string with colon, assume it's formatted
|
|
if (typeof duration === 'string' && duration.includes(':')) {
|
|
return duration;
|
|
}
|
|
|
|
// Otherwise treat as seconds
|
|
const seconds = typeof duration === 'string' ? parseInt(duration, 10) : duration;
|
|
if (isNaN(seconds)) return null;
|
|
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = Math.floor(seconds % 60);
|
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
// Use local thumbnail if available, otherwise fall back to the original URL
|
|
const thumbnailSrc = video.thumbnailPath
|
|
? `${BACKEND_URL}${video.thumbnailPath}`
|
|
: video.thumbnailUrl;
|
|
|
|
// Handle author click
|
|
const handleAuthorClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
navigate(`/author/${encodeURIComponent(video.author)}`);
|
|
};
|
|
|
|
// Handle confirm delete
|
|
const confirmDelete = async () => {
|
|
if (!onDeleteVideo) return;
|
|
|
|
setIsDeleting(true);
|
|
try {
|
|
await onDeleteVideo(video.id);
|
|
} catch (error) {
|
|
console.error('Error deleting video:', error);
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
// Handle delete click
|
|
const handleDeleteClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!onDeleteVideo) return;
|
|
setShowDeleteModal(true);
|
|
};
|
|
|
|
// Find collections this video belongs to
|
|
const videoCollections = collections.filter(collection =>
|
|
collection.videos.includes(video.id)
|
|
);
|
|
|
|
// Check if this video is the first in any collection
|
|
const isFirstInAnyCollection = !disableCollectionGrouping && videoCollections.some(collection =>
|
|
collection.videos[0] === video.id
|
|
);
|
|
|
|
// Get collection names where this video is the first
|
|
const firstInCollectionNames = videoCollections
|
|
.filter(collection => collection.videos[0] === video.id)
|
|
.map(collection => collection.name);
|
|
|
|
// Get the first collection ID where this video is the first video
|
|
const firstCollectionId = isFirstInAnyCollection
|
|
? videoCollections.find(collection => collection.videos[0] === video.id)?.id
|
|
: null;
|
|
|
|
// Handle video navigation
|
|
const handleVideoNavigation = () => {
|
|
// If this is the first video in a collection, navigate to the collection page
|
|
if (isFirstInAnyCollection && firstCollectionId) {
|
|
navigate(`/collection/${firstCollectionId}`);
|
|
} else {
|
|
// Otherwise navigate to the video player page
|
|
navigate(`/video/${video.id}`);
|
|
}
|
|
};
|
|
|
|
// Get source icon
|
|
const getSourceIcon = () => {
|
|
if (video.source === 'bilibili') {
|
|
return <OndemandVideo sx={{ color: '#23ade5' }} />; // Bilibili blue
|
|
} else if (video.source === 'local') {
|
|
return <Folder sx={{ color: '#4caf50' }} />; // Local green (using Folder as generic local icon, or maybe VideoFile if available)
|
|
} else if (video.source === 'missav') {
|
|
return <Movie sx={{ color: '#ff4081' }} />; // Pink for MissAV
|
|
}
|
|
return <YouTube sx={{ color: '#ff0000' }} />; // YouTube red
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Card
|
|
sx={{
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
position: 'relative',
|
|
transition: 'transform 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s, border-color 0.3s',
|
|
'&:hover': {
|
|
transform: 'translateY(-4px)',
|
|
boxShadow: theme.shadows[8],
|
|
'& .delete-btn': {
|
|
opacity: 1
|
|
}
|
|
},
|
|
border: isFirstInAnyCollection ? `1px solid ${theme.palette.primary.main}` : 'none'
|
|
}}
|
|
>
|
|
<CardActionArea onClick={handleVideoNavigation} sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
|
|
<Box sx={{ position: 'relative', paddingTop: '56.25%' /* 16:9 aspect ratio */ }}>
|
|
<CardMedia
|
|
component="img"
|
|
image={thumbnailSrc || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
|
alt={`${video.title} thumbnail`}
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover'
|
|
}}
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
target.onerror = null;
|
|
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
|
}}
|
|
/>
|
|
|
|
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
|
|
{getSourceIcon()}
|
|
</Box>
|
|
|
|
{video.partNumber && video.totalParts && video.totalParts > 1 && (
|
|
<Chip
|
|
label={`${t('part')} ${video.partNumber}/${video.totalParts}`}
|
|
size="small"
|
|
color="primary"
|
|
sx={{ position: 'absolute', bottom: 36, right: 8 }}
|
|
/>
|
|
)}
|
|
|
|
{video.duration && (
|
|
<Chip
|
|
label={formatDuration(video.duration)}
|
|
size="small"
|
|
sx={{
|
|
position: 'absolute',
|
|
bottom: 8,
|
|
right: 8,
|
|
height: 20,
|
|
fontSize: '0.75rem',
|
|
bgcolor: 'rgba(0,0,0,0.8)',
|
|
color: 'white'
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{isFirstInAnyCollection && (
|
|
<Chip
|
|
icon={<Folder />}
|
|
label={firstInCollectionNames.length > 1 ? `${firstInCollectionNames[0]} +${firstInCollectionNames.length - 1}` : firstInCollectionNames[0]}
|
|
color="secondary"
|
|
size="small"
|
|
sx={{ position: 'absolute', top: 8, left: 8 }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
<CardContent sx={{ flexGrow: 1, p: 2 }}>
|
|
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
|
{isFirstInAnyCollection ? (
|
|
<>
|
|
{firstInCollectionNames[0]}
|
|
{firstInCollectionNames.length > 1 && <Typography component="span" color="text.secondary" sx={{ fontSize: 'inherit' }}> +{firstInCollectionNames.length - 1}</Typography>}
|
|
</>
|
|
) : (
|
|
video.title
|
|
)}
|
|
</Typography>
|
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 'auto' }}>
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
onClick={handleAuthorClick}
|
|
sx={{
|
|
cursor: 'pointer',
|
|
'&:hover': { color: 'primary.main' },
|
|
fontWeight: 500
|
|
}}
|
|
>
|
|
{video.author}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{formatDate(video.date)}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
|
|
{video.viewCount || 0} {t('views')}
|
|
</Typography>
|
|
</Box>
|
|
</CardContent>
|
|
</CardActionArea>
|
|
|
|
{showDeleteButton && onDeleteVideo && !isMobile && (
|
|
<IconButton
|
|
className="delete-btn"
|
|
onClick={handleDeleteClick}
|
|
disabled={isDeleting}
|
|
size="small"
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 8,
|
|
right: 40, // Positioned to the left of the source icon
|
|
bgcolor: 'rgba(0,0,0,0.6)',
|
|
color: 'white',
|
|
opacity: 0, // Hidden by default, shown on hover
|
|
transition: 'opacity 0.2s',
|
|
'&:hover': {
|
|
bgcolor: 'error.main',
|
|
}
|
|
}}
|
|
>
|
|
<Delete fontSize="small" />
|
|
</IconButton>
|
|
)}
|
|
</Card>
|
|
|
|
<ConfirmationModal
|
|
isOpen={showDeleteModal}
|
|
onClose={() => setShowDeleteModal(false)}
|
|
onConfirm={confirmDelete}
|
|
title={t('deleteVideo')}
|
|
message={`${t('confirmDelete')} "${video.title}"?`}
|
|
confirmText={t('delete')}
|
|
cancelText={t('cancel')}
|
|
isDanger={true}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default VideoCard;
|