Add Sidebar, restructure App and DetailView

Add a new Sidebar component and integrate it into App.tsx (replacing Header), updating overall layout to a two-column flex layout and moving/footer adjustments. Substantially refactor DetailView: new responsive layout, progress bar, tabbed navigation (Overview, Cast, Tracks, Seasons, etc.), improved cast and tracks UI, various icon and metadata display tweaks, and several UX/responsiveness fixes. Also add AGENTS.md (project development guide) and minor related imports/cleanup across changed files.
This commit is contained in:
Lars Behrends
2026-04-16 13:51:08 +02:00
parent b57b22c30b
commit a407b57006
4 changed files with 735 additions and 230 deletions

338
AGENTS.md Normal file
View File

@@ -0,0 +1,338 @@
---
name: "Modern React Project Template"
description: "A comprehensive development guide for modern frontend projects based on React 18 + TypeScript + Vite, including complete development standards and best practices"
category: "Frontend Framework"
author: "Agents.md Collection"
authorUrl: "https://github.com/gakeez/agents_md_collection"
tags: ["react", "typescript", "vite", "frontend", "spa"]
lastUpdated: "2024-12-19"
---
# Modern React Project Development Guide
## Project Overview
This is a modern frontend project template based on React 18, TypeScript, and Vite. It's suitable for building high-performance Single Page Applications (SPA) with integrated modern development toolchain and best practices.
## Tech Stack
- **Frontend Framework**: React 18 + TypeScript
- **Build Tool**: Vite
- **State Management**: Zustand / Redux Toolkit
- **Routing**: React Router v6
- **UI Components**: Ant Design / Material-UI
- **Styling**: Tailwind CSS / Styled-components
- **Testing Framework**: Vitest + React Testing Library
- **Code Quality**: ESLint + Prettier + Husky
## Project Structure
```
react-project/
├── public/ # Static assets
│ ├── favicon.ico
│ └── index.html
├── src/
│ ├── components/ # Reusable components
│ │ ├── common/ # Common components
│ │ └── ui/ # UI components
│ ├── pages/ # Page components
│ ├── hooks/ # Custom Hooks
│ ├── store/ # State management
│ ├── services/ # API services
│ ├── utils/ # Utility functions
│ ├── types/ # TypeScript type definitions
│ ├── styles/ # Global styles
│ ├── constants/ # Constants
│ ├── App.tsx
│ └── main.tsx
├── tests/ # Test files
├── docs/ # Project documentation
├── .env.example # Environment variables example
├── package.json
├── tsconfig.json
├── vite.config.ts
└── README.md
```
## Development Guidelines
### Component Development Standards
1. **Function Components First**: Use function components and Hooks
2. **TypeScript Types**: Define interfaces for all props
3. **Component Naming**: Use PascalCase, file name matches component name
4. **Single Responsibility**: Each component handles only one functionality
```tsx
// Example: Button Component
interface ButtonProps {
variant: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant,
size = 'medium',
disabled = false,
onClick,
children
}) => {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
};
```
### State Management Standards
Using Zustand for state management:
```tsx
// store/userStore.ts
import { create } from 'zustand';
interface User {
id: string;
name: string;
email: string;
}
interface UserState {
user: User | null;
isLoading: boolean;
setUser: (user: User) => void;
clearUser: () => void;
setLoading: (loading: boolean) => void;
}
export const useUserStore = create<UserState>((set) => ({
user: null,
isLoading: false,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
setLoading: (isLoading) => set({ isLoading }),
}));
```
### API Service Standards
```tsx
// services/api.ts
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
});
// Request interceptor
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor
api.interceptors.response.use(
(response) => response.data,
(error) => {
console.error('API Error:', error);
return Promise.reject(error);
}
);
export default api;
```
## Environment Setup
### Development Requirements
- Node.js >= 18.0.0
- npm >= 8.0.0 or yarn >= 1.22.0
### Installation Steps
```bash
# 1. Create project
npm create vite@latest my-react-app -- --template react-ts
# 2. Navigate to project directory
cd my-react-app
# 3. Install dependencies
npm install
# 4. Install additional dependencies
npm install zustand react-router-dom axios
npm install -D @types/node
# 5. Start development server
npm run dev
```
### Environment Variables Configuration
```env
# .env.local
VITE_API_URL=http://localhost:3001/api
VITE_APP_TITLE=My React App
VITE_ENABLE_MOCK=false
```
## Routing Configuration
```tsx
// App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { HomePage } from './pages/HomePage';
import { AboutPage } from './pages/AboutPage';
import { NotFoundPage } from './pages/NotFoundPage';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
);
}
export default App;
```
## Testing Strategy
### Unit Testing Example
```tsx
// tests/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from '../src/components/Button';
describe('Button Component', () => {
test('renders button with text', () => {
render(<Button variant="primary">Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(
<Button variant="primary" onClick={handleClick}>
Click me
</Button>
);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
```
## Performance Optimization
### Code Splitting
```tsx
import { lazy, Suspense } from 'react';
const LazyComponent = lazy(() => import('./LazyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
);
}
```
### Memory Optimization
```tsx
import { memo, useMemo, useCallback } from 'react';
const ExpensiveComponent = memo(({ data, onUpdate }) => {
const processedData = useMemo(() => {
return data.map(item => ({ ...item, processed: true }));
}, [data]);
const handleUpdate = useCallback((id) => {
onUpdate(id);
}, [onUpdate]);
return (
<div>
{processedData.map(item => (
<div key={item.id} onClick={() => handleUpdate(item.id)}>
{item.name}
</div>
))}
</div>
);
});
```
## Deployment Configuration
### Build Production Version
```bash
npm run build
```
### Vite Configuration Optimization
```ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
},
},
},
},
server: {
port: 3000,
open: true,
},
});
```
## Common Issues
### Issue 1: Vite Development Server Slow Startup
**Solution**:
- Check dependency pre-build cache
- Use `npm run dev -- --force` to force rebuild
- Optimize optimizeDeps configuration in vite.config.ts
### Issue 2: TypeScript Type Errors
**Solution**:
- Ensure correct type definition packages are installed
- Check tsconfig.json configuration
- Use `npm run type-check` for type checking
## Reference Resources
- [React Official Documentation](https://react.dev/)
- [Vite Official Documentation](https://vitejs.dev/)
- [TypeScript Official Documentation](https://www.typescriptlang.org/)
- [React Router Documentation](https://reactrouter.com/)
- [Zustand Documentation](https://github.com/pmndrs/zustand)

View File

@@ -6,7 +6,7 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { LayoutGroup } from 'motion/react'; 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 Sidebar from './components/Sidebar';
import BrowseView from './components/BrowseView'; import BrowseView from './components/BrowseView';
import DashboardView from './components/DashboardView'; import DashboardView from './components/DashboardView';
import DetailView from './components/DetailView'; import DetailView from './components/DetailView';
@@ -338,17 +338,13 @@ function AppContent() {
}; };
return ( return (
<div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]"> <div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9] flex">
<Header <Sidebar
onSearch={handleSearch}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
enabledCategories={enabledCategories} enabledCategories={enabledCategories}
onToggleCategory={toggleCategory} onToggleCategory={toggleCategory}
transparent={location.pathname.startsWith('/media/') || location.pathname.startsWith('/cast/')}
/> />
<main> <main className="flex-1 lg:ml-72 flex flex-col">
<LayoutGroup> <LayoutGroup>
<Routes> <Routes>
<Route path="/" element={ <Route path="/" element={
@@ -493,25 +489,25 @@ function AppContent() {
} /> } />
</Routes> </Routes>
</LayoutGroup> </LayoutGroup>
</main>
{/* Footer */} {/* Footer */}
<footer className="py-8 px-6 border-t border-border/50 bg-muted/30 backdrop-blur-sm"> <footer className="py-8 px-6 border-t border-border/50 bg-muted/30 backdrop-blur-sm mt-auto">
<div className="max-w-[1920px] mx-auto flex flex-col md:flex-row items-center justify-between gap-4"> <div className="max-w-[1920px] mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-lg font-black text-muted-foreground"> <div className="flex items-center gap-2 text-lg font-black text-muted-foreground">
<div className="w-5 h-5 bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] rounded-full" /> <div className="w-5 h-5 bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] rounded-full" />
<span className="bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">kyoo</span> <span className="bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">kyoo</span>
</div>
<div className="flex items-center gap-6 text-sm font-bold text-muted-foreground">
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Terms</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Privacy</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Contact</a>
</div>
<p className="text-xs font-medium text-muted-foreground">
© 2026 Kyoo Media Discovery. All rights reserved.
</p>
</div> </div>
<div className="flex items-center gap-6 text-sm font-bold text-muted-foreground"> </footer>
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Terms</a> </main>
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Privacy</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Contact</a>
</div>
<p className="text-xs font-medium text-muted-foreground">
© 2026 Kyoo Media Discovery. All rights reserved.
</p>
</div>
</footer>
</div> </div>
); );
} }

View File

@@ -10,7 +10,10 @@ import {
ChevronRight, ChevronRight,
Search, Search,
ListFilter, ListFilter,
ChevronDown ChevronDown,
Calendar,
Clock,
Eye
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -28,6 +31,24 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
const [castLimit, setCastLimit] = useState(6); const [castLimit, setCastLimit] = useState(6);
const [showAllCast, setShowAllCast] = useState(false); const [showAllCast, setShowAllCast] = useState(false);
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set()); const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
const [progress, setProgress] = useState(70.8);
const hasEpisodes = media.episodes && media.episodes.length > 0;
const hasTracks = media.tracks && media.tracks.length > 0;
const hasCast = media.staff && media.staff.length > 0;
const tabs = [
'Overview',
...(hasCast ? ['Cast'] : []),
'Actions',
'History',
...(hasEpisodes ? ['Seasons'] : []),
...(hasTracks ? ['Tracks'] : []),
'Reviews',
'Suggestions',
'Watch On'
];
const [activeTab, setActiveTab] = useState(tabs[0]);
// Group episodes by season // Group episodes by season
const episodesBySeason = useMemo(() => { const episodesBySeason = useMemo(() => {
@@ -68,31 +89,32 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
const displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []); const displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []);
const hasMoreCast = (media.staff?.length || 0) > castLimit; const hasMoreCast = (media.staff?.length || 0) > castLimit;
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Banner */} {/* Banner */}
<div className="relative h-[450px] w-full overflow-hidden"> <div className="relative h-[450px] w-full overflow-hidden">
<img <img
src={media.banner || media.poster} src={media.banner || media.poster}
alt={media.title} alt={media.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/50 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-background via-background/50 to-transparent" />
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="absolute top-24 left-6 p-3 bg-black/30 hover:bg-black/50 backdrop-blur-md text-white rounded-2xl transition-all duration-300 hover:scale-110 z-10 border border-white/20" className="absolute top-24 left-6 p-3 bg-black/30 hover:bg-black/50 backdrop-blur-md text-white rounded-2xl transition-all duration-300 hover:scale-110 z-10 border border-white/20 lg:left-80"
> >
<ChevronLeft size={24} /> <ChevronLeft size={24} />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="max-w-[1920px] mx-auto px-6 -mt-32 relative z-10 pb-24"> <div className="max-w-[1920px] mx-auto px-6 py-8 pb-24 -mt-32 relative z-10">
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col lg:flex-row gap-8">
{/* Left Column: Poster + Metadata */} {/* Left Column: Cover Image */}
<div className="w-full md:w-[320px] shrink-0"> <div className="w-full lg:w-[400px] shrink-0">
<motion.div <motion.div
layoutId={`media-${media.id}`} layoutId={`media-${media.id}`}
className={`rounded-2xl overflow-hidden shadow-2xl bg-card border border-border/50 ${ className={`rounded-2xl overflow-hidden shadow-2xl bg-card border border-border/50 ${
@@ -108,184 +130,129 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</motion.div> </motion.div>
{/* Compact metadata under poster */}
<div className="mt-4 space-y-2">
{media.studios && media.studios.length > 0 && (
<p className="text-xs font-bold text-muted-foreground">
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Studios:</span>
{media.studios.join(', ')}
</p>
)}
{media.developers && media.developers.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Developers:</span>
{media.developers.map(dev => (
<Badge key={dev} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
{dev}
</Badge>
))}
</div>
)}
{media.platforms && media.platforms.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Platforms:</span>
{media.platforms.map(platform => (
<Badge key={platform} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
{platform}
</Badge>
))}
</div>
)}
{media.categories && media.categories.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Categories:</span>
{media.categories.map(category => (
<Badge key={category} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
{category}
</Badge>
))}
</div>
)}
{media.completionStatus && (
<p className="text-xs font-bold text-muted-foreground">
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Completion:</span>
{media.completionStatus}
</p>
)}
{media.source && (
<p className="text-xs font-bold text-muted-foreground">
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Source:</span>
{media.source}
</p>
)}
{media.playCount !== undefined && media.playCount !== null && (
<p className="text-xs font-bold text-muted-foreground">
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Play Count:</span>
{media.playCount}
</p>
)}
{media.playtime !== undefined && media.playtime !== null && media.playtime > 0 && (
<p className="text-xs font-bold text-muted-foreground">
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Playtime:</span>
{media.playtime}h
</p>
)}
{media.lastActivity && (
<p className="text-xs font-bold text-muted-foreground">
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Last Activity:</span>
{media.lastActivity}
</p>
)}
<div className="flex items-center gap-3">
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Links:</span>
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">Tvdb</Button>
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">AniDb</Button>
</div>
</div>
</div> </div>
{/* Right Column: Info */} {/* Right Column: Info */}
<div className="flex-1 pt-4 md:pt-8"> <div className="flex-1">
<div className="flex flex-wrap items-end justify-between gap-4 mb-8"> {/* Header with tags */}
<div> <div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-5xl font-black text-foreground mb-3"> <h1 className="text-4xl lg:text-5xl font-black text-foreground">
{media.title} <span className="text-muted-foreground font-medium">({media.year})</span> {media.title}
</h1> </h1>
<div className="flex items-center gap-4"> {media.status && (
<div className="flex items-center gap-2"> <Badge className={
<Button size="icon" className="rounded-full bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] shadow-lg shadow-[#6d28d9]/30 transition-all duration-300 hover:scale-110"> media.status === 'watching' || media.status === 'reading' || media.status === 'listening' || media.status === 'playing'
<Play size={20} fill="currentColor" /> ? 'bg-green-500/20 text-green-400 border-green-500/30 font-bold'
</Button> : media.status === 'completed'
<Button size="icon" variant="outline" className="rounded-full border-border hover:border-[#6d28d9]/50 transition-all duration-300 hover:scale-110"> ? 'bg-blue-500/20 text-blue-400 border-blue-500/30 font-bold'
<Bookmark size={20} /> : 'bg-gray-500/20 text-gray-400 border-gray-500/30 font-bold'
</Button> }>
<Button size="icon" variant="outline" className="rounded-full border-border hover:border-[#6d28d9]/50 transition-all duration-300 hover:scale-110"> {media.status.toUpperCase()}
<MoreHorizontal size={20} />
</Button>
</div>
<div className="flex items-center gap-1 text-foreground font-bold">
<Star size={20} className="text-yellow-500 fill-yellow-500" />
{media.rating} / 10
</div>
</div>
</div>
<div className="hidden lg:block text-right">
<h3 className="text-xs font-black text-[#6d28d9] uppercase tracking-wider mb-2">Genres</h3>
<div className="flex flex-col items-end gap-1">
{media.genres?.map(genre => (
<span key={genre} className="text-sm font-bold text-foreground hover:text-[#6d28d9] cursor-pointer transition-colors duration-300">
{genre}
</span>
))}
</div>
</div>
</div>
<div
className="text-foreground leading-relaxed mb-6 max-w-3xl prose prose-sm dark:prose-invert"
dangerouslySetInnerHTML={{ __html: media.description || '' }}
/>
{/* Tags */}
<div className="flex flex-wrap gap-2 mb-4">
{media.tags?.map(tag => (
<Badge key={tag} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20 border-none px-3 py-1 font-bold text-[10px] uppercase tracking-wider">
{tag}
</Badge> </Badge>
))} )}
{media.completionStatus && (
<Badge className="bg-purple-500/20 text-purple-400 border-purple-500/30 font-bold">{media.completionStatus.toUpperCase()}</Badge>
)}
</div> </div>
</div>
</div>
{/* Staff Section - Only show if staff data exists */} {/* Show Details */}
{media.staff && media.staff.length > 0 && ( <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
<section className="mt-20"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between mb-8"> <Calendar size={16} />
<h2 className="text-3xl font-black text-foreground">Cast & Crew</h2> <span>{media.year}</span>
<div className="flex items-center gap-4"> </div>
<span className="text-sm font-bold text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
{showAllCast ? media.staff.length : displayedCast.length} / {media.staff.length} <span>{media.status ? media.status.charAt(0).toUpperCase() + media.status.slice(1) : 'Unknown'}</span>
</span> </div>
{hasMoreCast && ( <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Button <Clock size={16} />
variant="outline" <span>{media.playtime ? `${media.playtime}h` : '12h 30m'}</span>
size="sm"
onClick={() => setShowAllCast(!showAllCast)}
className="rounded-xl border-border font-bold hover:border-[#6d28d9]/50 transition-all duration-300"
>
{showAllCast ? 'Show Less' : 'Show All'}
<ChevronDown size={16} className={`ml-2 transition-transform ${showAllCast ? 'rotate-180' : ''}`} />
</Button>
)}
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedCast.map(person => ( {/* Progress Bar */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-foreground">Progress</span>
<span className="text-sm font-bold text-[#6d28d9]">{progress}%</span>
</div>
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div <div
key={person.id} className="h-full bg-gradient-to-r from-[#6d28d9] to-[#8b5cf6] transition-all duration-500"
className="flex items-center gap-4 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 cursor-pointer group" style={{ width: `${progress}%` }}
onClick={() => onPersonClick(person)} />
</div>
</div>
{/* Navigation Tabs */}
<div className="flex flex-wrap gap-2 mb-6 border-b border-border/50 pb-4">
{tabs.map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
> >
<div className="w-16 h-20 rounded-xl overflow-hidden shrink-0 border border-border/30"> {tab}
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" referrerPolicy="no-referrer" /> </button>
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">{person.name}</h4>
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
</div>
<div className="w-16 h-20 rounded-xl overflow-hidden shrink-0 bg-muted border border-border/30">
<img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" />
</div>
</div>
))} ))}
</div> </div>
</section>
)}
{/* Episodes Section - Only show if episodes data exists */} {/* Genre Tags */}
{media.episodes && media.episodes.length > 0 && ( {activeTab === 'Overview' && (
<div className="flex flex-wrap gap-2 mb-6">
{media.genres?.map(genre => (
<Badge key={genre} variant="secondary" className="bg-muted/50 text-foreground hover:bg-muted/80 border border-border/50 px-3 py-1 font-bold text-sm">
{genre}
</Badge>
))}
</div>
)}
{/* Description */}
{activeTab === 'Overview' && (
<div
className="text-foreground leading-relaxed mb-8 max-w-4xl prose prose-sm dark:prose-invert"
dangerouslySetInnerHTML={{ __html: media.description || '' }}
/>
)}
{/* Acting Section - Horizontal Scrollable */}
{media.staff && media.staff.length > 0 && activeTab === 'Cast' && (
<section className="mt-12">
<h2 className="text-2xl font-black text-foreground mb-6">Acting</h2>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
{displayedCast.map(person => (
<div
key={person.id}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 cursor-pointer group"
onClick={() => onPersonClick(person)}
>
<div className="w-full h-56 rounded-xl overflow-hidden mb-3 border border-border/30">
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" referrerPolicy="no-referrer" />
</div>
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">{person.name}</h4>
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
</div>
))}
{hasMoreCast && (
<button
onClick={() => setShowAllCast(!showAllCast)}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 flex items-center justify-center"
>
<span className="font-bold text-[#6d28d9]">
{showAllCast ? 'Show Less' : `+${media.staff!.length - castLimit} more`}
</span>
</button>
)}
</div>
</section>
)}
{/* Episodes Section - Only show if episodes data exists and Seasons tab is active */}
{media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && (
<section className="mt-20"> <section className="mt-20">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
@@ -365,8 +332,8 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
</section> </section>
)} )}
{/* Tracks Section - Only show if tracks data exists (Music) */} {/* Tracks Section - Only show if tracks data exists and Tracks tab is active */}
{media.tracks && media.tracks.length > 0 && ( {media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && (
<section className="mt-20"> <section className="mt-20">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
@@ -388,37 +355,24 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
</div> </div>
</div> </div>
<div className="border border-border/50 rounded-2xl overflow-hidden bg-card/50 backdrop-blur-sm"> <div className="space-y-2">
<div className="divide-y divide-border/50"> {media.tracks.map(track => (
{media.tracks <div key={track.id} className="group cursor-pointer flex items-center gap-4 p-4 rounded-2xl hover:bg-muted/50 transition-colors duration-300 border border-transparent hover:border-border/30">
.sort((a, b) => a.track_number - b.track_number) <span className="text-sm font-bold text-muted-foreground w-8">{track.track_number}</span>
.map((track, index) => ( <div className="flex-1">
<div key={track.id} className="group cursor-pointer hover:bg-muted/50 transition-colors duration-300"> <h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
<div className="flex items-center gap-4 p-4"> {track.title}
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground group-hover:bg-gradient-to-br group-hover:from-[#6d28d9] group-hover:to-[#8b5cf6] group-hover:text-white transition-all duration-300"> </h3>
{track.track_number} <p className="text-sm text-muted-foreground">{track.artist}</p>
</div> </div>
<div className="flex-1 min-w-0"> <span className="text-xs font-bold text-muted-foreground">{track.duration ? `${track.duration}m` : '-'}</span>
<h3 className="font-bold text-foreground group-hover:text-[#6d28d9] transition-colors duration-300 truncate"> </div>
{track.title} ))}
</h3>
<p className="text-sm text-muted-foreground">{track.artist}</p>
</div>
{track.duration && (
<span className="text-xs font-bold text-muted-foreground">
{track.duration}s
</span>
)}
<Button size="icon" variant="ghost" className="text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[#6d28d9]/10 hover:text-[#6d28d9] rounded-xl">
<Play size={18} />
</Button>
</div>
</div>
))}
</div>
</div> </div>
</section> </section>
)} )}
</div>
</div>
</div> </div>
</div> </div>
); );

