first commit

This commit is contained in:
Lars Behrends
2026-04-09 10:29:11 +02:00
commit dda118a2f7
36 changed files with 14470 additions and 0 deletions

View 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>
);
}