feat: add HomeHeader, HomeSidebar, VideoGrid components
This commit is contained in:
108
frontend/src/components/HomeHeader.tsx
Normal file
108
frontend/src/components/HomeHeader.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Collections as CollectionsIcon, Delete as DeleteIcon, GridView, History, ViewSidebar } from '@mui/icons-material';
|
||||
import { Box, Button, IconButton, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material';
|
||||
import React from 'react';
|
||||
import SortControl from './SortControl';
|
||||
import { ViewMode } from '../hooks/useViewMode';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface HomeHeaderProps {
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
onSidebarToggle: () => void;
|
||||
selectedTagsCount: number;
|
||||
onDeleteFilteredClick: () => void;
|
||||
sortOption: string;
|
||||
sortAnchorEl: HTMLElement | null;
|
||||
onSortClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onSortClose: (option?: string) => void;
|
||||
}
|
||||
|
||||
export const HomeHeader: React.FC<HomeHeaderProps> = ({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onSidebarToggle,
|
||||
selectedTagsCount,
|
||||
onDeleteFilteredClick,
|
||||
sortOption,
|
||||
sortAnchorEl,
|
||||
onSortClick,
|
||||
onSortClose
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3, px: { xs: 2, sm: 0 } }}>
|
||||
<Typography variant="h5" fontWeight="bold" sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
onClick={onSidebarToggle}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
p: 1,
|
||||
display: { xs: 'none', md: 'inline-flex' },
|
||||
color: 'text.secondary',
|
||||
borderColor: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<ViewSidebar sx={{ transform: 'rotate(180deg)' }} />
|
||||
</Button>
|
||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
{t('videos')}
|
||||
</Box>
|
||||
{selectedTagsCount > 0 && (
|
||||
<Tooltip title={t('deleteAllFilteredVideos')}>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={onDeleteFilteredClick}
|
||||
size="small"
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box component="span" sx={{ display: { xs: 'block', md: 'none' } }}>
|
||||
{{
|
||||
'collections': t('collections'),
|
||||
'all-videos': t('allVideos'),
|
||||
'history': t('history')
|
||||
}[viewMode]}
|
||||
</Box>
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(_, newMode) => newMode && onViewModeChange(newMode)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="all-videos" sx={{ px: { xs: 2, md: 2 } }}>
|
||||
<GridView fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
{t('allVideos')}
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="collections" sx={{ px: { xs: 2, md: 2 } }}>
|
||||
<CollectionsIcon fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
{t('collections')}
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="history" sx={{ px: { xs: 2, md: 2 } }}>
|
||||
<History fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
{t('history')}
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<SortControl
|
||||
sortOption={sortOption}
|
||||
sortAnchorEl={sortAnchorEl}
|
||||
onSortClick={onSortClick}
|
||||
onSortClose={onSortClose}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
75
frontend/src/components/HomeSidebar.tsx
Normal file
75
frontend/src/components/HomeSidebar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Box, Collapse } from '@mui/material';
|
||||
import React from 'react';
|
||||
import AuthorsList from './AuthorsList';
|
||||
import Collections from './Collections';
|
||||
import TagsList from './TagsList';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
interface HomeSidebarProps {
|
||||
isSidebarOpen: boolean;
|
||||
collections: Collection[];
|
||||
availableTags: string[];
|
||||
selectedTags: string[];
|
||||
onTagToggle: (tag: string) => void;
|
||||
videos: Video[];
|
||||
}
|
||||
|
||||
export const HomeSidebar: React.FC<HomeSidebarProps> = ({
|
||||
isSidebarOpen,
|
||||
collections,
|
||||
availableTags,
|
||||
selectedTags,
|
||||
onTagToggle,
|
||||
videos
|
||||
}) => {
|
||||
return (
|
||||
<Box sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
<Collapse
|
||||
in={isSidebarOpen}
|
||||
orientation="horizontal"
|
||||
timeout={300}
|
||||
sx={{
|
||||
height: '100%',
|
||||
'& .MuiCollapse-wrapper': { height: '100%' },
|
||||
'& .MuiCollapse-wrapperInner': { height: '100%' }
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 280, mr: 4, flexShrink: 0, height: '100%', position: 'relative' }}>
|
||||
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<Box sx={{
|
||||
position: 'sticky',
|
||||
maxHeight: 'calc(100% - 80px)',
|
||||
minHeight: 'calc(100vh - 80px)',
|
||||
overflowY: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: 'rgba(0,0,0,0.1)',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&:hover::-webkit-scrollbar-thumb': {
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
},
|
||||
}}>
|
||||
<Collections collections={collections} />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TagsList
|
||||
availableTags={availableTags}
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={onTagToggle}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AuthorsList videos={videos} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
177
frontend/src/components/VideoGrid.tsx
Normal file
177
frontend/src/components/VideoGrid.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Grid } from '@mui/material';
|
||||
import React, { useMemo } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import CollectionCard from './CollectionCard';
|
||||
import VideoCard from './VideoCard';
|
||||
import { Collection, Video } from '../types';
|
||||
import { ViewMode } from '../hooks/useViewMode';
|
||||
|
||||
interface GridProps {
|
||||
xs: number;
|
||||
sm: number;
|
||||
md?: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
}
|
||||
|
||||
interface VideoGridProps {
|
||||
videos: Video[];
|
||||
sortedVideos: Video[];
|
||||
displayedVideos: Video[];
|
||||
collections: Collection[];
|
||||
viewMode: ViewMode;
|
||||
infiniteScroll: boolean;
|
||||
gridProps: GridProps;
|
||||
onDeleteVideo: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
export const VideoGrid: React.FC<VideoGridProps> = ({
|
||||
videos,
|
||||
sortedVideos,
|
||||
displayedVideos,
|
||||
collections,
|
||||
viewMode,
|
||||
infiniteScroll,
|
||||
gridProps,
|
||||
onDeleteVideo
|
||||
}) => {
|
||||
// Components for VirtuosoGrid - MUST be defined before any conditional returns
|
||||
// Using useMemo to create stable component references
|
||||
// These components must work with virtualization - avoid forcing all items to render
|
||||
const VirtuosoList = useMemo(() =>
|
||||
React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<typeof Grid>>((props, ref) => {
|
||||
// Extract style and other props, but ensure we don't force all items to render
|
||||
const { style, ...restProps } = props;
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
rowSpacing={{ xs: 2, sm: 3 }}
|
||||
columnSpacing={{ xs: 0, sm: 3 }}
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
// Critical: Don't set height or minHeight that would force all items to render
|
||||
// Let VirtuosoGrid handle the height calculation
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const VirtuosoItem = useMemo(() =>
|
||||
React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<typeof Grid>>((props, ref) => {
|
||||
const { style, ...restProps } = props;
|
||||
return (
|
||||
<Grid
|
||||
size={gridProps}
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
// Remove width override to let Grid handle sizing
|
||||
// VirtuosoGrid will manage which items are rendered
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[gridProps]
|
||||
);
|
||||
|
||||
const renderVideoItem = (video: Video) => {
|
||||
// In all-videos and history mode, ALWAYS render as VideoCard
|
||||
if (viewMode === 'all-videos' || viewMode === 'history') {
|
||||
return (
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
disableCollectionGrouping={true}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// In collections mode, check if this video is the first in a collection
|
||||
const collection = collections.find(c => c.videos[0] === video.id);
|
||||
|
||||
if (collection) {
|
||||
return (
|
||||
<CollectionCard
|
||||
collection={collection}
|
||||
videos={videos}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback (shouldn't happen often in collections view unless logic allows loose videos)
|
||||
return (
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (infiniteScroll) {
|
||||
return (
|
||||
<VirtuosoGrid
|
||||
key={`virtuoso-${viewMode}-${sortedVideos.length}`}
|
||||
useWindowScroll
|
||||
data={sortedVideos}
|
||||
components={{
|
||||
List: VirtuosoList,
|
||||
Item: VirtuosoItem
|
||||
}}
|
||||
overscan={5}
|
||||
itemContent={(_index, video) => renderVideoItem(video)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
rowSpacing={{ xs: 2, sm: 3 }}
|
||||
columnSpacing={{ xs: 0, sm: 3 }}
|
||||
>
|
||||
{displayedVideos.map((video) => {
|
||||
// In all-videos and history mode, ALWAYS render as VideoCard
|
||||
if (viewMode === 'all-videos' || viewMode === 'history') {
|
||||
return (
|
||||
<Grid size={gridProps} key={video.id}>
|
||||
{renderVideoItem(video)}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
// In collections mode, check if this video is the first in a collection
|
||||
const collection = collections.find(c => c.videos[0] === video.id);
|
||||
|
||||
// If it is, render CollectionCard
|
||||
if (collection) {
|
||||
return (
|
||||
<Grid size={gridProps} key={`collection-${collection.id}`}>
|
||||
<CollectionCard
|
||||
collection={collection}
|
||||
videos={videos}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise render VideoCard for non-collection videos
|
||||
return (
|
||||
<Grid size={gridProps} key={video.id}>
|
||||
{renderVideoItem(video)}
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
32
frontend/src/hooks/useGridLayout.ts
Normal file
32
frontend/src/hooks/useGridLayout.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface GridProps {
|
||||
xs: number;
|
||||
sm: number;
|
||||
md?: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
}
|
||||
|
||||
interface UseGridLayoutProps {
|
||||
isSidebarOpen: boolean;
|
||||
videoColumns: number;
|
||||
}
|
||||
|
||||
export const useGridLayout = ({ isSidebarOpen, videoColumns }: UseGridLayoutProps): GridProps => {
|
||||
return useMemo(() => {
|
||||
if (isSidebarOpen) {
|
||||
if (videoColumns === 2) return { xs: 12, sm: 6, lg: 6, xl: 6 };
|
||||
if (videoColumns === 3) return { xs: 12, sm: 6, lg: 4, xl: 4 };
|
||||
if (videoColumns === 4) return { xs: 12, sm: 6, lg: 4, xl: 3 };
|
||||
if (videoColumns === 5) return { xs: 12, sm: 6, md: 4, lg: 3, xl: 2 };
|
||||
return { xs: 12, sm: 6, md: 4, lg: 3, xl: 2 }; // 6 columns
|
||||
} else {
|
||||
if (videoColumns === 2) return { xs: 12, sm: 6, lg: 6, xl: 6 };
|
||||
if (videoColumns === 3) return { xs: 12, sm: 6, md: 4, lg: 4, xl: 4 };
|
||||
if (videoColumns === 4) return { xs: 12, sm: 6, md: 4, lg: 3, xl: 3 };
|
||||
if (videoColumns === 5) return { xs: 12, sm: 6, md: 4, lg: 2, xl: 2 };
|
||||
return { xs: 12, sm: 6, md: 4, lg: 2, xl: 2 }; // 6 columns
|
||||
}
|
||||
}, [isSidebarOpen, videoColumns]);
|
||||
};
|
||||
117
frontend/src/hooks/useHomePagination.ts
Normal file
117
frontend/src/hooks/useHomePagination.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Video } from '../types';
|
||||
|
||||
interface UseHomePaginationProps {
|
||||
sortedVideos: Video[];
|
||||
itemsPerPage: number;
|
||||
infiniteScroll: boolean;
|
||||
selectedTags: string[];
|
||||
}
|
||||
|
||||
interface UseHomePaginationReturn {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
displayedVideos: Video[];
|
||||
handlePageChange: (event: React.ChangeEvent<unknown>, value: number) => void;
|
||||
}
|
||||
|
||||
export const useHomePagination = ({
|
||||
sortedVideos,
|
||||
itemsPerPage,
|
||||
infiniteScroll,
|
||||
selectedTags
|
||||
}: UseHomePaginationProps): UseHomePaginationReturn => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
|
||||
// Reset page when switching tags (paginated mode only)
|
||||
const prevTagsRef = useRef(selectedTags);
|
||||
useEffect(() => {
|
||||
if (prevTagsRef.current !== selectedTags) {
|
||||
prevTagsRef.current = selectedTags;
|
||||
setSearchParams((prev: URLSearchParams) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('page', '1');
|
||||
return newParams;
|
||||
});
|
||||
}
|
||||
}, [selectedTags, setSearchParams]);
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(sortedVideos.length / itemsPerPage);
|
||||
|
||||
// Get displayed videos based on mode (Only used for PAGINATION)
|
||||
const displayedVideos = useMemo(() => {
|
||||
if (infiniteScroll) {
|
||||
// When infinite scroll is on, we ignore this slice and pass strict 'sortedVideos' to Virtuoso
|
||||
// but we might want to return sortedVideos directly here if used elsewhere
|
||||
return sortedVideos;
|
||||
} else {
|
||||
// For pagination, return current page
|
||||
return sortedVideos.slice(
|
||||
(page - 1) * itemsPerPage,
|
||||
page * itemsPerPage
|
||||
);
|
||||
}
|
||||
}, [infiniteScroll, sortedVideos, page, itemsPerPage]);
|
||||
|
||||
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
|
||||
setSearchParams((prev: URLSearchParams) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('page', value.toString());
|
||||
return newParams;
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Keyboard navigation for pagination (only when infinite scroll is disabled)
|
||||
useEffect(() => {
|
||||
if (infiniteScroll) {
|
||||
return; // Disable keyboard navigation when infinite scroll is enabled
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Don't handle keyboard navigation if user is typing in an input field
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle if there are multiple pages
|
||||
if (totalPages <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft' && page > 1) {
|
||||
event.preventDefault();
|
||||
setSearchParams((prev: URLSearchParams) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('page', (page - 1).toString());
|
||||
return newParams;
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else if (event.key === 'ArrowRight' && page < totalPages) {
|
||||
event.preventDefault();
|
||||
setSearchParams((prev: URLSearchParams) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('page', (page + 1).toString());
|
||||
return newParams;
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [page, totalPages, setSearchParams, infiniteScroll]);
|
||||
|
||||
return {
|
||||
page,
|
||||
totalPages,
|
||||
displayedVideos,
|
||||
handlePageChange
|
||||
};
|
||||
};
|
||||
84
frontend/src/hooks/useHomeSettings.ts
Normal file
84
frontend/src/hooks/useHomeSettings.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface HomeSettings {
|
||||
isSidebarOpen: boolean;
|
||||
itemsPerPage: number;
|
||||
infiniteScroll: boolean;
|
||||
videoColumns: number;
|
||||
settingsLoaded: boolean;
|
||||
}
|
||||
|
||||
interface UseHomeSettingsReturn extends HomeSettings {
|
||||
setIsSidebarOpen: (value: boolean) => void;
|
||||
setItemsPerPage: (value: number) => void;
|
||||
setInfiniteScroll: (value: boolean) => void;
|
||||
setVideoColumns: (value: number) => void;
|
||||
handleSidebarToggle: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useHomeSettings = (): UseHomeSettingsReturn => {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
const [infiniteScroll, setInfiniteScroll] = useState(false);
|
||||
const [videoColumns, setVideoColumns] = useState(4);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
||||
|
||||
// Fetch settings on mount
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
if (response.data) {
|
||||
if (typeof response.data.homeSidebarOpen !== 'undefined') {
|
||||
setIsSidebarOpen(response.data.homeSidebarOpen);
|
||||
}
|
||||
if (typeof response.data.itemsPerPage !== 'undefined') {
|
||||
setItemsPerPage(response.data.itemsPerPage);
|
||||
}
|
||||
if (typeof response.data.infiniteScroll !== 'undefined') {
|
||||
setInfiniteScroll(response.data.infiniteScroll);
|
||||
}
|
||||
if (typeof response.data.videoColumns !== 'undefined') {
|
||||
setVideoColumns(response.data.videoColumns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
} finally {
|
||||
setSettingsLoaded(true);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSidebarToggle = async () => {
|
||||
const newState = !isSidebarOpen;
|
||||
setIsSidebarOpen(newState);
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
const currentSettings = response.data;
|
||||
await axios.post(`${API_URL}/settings`, {
|
||||
...currentSettings,
|
||||
homeSidebarOpen: newState
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save sidebar state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isSidebarOpen,
|
||||
itemsPerPage,
|
||||
infiniteScroll,
|
||||
videoColumns,
|
||||
settingsLoaded,
|
||||
setIsSidebarOpen,
|
||||
setItemsPerPage,
|
||||
setInfiniteScroll,
|
||||
setVideoColumns,
|
||||
handleSidebarToggle
|
||||
};
|
||||
};
|
||||
80
frontend/src/hooks/useVideoFiltering.ts
Normal file
80
frontend/src/hooks/useVideoFiltering.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useMemo } from "react";
|
||||
import { Collection, Video } from "../types";
|
||||
import { ViewMode } from "./useViewMode";
|
||||
|
||||
interface UseVideoFilteringProps {
|
||||
videos: Video[];
|
||||
viewMode: ViewMode;
|
||||
selectedTags: string[];
|
||||
collections: Collection[];
|
||||
}
|
||||
|
||||
export const useVideoFiltering = ({
|
||||
videos,
|
||||
viewMode,
|
||||
selectedTags,
|
||||
collections,
|
||||
}: UseVideoFilteringProps): Video[] => {
|
||||
// Add default empty array to ensure videos is always an array
|
||||
const videoArray = Array.isArray(videos) ? videos : [];
|
||||
|
||||
return 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) => {
|
||||
// Must have lastPlayedAt
|
||||
if (!video.lastPlayedAt) return false;
|
||||
|
||||
// Apply tag filtering if tags are selected
|
||||
if (selectedTags.length > 0) {
|
||||
const videoTags = video.tags || [];
|
||||
return selectedTags.every((tag) => videoTags.includes(tag));
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.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]);
|
||||
};
|
||||
34
frontend/src/hooks/useViewMode.ts
Normal file
34
frontend/src/hooks/useViewMode.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
export type ViewMode = 'collections' | 'all-videos' | 'history';
|
||||
|
||||
interface UseViewModeReturn {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
handleViewModeChange: (mode: ViewMode) => void;
|
||||
}
|
||||
|
||||
export const useViewMode = (): UseViewModeReturn => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
const saved = localStorage.getItem('homeViewMode');
|
||||
return (saved as ViewMode) || 'all-videos';
|
||||
});
|
||||
|
||||
const handleViewModeChange = (mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem('homeViewMode', mode);
|
||||
setSearchParams((prev: URLSearchParams) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('page', '1');
|
||||
return newParams;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
handleViewModeChange
|
||||
};
|
||||
};
|
||||
@@ -1,37 +1,19 @@
|
||||
|
||||
import { Collections as CollectionsIcon, Delete as DeleteIcon, GridView, History, ViewSidebar } from '@mui/icons-material';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Container,
|
||||
Grid,
|
||||
IconButton,
|
||||
Pagination,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, Box, CircularProgress, Container, Pagination, Typography } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import AuthorsList from '../components/AuthorsList';
|
||||
import CollectionCard from '../components/CollectionCard';
|
||||
import Collections from '../components/Collections';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import SortControl from '../components/SortControl';
|
||||
import TagsList from '../components/TagsList';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { HomeHeader } from '../components/HomeHeader';
|
||||
import { HomeSidebar } from '../components/HomeSidebar';
|
||||
import { VideoGrid } from '../components/VideoGrid';
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
import { useGridLayout } from '../hooks/useGridLayout';
|
||||
import { useHomePagination } from '../hooks/useHomePagination';
|
||||
import { useHomeSettings } from '../hooks/useHomeSettings';
|
||||
import { useVideoFiltering } from '../hooks/useVideoFiltering';
|
||||
import { useVideoSort } from '../hooks/useVideoSort';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
import { useViewMode } from '../hooks/useViewMode';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
@@ -46,207 +28,31 @@ const Home: React.FC = () => {
|
||||
deleteVideos
|
||||
} = useVideo();
|
||||
const { collections } = useCollection();
|
||||
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
||||
|
||||
const [viewMode, setViewMode] = useState<'collections' | 'all-videos' | 'history'>(() => {
|
||||
const saved = localStorage.getItem('homeViewMode');
|
||||
return (saved as 'collections' | 'all-videos' | 'history') || 'all-videos';
|
||||
});
|
||||
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
const [infiniteScroll, setInfiniteScroll] = useState(false);
|
||||
const [videoColumns, setVideoColumns] = useState(4);
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
const [isDeleteFilteredOpen, setIsDeleteFilteredOpen] = useState(false);
|
||||
|
||||
// Determine Grid props based on sidebar and columns settings
|
||||
// Hoisted memoization to be used by both specialized and paginated views
|
||||
const gridProps = useMemo(() => {
|
||||
if (isSidebarOpen) {
|
||||
if (videoColumns === 2) return { xs: 12, sm: 6, lg: 6, xl: 6 };
|
||||
if (videoColumns === 3) return { xs: 12, sm: 6, lg: 4, xl: 4 };
|
||||
if (videoColumns === 4) return { xs: 12, sm: 6, lg: 4, xl: 3 };
|
||||
if (videoColumns === 5) return { xs: 12, sm: 6, md: 4, lg: 3, xl: 2 };
|
||||
return { xs: 12, sm: 6, md: 4, lg: 3, xl: 2 }; // 6 columns
|
||||
} else {
|
||||
if (videoColumns === 2) return { xs: 12, sm: 6, lg: 6, xl: 6 };
|
||||
if (videoColumns === 3) return { xs: 12, sm: 6, md: 4, lg: 4, xl: 4 };
|
||||
if (videoColumns === 4) return { xs: 12, sm: 6, md: 4, lg: 3, xl: 3 };
|
||||
if (videoColumns === 5) return { xs: 12, sm: 6, md: 4, lg: 2, xl: 2 };
|
||||
return { xs: 12, sm: 6, md: 4, lg: 2, xl: 2 }; // 6 columns
|
||||
}
|
||||
}, [isSidebarOpen, videoColumns]);
|
||||
|
||||
// Components for VirtuosoGrid - MUST be defined before any conditional returns
|
||||
// Using useMemo to create stable component references
|
||||
// These components must work with virtualization - avoid forcing all items to render
|
||||
const VirtuosoList = useMemo(() =>
|
||||
React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<typeof Grid>>((props, ref) => {
|
||||
// Extract style and other props, but ensure we don't force all items to render
|
||||
const { style, ...restProps } = props;
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
rowSpacing={{ xs: 2, sm: 3 }}
|
||||
columnSpacing={{ xs: 0, sm: 3 }}
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
// Critical: Don't set height or minHeight that would force all items to render
|
||||
// Let VirtuosoGrid handle the height calculation
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const VirtuosoItem = useMemo(() =>
|
||||
React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<typeof Grid>>((props, ref) => {
|
||||
const { style, ...restProps } = props;
|
||||
return (
|
||||
<Grid
|
||||
size={gridProps}
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
// Remove width override to let Grid handle sizing
|
||||
// VirtuosoGrid will manage which items are rendered
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[gridProps]
|
||||
);
|
||||
|
||||
|
||||
|
||||
// Fetch settings on mount
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
if (response.data) {
|
||||
if (typeof response.data.homeSidebarOpen !== 'undefined') {
|
||||
setIsSidebarOpen(response.data.homeSidebarOpen);
|
||||
}
|
||||
if (typeof response.data.itemsPerPage !== 'undefined') {
|
||||
setItemsPerPage(response.data.itemsPerPage);
|
||||
}
|
||||
if (typeof response.data.infiniteScroll !== 'undefined') {
|
||||
setInfiniteScroll(response.data.infiniteScroll);
|
||||
}
|
||||
if (typeof response.data.videoColumns !== 'undefined') {
|
||||
setVideoColumns(response.data.videoColumns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
} finally {
|
||||
setSettingsLoaded(true);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSidebarToggle = async () => {
|
||||
const newState = !isSidebarOpen;
|
||||
setIsSidebarOpen(newState);
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
const currentSettings = response.data;
|
||||
await axios.post(`${API_URL}/settings`, {
|
||||
...currentSettings,
|
||||
homeSidebarOpen: newState
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save sidebar state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset page when switching view modes or tags (paginated mode only)
|
||||
const prevTagsRef = useRef(selectedTags);
|
||||
useEffect(() => {
|
||||
if (prevTagsRef.current !== selectedTags) {
|
||||
prevTagsRef.current = selectedTags;
|
||||
setSearchParams((prev: URLSearchParams) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('page', '1');
|
||||
return newParams;
|
||||
});
|
||||
}
|
||||
}, [selectedTags, setSearchParams]);
|
||||
// Custom hooks
|
||||
const { viewMode, handleViewModeChange } = useViewMode();
|
||||
const {
|
||||
isSidebarOpen,
|
||||
itemsPerPage,
|
||||
infiniteScroll,
|
||||
videoColumns,
|
||||
settingsLoaded,
|
||||
handleSidebarToggle
|
||||
} = useHomeSettings();
|
||||
const gridProps = useGridLayout({ isSidebarOpen, videoColumns });
|
||||
|
||||
// Add default empty array to ensure videos is always an array
|
||||
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 => {
|
||||
// Must have lastPlayedAt
|
||||
if (!video.lastPlayedAt) return false;
|
||||
|
||||
// Apply tag filtering if tags are selected
|
||||
if (selectedTags.length > 0) {
|
||||
const videoTags = video.tags || [];
|
||||
return selectedTags.every(tag => videoTags.includes(tag));
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.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 filteredVideos = useVideoFiltering({
|
||||
videos: videoArray,
|
||||
viewMode,
|
||||
selectedTags,
|
||||
collections
|
||||
});
|
||||
|
||||
// Use the custom hook for sorting
|
||||
const {
|
||||
@@ -267,74 +73,17 @@ const Home: React.FC = () => {
|
||||
});
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(sortedVideos.length / itemsPerPage);
|
||||
|
||||
// Get displayed videos based on mode (Only used for PAGINATION)
|
||||
const displayedVideos = useMemo(() => {
|
||||
if (infiniteScroll) {
|
||||
// When infinite scroll is on, we ignore this slice and pass strict 'sortedVideos' to Virtuoso
|
||||
// but we might want to return sortedVideos directly here if used elsewhere
|
||||
return sortedVideos;
|
||||
} else {
|
||||
// For pagination, return current page
|
||||
return sortedVideos.slice(
|
||||
(page - 1) * itemsPerPage,
|
||||
page * itemsPerPage
|
||||
);
|
||||
}
|
||||
}, [infiniteScroll, sortedVideos, page, itemsPerPage]);
|
||||
|
||||
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
|
||||
setSearchParams((prev: URLSearchParams) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('page', value.toString());
|
||||
return newParams;
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Keyboard navigation for pagination (only when infinite scroll is disabled)
|
||||
useEffect(() => {
|
||||
if (infiniteScroll) {
|
||||
return; // Disable keyboard navigation when infinite scroll is enabled
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Don't handle keyboard navigation if user is typing in an input field
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle if there are multiple pages
|
||||
if (totalPages <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft' && page > 1) {
|
||||
event.preventDefault();
|
||||
setSearchParams((prev: URLSearchParams) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('page', (page - 1).toString());
|
||||
return newParams;
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else if (event.key === 'ArrowRight' && page < totalPages) {
|
||||
event.preventDefault();
|
||||
setSearchParams((prev: URLSearchParams) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('page', (page + 1).toString());
|
||||
return newParams;
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [page, totalPages, setSearchParams, infiniteScroll]);
|
||||
const {
|
||||
page,
|
||||
totalPages,
|
||||
displayedVideos,
|
||||
handlePageChange
|
||||
} = useHomePagination({
|
||||
sortedVideos,
|
||||
itemsPerPage,
|
||||
infiniteScroll,
|
||||
selectedTags
|
||||
});
|
||||
|
||||
if (!settingsLoaded || (loading && videoArray.length === 0)) {
|
||||
return (
|
||||
@@ -353,16 +102,6 @@ const Home: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const handleViewModeChange = (mode: 'collections' | 'all-videos' | 'history') => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem('homeViewMode', mode);
|
||||
setSearchParams((prev: URLSearchParams) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('page', '1');
|
||||
return newParams;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -409,123 +148,28 @@ const Home: React.FC = () => {
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
{/* Sidebar container for Collections, Authors, and Tags */}
|
||||
<Box sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
<Collapse in={isSidebarOpen} orientation="horizontal" timeout={300} sx={{ height: '100%', '& .MuiCollapse-wrapper': { height: '100%' }, '& .MuiCollapse-wrapperInner': { height: '100%' } }}>
|
||||
<Box sx={{ width: 280, mr: 4, flexShrink: 0, height: '100%', position: 'relative' }}>
|
||||
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<Box sx={{
|
||||
position: 'sticky',
|
||||
maxHeight: 'calc(100% - 80px)',
|
||||
minHeight: 'calc(100vh - 80px)',
|
||||
overflowY: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: 'rgba(0,0,0,0.1)',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&:hover::-webkit-scrollbar-thumb': {
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
},
|
||||
}}>
|
||||
<Collections collections={collections} />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TagsList
|
||||
availableTags={availableTags}
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AuthorsList videos={videoArray} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
<HomeSidebar
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
collections={collections}
|
||||
availableTags={availableTags}
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
videos={videoArray}
|
||||
/>
|
||||
|
||||
{/* Videos grid */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
{/* View mode toggle */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3, px: { xs: 2, sm: 0 } }}>
|
||||
<Typography variant="h5" fontWeight="bold" sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
onClick={handleSidebarToggle}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
p: 1,
|
||||
display: { xs: 'none', md: 'inline-flex' },
|
||||
color: 'text.secondary',
|
||||
borderColor: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<ViewSidebar sx={{ transform: 'rotate(180deg)' }} />
|
||||
</Button>
|
||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
{t('videos')}
|
||||
</Box>
|
||||
{selectedTags.length > 0 && (
|
||||
<Tooltip title={t('deleteAllFilteredVideos')}>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => setIsDeleteFilteredOpen(true)}
|
||||
size="small"
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box component="span" sx={{ display: { xs: 'block', md: 'none' } }}>
|
||||
{{
|
||||
'collections': t('collections'),
|
||||
'all-videos': t('allVideos'),
|
||||
'history': t('history')
|
||||
}[viewMode]}
|
||||
</Box>
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(_, newMode) => newMode && handleViewModeChange(newMode)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="all-videos" sx={{ px: { xs: 2, md: 2 } }}>
|
||||
<GridView fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
{t('allVideos')}
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="collections" sx={{ px: { xs: 2, md: 2 } }}>
|
||||
<CollectionsIcon fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
{t('collections')}
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="history" sx={{ px: { xs: 2, md: 2 } }}>
|
||||
<History fontSize="small" sx={{ mr: { xs: 0, md: 1 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
{t('history')}
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<SortControl
|
||||
sortOption={sortOption}
|
||||
sortAnchorEl={sortAnchorEl}
|
||||
onSortClick={handleSortClick}
|
||||
onSortClose={handleSortClose}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<HomeHeader
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onSidebarToggle={handleSidebarToggle}
|
||||
selectedTagsCount={selectedTags.length}
|
||||
onDeleteFilteredClick={() => setIsDeleteFilteredOpen(true)}
|
||||
sortOption={sortOption}
|
||||
sortAnchorEl={sortAnchorEl}
|
||||
onSortClick={handleSortClick}
|
||||
onSortClose={handleSortClose}
|
||||
/>
|
||||
{viewMode === 'collections' && displayedVideos.length === 0 ? (
|
||||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
@@ -533,113 +177,18 @@ const Home: React.FC = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
infiniteScroll ? (
|
||||
<VirtuosoGrid
|
||||
key={`virtuoso-${viewMode}-${sortedVideos.length}`}
|
||||
useWindowScroll
|
||||
data={sortedVideos}
|
||||
components={{
|
||||
List: VirtuosoList,
|
||||
Item: VirtuosoItem
|
||||
}}
|
||||
overscan={5}
|
||||
itemContent={(_index, video) => {
|
||||
// In all-videos and history mode, ALWAYS render as VideoCard
|
||||
if (viewMode === 'all-videos' || viewMode === 'history') {
|
||||
return (
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
disableCollectionGrouping={true}
|
||||
onDeleteVideo={deleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// In collections mode, check if this video is the first in a collection
|
||||
// Since sorting logic filters this, we should generally be good,
|
||||
// but we still want to render CollectionCard where appropriate.
|
||||
// The `sortedVideos` for collections mode ONLY contains the "representatives".
|
||||
// So we just need to find the collection it represents.
|
||||
|
||||
// Find the collection this video represents (it must be the first video)
|
||||
const collection = collections.find(c => c.videos[0] === video.id);
|
||||
|
||||
if (collection) {
|
||||
return (
|
||||
<CollectionCard
|
||||
collection={collection}
|
||||
videos={videoArray}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback (shouldn't happen often in collections view unless logic allows loose videos)
|
||||
return (
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={deleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Grid
|
||||
container
|
||||
rowSpacing={{ xs: 2, sm: 3 }}
|
||||
columnSpacing={{ xs: 0, sm: 3 }}
|
||||
>
|
||||
{displayedVideos.map((video) => {
|
||||
// In all-videos and history mode, ALWAYS render as VideoCard
|
||||
if (viewMode === 'all-videos' || viewMode === 'history') {
|
||||
return (
|
||||
<Grid size={gridProps} key={video.id}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
disableCollectionGrouping={true}
|
||||
onDeleteVideo={deleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
// In collections mode, check if this video is the first in a collection
|
||||
const collection = collections.find(c => c.videos[0] === video.id);
|
||||
|
||||
// If it is, render CollectionCard
|
||||
if (collection) {
|
||||
return (
|
||||
<Grid size={gridProps} key={`collection-${collection.id}`}>
|
||||
<CollectionCard
|
||||
collection={collection}
|
||||
videos={videoArray}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise render VideoCard for non-collection videos
|
||||
return (
|
||||
<Grid size={gridProps} key={video.id}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={deleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)
|
||||
<VideoGrid
|
||||
videos={videoArray}
|
||||
sortedVideos={sortedVideos}
|
||||
displayedVideos={displayedVideos}
|
||||
collections={collections}
|
||||
viewMode={viewMode}
|
||||
infiniteScroll={infiniteScroll}
|
||||
gridProps={gridProps}
|
||||
onDeleteVideo={deleteVideo}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{!infiniteScroll && totalPages > 1 && (
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center', px: { xs: 2, sm: 0 } }}>
|
||||
<Pagination
|
||||
@@ -654,7 +203,7 @@ const Home: React.FC = () => {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box >
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
</Container >
|
||||
|
||||
Reference in New Issue
Block a user