217
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,217 @@
import { useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
BookOpen,
Film,
Tv,
Gamepad2,
Users,
Tag,
Music as MusicIcon,
Monitor,
Eye,
Dumbbell,
Calendar,
FolderKanban,
Settings,
Sun,
LogOut,
ChevronDown,
ChevronRight,
Menu,
X
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/contexts/ThemeContext';
import { MediaCategory } from '@/types';
interface SidebarProps {
enabledCategories: MediaCategory[];
onToggleCategory: (category: MediaCategory) => void;
}
export default function Sidebar({ enabledCategories, onToggleCategory }: SidebarProps) {
const [isMediaExpanded, setIsMediaExpanded] = useState(true);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const { theme, setTheme } = useTheme();
const location = useLocation();
const categoryPaths: Record<MediaCategory, string> = {
'Anime': 'anime',
'Movies': 'movies',
'TV Series': 'tv-series',
'Music': 'music',
'Books': 'books',
'Games': 'games',
'Consoles': 'consoles',
'Adult': 'adult'
};
const categoryIcons: Record<string, any> = {
'Audio Book': <BookOpen size={18} />,
'Book': <BookOpen size={18} />,
'Movie': <Film size={18} />,
'Music': <MusicIcon size={18} />,
'Show': <Tv size={18} />,
'Video Game': <Gamepad2 size={18} />,
'Consoles': <Monitor size={18} />,
'Adult': <Eye size={18} />,
'Groups': <Users size={18} />,
'People': <Users size={18} />,
'Genres': <Tag size={18} />
};
const navItems = [
{ icon: <LayoutDashboard size={18} />, label: 'Dashboard', path: '/' },
{
icon: <Film size={18} />,
label: 'Media',
hasSubmenu: true,
submenu: [
...(enabledCategories.includes('Anime') ? [{ label: 'Anime', path: '/anime' }] : []),
...(enabledCategories.includes('Books') ? [{ label: 'Book', path: '/books' }] : []),
...(enabledCategories.includes('Movies') ? [{ label: 'Movie', path: '/movies' }] : []),
...(enabledCategories.includes('Music') ? [{ label: 'Music', path: '/music' }] : []),
...(enabledCategories.includes('TV Series') ? [{ label: 'Show', path: '/tv-series' }] : []),
...(enabledCategories.includes('Games') ? [{ label: 'Video Game', path: '/games' }] : []),
...(enabledCategories.includes('Consoles') ? [{ label: 'Consoles', path: '/consoles' }] : []),
...(enabledCategories.includes('Adult') ? [{ label: 'Adult', path: '/adult' }] : []),
{ label: 'People', path: '/cast' },
{ label: 'Genres', path: '/browse' }
].filter(Boolean)
},
//{ icon: <Dumbbell size={18} />, label: 'Fitness', path: '/fitness' },
//{ icon: <Calendar size={18} />, label: 'Calendar', path: '/calendar' },
//{ icon: <FolderKanban size={18} />, label: 'Collections', path: '/collections' },
{ icon: <Settings size={18} />, label: 'Settings', path: '/settings' }
];
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
const handleLogout = () => {
console.log('Logout clicked');
};
return (
<>
{/* Mobile menu button */}
<button
onClick={() => setIsMobileOpen(!isMobileOpen)}
className="lg:hidden fixed top-4 left-4 z-50 p-2 bg-card rounded-lg border border-border/50 hover:bg-muted transition-colors"
>
{isMobileOpen ? <X size={20} /> : <Menu size={20} />}
</button>
{/* Overlay for mobile */}
{isMobileOpen && (
<div
className="lg:hidden fixed inset-0 bg-black/50 z-40"
onClick={() => setIsMobileOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed left-0 top-0 bottom-0 w-72 bg-card border-r border-border/50 z-50 flex flex-col transition-transform duration-300',
isMobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
)}
>
{/* Logo */}
<div className="p-6 border-b border-border/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] rounded-xl flex items-center justify-center shadow-lg shadow-[#6d28d9]/30">
<div className="w-5 h-5 rounded-full bg-white" />
</div>
<span className="text-xl font-black text-foreground">kyoo</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
{navItems.map((item) => (
<div key={item.label}>
{item.hasSubmenu ? (
<div>
<button
onClick={() => setIsMediaExpanded(!isMediaExpanded)}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl hover:bg-muted/50 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="text-muted-foreground group-hover:text-foreground transition-colors">
{item.icon}
</div>
<span className="font-bold text-foreground">{item.label}</span>
</div>
{isMediaExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
{isMediaExpanded && item.submenu && (
<div className="ml-4 mt-1 space-y-1">
{item.submenu.map((subItem) => (
<NavLink
key={subItem.label}
to={subItem.path}
onClick={() => setIsMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors',
isActive
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)
}
>
{categoryIcons[subItem.label]}
{subItem.label}
</NavLink>
))}
</div>
)}
</div>
) : (
<NavLink
to={item.path}
onClick={() => setIsMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-xl transition-colors group',
isActive
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)
}
>
<div className={cn('transition-colors', location.pathname === item.path ? 'text-[#6d28d9]' : 'group-hover:text-foreground')}>
{item.icon}
</div>
<span className="font-bold">{item.label}</span>
</NavLink>
)}
</div>
))}
</nav>
{/* Bottom section */}
<div className="p-4 border-t border-border/50 space-y-2">
<button
onClick={toggleTheme}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<Sun size={18} />
<span className="font-medium">{theme === 'dark' ? 'Light theme' : 'Dark theme'}</span>
</button>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<LogOut size={18} />
<span className="font-medium">Logout</span>
</button>
</div>
</aside>
</>
);
}