mirror of
https://github.com/ceratic/MediaCollectorLibaryFrontend.git
synced 2026-05-13 23:56:45 +02:00
Refactor imports and remove debug logs; add MediaDetailView and MediaListView components with status configuration
This commit is contained in:
@@ -154,10 +154,6 @@ export default function Layout({ children }: LayoutProps) {
|
||||
FiltersComponent: ContentFilters
|
||||
}
|
||||
|
||||
// Debug logs
|
||||
console.log('Layout.tsx - State:', { viewMode, gridColumns, coverSize, currentView })
|
||||
console.log('Layout.tsx - viewContextValue:', viewContextValue)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden relative">
|
||||
{/* Mobile Menu Overlay */}
|
||||
@@ -275,7 +271,6 @@ export default function Layout({ children }: LayoutProps) {
|
||||
value={gridColumns}
|
||||
onChange={(e) => {
|
||||
const newColumns = parseInt(e.target.value)
|
||||
console.log('Layout.tsx - Grid slider changed:', newColumns)
|
||||
setGridColumns(newColumns)
|
||||
}}
|
||||
className="w-12 md:w-16 h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-brand-500"
|
||||
@@ -296,7 +291,6 @@ export default function Layout({ children }: LayoutProps) {
|
||||
value={coverSize}
|
||||
onChange={(e) => {
|
||||
const newSize = parseInt(e.target.value)
|
||||
console.log('Layout.tsx - Cover slider changed:', newSize)
|
||||
setCoverSize(newSize)
|
||||
}}
|
||||
className="w-16 md:w-20 h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-brand-500"
|
||||
@@ -310,7 +304,6 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<div className="flex bg-slate-800/50 p-0.5 md:p-1 rounded-full border border-white/5">
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('Layout.tsx - View mode changed to: list')
|
||||
setViewMode('list')
|
||||
}}
|
||||
className={`p-1.5 md:p-2 rounded-full transition-all ${viewMode === 'list' ? 'bg-white text-brand-900 shadow' : 'text-slate-400 hover:text-white'}`}
|
||||
@@ -319,7 +312,6 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('Layout.tsx - View mode changed to: grid')
|
||||
setViewMode('grid')
|
||||
}}
|
||||
className={`p-1.5 md:p-2 rounded-full transition-all ${viewMode === 'grid' ? 'bg-white text-brand-900 shadow' : 'text-slate-400 hover:text-white'}`}
|
||||
@@ -328,7 +320,6 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('Layout.tsx - View mode changed to: cover')
|
||||
setViewMode('cover')
|
||||
}}
|
||||
className={`p-1.5 md:p-2 rounded-full transition-all ${viewMode === 'cover' ? 'bg-white text-brand-900 shadow' : 'text-slate-400 hover:text-white'}`}
|
||||
|
||||
409
src/components/MediaDetailView.tsx
Normal file
409
src/components/MediaDetailView.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ArrowLeft, Calendar, Clock, Trophy, Monitor, Star, Heart, PlayCircle, Building2, Edit2, Sparkles, Layers, Tv, Users, User, Gamepad2, CheckCircle2 } from 'lucide-react';
|
||||
import { MediaItem } from '../types';
|
||||
import { STATUS_CONFIG } from '../constants';
|
||||
|
||||
interface MediaDetailViewProps {
|
||||
item: MediaItem | null;
|
||||
allMedia: MediaItem[];
|
||||
// Removed isOpen since it is now conditionally rendered by parent
|
||||
onBack: () => void;
|
||||
onEdit: (item: MediaItem) => void;
|
||||
onToggleFavorite: (id: string, isFav: boolean) => void;
|
||||
onSelectRelated: (item: MediaItem) => void;
|
||||
onSelectPerson?: (personName: string) => void; // Added prop
|
||||
}
|
||||
|
||||
export const MediaDetailView: React.FC<MediaDetailViewProps> = ({
|
||||
item,
|
||||
allMedia,
|
||||
onBack,
|
||||
onEdit,
|
||||
onToggleFavorite,
|
||||
onSelectRelated,
|
||||
onSelectPerson
|
||||
}) => {
|
||||
const relatedItems = useMemo(() => {
|
||||
if (!item || !allMedia) return [];
|
||||
|
||||
return allMedia
|
||||
.filter(i => i.id !== item.id) // Exclude itself
|
||||
.map(candidate => {
|
||||
let score = 0;
|
||||
// Same type gives baseline relevance
|
||||
if (candidate.type === item.type) score += 2;
|
||||
|
||||
// Same Studio/Developer (strong signal)
|
||||
if (item.studio && candidate.studio && item.studio === candidate.studio) score += 3;
|
||||
|
||||
// Overlapping Genres
|
||||
const sharedGenres = candidate.genres.filter(g => item.genres.includes(g)).length;
|
||||
score += sharedGenres;
|
||||
|
||||
return { item: candidate, score };
|
||||
})
|
||||
.filter(x => x.score > 0) // Only show if there is some relevance
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 5) // Top 5
|
||||
.map(x => x.item);
|
||||
}, [item, allMedia]);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const statusConfig = STATUS_CONFIG[item.status];
|
||||
|
||||
// Helper to calculate progress percentage for bars
|
||||
const getProgress = (current: number, total: number) => {
|
||||
if (!total || total === 0) return 0;
|
||||
return Math.min(100, Math.round((current / total) * 100));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300 bg-transparent">
|
||||
|
||||
{/* Navigation Bar (Page Header) */}
|
||||
<div className="absolute top-0 left-0 right-0 z-30 p-6 pointer-events-none">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="pointer-events-auto flex items-center gap-2 px-4 py-2 bg-black/40 hover:bg-black/60 backdrop-blur-md border border-white/10 text-white rounded-full transition-all hover:pl-3 group shadow-lg"
|
||||
>
|
||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-medium">Zurück</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto custom-scrollbar flex-1 pb-20">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-72 md:h-[450px] w-full shrink-0">
|
||||
<img
|
||||
src={item.backdropUrl || item.coverUrl}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover object-top opacity-60 mask-image-gradient"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-dark-bg via-dark-bg/40 to-transparent" />
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-12 flex items-end gap-10 max-w-7xl mx-auto w-full">
|
||||
{/* Poster Image - Overlapping */}
|
||||
<img
|
||||
src={item.coverUrl}
|
||||
alt={item.title}
|
||||
className="w-48 h-72 md:w-64 md:h-96 object-cover rounded-xl shadow-2xl border-4 border-dark-bg hidden md:block -mb-20 z-10"
|
||||
/>
|
||||
|
||||
{/* Title & Basic Info */}
|
||||
<div className="flex-1 min-w-0 pb-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className={`inline-flex items-center space-x-1 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${statusConfig.color} border border-white/5`}>
|
||||
{statusConfig.icon}
|
||||
<span>{statusConfig.label}</span>
|
||||
</span>
|
||||
{item.favorite && (
|
||||
<span className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-400 bg-red-500/10 rounded-full border border-red-500/20">
|
||||
<Heart size={12} fill="currentColor" /> Favorit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-white leading-tight mb-4 drop-shadow-xl">
|
||||
{item.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm md:text-base text-slate-300 font-medium">
|
||||
<div className="flex items-center gap-1.5 text-yellow-400 bg-yellow-400/10 px-2 py-1 rounded">
|
||||
<Star size={18} fill="currentColor" /> {item.rating}
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5"><Calendar size={18} /> {item.releaseYear}</span>
|
||||
{item.studio && <span className="flex items-center gap-1.5"><Building2 size={18} /> {item.studio}</span>}
|
||||
{item.type === 'game' && item.platform && <span className="flex items-center gap-1.5"><Monitor size={18} /> {item.platform}</span>}
|
||||
{item.type === 'adult' && item.platform && <span className="flex items-center gap-1.5 text-purple-400"><Monitor size={18} /> {item.platform}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="p-8 md:p-12 pt-8 md:pt-24 max-w-7xl mx-auto w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
|
||||
{/* Left Column: Description & Actions */}
|
||||
<div className="lg:col-span-2 space-y-10">
|
||||
{/* Genres */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.genres.map(g => (
|
||||
<span key={g} className="px-4 py-2 rounded-full bg-slate-800 text-slate-200 text-sm border border-dark-border font-medium">
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-white mb-4">Beschreibung</h3>
|
||||
<p className="text-slate-300 leading-relaxed text-lg">
|
||||
{item.description || "Keine Beschreibung verfügbar."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cast & Crew Section (Updated to include Adult) */}
|
||||
{(item.type === 'movie' || item.type === 'series' || item.type === 'adult') && (
|
||||
<div className="animate-in slide-in-from-bottom-6 duration-500 delay-100">
|
||||
<h3 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
|
||||
<Users size={22} className="text-brand-500" />
|
||||
{item.type === 'adult' ? 'Performer & Besetzung' : 'Besetzung & Crew'}
|
||||
</h3>
|
||||
|
||||
{/* Cast List */}
|
||||
{item.cast && item.cast.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{item.cast.map((member, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => onSelectPerson && onSelectPerson(member.name)}
|
||||
className="flex items-center gap-3 bg-slate-800/50 p-2 rounded-xl border border-white/5 hover:border-brand-500/30 transition-colors group cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full overflow-hidden bg-slate-700 shrink-0 border border-white/10">
|
||||
{member.imageUrl ? (
|
||||
<img src={member.imageUrl} alt={member.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-500">
|
||||
<User size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold text-white truncate group-hover:text-brand-400 transition-colors">{member.name}</div>
|
||||
<div className="text-[10px] text-slate-400 truncate">{member.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center p-8 border border-dashed border-white/10 rounded-xl bg-slate-800/20">
|
||||
<Users size={32} className="text-slate-600 mb-2" />
|
||||
<p className="text-slate-500 text-sm">Keine Informationen zur Besetzung verfügbar.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions Bar */}
|
||||
<div className="flex gap-4 pt-4 border-b border-dark-border pb-10">
|
||||
<button
|
||||
onClick={() => onEdit(item)}
|
||||
className="px-6 py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-lg font-medium transition-colors border border-dark-border flex items-center gap-2"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggleFavorite(item.id, !item.favorite)}
|
||||
className={`px-6 py-3 rounded-lg font-medium transition-colors border flex items-center justify-center gap-2 ${
|
||||
item.favorite
|
||||
? 'bg-red-500/10 border-red-500/30 text-red-500 hover:bg-red-500/20'
|
||||
: 'bg-slate-800 border-dark-border hover:bg-slate-700 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Heart size={20} fill={item.favorite ? "currentColor" : "none"} />
|
||||
{item.favorite ? 'Favorisiert' : 'Favorisieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Stats & Metadata */}
|
||||
<div className="space-y-8">
|
||||
{/* Stats Box */}
|
||||
<div className="bg-dark-surface p-8 rounded-2xl border border-dark-border space-y-8 shadow-xl">
|
||||
<h3 className="font-bold text-white flex items-center gap-2 text-lg">
|
||||
<Trophy size={24} className="text-brand-500" />
|
||||
Statistiken
|
||||
</h3>
|
||||
|
||||
{/* Game Stats */}
|
||||
{item.type === 'game' && (
|
||||
<div className="space-y-6">
|
||||
{/* Playtime & Completion */}
|
||||
<div className="bg-slate-800/50 p-6 rounded-xl border border-white/10 relative overflow-hidden">
|
||||
<div className="relative z-10 flex flex-col gap-6">
|
||||
{/* Playtime Header */}
|
||||
<div>
|
||||
<div className="text-sm text-brand-400 uppercase font-bold mb-1 flex items-center gap-2">
|
||||
<Clock size={18} /> Spielzeit
|
||||
</div>
|
||||
<div className="text-4xl font-extrabold text-white tracking-tight">
|
||||
{item.playtime || 0} <span className="text-lg font-medium text-slate-500">Std.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completion Progress */}
|
||||
<div>
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<div className="text-xs text-slate-400 uppercase font-bold flex items-center gap-1">
|
||||
<CheckCircle2 size={14} className="text-blue-500" /> Fortschritt
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{item.completionRate || 0}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-slate-900 h-4 rounded-full overflow-hidden border border-white/5 relative">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-600 to-blue-400 h-full rounded-full shadow-[0_0_10px_rgba(59,130,246,0.5)] transition-all duration-1000 ease-out"
|
||||
style={{ width: `${item.completionRate || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Icon */}
|
||||
<Gamepad2 size={96} className="absolute -right-6 -bottom-6 text-white/5 rotate-12" />
|
||||
</div>
|
||||
|
||||
{/* Achievements */}
|
||||
{(item.achievementsTotal || 0) > 0 && (
|
||||
<div className="bg-slate-800/30 rounded-xl p-5 border border-white/5">
|
||||
<div className="flex justify-between items-end mb-3">
|
||||
<div className="text-xs text-slate-400 uppercase font-bold flex items-center gap-1">
|
||||
<Trophy size={14} className="text-yellow-500" /> Erfolge
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{Math.round(((item.achievementsUnlocked || 0) / (item.achievementsTotal || 1)) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-slate-900 h-4 rounded-full overflow-hidden border border-white/5 relative mb-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-yellow-600 to-yellow-400 h-full rounded-full shadow-[0_0_10px_rgba(234,179,8,0.5)] transition-all duration-1000 ease-out"
|
||||
style={{ width: `${getProgress(item.achievementsUnlocked || 0, item.achievementsTotal || 1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-slate-500 font-medium">
|
||||
<span>{item.achievementsUnlocked} freigeschaltet</span>
|
||||
<span>{item.achievementsTotal} gesamt</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Series Stats */}
|
||||
{item.type === 'series' && (
|
||||
<div className="space-y-6">
|
||||
{/* Enhanced Season Display */}
|
||||
<div className="bg-slate-800/50 p-6 rounded-xl border border-white/10 flex items-center justify-between relative overflow-hidden">
|
||||
<div className="relative z-10">
|
||||
<div className="text-sm text-brand-400 uppercase font-bold mb-1 flex items-center gap-2">
|
||||
<Layers size={18} /> Anzahl Staffeln
|
||||
</div>
|
||||
<div className="text-4xl font-extrabold text-white tracking-tight">{item.seasons || 1}</div>
|
||||
</div>
|
||||
{/* Decorative Icon */}
|
||||
<Layers size={64} className="absolute -right-4 -bottom-4 text-white/5 rotate-12" />
|
||||
|
||||
<div className="h-12 w-1 bg-white/10 rounded-full mx-4"></div>
|
||||
|
||||
<div className="text-right relative z-10">
|
||||
<div className="text-xs text-slate-400 uppercase font-bold mb-1">Gesamt Episoden</div>
|
||||
<div className="text-2xl font-bold text-white">{item.episodesTotal || '?'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800/30 rounded-xl p-5 border border-white/5">
|
||||
<div className="flex justify-between items-end mb-4">
|
||||
<div className="text-xs text-slate-400 uppercase font-bold flex items-center gap-1">
|
||||
<PlayCircle size={14} className="text-green-500" /> Episoden-Fortschritt
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{Math.round(((item.episodesWatched || 0) / (item.episodesTotal || 1)) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-slate-900 h-5 rounded-full overflow-hidden border border-white/5 relative mb-3">
|
||||
<div
|
||||
className="bg-gradient-to-r from-green-600 to-green-400 h-full rounded-full shadow-[0_0_15px_rgba(74,222,128,0.4)] transition-all duration-1000 ease-out"
|
||||
style={{ width: `${getProgress(item.episodesWatched || 0, item.episodesTotal || 1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-400 text-xs">Bereits gesehen</span>
|
||||
<span className="text-white font-bold">{item.episodesWatched || 0} <span className="text-slate-500 font-normal">Episoden</span></span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-slate-400 text-xs">Verfügbar</span>
|
||||
<span className="text-white font-bold">{item.episodesTotal || '?'} <span className="text-slate-500 font-normal">Episoden</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Movie & Adult Stats (Duration) */}
|
||||
{(item.type === 'movie' || item.type === 'adult') && (
|
||||
<div>
|
||||
<div className="text-xs text-slate-400 uppercase font-bold mb-1">Laufzeit</div>
|
||||
<div className="text-3xl font-bold text-white">{item.duration} <span className="text-base font-normal text-slate-500">Minuten</span></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Metadata Info Box */}
|
||||
<div className="bg-dark-surface/50 p-6 rounded-2xl border border-dark-border">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between py-2 border-b border-white/5">
|
||||
<span className="text-slate-400">Hinzugefügt am</span>
|
||||
<span className="text-slate-200">{new Date(item.addedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-white/5">
|
||||
<span className="text-slate-400">Typ</span>
|
||||
<span className="text-slate-200 capitalize">{item.type}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-slate-400">ID</span>
|
||||
<span className="text-slate-500 font-mono text-xs">{item.id.substring(0,8)}...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Media Section */}
|
||||
{relatedItems.length > 0 && (
|
||||
<div className="mt-16 mb-8 animate-in slide-in-from-bottom-5 duration-300">
|
||||
<h3 className="text-2xl font-bold text-white mb-6 flex items-center gap-2">
|
||||
<Sparkles size={24} className="text-brand-500" />
|
||||
Ähnliche Titel
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
||||
{relatedItems.map(related => (
|
||||
<div
|
||||
key={related.id}
|
||||
onClick={() => onSelectRelated(related)}
|
||||
className="group cursor-pointer bg-dark-surface rounded-xl overflow-hidden border border-dark-border hover:border-brand-500/50 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="aspect-[2/3] overflow-hidden relative">
|
||||
<img
|
||||
src={related.coverUrl}
|
||||
alt={related.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
<div className="absolute top-2 right-2 px-1.5 py-0.5 bg-black/60 backdrop-blur-md rounded text-[10px] text-white flex items-center gap-1 font-bold">
|
||||
<Star size={10} className="text-yellow-400 fill-current" />
|
||||
{related.rating}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h4 className="font-bold text-white text-sm truncate group-hover:text-brand-400 transition-colors">{related.title}</h4>
|
||||
<p className="text-xs text-slate-500">{related.releaseYear}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
129
src/components/MediaListView.tsx
Normal file
129
src/components/MediaListView.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { MediaItem } from '../types';
|
||||
import { Monitor, Star, Check, Circle, Clock, CheckCircle2, Gamepad2, Film, Tv } from 'lucide-react';
|
||||
import { STATUS_CONFIG } from '../constants';
|
||||
|
||||
interface MediaListViewProps {
|
||||
items: MediaItem[];
|
||||
onSelect: (item: MediaItem) => void;
|
||||
selectedId: string | null;
|
||||
onToggleSelect: (id: string) => void;
|
||||
multiSelectedIds: Set<string>;
|
||||
}
|
||||
|
||||
export const MediaListView: React.FC<MediaListViewProps> = ({
|
||||
items,
|
||||
onSelect,
|
||||
selectedId,
|
||||
onToggleSelect,
|
||||
multiSelectedIds
|
||||
}) => {
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch(type) {
|
||||
case 'game': return <Gamepad2 size={14} className="text-brand-400" />;
|
||||
case 'movie': return <Film size={14} className="text-purple-400" />;
|
||||
case 'series': return <Tv size={14} className="text-pink-400" />;
|
||||
default: return <Circle size={14} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const config = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG];
|
||||
const iconClass = `${config.color.replace('bg-', 'text-').replace('/10', '')}`;
|
||||
|
||||
switch(config.icon) {
|
||||
case 'check-circle': return <CheckCircle2 className={`w-4 h-4 ${iconClass}`} />;
|
||||
case 'monitor': return <Monitor className={`w-4 h-4 ${iconClass}`} />;
|
||||
case 'clock': return <Clock className={`w-4 h-4 ${iconClass}`} />;
|
||||
case 'circle': return <Circle className={`w-4 h-4 ${iconClass}`} />;
|
||||
default: return <Circle className={`w-4 h-4 ${iconClass}`} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-white text-slate-900 dark:bg-[#1e293b] dark:text-slate-200 text-sm overflow-hidden flex flex-col border-r border-dark-border">
|
||||
{/* Table Header */}
|
||||
<div className="flex items-center bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 font-bold text-xs uppercase tracking-wide text-slate-500 h-8 shrink-0 select-none">
|
||||
<div className="w-10 flex justify-center">{/* Checkbox */}</div>
|
||||
<div className="w-10 flex justify-center">Type</div>
|
||||
<div className="flex-1 px-3 border-l border-slate-200 dark:border-slate-700/50">Title</div>
|
||||
<div className="w-32 px-3 border-l border-slate-200 dark:border-slate-700/50 hidden md:block">Platform</div>
|
||||
<div className="w-20 px-3 border-l border-slate-200 dark:border-slate-700/50 text-center">Year</div>
|
||||
<div className="w-20 px-3 border-l border-slate-200 dark:border-slate-700/50 text-center">Score</div>
|
||||
<div className="w-10 flex justify-center border-l border-slate-200 dark:border-slate-700/50">Stat</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{items.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">No items found</div>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const isRowSelected = selectedId === item.id;
|
||||
const isChecked = multiSelectedIds.has(item.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item)}
|
||||
className={`flex items-center h-9 border-b border-slate-100 dark:border-slate-700/50 cursor-pointer transition-colors ${
|
||||
isRowSelected
|
||||
? 'bg-blue-500 text-white'
|
||||
: index % 2 === 0 ? 'bg-slate-50 dark:bg-slate-800/30 hover:bg-blue-50 dark:hover:bg-slate-700' : 'bg-white dark:bg-transparent hover:bg-blue-50 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className="w-10 flex justify-center shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => onToggleSelect(item.id)}
|
||||
className={`rounded border-slate-300 dark:border-slate-600 ${isRowSelected ? 'text-white border-white' : 'text-blue-500'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`w-10 flex justify-center shrink-0 ${isRowSelected ? 'text-white' : ''}`}>
|
||||
{getTypeIcon(item.type)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex-1 px-3 truncate font-medium">
|
||||
{item.title}
|
||||
</div>
|
||||
|
||||
{/* Platform (Hidden on mobile) */}
|
||||
<div className={`w-32 px-3 truncate hidden md:block text-xs ${isRowSelected ? 'text-blue-100' : 'text-slate-500'}`}>
|
||||
{item.platform || item.type}
|
||||
</div>
|
||||
|
||||
{/* Year */}
|
||||
<div className={`w-20 px-3 text-center text-xs ${isRowSelected ? 'text-blue-100' : 'text-slate-500'}`}>
|
||||
{item.releaseYear}
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<div className={`w-20 px-3 text-center text-xs font-mono ${isRowSelected ? 'text-blue-100' : 'text-slate-500'}`}>
|
||||
{item.rating > 0 ? item.rating.toFixed(1) : '-'}
|
||||
</div>
|
||||
|
||||
{/* Status Icon */}
|
||||
<div className={`w-10 flex justify-center shrink-0 ${isRowSelected ? 'text-white' : ''}`}>
|
||||
{getStatusIcon(item.status)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Status Bar matching screenshot */}
|
||||
<div className="h-6 bg-slate-200 dark:bg-slate-900 border-t border-slate-300 dark:border-slate-800 flex items-center px-2 text-[10px] text-slate-500 select-none">
|
||||
<span>{items.length} Items listed</span>
|
||||
<div className="mx-2 h-3 w-px bg-slate-400 dark:bg-slate-700"></div>
|
||||
<span>{multiSelectedIds.size} Selected</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
src/constants/index.ts
Normal file
27
src/constants/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const STATUS_CONFIG = {
|
||||
completed: {
|
||||
label: 'Completed',
|
||||
color: 'bg-green-500/10',
|
||||
icon: 'check-circle'
|
||||
},
|
||||
watching: {
|
||||
label: 'Watching',
|
||||
color: 'bg-blue-500/10',
|
||||
icon: 'monitor'
|
||||
},
|
||||
plan_to_watch: {
|
||||
label: 'Plan to Watch',
|
||||
color: 'bg-yellow-500/10',
|
||||
icon: 'clock'
|
||||
},
|
||||
on_hold: {
|
||||
label: 'On Hold',
|
||||
color: 'bg-orange-500/10',
|
||||
icon: 'circle'
|
||||
},
|
||||
dropped: {
|
||||
label: 'Dropped',
|
||||
color: 'bg-red-500/10',
|
||||
icon: 'circle'
|
||||
}
|
||||
} as const;
|
||||
@@ -16,8 +16,8 @@ import { useAdults } from '../hooks/useApi'
|
||||
import { Tooltip } from '../components/MicroInteractions'
|
||||
import { ViewContext } from '../components/Layout'
|
||||
|
||||
// Import from alternative frontend
|
||||
import { MediaListView } from '../../../frontend/components/MediaListView'
|
||||
// Import from components
|
||||
import { MediaListView } from '../components/MediaListView'
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
|
||||
@@ -16,9 +16,9 @@ import { useAdults } from '../hooks/useApi'
|
||||
import { Tooltip } from '../components/MicroInteractions'
|
||||
import { ViewContext } from '../components/Layout'
|
||||
|
||||
// Import from alternative frontend
|
||||
import { MediaListView } from '../../../frontend/components/MediaListView'
|
||||
import { MediaDetailView } from '../../../frontend/components/MediaDetailView'
|
||||
// Import from components
|
||||
import { MediaListView } from '../components/MediaListView'
|
||||
import { MediaDetailView } from '../components/MediaDetailView'
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
|
||||
@@ -5,9 +5,9 @@ import { ViewContext } from '../components/Layout'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ComputerDesktopIcon as GamepadIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
// Import from alternative frontend
|
||||
import { MediaListView } from '../../../frontend/components/MediaListView'
|
||||
import { MediaDetailView } from '../../../frontend/components/MediaDetailView'
|
||||
// Import from components
|
||||
import { MediaListView } from '../components/MediaListView'
|
||||
import { MediaDetailView } from '../components/MediaDetailView'
|
||||
|
||||
export default function Games() {
|
||||
const viewContext = useContext(ViewContext)
|
||||
|
||||
@@ -6,9 +6,9 @@ import { useMovies } from '../hooks/useApi'
|
||||
import { ViewContext } from '../components/Layout'
|
||||
import { FilmIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
// Import from alternative frontend
|
||||
import { MediaListView } from '../../../frontend/components/MediaListView'
|
||||
import { MediaDetailView } from '../../../frontend/components/MediaDetailView'
|
||||
// Import from components
|
||||
import { MediaListView } from '../components/MediaListView'
|
||||
import { MediaDetailView } from '../components/MediaDetailView'
|
||||
|
||||
export default function Movies() {
|
||||
const viewContext = useContext(ViewContext)
|
||||
|
||||
@@ -6,9 +6,9 @@ import { TvIcon } from '@heroicons/react/24/outline'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Play, Eye, Lock} from 'lucide-react'
|
||||
|
||||
// Import from alternative frontend
|
||||
import { MediaListView } from '../../../frontend/components/MediaListView'
|
||||
import { MediaDetailView } from '../../../frontend/components/MediaDetailView'
|
||||
// Import from components
|
||||
import { MediaListView } from '../components/MediaListView'
|
||||
import { MediaDetailView } from '../components/MediaDetailView'
|
||||
|
||||
export default function TVShows() {
|
||||
const viewContext = useContext(ViewContext)
|
||||
|
||||
@@ -4,14 +4,6 @@ import { PaginatedResponse } from '../types'
|
||||
// API base configuration
|
||||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api'
|
||||
|
||||
// Types for API responses
|
||||
interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface LocalPaginatedResponse<T> {
|
||||
items: T[]
|
||||
pagination: {
|
||||
@@ -228,6 +220,10 @@ export const tvShowsApi = {
|
||||
// Return fallback data if API fails
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
pagination: {
|
||||
total: 0,
|
||||
per_page: 20,
|
||||
@@ -512,6 +508,10 @@ export const adultApi = {
|
||||
// Return fallback data if API fails
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
pagination: {
|
||||
total: 0,
|
||||
per_page: 20,
|
||||
@@ -578,6 +578,10 @@ export const actorsApi = {
|
||||
// Return fallback data if API fails
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
pagination: {
|
||||
total: 0,
|
||||
per_page: 20,
|
||||
|
||||
@@ -72,11 +72,22 @@ export interface SearchParams {
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
pagination?: {
|
||||
total: number
|
||||
per_page: number
|
||||
current_page: number
|
||||
last_page: number
|
||||
}
|
||||
available_filters?: {
|
||||
genres?: string[]
|
||||
directors?: string[]
|
||||
sources?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type ViewMode = 'grid' | 'list' | 'covers'
|
||||
export type ViewMode = 'grid' | 'list' | 'covers' | 'cover'
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: '../public/react',
|
||||
outDir: '../public/dist',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user