Use Zustand store; modularize API & routes

Introduce a centralized Zustand store and refactor app state out of App.tsx into src/store/appStore.ts. Modularize API surface by moving media/cast/settings/converters/types into src/lib/api/* and re-exporting from src/api.ts for backward compatibility. Replace inline route helpers with dedicated route components (MediaDetailRoute, CastDetailRoute, CategoryBrowseRoute) and wire CATEGORY_PATHS/PATH_TO_CATEGORY constants. Update AddMediaView UI (icons, layout) and adjust settings/category handling to use DEFAULT_SETTINGS and the store. Add zustand to package.json/package-lock.json and include a new React SKILL.md. Overall changes improve state management, API organization, and route/component separation for better maintainability and code-splitting.
This commit is contained in:
Lars Behrends
2026-04-16 14:53:46 +02:00
parent a407b57006
commit 432416cfc5
22 changed files with 1843 additions and 1342 deletions
+363 -283
View File
@@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { createMedia, type CreateMediaInput } from '@/api';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, Film, Calendar, Star, User, BookOpen, Music as MusicIcon, Gamepad2, Monitor, Hash, Tag, Users, FileText, Globe, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
interface AddMediaViewProps {
@@ -180,8 +180,22 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
}
};
const getCategoryIcon = (category: MediaCategory) => {
const icons: Record<MediaCategory, any> = {
'Anime': <Film size={18} />,
'Movies': <Film size={18} />,
'TV Series': <Film size={18} />,
'Music': <MusicIcon size={18} />,
'Books': <BookOpen size={18} />,
'Games': <Gamepad2 size={18} />,
'Consoles': <Monitor size={18} />,
'Adult': <Star size={18} />
};
return icons[category] || <Film size={18} />;
};
return (
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
<div className="pt-24 pb-12 px-6">
<Button
variant="ghost"
onClick={() => navigate('/')}
@@ -191,11 +205,18 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
Back to Browse
</Button>
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50">
<h1 className="text-4xl font-black text-foreground mb-2">Add New Media</h1>
<p className="text-muted-foreground font-medium text-lg mb-8">
Add a new item to your {activeCategory} library.
</p>
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50 max-w-[1600px] mx-auto">
<div className="flex items-center gap-4 mb-8">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] flex items-center justify-center shadow-lg shadow-[#6d28d9]/30">
{getCategoryIcon(activeCategory)}
</div>
<div>
<h1 className="text-4xl font-black text-foreground mb-1">Add New Media</h1>
<p className="text-muted-foreground font-medium text-lg">
Add a new item to your {activeCategory} library.
</p>
</div>
</div>
{submitStatus === 'success' && (
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl backdrop-blur-sm">
@@ -209,53 +230,62 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
</div>
)}
<form onSubmit={handleAddSubmit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="title" className="text-sm font-black text-foreground">Title</Label>
<Input
id="title"
value={newMedia.title}
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
placeholder="e.g. Mob Psycho 100"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="year" className="text-sm font-black text-foreground">Year</Label>
<Input
id="year"
value={newMedia.year}
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
placeholder="2024"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
<form onSubmit={handleAddSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic Info Card */}
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
<FileText size={16} />
</div>
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
</div>
<div className="grid gap-2">
<Label htmlFor="category" className="text-sm font-black text-foreground">Category</Label>
<select
id="category"
value={newMedia.category}
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
{enabledCategories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="type" className="text-sm font-black text-foreground">Type</Label>
<select
id="type"
value={newMedia.type}
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
{newMedia.category === 'Music' ? (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="title" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Title</Label>
<Input
id="title"
value={newMedia.title}
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
placeholder="e.g. Mob Psycho 100"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="year" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Year</Label>
<Input
id="year"
value={newMedia.year}
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
placeholder="2024"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="category" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Category</Label>
<select
id="category"
value={newMedia.category}
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
{enabledCategories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="type" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Type</Label>
<select
id="type"
value={newMedia.type}
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
{newMedia.category === 'Music' ? (
<>
<option value="Album">Album</option>
<option value="Single">Single</option>
@@ -284,287 +314,337 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
<option value="Movie">Movie</option>
</>
)}
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="status" className="text-sm font-black text-foreground">Status</Label>
<select
id="status"
value={newMedia.status}
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="Released">Released</option>
<option value="Ongoing">Ongoing</option>
<option value="Upcoming">Upcoming</option>
<option value="Completed">Completed</option>
<option value="Watching">Watching</option>
<option value="Reading">Reading</option>
<option value="Listening">Listening</option>
<option value="Playing">Playing</option>
<option value="Dropped">Dropped</option>
<option value="On Hold">On Hold</option>
</select>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="status" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Status</Label>
<select
id="status"
value={newMedia.status}
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="Released">Released</option>
<option value="Ongoing">Ongoing</option>
<option value="Upcoming">Upcoming</option>
<option value="Completed">Completed</option>
<option value="Watching">Watching</option>
<option value="Reading">Reading</option>
<option value="Listening">Listening</option>
<option value="Playing">Playing</option>
<option value="Dropped">Dropped</option>
<option value="On Hold">On Hold</option>
</select>
</div>
</div>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="aspectRatio" className="text-sm font-black text-foreground">Aspect Ratio (Format)</Label>
<select
id="aspectRatio"
value={newMedia.aspectRatio}
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="2/3">2:3 (Standard Poster)</option>
<option value="16/9">16:9 (Wide Thumbnail)</option>
<option value="1/1">1:1 (Square)</option>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="poster" className="text-sm font-black text-foreground">Poster URL</Label>
<Input
id="poster"
value={newMedia.poster}
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
placeholder="https://example.com/poster.jpg"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="banner" className="text-sm font-black text-foreground">Banner URL (Optional)</Label>
<Input
id="banner"
value={newMedia.banner}
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
placeholder="https://example.com/banner.jpg"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description" className="text-sm font-black text-foreground">Description (Optional)</Label>
<textarea
id="description"
value={newMedia.description}
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
placeholder="Brief description..."
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl p-3 h-20 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none resize-none"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="rating" className="text-sm font-black text-foreground">Rating (Optional)</Label>
<Input
id="rating"
type="number"
step="0.1"
min="0"
max="10"
value={newMedia.rating}
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
placeholder="8.5"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
<>
{/* Media Info Card */}
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
<Globe size={16} />
</div>
<h3 className="text-lg font-black text-foreground">Media Information</h3>
</div>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="poster" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Poster URL</Label>
<Input
id="poster"
value={newMedia.poster}
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
placeholder="https://example.com/poster.jpg"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="banner" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Banner URL (optional)</Label>
<Input
id="banner"
value={newMedia.banner}
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
placeholder="https://example.com/banner.jpg"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="runtime" className="text-sm font-black text-foreground">Runtime (min)</Label>
<Label htmlFor="aspectRatio" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Aspect Ratio</Label>
<select
id="aspectRatio"
value={newMedia.aspectRatio}
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="2/3">2:3 (Poster)</option>
<option value="16/9">16:9 (Banner)</option>
<option value="1/1">1:1 (Square)</option>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="rating" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Rating (0-10)</Label>
<Input
id="rating"
type="number"
min="0"
max="10"
step="0.1"
value={newMedia.rating}
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
placeholder="8.5"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="description" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Description</Label>
<textarea
id="description"
value={newMedia.description}
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
placeholder="Enter a description..."
rows={4}
className="bg-background border-border/50 rounded-xl p-3 text-sm focus:ring-2 focus:ring-[#6d28d9]/50 outline-none resize-none"
/>
</div>
</div>
</div>
{/* Production Details Card - for Movies/TV/Anime */}
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
<Clock size={16} />
</div>
<h3 className="text-lg font-black text-foreground">Production Details</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="runtime" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Runtime (minutes)</Label>
<Input
id="runtime"
type="number"
value={newMedia.runtime}
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
placeholder="120"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="releaseDate" className="text-sm font-black text-foreground">Release Date</Label>
<Label htmlFor="releaseDate" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Release Date</Label>
<Input
id="releaseDate"
type="date"
value={newMedia.releaseDate}
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="director" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Director</Label>
<Input
id="director"
value={newMedia.director}
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
placeholder="Director name"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="writer" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Writer</Label>
<Input
id="writer"
value={newMedia.writer}
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
placeholder="Writer name"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="director" className="text-sm font-black text-foreground">Director</Label>
<Input
id="director"
value={newMedia.director}
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
placeholder="Director name"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="writer" className="text-sm font-black text-foreground">Writer</Label>
<Input
id="writer"
value={newMedia.writer}
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
placeholder="Writer name"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
</>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="genres" className="text-sm font-black text-foreground">Genres (comma-separated)</Label>
<Input
id="genres"
value={newMedia.genres}
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
placeholder="Action, Drama, Sci-Fi"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="tags" className="text-sm font-black text-foreground">Tags (comma-separated)</Label>
<Input
id="tags"
value={newMedia.tags}
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
placeholder="Classic, Best-selling"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="studios" className="text-sm font-black text-foreground">Studios (comma-separated)</Label>
<Input
id="studios"
value={newMedia.studios}
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
placeholder="Studio A, Studio B"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="source" className="text-sm font-black text-foreground">Source / Quelle</Label>
<Input
id="source"
value={newMedia.source}
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
placeholder="e.g. username, xbvr, stashapp"
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
{/* Classification Card */}
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
<Tag size={16} />
</div>
<h3 className="text-lg font-black text-foreground">Classification</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="genres" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Genres (comma-separated)</Label>
<Input
id="genres"
value={newMedia.genres}
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
placeholder="Action, Drama, Sci-Fi"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="tags" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Tags (comma-separated)</Label>
<Input
id="tags"
value={newMedia.tags}
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
placeholder="Classic, Best-selling"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="studios" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Studios (comma-separated)</Label>
<Input
id="studios"
value={newMedia.studios}
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
placeholder="Studio A, Studio B"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="source" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Source / Quelle</Label>
<Input
id="source"
value={newMedia.source}
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
placeholder="e.g. username, xbvr, stashapp"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
</div>
</div>
{/* Cast/Staff Section */}
{/* Cast/Staff Card */}
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-black text-foreground">Cast & Crew</Label>
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50 lg:col-span-2">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
<Users size={16} />
</div>
<h3 className="text-lg font-black text-foreground">Cast & Crew</h3>
</div>
{/* Staff List */}
{staff.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Staff List */}
<div className="space-y-2">
{staff.map((member, index) => (
<div key={index} className="flex items-center gap-3 p-3 bg-muted/50 backdrop-blur-sm rounded-xl border border-border/50">
{member.photo && (
<img
src={member.photo}
alt={member.name}
className="w-12 h-12 rounded-xl object-cover border border-border/30"
referrerPolicy="no-referrer"
/>
)}
<div className="flex-1 min-w-0">
<p className="font-bold text-foreground truncate">{member.name}</p>
<p className="text-xs text-muted-foreground">{member.role}{member.characterName ? ` as ${member.characterName}` : ''}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setStaff(prev => prev.filter((_, i) => i !== index))}
className="h-8 w-8 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-xl"
>
×
</Button>
{staff.length > 0 && (
<>
{staff.map((member, index) => (
<div key={index} className="flex items-center gap-3 p-3 bg-background rounded-xl border border-border/50">
{member.photo && (
<img
src={member.photo}
alt={member.name}
className="w-12 h-12 rounded-xl object-cover border border-border/30"
referrerPolicy="no-referrer"
/>
)}
<div className="flex-1 min-w-0">
<p className="font-bold text-foreground truncate">{member.name}</p>
<p className="text-xs text-muted-foreground">{member.role}{member.characterName ? ` as ${member.characterName}` : ''}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setStaff(prev => prev.filter((_, i) => i !== index))}
className="h-8 w-8 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-xl"
>
×
</Button>
</div>
))}
</>
)}
{staff.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">
No cast members added yet
</div>
))}
)}
</div>
)}
{/* Add Staff Form */}
<div className="grid gap-3 p-4 bg-muted/30 backdrop-blur-sm rounded-xl border border-border/50">
<div className="grid gap-2">
<Label htmlFor="staffName" className="text-xs font-black text-foreground">Name</Label>
<Input
id="staffName"
placeholder="Actor name"
className="bg-background border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const input = e.target as HTMLInputElement;
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
if (input.value && roleInput?.value) {
addStaffMember();
}
}
}}
/>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Add Staff Form */}
<div className="grid gap-3 p-4 bg-background rounded-xl border border-border/50">
<div className="grid gap-2">
<Label htmlFor="staffRole" className="text-xs font-black text-foreground">Role</Label>
<Label htmlFor="staffName" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Name</Label>
<Input
id="staffRole"
placeholder="e.g. Actor, Director"
className="bg-background border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
id="staffName"
placeholder="Actor name"
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const input = e.target as HTMLInputElement;
const nameInput = document.getElementById('staffName') as HTMLInputElement;
if (input.value && nameInput?.value) {
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
if (input.value && roleInput?.value) {
addStaffMember();
}
}
}}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor="staffRole" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Role</Label>
<Input
id="staffRole"
placeholder="e.g. Actor, Director"
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const input = e.target as HTMLInputElement;
const nameInput = document.getElementById('staffName') as HTMLInputElement;
if (input.value && nameInput?.value) {
addStaffMember();
}
}
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="staffCharacter" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Character (optional)</Label>
<Input
id="staffCharacter"
placeholder="Character name"
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="staffCharacter" className="text-xs font-black text-foreground">Character (optional)</Label>
<Label htmlFor="staffPhoto" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Photo URL (optional)</Label>
<Input
id="staffCharacter"
placeholder="Character name"
className="bg-background border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
id="staffPhoto"
placeholder="https://example.com/photo.jpg"
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<Button
type="button"
onClick={addStaffMember}
variant="outline"
className="w-full border-border/50 text-sm font-bold hover:border-[#6d28d9]/50 hover:bg-[#6d28d9]/10 rounded-xl transition-all duration-300"
>
+ Add Cast Member
</Button>
</div>
<div className="grid gap-2">
<Label htmlFor="staffPhoto" className="text-xs font-black text-foreground">Photo URL (optional)</Label>
<Input
id="staffPhoto"
placeholder="https://example.com/photo.jpg"
className="bg-background border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<Button
type="button"
onClick={addStaffMember}
variant="outline"
className="w-full border-border/50 text-sm font-bold hover:border-[#6d28d9]/50 hover:bg-[#6d28d9]/10 rounded-xl transition-all duration-300"
>
+ Add Cast Member
</Button>
</div>
</div>
)}
<Button
type="submit"
disabled={isSubmitting}
className="w-full bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/30 transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
>
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
</Button>
{/* Submit Button - Full Width */}
<div className="lg:col-span-2">
<Button
type="submit"
disabled={isSubmitting}
className="w-full bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/30 transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
>
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
</Button>
</div>
</form>
</div>
</div>
+6 -12
View File
@@ -20,11 +20,13 @@ import {
ChevronDown,
ChevronRight,
Menu,
X
X,
Plus
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/contexts/ThemeContext';
import { MediaCategory } from '@/types';
import { CATEGORY_PATHS } from '@/constants';
interface SidebarProps {
enabledCategories: MediaCategory[];
@@ -37,16 +39,6 @@ export default function Sidebar({ enabledCategories, onToggleCategory }: Sidebar
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} />,
@@ -84,7 +76,9 @@ export default function Sidebar({ enabledCategories, onToggleCategory }: Sidebar
//{ 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' }
{ icon: <Plus size={18} />, label: 'Add Media', path: '/add' },
{ icon: <Settings size={18} />, label: 'Settings', path: '/settings' },
{ icon: <FolderKanban size={18} />, label: 'Import', path: '/import' }
];
const toggleTheme = () => {
+46
View File
@@ -0,0 +1,46 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { Staff } from '../../types';
import { fetchCastById, convertApiCastToStaff } from '../../api';
import CastDetailView from '../CastDetailView';
import Loading from '../ui/loading';
export default function CastDetailRoute() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
useEffect(() => {
const loadCast = async () => {
if (id) {
setLoading(true);
try {
const castData = await fetchCastById(id);
if (castData) {
const person = convertApiCastToStaff(castData);
setSelectedPerson(person);
} else {
navigate('/cast');
}
} catch (error) {
console.error('Failed to load cast:', error);
navigate('/cast');
} finally {
setLoading(false);
}
}
};
loadCast();
}, [id, navigate]);
if (loading) return <Loading message="Loading cast details..." />;
if (!selectedPerson) return null;
return (
<CastDetailView
person={selectedPerson}
relatedMedia={[]}
/>
);
}
@@ -0,0 +1,49 @@
import { useParams } from 'react-router-dom';
import { Media, Staff, MediaCategory } from '../../types';
import BrowseView from '../BrowseView';
interface CategoryBrowseRouteProps {
mediaList: Media[];
onMediaClick: (media: Media) => void;
itemsPerPage?: number;
gridItemSize?: number;
onGridItemSizeChange: (size: number) => void;
loading: boolean;
}
export default function CategoryBrowseRoute({
mediaList,
onMediaClick,
itemsPerPage,
gridItemSize,
onGridItemSizeChange,
loading
}: CategoryBrowseRouteProps) {
const { category } = useParams<{ category: string }>();
// Map URL path to category
const categoryMap: Record<string, MediaCategory> = {
'anime': 'Anime',
'movies': 'Movies',
'tv-series': 'TV Series',
'music': 'Music',
'books': 'Books',
'games': 'Games',
'consoles': 'Consoles',
'adult': 'Adult'
};
const activeCategory = category ? categoryMap[category] : 'Anime';
return (
<BrowseView
mediaList={mediaList}
onMediaClick={onMediaClick}
activeCategory={activeCategory}
itemsPerPage={itemsPerPage}
gridItemSize={gridItemSize}
onGridItemSizeChange={onGridItemSizeChange}
loading={loading}
/>
);
}
@@ -0,0 +1,50 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { Media, Staff } from '../../types';
import { fetchMediaById } from '../../api';
import DetailView from '../DetailView';
import Loading from '../ui/loading';
interface MediaDetailRouteProps {
allMedia: Media[];
onPersonClick: (person: Staff) => void;
}
export default function MediaDetailRoute({ allMedia, onPersonClick }: MediaDetailRouteProps) {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
useEffect(() => {
const loadMedia = async () => {
if (id) {
setLoading(true);
try {
const fetchedMedia = await fetchMediaById(id);
if (fetchedMedia) {
setSelectedMedia(fetchedMedia);
} else {
navigate('/');
}
} catch (error) {
console.error('Failed to fetch media:', error);
navigate('/');
} finally {
setLoading(false);
}
}
};
loadMedia();
}, [id, navigate]);
if (loading) return <Loading message="Loading media details..." />;
if (!selectedMedia) return null;
return (
<DetailView
media={selectedMedia}
onPersonClick={onPersonClick}
/>
);
}