feat: Add options to delete videos with a collection

This commit is contained in:
Peifan Li
2025-11-21 17:23:29 -05:00
parent 8985c3d352
commit 6f77ee352f
8 changed files with 346 additions and 121 deletions

View File

@@ -95,6 +95,17 @@ const updateCollection = (req, res) => {
const deleteCollection = (req, res) => {
try {
const { id } = req.params;
const { deleteVideos } = req.query;
// If deleteVideos is true, delete all videos in the collection first
if (deleteVideos === 'true') {
const collection = storageService.getCollectionById(id);
if (collection && collection.videos && collection.videos.length > 0) {
collection.videos.forEach(videoId => {
storageService.deleteVideo(videoId);
});
}
}
const success = storageService.deleteCollection(id);

View File

@@ -1597,8 +1597,71 @@ body {
background-color: var(--hover-color);
}
/* Unified Modal Button Styles */
.modal-btn {
width: 100%;
padding: 12px 20px;
border: none;
border-radius: var(--border-radius);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.modal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-btn.primary-btn {
background-color: var(--primary-color);
color: white;
}
.modal-btn.primary-btn:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.modal-btn.secondary-btn {
background-color: rgba(255, 255, 255, 0.1);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.modal-btn.secondary-btn:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.15);
border-color: var(--text-color);
}
.modal-btn.danger-btn {
background-color: rgba(255, 62, 62, 0.15);
color: var(--primary-color);
border: 1px solid var(--primary-color);
}
.modal-btn.danger-btn:hover:not(:disabled) {
background-color: var(--primary-color);
color: white;
}
.modal-btn.cancel-btn {
background-color: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.modal-btn.cancel-btn:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.05);
border-color: var(--text-color);
color: var(--text-color);
}
/* Video collections info */
.video-collections {}
.video-collections-title {
font-weight: bold;
@@ -2151,4 +2214,57 @@ body {
color: var(--text-secondary);
background: var(--bg-secondary);
border-radius: 8px;
}
/* Manage Page Sections */
.manage-section {
margin-bottom: 40px;
}
.manage-section h2 {
margin-bottom: 20px;
color: var(--text-color);
font-size: 1.5rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
}
/* Modal Actions */
.column-actions {
flex-direction: column;
align-items: stretch;
}
.column-actions button {
width: 100%;
margin-bottom: 10px;
justify-content: center;
}
.cancel-btn {
background-color: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px 15px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
}
.cancel-btn:hover {
background-color: rgba(255, 255, 255, 0.05);
color: var(--text-color);
border-color: var(--text-secondary);
}
.delete-btn.danger {
background-color: rgba(255, 62, 62, 0.1);
color: var(--primary-color);
border: 1px solid var(--primary-color);
}
.delete-btn.danger:hover {
background-color: var(--primary-color);
color: white;
}

View File

@@ -520,28 +520,21 @@ function App() {
// Delete a collection
const handleDeleteCollection = async (collectionId, deleteVideos = false) => {
try {
// Get the collection before deleting it
const collection = collections.find(c => c.id === collectionId);
if (!collection) {
return { success: false, error: 'Collection not found' };
}
// If deleteVideos is true, delete all videos in the collection
if (deleteVideos && collection.videos.length > 0) {
for (const videoId of collection.videos) {
await handleDeleteVideo(videoId);
}
}
// Delete the collection
await axios.delete(`${API_URL}/collections/${collectionId}`);
// Delete the collection with optional video deletion
await axios.delete(`${API_URL}/collections/${collectionId}`, {
params: { deleteVideos: deleteVideos ? 'true' : 'false' }
});
// Update the collections state
setCollections(prevCollections =>
prevCollections.filter(collection => collection.id !== collectionId)
);
// If videos were deleted, refresh the videos list
if (deleteVideos) {
await fetchVideos();
}
return { success: true };
} catch (error) {
console.error('Error deleting collection:', error);
@@ -688,6 +681,8 @@ function App() {
<ManagePage
videos={videos}
onDeleteVideo={handleDeleteVideo}
collections={collections}
onDeleteCollection={handleDeleteCollection}
/>
}
/>

View File

@@ -50,16 +50,7 @@ const AuthorsList = ({ videos }) => {
</li>
))}
</ul>
<div className="manage-videos-link-container" style={{ marginTop: '1rem', borderTop: '1px solid var(--border-color)', paddingTop: '0.5rem' }}>
<Link
to="/manage"
className="author-link manage-link"
onClick={() => setIsOpen(false)}
style={{ fontWeight: 'bold', color: 'var(--primary-color)' }}
>
Manage Videos
</Link>
</div>
</div>
</div>
);

View File

@@ -14,22 +14,22 @@ const CollectionPage = ({ collections, videos, onDeleteVideo, onDeleteCollection
useEffect(() => {
if (collections && collections.length > 0) {
const foundCollection = collections.find(c => c.id === id);
if (foundCollection) {
setCollection(foundCollection);
// Find all videos that are in this collection
const videosInCollection = videos.filter(video =>
const videosInCollection = videos.filter(video =>
foundCollection.videos.includes(video.id)
);
setCollectionVideos(videosInCollection);
} else {
// Collection not found, redirect to home
navigate('/');
}
}
setLoading(false);
}, [id, collections, videos, navigate]);
@@ -79,11 +79,8 @@ const CollectionPage = ({ collections, videos, onDeleteVideo, onDeleteCollection
<h2 className="collection-title">Collection: {collection.name}</h2>
<span className="video-count">{collectionVideos.length} video{collectionVideos.length !== 1 ? 's' : ''}</span>
</div>
<button className="delete-collection-button" onClick={handleShowDeleteModal}>
Delete Collection
</button>
</div>
{collectionVideos.length === 0 ? (
<div className="no-videos">
<p>No videos in this collection.</p>
@@ -91,9 +88,9 @@ const CollectionPage = ({ collections, videos, onDeleteVideo, onDeleteCollection
) : (
<div className="videos-grid">
{collectionVideos.map(video => (
<VideoCard
key={video.id}
video={video}
<VideoCard
key={video.id}
video={video}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
/>

View File

@@ -1,12 +1,13 @@
import { Link } from 'react-router-dom';
import AuthorsList from '../components/AuthorsList';
import Collections from '../components/Collections';
import VideoCard from '../components/VideoCard';
const Home = ({
videos = [],
loading,
error,
onDeleteVideo,
const Home = ({
videos = [],
loading,
error,
onDeleteVideo,
collections = [],
isSearchMode = false,
searchTerm = '',
@@ -29,14 +30,14 @@ const Home = ({
// Filter videos to only show the first video from each collection
const filteredVideos = videoArray.filter(video => {
// If the video is not in any collection, show it
const videoCollections = collections.filter(collection =>
const videoCollections = collections.filter(collection =>
collection.videos.includes(video.id)
);
if (videoCollections.length === 0) {
return true;
}
// For each collection this video is in, check if it's the first video
return videoCollections.some(collection => {
// Get the first video ID in this collection
@@ -50,13 +51,13 @@ const Home = ({
if (isSearchMode) {
const hasLocalResults = localSearchResults && localSearchResults.length > 0;
const hasYouTubeResults = searchResults && searchResults.length > 0;
return (
<div className="search-results">
<div className="search-header">
<h2>Search Results for "{searchTerm}"</h2>
</div>
{/* Local Video Results */}
<div className="search-results-section">
<h3 className="section-title">From Your Library</h3>
@@ -76,11 +77,11 @@ const Home = ({
<p className="no-results">No matching videos in your library.</p>
)}
</div>
{/* YouTube Search Results */}
<div className="search-results-section">
<h3 className="section-title">From YouTube</h3>
{youtubeLoading ? (
<div className="youtube-loading">
<div className="loading-spinner"></div>
@@ -92,8 +93,8 @@ const Home = ({
<div key={result.id} className="search-result-card">
<div className="search-result-thumbnail">
{result.thumbnailUrl ? (
<img
src={result.thumbnailUrl}
<img
src={result.thumbnailUrl}
alt={result.title}
onError={(e) => {
e.target.onerror = null;
@@ -122,7 +123,7 @@ const Home = ({
{result.source}
</span>
</div>
<button
<button
className="download-btn"
onClick={() => onDownload(result.sourceUrl, result.title)}
>
@@ -153,17 +154,27 @@ const Home = ({
<div className="sidebar-container">
{/* Collections list */}
<Collections collections={collections} />
{/* Authors list */}
<AuthorsList videos={videoArray} />
<div className="manage-videos-link-container" style={{ marginTop: '1rem', paddingTop: '0.5rem' }}>
<Link
to="/manage"
className="author-link manage-link"
style={{ fontWeight: 'bold', color: 'var(--primary-color)', display: 'block', textAlign: 'center' }}
>
Manage Videos
</Link>
</div>
</div>
{/* Videos grid */}
<div className="videos-grid">
{filteredVideos.map(video => (
<VideoCard
key={video.id}
video={video}
<VideoCard
key={video.id}
video={video}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
collections={collections}

View File

@@ -3,9 +3,11 @@ import { Link } from 'react-router-dom';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
const ManagePage = ({ videos, onDeleteVideo }) => {
const ManagePage = ({ videos, onDeleteVideo, collections = [], onDeleteCollection }) => {
const [searchTerm, setSearchTerm] = useState('');
const [deletingId, setDeletingId] = useState(null);
const [collectionToDelete, setCollectionToDelete] = useState(null);
const [isDeletingCollection, setIsDeletingCollection] = useState(false);
const filteredVideos = videos.filter(video =>
video.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -20,6 +22,19 @@ const ManagePage = ({ videos, onDeleteVideo }) => {
}
};
const confirmDeleteCollection = (collection) => {
setCollectionToDelete(collection);
};
const handleCollectionDelete = async (deleteVideos) => {
if (!collectionToDelete) return;
setIsDeletingCollection(true);
await onDeleteCollection(collectionToDelete.id, deleteVideos);
setIsDeletingCollection(false);
setCollectionToDelete(null);
};
const getThumbnailSrc = (video) => {
if (video.thumbnailPath) {
return `${BACKEND_URL}${video.thumbnailPath}`;
@@ -30,64 +45,141 @@ const ManagePage = ({ videos, onDeleteVideo }) => {
return (
<div className="manage-page">
<div className="manage-header">
<h1>Manage Videos</h1>
<h1>Manage Content</h1>
<Link to="/" className="back-link"> Back to Home</Link>
</div>
<div className="manage-controls">
<input
type="text"
placeholder="Search videos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="manage-search"
/>
<div className="video-count">
{filteredVideos.length} videos found
{/* Delete Collection Modal */}
{collectionToDelete && (
<div className="modal-overlay" onClick={() => !isDeletingCollection && setCollectionToDelete(null)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Delete Collection</h2>
<p style={{ marginBottom: '0.5rem' }}>
You are about to delete the collection <strong>"{collectionToDelete.name}"</strong>.
</p>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '1.5rem' }}>
This collection contains {collectionToDelete.videos.length} video{collectionToDelete.videos.length !== 1 ? 's' : ''}.
</p>
<div className="modal-actions" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<button
className="modal-btn secondary-btn"
onClick={() => handleCollectionDelete(false)}
disabled={isDeletingCollection}
>
{isDeletingCollection ? 'Deleting...' : 'Delete Collection Only'}
</button>
<button
className="modal-btn danger-btn"
onClick={() => handleCollectionDelete(true)}
disabled={isDeletingCollection}
>
{isDeletingCollection ? 'Deleting...' : 'Delete Collection & Videos'}
</button>
<button
className="modal-btn cancel-btn"
onClick={() => setCollectionToDelete(null)}
disabled={isDeletingCollection}
>
Cancel
</button>
</div>
</div>
</div>
)}
<div className="manage-section">
<h2>Collections ({collections.length})</h2>
<div className="manage-list">
{collections.length > 0 ? (
<table className="manage-table">
<thead>
<tr>
<th>Name</th>
<th>Videos</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{collections.map(collection => (
<tr key={collection.id}>
<td className="col-title">{collection.name}</td>
<td>{collection.videos.length} videos</td>
<td>{new Date(collection.createdAt).toLocaleDateString()}</td>
<td className="col-actions">
<button
className="delete-btn-small"
onClick={() => confirmDeleteCollection(collection)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="no-videos-found">
No collections found.
</div>
)}
</div>
</div>
<div className="manage-list">
{filteredVideos.length > 0 ? (
<table className="manage-table">
<thead>
<tr>
<th>Thumbnail</th>
<th>Title</th>
<th>Author</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filteredVideos.map(video => (
<tr key={video.id}>
<td className="col-thumbnail">
<img
src={getThumbnailSrc(video)}
alt={video.title}
className="manage-thumbnail"
/>
</td>
<td className="col-title">{video.title}</td>
<td className="col-author">{video.author}</td>
<td className="col-actions">
<button
className="delete-btn-small"
onClick={() => handleDelete(video.id)}
disabled={deletingId === video.id}
>
{deletingId === video.id ? 'Deleting...' : 'Delete'}
</button>
</td>
<div className="manage-section">
<h2>Videos ({filteredVideos.length})</h2>
<div className="manage-controls">
<input
type="text"
placeholder="Search videos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="manage-search"
/>
</div>
<div className="manage-list">
{filteredVideos.length > 0 ? (
<table className="manage-table">
<thead>
<tr>
<th>Thumbnail</th>
<th>Title</th>
<th>Author</th>
<th>Actions</th>
</tr>
))}
</tbody>
</table>
) : (
<div className="no-videos-found">
No videos found matching your search.
</div>
)}
</thead>
<tbody>
{filteredVideos.map(video => (
<tr key={video.id}>
<td className="col-thumbnail">
<img
src={getThumbnailSrc(video)}
alt={video.title}
className="manage-thumbnail"
/>
</td>
<td className="col-title">{video.title}</td>
<td className="col-author">{video.author}</td>
<td className="col-actions">
<button
className="delete-btn-small"
onClick={() => handleDelete(video.id)}
disabled={deletingId === video.id}
>
{deletingId === video.id ? 'Deleting...' : 'Delete'}
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="no-videos-found">
No videos found matching your search.
</div>
)}
</div>
</div>
</div>
);

View File

@@ -300,20 +300,27 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
{/* Collection Modal */}
{showCollectionModal && (
<div className="modal-overlay">
<div className="modal-content">
<div className="modal-overlay" onClick={handleCloseModal}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Add to Collection</h2>
{videoCollections.length > 0 && (
<div className="current-collection">
<p className="collection-note">
This video is currently in the collection: <strong>{videoCollections[0].name}</strong>
<div className="current-collection" style={{
marginBottom: '1.5rem',
padding: '1rem',
backgroundColor: 'rgba(62, 166, 255, 0.1)',
borderRadius: '8px',
border: '1px solid rgba(62, 166, 255, 0.3)'
}}>
<p style={{ margin: '0 0 0.5rem 0', color: 'var(--text-color)' }}>
Currently in: <strong>{videoCollections[0].name}</strong>
</p>
<p className="collection-warning">
<p style={{ margin: '0 0 1rem 0', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
Adding to a different collection will remove it from the current one.
</p>
<button
className="remove-from-collection"
className="modal-btn danger-btn"
style={{ width: '100%' }}
onClick={handleRemoveFromCollection}
>
Remove from Collection
@@ -322,7 +329,7 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
)}
{collections && collections.length > 0 && (
<div className="existing-collections">
<div className="existing-collections" style={{ marginBottom: '1.5rem' }}>
<h3>Add to existing collection:</h3>
<select
value={selectedCollection}
@@ -340,6 +347,8 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
))}
</select>
<button
className="modal-btn primary-btn"
style={{ width: '100%', marginTop: '0.5rem' }}
onClick={handleAddToExistingCollection}
disabled={!selectedCollection}
>
@@ -348,15 +357,18 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
</div>
)}
<div className="new-collection">
<div className="new-collection" style={{ marginBottom: '1.5rem' }}>
<h3>Create new collection:</h3>
<input
type="text"
placeholder="Collection name"
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && newCollectionName.trim() && handleCreateCollection()}
/>
<button
className="modal-btn primary-btn"
style={{ width: '100%', marginTop: '0.5rem' }}
onClick={handleCreateCollection}
disabled={!newCollectionName.trim()}
>
@@ -364,7 +376,7 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
</button>
</div>
<button className="close-modal" onClick={handleCloseModal}>
<button className="modal-btn cancel-btn" style={{ width: '100%' }} onClick={handleCloseModal}>
Cancel
</button>
</div>