Add Dashboard view and routing; mobile header menu

Introduce a new DashboardView component (src/components/DashboardView.tsx) that shows collection stats, recent/top/most-played lists and uses motion + Loading. Wire the dashboard into App (src/App.tsx): import DashboardView, add a root route for /, add per-category routes (/anime, /movies, /tv-series, etc.), map URL paths to MediaCategory, and update navigation/search behavior to use category paths (navigate to /<category>). Update Header (src/components/Header.tsx) to use NavLink for category links, add a mobile menu toggle with a Menu icon, and add URL-friendly category path mapping for consistent navigation.
This commit is contained in:
Lars Behrends
2026-04-12 23:30:43 +02:00
parent 6250164656
commit a6d153ac1e
3 changed files with 464 additions and 13 deletions

View File

@@ -8,6 +8,7 @@ import { LayoutGroup } from 'motion/react';
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom'; import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
import Header from './components/Header'; import Header from './components/Header';
import BrowseView from './components/BrowseView'; import BrowseView from './components/BrowseView';
import DashboardView from './components/DashboardView';
import DetailView from './components/DetailView'; import DetailView from './components/DetailView';
import CastView from './components/CastView'; import CastView from './components/CastView';
import CastDetailView from './components/CastDetailView'; import CastDetailView from './components/CastDetailView';
@@ -40,6 +41,29 @@ function AppContent() {
const [apiMedia, setApiMedia] = useState<Media[]>([]); const [apiMedia, setApiMedia] = useState<Media[]>([]);
const [mediaLoading, setMediaLoading] = useState(true); const [mediaLoading, setMediaLoading] = useState(true);
// Map URL paths to categories
const pathToCategory: Record<string, MediaCategory> = {
'anime': 'Anime',
'movies': 'Movies',
'tv-series': 'TV Series',
'music': 'Music',
'books': 'Books',
'games': 'Games',
'consoles': 'Consoles',
'adult': 'Adult'
};
// Set category from URL path on mount or location change
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean);
if (pathParts.length === 1 && pathToCategory[pathParts[0]]) {
const category = pathToCategory[pathParts[0]];
if (enabledCategories.includes(category)) {
setActiveCategory(category);
}
}
}, [location.pathname, enabledCategories]);
useEffect(() => { useEffect(() => {
const loadSettingsFromApi = async () => { const loadSettingsFromApi = async () => {
try { try {
@@ -131,8 +155,18 @@ function AppContent() {
const handleCategoryChange = (category: MediaCategory) => { const handleCategoryChange = (category: MediaCategory) => {
setActiveCategory(category); setActiveCategory(category);
setSearchParams({ category }); // Map category names to URL-friendly paths
navigate('/'); const categoryPaths: Record<MediaCategory, string> = {
'Anime': 'anime',
'Movies': 'movies',
'TV Series': 'tv-series',
'Music': 'music',
'Books': 'books',
'Games': 'games',
'Consoles': 'consoles',
'Adult': 'adult'
};
navigate(`/${categoryPaths[category]}`);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
@@ -300,7 +334,7 @@ function AppContent() {
params.delete('search'); params.delete('search');
} }
setSearchParams(params); setSearchParams(params);
navigate('/'); navigate('/browse');
}; };
return ( return (
@@ -318,6 +352,13 @@ function AppContent() {
<LayoutGroup> <LayoutGroup>
<Routes> <Routes>
<Route path="/" element={ <Route path="/" element={
<DashboardView
mediaList={apiMedia.length > 0 ? apiMedia : [...MOCK_MEDIA, ...customMedia, DETAIL_MEDIA].filter(m => enabledCategories.includes(m.category))}
onMediaClick={handleMediaClick}
loading={mediaLoading}
/>
} />
<Route path="/browse" element={
<BrowseView <BrowseView
mediaList={filteredMedia} mediaList={filteredMedia}
onMediaClick={handleMediaClick} onMediaClick={handleMediaClick}
@@ -328,6 +369,94 @@ function AppContent() {
loading={mediaLoading} loading={mediaLoading}
/> />
} /> } />
<Route path="/anime" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory="Anime"
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/movies" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory="Movies"
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/tv-series" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory="TV Series"
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/music" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory="Music"
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/books" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory="Books"
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/games" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory="Games"
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/consoles" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory="Consoles"
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/adult" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory="Adult"
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/media/:id" element={ <Route path="/media/:id" element={
<MediaDetailRoute <MediaDetailRoute
selectedMedia={selectedMedia} selectedMedia={selectedMedia}

View File

@@ -0,0 +1,265 @@
import { Media, MediaCategory } from '@/types';
import MediaCard from './MediaCard';
import { Film, Tv, Music, Book, Gamepad2, Users, Star, TrendingUp, Clock, Hash, Play, Award } from 'lucide-react';
import { useMemo } from 'react';
import { motion } from 'motion/react';
import Loading from '@/components/ui/loading';
interface DashboardViewProps {
mediaList: Media[];
onMediaClick: (media: Media) => void;
loading?: boolean;
}
export default function DashboardView({ mediaList, onMediaClick, loading = false }: DashboardViewProps) {
// Calculate statistics
const stats = useMemo(() => {
const totalMedia = mediaList.length;
const categories = mediaList.reduce((acc, media) => {
acc[media.category] = (acc[media.category] || 0) + 1;
return acc;
}, {} as Record<MediaCategory, number>);
const totalRating = mediaList.reduce((sum, media) => sum + (media.rating || 0), 0);
const avgRating = totalRating > 0 ? (totalRating / mediaList.filter(m => m.rating).length).toFixed(1) : '0.0';
const totalPlaytime = mediaList.reduce((sum, media) => sum + (media.playtime || 0), 0);
const totalPlayCount = mediaList.reduce((sum, media) => sum + (media.playCount || 0), 0);
return {
totalMedia,
categories,
avgRating,
totalPlaytime,
totalPlayCount
};
}, [mediaList]);
// Get recently added media (sorted by some indicator - using index as proxy)
const recentMedia = useMemo(() => {
return [...mediaList].slice(0, 8);
}, [mediaList]);
// Get top rated media
const topRatedMedia = useMemo(() => {
return [...mediaList]
.filter(m => m.rating && m.rating > 0)
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
.slice(0, 8);
}, [mediaList]);
// Get most played media
const mostPlayedMedia = useMemo(() => {
return [...mediaList]
.filter(m => m.playCount && m.playCount > 0)
.sort((a, b) => (b.playCount || 0) - (a.playCount || 0))
.slice(0, 8);
}, [mediaList]);
// Category icons mapping
const categoryIcons: Record<MediaCategory, any> = {
'Anime': Tv,
'Movies': Film,
'TV Series': Tv,
'Music': Music,
'Books': Book,
'Games': Gamepad2,
'Consoles': Gamepad2,
'Adult': Users
};
// Category colors
const categoryColors: Record<MediaCategory, string> = {
'Anime': 'bg-purple-500/10 text-purple-500 border-purple-500/20',
'Movies': 'bg-blue-500/10 text-blue-500 border-blue-500/20',
'TV Series': 'bg-green-500/10 text-green-500 border-green-500/20',
'Music': 'bg-pink-500/10 text-pink-500 border-pink-500/20',
'Books': 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
'Games': 'bg-red-500/10 text-red-500 border-red-500/20',
'Consoles': 'bg-orange-500/10 text-orange-500 border-orange-500/20',
'Adult': 'bg-gray-500/10 text-gray-500 border-gray-500/20'
};
const formatPlaytime = (minutes: number) => {
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
if (loading) {
return <Loading message="Loading dashboard..." />;
}
return (
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-black text-foreground mb-2">Dashboard</h1>
<p className="text-muted-foreground font-medium">Overview of your media collection</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-muted/50 rounded-xl p-6 border border-border"
>
<div className="flex items-center justify-between mb-4">
<Hash className="w-8 h-8 text-[#6d28d9]" />
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
</div>
<div className="text-3xl font-black text-foreground">{stats.totalMedia}</div>
<div className="text-sm text-muted-foreground font-medium mt-1">Media Items</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-muted/50 rounded-xl p-6 border border-border"
>
<div className="flex items-center justify-between mb-4">
<Star className="w-8 h-8 text-yellow-500" />
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Average</span>
</div>
<div className="text-3xl font-black text-foreground">{stats.avgRating}</div>
<div className="text-sm text-muted-foreground font-medium mt-1">Rating</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-muted/50 rounded-xl p-6 border border-border"
>
<div className="flex items-center justify-between mb-4">
<Play className="w-8 h-8 text-green-500" />
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
</div>
<div className="text-3xl font-black text-foreground">{stats.totalPlayCount}</div>
<div className="text-sm text-muted-foreground font-medium mt-1">Play Count</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-muted/50 rounded-xl p-6 border border-border"
>
<div className="flex items-center justify-between mb-4">
<Clock className="w-8 h-8 text-blue-500" />
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
</div>
<div className="text-3xl font-black text-foreground">{formatPlaytime(stats.totalPlaytime)}</div>
<div className="text-sm text-muted-foreground font-medium mt-1">Playtime</div>
</motion.div>
</div>
{/* Category Breakdown */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="bg-muted/50 rounded-xl p-6 border border-border mb-8"
>
<h2 className="text-lg font-black text-foreground mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[#6d28d9]" />
Category Breakdown
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-3">
{(Object.keys(stats.categories) as MediaCategory[]).map((category) => {
const Icon = categoryIcons[category];
const count = stats.categories[category] || 0;
const percentage = stats.totalMedia > 0 ? ((count / stats.totalMedia) * 100).toFixed(1) : '0';
return (
<div
key={category}
className={`rounded-lg p-4 border ${categoryColors[category]} flex flex-col items-center justify-center gap-2`}
>
<Icon className="w-6 h-6" />
<div className="text-xs font-bold uppercase tracking-wider">{category}</div>
<div className="text-2xl font-black">{count}</div>
<div className="text-xs font-medium opacity-75">{percentage}%</div>
</div>
);
})}
</div>
</motion.div>
{/* Recent Media */}
{recentMedia.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="mb-8"
>
<h2 className="text-lg font-black text-foreground mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-[#6d28d9]" />
Recent Additions
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-4">
{recentMedia.map((media) => (
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
))}
</div>
</motion.div>
)}
{/* Top Rated Media */}
{topRatedMedia.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
className="mb-8"
>
<h2 className="text-lg font-black text-foreground mb-4 flex items-center gap-2">
<Award className="w-5 h-5 text-[#6d28d9]" />
Top Rated
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-4">
{topRatedMedia.map((media) => (
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
))}
</div>
</motion.div>
)}
{/* Most Played Media */}
{mostPlayedMedia.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8 }}
className="mb-8"
>
<h2 className="text-lg font-black text-foreground mb-4 flex items-center gap-2">
<Play className="w-5 h-5 text-[#6d28d9]" />
Most Played
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-4">
{mostPlayedMedia.map((media) => (
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
))}
</div>
</motion.div>
)}
{/* Empty State */}
{mediaList.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
<Hash size={32} />
</div>
<p className="text-lg font-bold">No media found</p>
<p className="text-sm">Start by adding media to your collection</p>
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { Search, User, X, Plus, Download, Settings } from 'lucide-react'; import { Search, User, X, Plus, Download, Settings, Menu } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink, useLocation } from 'react-router-dom';
import { MediaCategory } from '@/types'; import { MediaCategory } from '@/types';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
@@ -25,7 +25,21 @@ export default function Header({
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { theme } = useTheme(); const { theme } = useTheme();
const location = useLocation();
// Map category names to URL-friendly paths
const categoryPaths: Record<MediaCategory, string> = {
'Anime': 'anime',
'Movies': 'movies',
'TV Series': 'tv-series',
'Music': 'music',
'Books': 'books',
'Games': 'games',
'Consoles': 'consoles',
'Adult': 'adult'
};
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
@@ -80,20 +94,31 @@ export default function Header({
</div> </div>
kyoo kyoo
</Link> </Link>
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className={cn(
"md:hidden p-2 transition-colors",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white"
: "text-foreground hover:text-foreground"
)}
>
<Menu size={20} />
</button>
<nav className="hidden md:flex items-center gap-6"> <nav className="hidden md:flex items-center gap-6">
{enabledCategories.map(cat => ( {enabledCategories.map(cat => (
<button <NavLink
key={cat} key={cat}
onClick={() => onCategoryChange(cat)} to={`/${categoryPaths[cat]}`}
className={cn( className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider", "text-sm font-bold transition-colors uppercase tracking-wider",
(transparent && !scrolled) || !transparent (transparent && !scrolled) || !transparent
? activeCategory === cat ? "text-white" : "text-white/60 hover:text-white" ? isActive ? "text-white" : "text-white/60 hover:text-white"
: activeCategory === cat ? "text-foreground" : "text-muted-foreground hover:text-foreground" : isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground"
)} )}
> >
{cat} {cat}
</button> </NavLink>
))} ))}
<div className={cn( <div className={cn(
"w-px h-4 mx-2", "w-px h-4 mx-2",
@@ -188,6 +213,38 @@ export default function Header({
/> />
</button> </button>
</div> </div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden absolute top-full left-0 right-0 bg-background border-b border-border shadow-lg">
<nav className="flex flex-col p-4 gap-2">
{enabledCategories.map(cat => (
<NavLink
key={cat}
to={`/${categoryPaths[cat]}`}
onClick={() => setIsMobileMenuOpen(false)}
className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
isActive ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{cat}
</NavLink>
))}
<div className="w-full h-px bg-border my-2" />
<NavLink
to="/cast"
onClick={() => setIsMobileMenuOpen(false)}
className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
isActive ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
CAST
</NavLink>
</nav>
</div>
)}
</header> </header>
); );
} }