style: Update video player page layout and styling

This commit is contained in:
Peifan Li
2025-11-21 14:54:14 -05:00
parent fa0f06386e
commit f9754c86b2
7 changed files with 534 additions and 288 deletions

View File

@@ -1,5 +1,5 @@
{
"isDownloading": false,
"title": "",
"timestamp": 1763753271967
"timestamp": 1763754827104
}

View File

@@ -50,44 +50,39 @@ const updateCollection = (req, res) => {
const { id } = req.params;
const { name, videoId, action } = req.body;
const collection = storageService.getCollectionById(id);
// Update the collection atomically
const updatedCollection = storageService.atomicUpdateCollection(
id,
(collection) => {
// Update the collection
if (name) {
collection.name = name;
}
if (!collection) {
// Add or remove a video
if (videoId) {
if (action === "add") {
// Add the video if it's not already in the collection
if (!collection.videos.includes(videoId)) {
collection.videos.push(videoId);
}
} else if (action === "remove") {
// Remove the video
collection.videos = collection.videos.filter((v) => v !== videoId);
}
}
return collection;
}
);
if (!updatedCollection) {
return res
.status(404)
.json({ success: false, error: "Collection not found" });
.json({ success: false, error: "Collection not found or update failed" });
}
// Update the collection
if (name) {
collection.name = name;
}
// Add or remove a video
if (videoId) {
if (action === "add") {
// Add the video if it's not already in the collection
if (!collection.videos.includes(videoId)) {
collection.videos.push(videoId);
}
} else if (action === "remove") {
// Remove the video
collection.videos = collection.videos.filter((v) => v !== videoId);
}
}
collection.updatedAt = new Date().toISOString();
// Save the updated collection
const success = storageService.updateCollection(collection);
if (!success) {
return res
.status(500)
.json({ success: false, error: "Failed to update collection" });
}
res.json(collection);
res.json(updatedCollection);
} catch (error) {
console.error("Error updating collection:", error);
res

View File

@@ -129,11 +129,10 @@ const downloadVideo = async (req, res) => {
// Add to collection if needed
if (collectionId && firstPartResult.videoData) {
const collection = storageService.getCollectionById(collectionId);
if (collection) {
storageService.atomicUpdateCollection(collectionId, (collection) => {
collection.videos.push(firstPartResult.videoData.id);
storageService.updateCollection(collection);
}
return collection;
});
}
// Set up background download for remaining parts

View File

@@ -13,8 +13,10 @@ const storageService = require("./storageService");
// Helper function to download Bilibili video
async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
try {
// Create a temporary directory for the download
const tempDir = path.join(VIDEOS_DIR, "temp");
// Create a unique temporary directory for the download
const timestamp = Date.now();
const random = Math.floor(Math.random() * 10000);
const tempDir = path.join(VIDEOS_DIR, `temp_${timestamp}_${random}`);
fs.ensureDirSync(tempDir);
console.log("Downloading Bilibili video to temp directory:", tempDir);
@@ -128,7 +130,6 @@ async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
console.error("Error in downloadBilibiliVideo:", error);
// Make sure we clean up the temp directory if it exists
const tempDir = path.join(VIDEOS_DIR, "temp");
if (fs.existsSync(tempDir)) {
fs.removeSync(tempDir);
}
@@ -317,17 +318,14 @@ async function downloadRemainingBilibiliParts(
// If download was successful and we have a collection ID, add to collection
if (result.success && collectionId && result.videoData) {
try {
const collection = storageService.getCollectionById(collectionId);
if (collection) {
// Add video to collection
storageService.atomicUpdateCollection(collectionId, (collection) => {
collection.videos.push(result.videoData.id);
storageService.updateCollection(collection);
return collection;
});
console.log(
`Added part ${part}/${totalParts} to collection ${collectionId}`
);
}
console.log(
`Added part ${part}/${totalParts} to collection ${collectionId}`
);
} catch (collectionError) {
console.error(
`Error adding part ${part}/${totalParts} to collection:`,

View File

@@ -142,18 +142,36 @@ function saveCollection(collection) {
return collection;
}
// Update a collection
function updateCollection(updatedCollection) {
// Atomic update for a collection
function atomicUpdateCollection(id, updateFn) {
let collections = getCollections();
const index = collections.findIndex((c) => c.id === updatedCollection.id);
const index = collections.findIndex((c) => c.id === id);
if (index === -1) {
return false;
return null;
}
// Create a deep copy of the collection to avoid reference issues
const originalCollection = collections[index];
const collectionCopy = JSON.parse(JSON.stringify(originalCollection));
// Apply the update function
const updatedCollection = updateFn(collectionCopy);
// If the update function returned null or undefined, abort the update
if (!updatedCollection) {
return null;
}
updatedCollection.updatedAt = new Date().toISOString();
// Update the collection in the array
collections[index] = updatedCollection;
// Write back to file synchronously
fs.writeFileSync(COLLECTIONS_DATA_PATH, JSON.stringify(collections, null, 2));
return true;
return updatedCollection;
}
// Delete a collection
@@ -183,6 +201,6 @@ module.exports = {
getCollections,
getCollectionById,
saveCollection,
updateCollection,
atomicUpdateCollection,
deleteCollection,
};

View File

@@ -25,7 +25,8 @@
box-sizing: border-box;
}
html, body {
html,
body {
width: 100%;
height: 100%;
overflow-x: hidden;
@@ -91,7 +92,8 @@ body {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: 1rem;
min-width: 0; /* Prevents flex item from overflowing */
min-width: 0;
/* Prevents flex item from overflowing */
background-color: var(--background-lighter);
color: var(--text-color);
}
@@ -143,7 +145,8 @@ body {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-lg);
width: 100%;
align-items: start; /* Align items to the start of the grid cell */
align-items: start;
/* Align items to the start of the grid cell */
}
/* Home Container */
@@ -163,7 +166,7 @@ body {
.home-container {
padding: var(--spacing-md);
}
.videos-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--spacing-md);
@@ -197,7 +200,8 @@ body {
.thumbnail-container {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 aspect ratio */
padding-top: 56.25%;
/* 16:9 aspect ratio */
overflow: hidden;
background-color: var(--background-darker);
flex: 0 0 auto;
@@ -242,7 +246,8 @@ body {
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
color: var(--text-color);
max-height: 42px; /* Approximately 2 lines of text with line-height 1.3 */
max-height: 42px;
/* Approximately 2 lines of text with line-height 1.3 */
}
.video-title.clickable:hover {
@@ -257,17 +262,53 @@ body {
margin-top: auto;
}
/* Video Player Page */
.video-player-container {
width: 100%;
/* Video Player Page Layout */
.video-player-page {
display: flex;
flex-direction: column;
max-width: 1800px;
margin: 0 auto;
padding: 24px;
gap: 24px;
}
@media (min-width: 1024px) {
.video-player-page {
flex-direction: row;
align-items: flex-start;
}
}
/* Main Content Column */
.video-main-content {
flex: 1;
width: 100%;
min-width: 0;
/* Prevents flex item from overflowing */
}
/* Sidebar Column */
.video-sidebar {
width: 100%;
flex-shrink: 0;
}
@media (min-width: 1024px) {
.video-sidebar {
width: 400px;
}
}
/* Video Player Wrapper */
.video-wrapper {
position: relative;
padding-top: 56.25%; /* 16:9 aspect ratio */
padding-top: 56.25%;
/* 16:9 aspect ratio */
background-color: #000;
border-radius: var(--border-radius);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
margin-bottom: 16px;
}
.video-player {
@@ -278,57 +319,234 @@ body {
height: 100%;
}
.video-details {
background-color: var(--card-background);
padding: var(--spacing-lg);
border-radius: var(--border-radius);
margin-top: var(--spacing-md);
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
/* Video Info Section */
.video-info-section {
color: var(--text-color);
}
.video-details-header {
.video-title-h1 {
font-size: 1.25rem;
font-weight: 700;
line-height: 1.4;
margin-bottom: 12px;
color: #fff;
}
.video-actions-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-md);
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.video-details-header h1 {
font-size: 1.5rem;
margin-right: var(--spacing-md);
color: var(--text-color);
.video-primary-actions {
display: flex;
gap: 10px;
}
.delete-btn {
background-color: var(--primary-color);
color: var(--text-color);
border: none;
border-radius: var(--border-radius);
padding: var(--spacing-sm) var(--spacing-md);
/* Action Buttons */
.action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 18px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: background-color 0.2s;
}
.delete-btn:hover {
background-color: var(--primary-hover);
.btn-secondary {
background-color: rgba(255, 255, 255, 0.1);
color: #fff;
}
.delete-btn:disabled {
background-color: var(--secondary-color);
cursor: not-allowed;
.btn-secondary:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.video-details-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
margin-top: var(--spacing-md);
.btn-primary {
background-color: var(--text-color);
color: #000;
}
.btn-primary:hover {
background-color: #e0e0e0;
}
.btn-danger {
background-color: rgba(255, 255, 255, 0.1);
color: #ff3e3e;
}
.btn-danger:hover {
background-color: rgba(255, 62, 62, 0.15);
}
/* Channel/Description Box */
.channel-desc-container {
background-color: #2a2a2a;
border-radius: 12px;
padding: 16px;
margin-top: 16px;
}
.channel-row {
display: flex;
align-items: center;
margin-bottom: 12px;
gap: 12px;
}
.channel-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: #fff;
font-size: 1.2rem;
}
.channel-info {
flex: 1;
}
.channel-name {
font-weight: 600;
font-size: 1rem;
color: #fff;
text-decoration: none;
}
.channel-name:hover {
color: var(--text-secondary);
}
.video-stats {
font-size: 0.85rem;
color: var(--text-secondary);
}
.description-text {
font-size: 0.9rem;
line-height: 1.5;
color: #fff;
white-space: pre-wrap;
}
.description-meta {
margin-top: 12px;
font-size: 0.85rem;
color: var(--text-secondary);
display: flex;
gap: 16px;
}
/* Sidebar - Up Next */
.sidebar-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 16px;
color: #fff;
}
.related-videos-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.related-video-card {
display: flex;
gap: 8px;
cursor: pointer;
text-decoration: none;
}
.related-video-thumbnail {
position: relative;
width: 168px;
height: 94px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background-color: #2a2a2a;
}
.related-video-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.related-video-info {
flex: 1;
min-width: 0;
}
.related-video-title {
font-size: 0.9rem;
font-weight: 600;
line-height: 1.4;
color: #fff;
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.related-video-author,
.related-video-meta {
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.4;
}
.duration-badge {
position: absolute;
bottom: 4px;
right: 4px;
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 2px 4px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
/* Collection Tags */
.collection-tags {
display: flex;
gap: 8px;
margin-top: 8px;
}
.collection-pill {
background-color: rgba(62, 166, 255, 0.2);
color: #3ea6ff;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
}
.collection-pill:hover {
background-color: rgba(62, 166, 255, 0.3);
}
/* Loading and Error States */
.loading, .error {
.loading,
.error {
text-align: center;
padding: var(--spacing-xl);
font-size: 1.2rem;
@@ -349,25 +567,25 @@ body {
align-items: stretch;
gap: var(--spacing-md);
}
.url-form {
max-width: 100%;
margin-left: 0;
}
.logo {
margin-bottom: var(--spacing-sm);
}
.videos-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--spacing-md);
}
.video-details-header {
flex-direction: column;
}
.delete-btn {
margin-top: var(--spacing-sm);
}
@@ -378,23 +596,23 @@ body {
grid-template-columns: 1fr;
gap: var(--spacing-md);
}
.main-content {
padding: var(--spacing-md);
}
.video-details-meta {
grid-template-columns: 1fr;
}
.url-form .form-group {
flex-direction: column;
}
.submit-btn {
width: 100%;
}
.form-error {
text-align: left;
}
@@ -446,13 +664,13 @@ body {
.author-videos-container {
padding: var(--spacing-md);
}
.author-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-sm);
}
.author-info {
width: 100%;
margin-top: var(--spacing-sm);
@@ -735,7 +953,8 @@ body {
gap: 10px;
}
.play-btn, .delete-btn {
.play-btn,
.delete-btn {
flex: 1;
padding: 8px 15px;
border-radius: 4px;
@@ -768,7 +987,8 @@ body {
border-color: #ff3e3e;
}
.video-link, .author-link {
.video-link,
.author-link {
text-decoration: none;
}
@@ -794,28 +1014,12 @@ body {
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
0% {
transform: rotate(0deg);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.search-results-grid {
grid-template-columns: 1fr;
}
.search-result-thumbnail {
height: 200px;
}
.search-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.search-header h2 {
font-size: 1.5rem;
100% {
transform: rotate(360deg);
}
}
@@ -852,9 +1056,11 @@ body {
0% {
box-shadow: 0 0 0 0 rgba(255, 62, 62, 0.4);
}
70% {
box-shadow: 0 0 0 10px rgba(255, 62, 62, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 62, 62, 0);
}
@@ -877,8 +1083,15 @@ body {
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* Specific style for delete button in VideoPlayer */
@@ -907,7 +1120,8 @@ body {
}
.authors-dropdown-toggle {
display: none; /* Hidden on desktop */
display: none;
/* Hidden on desktop */
padding: var(--spacing-md);
background-color: var(--background-darker);
cursor: pointer;
@@ -962,30 +1176,32 @@ body {
.home-content {
flex-direction: column;
}
.authors-container {
width: 100%;
margin-bottom: var(--spacing-md);
}
.authors-dropdown-toggle {
display: flex;
}
.authors-list {
display: none;
max-height: 0;
transition: max-height 0.3s ease;
overflow: hidden;
}
.authors-list.open {
display: block;
max-height: 500px; /* Adjust as needed */
max-height: 500px;
/* Adjust as needed */
}
.authors-list .authors-title {
display: none; /* Hide the duplicate title in mobile view */
display: none;
/* Hide the duplicate title in mobile view */
}
}
@@ -1031,7 +1247,8 @@ body {
}
.collections-dropdown-toggle {
display: none; /* Hidden on desktop */
display: none;
/* Hidden on desktop */
padding: var(--spacing-md);
background-color: var(--background-darker);
cursor: pointer;
@@ -1158,6 +1375,7 @@ body {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -1253,8 +1471,7 @@ body {
}
/* Video collections info */
.video-collections {
}
.video-collections {}
.video-collections-title {
font-weight: bold;
@@ -1313,25 +1530,27 @@ body {
width: 100%;
margin-bottom: var(--spacing-md);
}
.collections-dropdown-toggle {
display: flex;
}
.collections-list {
display: none;
max-height: 0;
transition: max-height 0.3s ease;
overflow: hidden;
}
.collections-list.open {
display: block;
max-height: 500px; /* Adjust as needed */
max-height: 500px;
/* Adjust as needed */
}
.collections-list .collections-title {
display: none; /* Hide the duplicate title in mobile view */
display: none;
/* Hide the duplicate title in mobile view */
}
}
@@ -1339,19 +1558,19 @@ body {
.collections-dropdown-toggle .dropdown-arrow {
display: none;
}
.collections-list {
display: block !important;
}
.collections-title {
display: block;
}
.collections-dropdown-toggle h3 {
display: none;
}
.home-content {
display: grid;
grid-template-columns: 250px 1fr;
@@ -1361,17 +1580,17 @@ body {
"sidebar videos";
gap: 20px;
}
.sidebar-container {
grid-area: sidebar;
display: flex;
flex-direction: column;
}
.collections-container {
margin-bottom: 20px;
}
.videos-grid {
grid-area: videos;
}
@@ -1430,7 +1649,7 @@ body {
margin: var(--spacing-md) 0;
width: 100%;
}
.delete-collection-button {
margin-top: var(--spacing-md);
width: 100%;
@@ -1659,4 +1878,4 @@ body {
display: inline-block;
margin-left: 3px;
font-weight: bold;
}
}

View File

@@ -13,28 +13,27 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
const [error, setError] = useState(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState(null);
const [isDeleted, setIsDeleted] = useState(false);
const [showCollectionModal, setShowCollectionModal] = useState(false);
const [newCollectionName, setNewCollectionName] = useState('');
const [selectedCollection, setSelectedCollection] = useState('');
const [videoCollections, setVideoCollections] = useState([]);
useEffect(() => {
// Don't try to fetch the video if it's being deleted or has been deleted
if (isDeleting || isDeleted) {
// Don't try to fetch the video if it's being deleted
if (isDeleting) {
return;
}
const fetchVideo = async () => {
// First check if the video is in the videos prop
const foundVideo = videos.find(v => v.id === id);
if (foundVideo) {
setVideo(foundVideo);
setLoading(false);
return;
}
// If not found in props, try to fetch from API
try {
const response = await axios.get(`${API_URL}/videos/${id}`);
@@ -43,7 +42,7 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
} catch (err) {
console.error('Error fetching video:', err);
setError('Video not found or could not be loaded.');
// Redirect to home after 3 seconds if video not found
setTimeout(() => {
navigate('/');
@@ -54,12 +53,12 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
};
fetchVideo();
}, [id, videos, navigate, isDeleting, isDeleted]);
}, [id, videos, navigate, isDeleting]);
// Find collections that contain this video
useEffect(() => {
if (collections && collections.length > 0 && id) {
const belongsToCollections = collections.filter(collection =>
const belongsToCollections = collections.filter(collection =>
collection.videos.includes(id)
);
setVideoCollections(belongsToCollections);
@@ -73,11 +72,11 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
if (!dateString || dateString.length !== 8) {
return 'Unknown date';
}
const year = dateString.substring(0, 4);
const month = dateString.substring(4, 6);
const day = dateString.substring(6, 8);
return `${year}-${month}-${day}`;
};
@@ -100,16 +99,10 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
try {
const result = await onDeleteVideo(id);
if (result.success) {
setIsDeleted(true);
// Navigate to the previous page if available, otherwise go to home
if (window.history.length > 1) {
navigate(-1); // Go back to the previous page
} else {
navigate('/', { replace: true });
}
// Navigate to home immediately after successful deletion
navigate('/', { replace: true });
} else {
setDeleteError(result.error || 'Failed to delete video');
setIsDeleting(false);
@@ -170,10 +163,6 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
}
};
if (isDeleted) {
return <div className="loading">Video deleted successfully. Redirecting...</div>;
}
if (loading) {
return <div className="loading">Loading video...</div>;
}
@@ -182,101 +171,129 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
return <div className="error">{error || 'Video not found'}</div>;
}
// Get source badge
const getSourceBadge = () => {
if (video.source === 'bilibili') {
return <span className="source-badge bilibili">Bilibili</span>;
}
return <span className="source-badge youtube">YouTube</span>;
};
// Get related videos (exclude current video)
const relatedVideos = videos.filter(v => v.id !== id).slice(0, 10);
return (
<div className="video-player-container">
<div className="video-wrapper">
<video
className="video-player"
controls
autoPlay
src={`${BACKEND_URL}${video.videoPath || video.url}`}
>
Your browser does not support the video tag.
</video>
</div>
<div className="video-details">
<div className="video-details-header">
<div className="title-container">
<h1>{video.title}</h1>
{getSourceBadge()}
</div>
<div className="video-actions">
<button
className="collection-btn"
onClick={handleAddToCollection}
>
Add to Collection
</button>
<button
className="delete-btn"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete Video'}
</button>
</div>
<div className="video-player-page">
{/* Main Content Column */}
<div className="video-main-content">
<div className="video-wrapper">
<video
className="video-player"
controls
autoPlay
src={`${BACKEND_URL}${video.videoPath || video.url}`}
>
Your browser does not support the video tag.
</video>
</div>
{deleteError && (
<div className="error" style={{ marginTop: '0.5rem' }}>
{deleteError}
</div>
)}
<div className="video-details-meta">
<div>
<strong>Author:</strong>{' '}
<span
className="author-link"
onClick={handleAuthorClick}
role="button"
tabIndex="0"
aria-label={`View all videos by ${video.author}`}
>
{video.author}
</span>
</div>
<div>
<strong>Upload Date:</strong> {formatDate(video.date)}
</div>
<div>
<strong>Added:</strong> {new Date(video.addedAt).toLocaleString()}
</div>
{video.sourceUrl && (
<div>
<strong>Source:</strong>{' '}
<a
href={video.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="source-link"
>
Original Video
</a>
</div>
)}
{videoCollections.length > 0 && (
<div className="video-collections">
<div className="video-collections-title">Collection:</div>
<div className="video-collections-list">
<span
key={videoCollections[0].id}
className="video-collection-tag"
onClick={() => handleCollectionClick(videoCollections[0].id)}
>
{videoCollections[0].name}
</span>
<div className="video-info-section">
<h1 className="video-title-h1">{video.title}</h1>
<div className="video-actions-row">
<div className="video-primary-actions">
<div className="channel-row" style={{ marginBottom: 0 }}>
<div className="channel-avatar">
{video.author ? video.author.charAt(0).toUpperCase() : 'A'}
</div>
<div className="channel-info">
<div
className="channel-name clickable"
onClick={handleAuthorClick}
>
{video.author}
</div>
<div className="video-stats">
{/* Placeholder for subscribers if we had that data */}
</div>
</div>
</div>
</div>
<div className="video-primary-actions">
<button
className="action-btn btn-secondary"
onClick={handleAddToCollection}
>
<span>+ Save</span>
</button>
<button
className="action-btn btn-danger"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
<div className="channel-desc-container">
<div className="video-stats" style={{ marginBottom: '8px', color: '#fff', fontWeight: 'bold' }}>
{/* Views would go here */}
{formatDate(video.date)}
</div>
<div className="description-text">
{/* We don't have a real description, so we'll show some metadata */}
<p>Source: {video.source === 'bilibili' ? 'Bilibili' : 'YouTube'}</p>
{video.sourceUrl && (
<p>
Original Link: <a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: '#3ea6ff' }}>{video.sourceUrl}</a>
</p>
)}
</div>
{videoCollections.length > 0 && (
<div className="collection-tags">
{videoCollections.map(c => (
<span
key={c.id}
className="collection-pill"
onClick={() => handleCollectionClick(c.id)}
>
{c.name}
</span>
))}
</div>
)}
</div>
</div>
</div>
{/* Sidebar Column - Up Next */}
<div className="video-sidebar">
<h3 className="sidebar-title">Up Next</h3>
<div className="related-videos-list">
{relatedVideos.map(relatedVideo => (
<div
key={relatedVideo.id}
className="related-video-card"
onClick={() => navigate(`/video/${relatedVideo.id}`)}
>
<div className="related-video-thumbnail">
<img
src={`${BACKEND_URL}${relatedVideo.thumbnailPath}`}
alt={relatedVideo.title}
onError={(e) => {
e.target.onerror = null;
e.target.src = 'https://via.placeholder.com/168x94?text=No+Thumbnail';
}}
/>
<span className="duration-badge">{relatedVideo.duration || '00:00'}</span>
</div>
<div className="related-video-info">
<div className="related-video-title">{relatedVideo.title}</div>
<div className="related-video-author">{relatedVideo.author}</div>
<div className="related-video-meta">
{formatDate(relatedVideo.date)}
</div>
</div>
</div>
))}
{relatedVideos.length === 0 && (
<div className="no-videos">No other videos available</div>
)}
</div>
</div>
@@ -286,7 +303,7 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
<div className="modal-overlay">
<div className="modal-content">
<h2>Add to Collection</h2>
{videoCollections.length > 0 && (
<div className="current-collection">
<p className="collection-note">
@@ -295,7 +312,7 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
<p className="collection-warning">
Adding to a different collection will remove it from the current one.
</p>
<button
<button
className="remove-from-collection"
onClick={handleRemoveFromCollection}
>
@@ -303,18 +320,18 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
</button>
</div>
)}
{collections && collections.length > 0 && (
<div className="existing-collections">
<h3>Add to existing collection:</h3>
<select
value={selectedCollection}
<select
value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)}
>
<option value="">Select a collection</option>
{collections.map(collection => (
<option
key={collection.id}
<option
key={collection.id}
value={collection.id}
disabled={videoCollections.length > 0 && videoCollections[0].id === collection.id}
>
@@ -322,7 +339,7 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
</option>
))}
</select>
<button
<button
onClick={handleAddToExistingCollection}
disabled={!selectedCollection}
>
@@ -330,23 +347,23 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
</button>
</div>
)}
<div className="new-collection">
<h3>Create new collection:</h3>
<input
type="text"
placeholder="Collection name"
<input
type="text"
placeholder="Collection name"
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
/>
<button
<button
onClick={handleCreateCollection}
disabled={!newCollectionName.trim()}
>
Create Collection
</button>
</div>
<button className="close-modal" onClick={handleCloseModal}>
Cancel
</button>