first commit
This commit is contained in:
422
src/components/BrowseView.tsx
Normal file
422
src/components/BrowseView.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import MediaCard from './MediaCard';
|
||||
import MediaListItem from './MediaListItem';
|
||||
import { Filter, LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
|
||||
interface BrowseViewProps {
|
||||
mediaList: Media[];
|
||||
onMediaClick: (media: Media) => void;
|
||||
onAddMedia: (media: Media) => void;
|
||||
activeCategory: MediaCategory;
|
||||
}
|
||||
|
||||
export default function BrowseView({ mediaList, onMediaClick, onAddMedia, activeCategory }: BrowseViewProps) {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
||||
const [sortBy, setSortBy] = useState<string>('default');
|
||||
|
||||
// Add Media Dialog State
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [newMedia, setNewMedia] = useState({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: activeCategory as MediaCategory,
|
||||
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1'
|
||||
});
|
||||
|
||||
// Update category and default aspect ratio when activeCategory changes
|
||||
useEffect(() => {
|
||||
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
if (activeCategory === 'Music') defaultAspect = '1/1';
|
||||
if (activeCategory === 'Games' || activeCategory === 'Adult') defaultAspect = '16/9';
|
||||
|
||||
setNewMedia(prev => ({
|
||||
...prev,
|
||||
category: activeCategory,
|
||||
aspectRatio: defaultAspect
|
||||
}));
|
||||
}, [activeCategory]);
|
||||
|
||||
const handleAddSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMedia.title || !newMedia.poster) return;
|
||||
|
||||
onAddMedia({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
title: newMedia.title,
|
||||
year: newMedia.year || new Date().getFullYear().toString(),
|
||||
poster: newMedia.poster,
|
||||
category: newMedia.category,
|
||||
aspectRatio: newMedia.aspectRatio,
|
||||
status: 'planned'
|
||||
});
|
||||
|
||||
setNewMedia({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: activeCategory,
|
||||
aspectRatio: '2/3'
|
||||
});
|
||||
setIsAddDialogOpen(false);
|
||||
};
|
||||
|
||||
// Filter states
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
|
||||
|
||||
// Extract unique values for filters
|
||||
const allTypes = useMemo(() => Array.from(new Set(mediaList.map(m => m.type).filter(Boolean))), [mediaList]);
|
||||
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
|
||||
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
|
||||
|
||||
const filteredMedia = useMemo(() => {
|
||||
return mediaList.filter(media => {
|
||||
if (selectedType && media.type !== selectedType) return false;
|
||||
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
|
||||
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [mediaList, selectedType, selectedGenre, selectedStudio]);
|
||||
|
||||
// Reset to first page when mediaList or filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filteredMedia, sortBy]);
|
||||
|
||||
const sortedMedia = useMemo(() => {
|
||||
const list = [...filteredMedia];
|
||||
if (sortBy === 'title-asc') {
|
||||
return list.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
if (sortBy === 'title-desc') {
|
||||
return list.sort((a, b) => b.title.localeCompare(a.title));
|
||||
}
|
||||
return list;
|
||||
}, [filteredMedia, sortBy]);
|
||||
|
||||
const totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
|
||||
|
||||
const paginatedMedia = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
return sortedMedia.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [sortedMedia, currentPage, itemsPerPage]);
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setCurrentPage((prev) => Math.max(prev - 1, 1));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto">
|
||||
{/* Filters Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Type Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedType ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<Filter size={16} />
|
||||
{selectedType || 'Media Type'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setSelectedType(null)}>All Types</DropdownMenuItem>
|
||||
{allTypes.map(type => (
|
||||
<DropdownMenuItem key={type} onClick={() => setSelectedType(type!)}>{type}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Genre Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<Star size={16} />
|
||||
{selectedGenre || 'Genres'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
|
||||
{allGenres.sort().map(genre => (
|
||||
<DropdownMenuItem key={genre} onClick={() => setSelectedGenre(genre)}>{genre}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Studio Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
Studios
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
|
||||
{allStudios.sort().map(studio => (
|
||||
<DropdownMenuItem key={studio} onClick={() => setSelectedStudio(studio)}>{studio}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{(selectedType || selectedGenre || selectedStudio) && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-zinc-400 font-bold"
|
||||
onClick={() => {
|
||||
setSelectedType(null);
|
||||
setSelectedGenre(null);
|
||||
setSelectedStudio(null);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black rounded-full px-6 h-11 shadow-lg shadow-[#6d28d9]/20 gap-2">
|
||||
<Plus size={20} />
|
||||
ADD NEW
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
|
||||
<form onSubmit={handleAddSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-black text-zinc-900">Add New Media</DialogTitle>
|
||||
<DialogDescription className="text-zinc-500 font-medium">
|
||||
Manually add a new item to your {activeCategory} library.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="year" className="text-sm font-black text-zinc-700">Year</Label>
|
||||
<Input
|
||||
id="year"
|
||||
value={newMedia.year}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
||||
placeholder="2024"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category" className="text-sm font-black text-zinc-700">Category</Label>
|
||||
<select
|
||||
id="category"
|
||||
value={newMedia.category}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'].map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">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-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="2/3">2:3 (Standard Poster - Anime/Movies)</option>
|
||||
<option value="16/9">16:9 (Wide Thumbnail - Games/Adult)</option>
|
||||
<option value="1/1">1:1 (Square - Music)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="poster" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20">
|
||||
SAVE TO LIBRARY
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-zinc-600 font-bold gap-2">
|
||||
<ArrowUpDown size={16} />
|
||||
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('title-asc')}>Title (A-Z)</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('title-desc')}>Title (Z-A)</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center bg-zinc-100 rounded-md p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all",
|
||||
viewMode === 'grid' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400"
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all",
|
||||
viewMode === 'list' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400"
|
||||
)}
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{mediaList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
|
||||
<div className="w-16 h-16 bg-zinc-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Filter size={32} />
|
||||
</div>
|
||||
<p className="text-lg font-bold">No results found</p>
|
||||
<p className="text-sm">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-x-4 gap-y-8"
|
||||
: "flex flex-col gap-2"
|
||||
)}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{paginatedMedia.map((media) => (
|
||||
viewMode === 'grid' ? (
|
||||
<MediaCard
|
||||
key={media.id}
|
||||
media={media}
|
||||
onClick={onMediaClick}
|
||||
/>
|
||||
) : (
|
||||
<MediaListItem
|
||||
key={media.id}
|
||||
media={media}
|
||||
onClick={onMediaClick}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{mediaList.length > 0 && (
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-zinc-100 pt-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-zinc-500 font-medium">Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{[8, 12, 16, 24, 48].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
|
||||
<span className="text-sm text-zinc-400 font-medium">of</span>
|
||||
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user