feat: Add sorting functionality for videos on Home page
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { Collections as CollectionsIcon, GridView, History, ViewSidebar } from '@mui/icons-material';
|
import { AccessTime, Collections as CollectionsIcon, GridView, History, Shuffle, Sort, SortByAlpha, ViewSidebar, Visibility } from '@mui/icons-material';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
@@ -8,13 +8,17 @@ import {
|
|||||||
Collapse,
|
Collapse,
|
||||||
Container,
|
Container,
|
||||||
Grid,
|
Grid,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
Pagination,
|
Pagination,
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import AuthorsList from '../components/AuthorsList';
|
import AuthorsList from '../components/AuthorsList';
|
||||||
import CollectionCard from '../components/CollectionCard';
|
import CollectionCard from '../components/CollectionCard';
|
||||||
@@ -48,6 +52,9 @@ const Home: React.FC = () => {
|
|||||||
const saved = localStorage.getItem('homeViewMode');
|
const saved = localStorage.getItem('homeViewMode');
|
||||||
return (saved as 'collections' | 'all-videos' | 'history') || 'collections';
|
return (saved as 'collections' | 'all-videos' | 'history') || 'collections';
|
||||||
});
|
});
|
||||||
|
const [sortOption, setSortOption] = useState<string>('dateDesc');
|
||||||
|
const [sortAnchorEl, setSortAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const [shuffleSeed, setShuffleSeed] = useState<number>(0);
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||||
|
|
||||||
@@ -135,6 +142,75 @@ const Home: React.FC = () => {
|
|||||||
// Add default empty array to ensure videos is always an array
|
// Add default empty array to ensure videos is always an array
|
||||||
const videoArray = Array.isArray(videos) ? videos : [];
|
const videoArray = Array.isArray(videos) ? videos : [];
|
||||||
|
|
||||||
|
|
||||||
|
// Filter videos based on view mode
|
||||||
|
const filteredVideos = useMemo(() => {
|
||||||
|
if (viewMode === 'all-videos') {
|
||||||
|
return videoArray.filter(video => {
|
||||||
|
// In all-videos mode, only apply tag filtering
|
||||||
|
if (selectedTags.length > 0) {
|
||||||
|
const videoTags = video.tags || [];
|
||||||
|
return selectedTags.every(tag => videoTags.includes(tag));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'history') {
|
||||||
|
return videoArray
|
||||||
|
.filter(video => video.lastPlayedAt)
|
||||||
|
.sort((a, b) => (b.lastPlayedAt || 0) - (a.lastPlayedAt || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collections mode
|
||||||
|
return videoArray.filter(video => {
|
||||||
|
// In collections mode, show only first video from each collection
|
||||||
|
// Tag filtering
|
||||||
|
if (selectedTags.length > 0) {
|
||||||
|
const videoTags = video.tags || [];
|
||||||
|
const hasMatchingTag = selectedTags.every(tag => videoTags.includes(tag));
|
||||||
|
if (!hasMatchingTag) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the video is not in any collection, show it
|
||||||
|
const videoCollections = collections.filter(collection =>
|
||||||
|
collection.videos.includes(video.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (videoCollections.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const firstVideoId = collection.videos[0];
|
||||||
|
// Show this video if it's the first in at least one collection
|
||||||
|
return video.id === firstVideoId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [viewMode, videoArray, selectedTags, collections]);
|
||||||
|
|
||||||
|
const sortedVideos = useMemo(() => {
|
||||||
|
const result = [...filteredVideos];
|
||||||
|
switch (sortOption) {
|
||||||
|
case 'dateDesc':
|
||||||
|
return result.sort((a, b) => new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime());
|
||||||
|
case 'dateAsc':
|
||||||
|
return result.sort((a, b) => new Date(a.addedAt).getTime() - new Date(b.addedAt).getTime());
|
||||||
|
case 'viewsDesc':
|
||||||
|
return result.sort((a, b) => (b.viewCount || 0) - (a.viewCount || 0));
|
||||||
|
case 'viewsAsc':
|
||||||
|
return result.sort((a, b) => (a.viewCount || 0) - (b.viewCount || 0));
|
||||||
|
case 'nameAsc':
|
||||||
|
return result.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
case 'random':
|
||||||
|
return result.sort(() => 0.5 - Math.random());
|
||||||
|
default:
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}, [filteredVideos, sortOption, shuffleSeed]);
|
||||||
|
|
||||||
if (!settingsLoaded || (loading && videoArray.length === 0)) {
|
if (!settingsLoaded || (loading && videoArray.length === 0)) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||||
@@ -153,45 +229,7 @@ const Home: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter videos based on view mode
|
// Filter videos based on view mode
|
||||||
const filteredVideos = viewMode === 'all-videos'
|
|
||||||
? videoArray.filter(video => {
|
|
||||||
// In all-videos mode, only apply tag filtering
|
|
||||||
if (selectedTags.length > 0) {
|
|
||||||
const videoTags = video.tags || [];
|
|
||||||
return selectedTags.every(tag => videoTags.includes(tag));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
: viewMode === 'history'
|
|
||||||
? videoArray
|
|
||||||
.filter(video => video.lastPlayedAt)
|
|
||||||
.sort((a, b) => (b.lastPlayedAt || 0) - (a.lastPlayedAt || 0))
|
|
||||||
: videoArray.filter(video => {
|
|
||||||
// In collections mode, show only first video from each collection
|
|
||||||
// Tag filtering
|
|
||||||
if (selectedTags.length > 0) {
|
|
||||||
const videoTags = video.tags || [];
|
|
||||||
const hasMatchingTag = selectedTags.every(tag => videoTags.includes(tag));
|
|
||||||
if (!hasMatchingTag) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the video is not in any collection, show it
|
|
||||||
const videoCollections = collections.filter(collection =>
|
|
||||||
collection.videos.includes(video.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (videoCollections.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
const firstVideoId = collection.videos[0];
|
|
||||||
// Show this video if it's the first in at least one collection
|
|
||||||
return video.id === firstVideoId;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleViewModeChange = (mode: 'collections' | 'all-videos' | 'history') => {
|
const handleViewModeChange = (mode: 'collections' | 'all-videos' | 'history') => {
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
@@ -203,9 +241,38 @@ const Home: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSortClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setSortAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortClose = (option?: string) => {
|
||||||
|
if (option) {
|
||||||
|
if (option === 'random') {
|
||||||
|
setShuffleSeed(prev => prev + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option !== sortOption) {
|
||||||
|
setSortOption(option);
|
||||||
|
setSearchParams((prev: URLSearchParams) => {
|
||||||
|
const newParams = new URLSearchParams(prev);
|
||||||
|
newParams.set('page', '1');
|
||||||
|
return newParams;
|
||||||
|
});
|
||||||
|
} else if (option === 'random') {
|
||||||
|
// Even if it matches, if it is random, we want to reset page to 1 because the order changed
|
||||||
|
setSearchParams((prev: URLSearchParams) => {
|
||||||
|
const newParams = new URLSearchParams(prev);
|
||||||
|
newParams.set('page', '1');
|
||||||
|
return newParams;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSortAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Pagination logic
|
// Pagination logic
|
||||||
const totalPages = Math.ceil(filteredVideos.length / itemsPerPage);
|
const totalPages = Math.ceil(sortedVideos.length / itemsPerPage);
|
||||||
const displayedVideos = filteredVideos.slice(
|
const displayedVideos = sortedVideos.slice(
|
||||||
(page - 1) * itemsPerPage,
|
(page - 1) * itemsPerPage,
|
||||||
page * itemsPerPage
|
page * itemsPerPage
|
||||||
);
|
);
|
||||||
@@ -300,31 +367,89 @@ const Home: React.FC = () => {
|
|||||||
}[viewMode]}
|
}[viewMode]}
|
||||||
</Box>
|
</Box>
|
||||||
</Typography>
|
</Typography>
|
||||||
<ToggleButtonGroup
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
value={viewMode}
|
<ToggleButtonGroup
|
||||||
exclusive
|
value={viewMode}
|
||||||
onChange={(_, newMode) => newMode && handleViewModeChange(newMode)}
|
exclusive
|
||||||
size="small"
|
onChange={(_, newMode) => newMode && handleViewModeChange(newMode)}
|
||||||
>
|
size="small"
|
||||||
<ToggleButton value="all-videos" sx={{ px: { xs: 3, md: 2 } }}>
|
>
|
||||||
<GridView fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
<ToggleButton value="all-videos" sx={{ px: { xs: 2, md: 2 } }}>
|
||||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
<GridView fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
||||||
{t('allVideos')}
|
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||||
</Box>
|
{t('allVideos')}
|
||||||
</ToggleButton>
|
</Box>
|
||||||
<ToggleButton value="collections" sx={{ px: { xs: 3, md: 2 } }}>
|
</ToggleButton>
|
||||||
<CollectionsIcon fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
<ToggleButton value="collections" sx={{ px: { xs: 2, md: 2 } }}>
|
||||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
<CollectionsIcon fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
||||||
{t('collections')}
|
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||||
</Box>
|
{t('collections')}
|
||||||
</ToggleButton>
|
</Box>
|
||||||
<ToggleButton value="history" sx={{ px: { xs: 3, md: 2 } }}>
|
</ToggleButton>
|
||||||
<History fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
<ToggleButton value="history" sx={{ px: { xs: 2, md: 2 } }}>
|
||||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
<History fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
||||||
{t('history')}
|
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||||
</Box>
|
{t('history')}
|
||||||
</ToggleButton>
|
</Box>
|
||||||
</ToggleButtonGroup>
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleSortClick}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
minWidth: 'auto',
|
||||||
|
px: 1,
|
||||||
|
color: 'text.secondary',
|
||||||
|
borderColor: 'text.secondary'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sort />
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
anchorEl={sortAnchorEl}
|
||||||
|
open={Boolean(sortAnchorEl)}
|
||||||
|
onClose={() => handleSortClose()}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={() => handleSortClose('dateDesc')} selected={sortOption === 'dateDesc'}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<AccessTime fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{t('dateDesc')}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => handleSortClose('dateAsc')} selected={sortOption === 'dateAsc'}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<AccessTime fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{t('dateAsc')}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => handleSortClose('viewsDesc')} selected={sortOption === 'viewsDesc'}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Visibility fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{t('viewsDesc')}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => handleSortClose('viewsAsc')} selected={sortOption === 'viewsAsc'}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Visibility fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{t('viewsAsc')}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => handleSortClose('nameAsc')} selected={sortOption === 'nameAsc'}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<SortByAlpha fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{t('nameAsc')}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => handleSortClose('random')} selected={sortOption === 'random'}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Shuffle fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{t('random')}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{viewMode === 'collections' && displayedVideos.length === 0 ? (
|
{viewMode === 'collections' && displayedVideos.length === 0 ? (
|
||||||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
<Box sx={{ py: 8, textAlign: 'center' }}>
|
||||||
|
|||||||
@@ -397,4 +397,14 @@ export const en = {
|
|||||||
videoSkippedDeleted: "Video was previously deleted, skipped download",
|
videoSkippedDeleted: "Video was previously deleted, skipped download",
|
||||||
downloading: "Downloading...",
|
downloading: "Downloading...",
|
||||||
poweredBy: "Powered by MyTube",
|
poweredBy: "Powered by MyTube",
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
sort: "Sort",
|
||||||
|
sortBy: "Sort by",
|
||||||
|
dateDesc: "Date Added (Newest)",
|
||||||
|
dateAsc: "Date Added (Oldest)",
|
||||||
|
viewsDesc: "Views (High to Low)",
|
||||||
|
viewsAsc: "Views (Low to High)",
|
||||||
|
nameAsc: "Name (A-Z)",
|
||||||
|
random: "Random Shuffle",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user