add media page
This commit is contained in:
20
src/App.tsx
20
src/App.tsx
@@ -10,17 +10,18 @@ import BrowseView from './components/BrowseView';
|
|||||||
import DetailView from './components/DetailView';
|
import DetailView from './components/DetailView';
|
||||||
import CastView from './components/CastView';
|
import CastView from './components/CastView';
|
||||||
import CastDetailView from './components/CastDetailView';
|
import CastDetailView from './components/CastDetailView';
|
||||||
|
import AddMediaView from './components/AddMediaView';
|
||||||
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
||||||
import { Media, Staff, MediaCategory } from './types';
|
import { Media, Staff, MediaCategory } from './types';
|
||||||
import { fetchAllMedia, fetchMediaById } from './api';
|
import { fetchAllMedia, fetchMediaById } from './api';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail'>('browse');
|
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail' | 'add'>('browse');
|
||||||
const [activeCategory, setActiveCategory] = useState<MediaCategory>('Anime');
|
const [activeCategory, setActiveCategory] = useState<MediaCategory>('Anime');
|
||||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||||
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
||||||
const [customMedia, setCustomMedia] = useState<Media[]>([]);
|
const [customMedia, setCustomMedia] = useState<Media[]>([]);
|
||||||
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
|
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
|
||||||
|
|
||||||
@@ -61,6 +62,11 @@ export default function App() {
|
|||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddMediaView = () => {
|
||||||
|
setCurrentView('add');
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
const allMedia = useMemo(() => {
|
const allMedia = useMemo(() => {
|
||||||
// Use API data if available, otherwise fall back to mock data
|
// Use API data if available, otherwise fall back to mock data
|
||||||
let list: Media[] = [];
|
let list: Media[] = [];
|
||||||
@@ -83,7 +89,7 @@ export default function App() {
|
|||||||
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
|
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
|
||||||
}, [activeCategory, enabledCategories, customMedia, apiMedia]);
|
}, [activeCategory, enabledCategories, customMedia, apiMedia]);
|
||||||
|
|
||||||
const handleAddMedia = async (newMedia: Media) => {
|
const handleAddMedia = async () => {
|
||||||
// Reload all media from API to get the newly added item
|
// Reload all media from API to get the newly added item
|
||||||
try {
|
try {
|
||||||
const media = await fetchAllMedia();
|
const media = await fetchAllMedia();
|
||||||
@@ -196,6 +202,7 @@ export default function App() {
|
|||||||
<Header
|
<Header
|
||||||
onBrowse={handleBack}
|
onBrowse={handleBack}
|
||||||
onCast={handleCastClick}
|
onCast={handleCastClick}
|
||||||
|
onAddMedia={handleAddMediaView}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
activeCategory={activeCategory}
|
activeCategory={activeCategory}
|
||||||
onCategoryChange={handleCategoryChange}
|
onCategoryChange={handleCategoryChange}
|
||||||
@@ -210,7 +217,6 @@ export default function App() {
|
|||||||
<BrowseView
|
<BrowseView
|
||||||
mediaList={filteredMedia}
|
mediaList={filteredMedia}
|
||||||
onMediaClick={handleMediaClick}
|
onMediaClick={handleMediaClick}
|
||||||
onAddMedia={handleAddMedia}
|
|
||||||
activeCategory={activeCategory}
|
activeCategory={activeCategory}
|
||||||
/>
|
/>
|
||||||
) : currentView === 'cast' ? (
|
) : currentView === 'cast' ? (
|
||||||
@@ -230,6 +236,12 @@ export default function App() {
|
|||||||
relatedMedia={allMedia.filter(m => m.staff?.some(s => s.id === selectedPerson.id))}
|
relatedMedia={allMedia.filter(m => m.staff?.some(s => s.id === selectedPerson.id))}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
) : currentView === 'add' ? (
|
||||||
|
<AddMediaView
|
||||||
|
activeCategory={activeCategory}
|
||||||
|
onBack={handleBack}
|
||||||
|
onAddComplete={handleAddMedia}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
selectedMedia && (
|
selectedMedia && (
|
||||||
<DetailView
|
<DetailView
|
||||||
|
|||||||
@@ -176,15 +176,15 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map API category to MediaCategory
|
// Map API category to MediaCategory
|
||||||
let mediaCategory: 'Anime' | 'Movies' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games' = 'Movies';
|
let mediaCategory: 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games' = 'Movies';
|
||||||
const apiCategory = apiItem.category?.toLowerCase();
|
const apiCategory = apiItem.category?.toLowerCase();
|
||||||
|
|
||||||
console.log('API Category:', apiItem.category, 'Lowercased:', apiCategory, 'Type:', apiType);
|
|
||||||
|
|
||||||
if (apiCategory === 'anime') {
|
if (apiCategory === 'anime') {
|
||||||
mediaCategory = 'Anime';
|
mediaCategory = 'Anime';
|
||||||
} else if (apiCategory === 'movie' || apiCategory === 'movies') {
|
} else if (apiCategory === 'movie' || apiCategory === 'movies') {
|
||||||
mediaCategory = 'Movies';
|
mediaCategory = 'Movies';
|
||||||
|
} else if (apiCategory === 'tv' || apiCategory === 'series' || apiCategory === 'tv series' || apiType === 'tv' || apiType === 'episode') {
|
||||||
|
mediaCategory = 'TV Series';
|
||||||
} else if (apiCategory === 'music' || apiType === 'album' || apiType === 'single') {
|
} else if (apiCategory === 'music' || apiType === 'album' || apiType === 'single') {
|
||||||
mediaCategory = 'Music';
|
mediaCategory = 'Music';
|
||||||
} else if (apiCategory === 'book' || apiCategory === 'books' || apiType === 'hardcover' || apiType === 'e-book') {
|
} else if (apiCategory === 'book' || apiCategory === 'books' || apiType === 'hardcover' || apiType === 'e-book') {
|
||||||
@@ -202,8 +202,6 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
|||||||
mediaCategory = 'Movies';
|
mediaCategory = 'Movies';
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Mapped to:', mediaCategory);
|
|
||||||
|
|
||||||
// Map API status to Media status allowed values
|
// Map API status to Media status allowed values
|
||||||
let mediaStatus: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold' = 'completed';
|
let mediaStatus: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold' = 'completed';
|
||||||
const apiStatus = apiItem.status?.toLowerCase();
|
const apiStatus = apiItem.status?.toLowerCase();
|
||||||
|
|||||||
421
src/components/AddMediaView.tsx
Normal file
421
src/components/AddMediaView.tsx
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { MediaCategory } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { createMedia, type CreateMediaInput } from '@/api';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AddMediaViewProps {
|
||||||
|
activeCategory: MediaCategory;
|
||||||
|
onBack: () => void;
|
||||||
|
onAddComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddMediaView({ activeCategory, onBack, onAddComplete }: AddMediaViewProps) {
|
||||||
|
const [newMedia, setNewMedia] = useState({
|
||||||
|
title: '',
|
||||||
|
year: '',
|
||||||
|
poster: '',
|
||||||
|
banner: '',
|
||||||
|
description: '',
|
||||||
|
rating: '',
|
||||||
|
category: activeCategory,
|
||||||
|
type: 'Movie' as string,
|
||||||
|
status: 'Released' as string,
|
||||||
|
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1',
|
||||||
|
runtime: '',
|
||||||
|
director: '',
|
||||||
|
writer: '',
|
||||||
|
releaseDate: '',
|
||||||
|
genres: '' as string,
|
||||||
|
tags: '' as string,
|
||||||
|
studios: '' as string
|
||||||
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
// Update category, default aspect ratio, and default type when activeCategory changes
|
||||||
|
useEffect(() => {
|
||||||
|
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
|
||||||
|
let defaultType = 'Movie';
|
||||||
|
|
||||||
|
if (activeCategory === 'Music') {
|
||||||
|
defaultAspect = '1/1';
|
||||||
|
defaultType = 'Album';
|
||||||
|
} else if (activeCategory === 'Games') {
|
||||||
|
defaultAspect = '16/9';
|
||||||
|
defaultType = 'Game';
|
||||||
|
} else if (activeCategory === 'Adult') {
|
||||||
|
defaultAspect = '16/9';
|
||||||
|
defaultType = 'Movie';
|
||||||
|
} else if (activeCategory === 'Anime') {
|
||||||
|
defaultType = 'TV';
|
||||||
|
} else if (activeCategory === 'TV Series') {
|
||||||
|
defaultType = 'TV';
|
||||||
|
} else if (activeCategory === 'Books') {
|
||||||
|
defaultType = 'Hardcover';
|
||||||
|
} else if (activeCategory === 'Consoles') {
|
||||||
|
defaultType = 'Console';
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewMedia(prev => ({
|
||||||
|
...prev,
|
||||||
|
category: activeCategory,
|
||||||
|
aspectRatio: defaultAspect,
|
||||||
|
type: defaultType
|
||||||
|
}));
|
||||||
|
}, [activeCategory]);
|
||||||
|
|
||||||
|
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newMedia.title || !newMedia.poster) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setSubmitStatus('idle');
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
// Convert category from plural to singular to match API format
|
||||||
|
const categoryMap: Record<string, string> = {
|
||||||
|
'Anime': 'Anime',
|
||||||
|
'Movies': 'Movie',
|
||||||
|
'TV Series': 'TV',
|
||||||
|
'Music': 'Music',
|
||||||
|
'Books': 'Book',
|
||||||
|
'Consoles': 'Console',
|
||||||
|
'Games': 'Game',
|
||||||
|
'Adult': 'Adult'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaInput: CreateMediaInput = {
|
||||||
|
title: newMedia.title,
|
||||||
|
year: parseInt(newMedia.year) || new Date().getFullYear(),
|
||||||
|
poster: newMedia.poster,
|
||||||
|
banner: newMedia.banner || null,
|
||||||
|
description: newMedia.description || null,
|
||||||
|
rating: newMedia.rating ? parseFloat(newMedia.rating) : null,
|
||||||
|
category: categoryMap[newMedia.category] || newMedia.category,
|
||||||
|
type: newMedia.type,
|
||||||
|
status: newMedia.status,
|
||||||
|
aspectRatio: newMedia.aspectRatio,
|
||||||
|
runtime: newMedia.runtime ? parseInt(newMedia.runtime) : null,
|
||||||
|
director: newMedia.director || null,
|
||||||
|
writer: newMedia.writer || null,
|
||||||
|
releaseDate: newMedia.releaseDate || null,
|
||||||
|
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
|
||||||
|
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
|
||||||
|
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : []
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdMedia = await createMedia(mediaInput);
|
||||||
|
|
||||||
|
if (createdMedia) {
|
||||||
|
setSubmitStatus('success');
|
||||||
|
onAddComplete();
|
||||||
|
|
||||||
|
// Reset form after successful submission
|
||||||
|
setNewMedia({
|
||||||
|
title: '',
|
||||||
|
year: '',
|
||||||
|
poster: '',
|
||||||
|
banner: '',
|
||||||
|
description: '',
|
||||||
|
rating: '',
|
||||||
|
category: activeCategory,
|
||||||
|
type: 'Movie',
|
||||||
|
status: 'Released',
|
||||||
|
aspectRatio: '2/3',
|
||||||
|
runtime: '',
|
||||||
|
director: '',
|
||||||
|
writer: '',
|
||||||
|
releaseDate: '',
|
||||||
|
genres: '',
|
||||||
|
tags: '',
|
||||||
|
studios: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitStatus('error');
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : 'Failed to add media. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="mb-6 gap-2 text-zinc-600 hover:text-zinc-900"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Back to Browse
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-3xl shadow-xl p-8">
|
||||||
|
<h1 className="text-3xl font-black text-zinc-900 mb-2">Add New Media</h1>
|
||||||
|
<p className="text-zinc-500 font-medium mb-8">
|
||||||
|
Add a new item to your {activeCategory} library.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{submitStatus === 'success' && (
|
||||||
|
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl">
|
||||||
|
<p className="text-green-800 font-bold">✓ Successfully added to library!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitStatus === 'error' && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||||
|
<p className="text-red-800 font-bold">✗ Error: {errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleAddSubmit} className="space-y-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', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'].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-zinc-700">Type</Label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
value={newMedia.type}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{newMedia.category === 'Music' ? (
|
||||||
|
<>
|
||||||
|
<option value="Album">Album</option>
|
||||||
|
<option value="Single">Single</option>
|
||||||
|
</>
|
||||||
|
) : newMedia.category === 'Books' ? (
|
||||||
|
<>
|
||||||
|
<option value="Hardcover">Hardcover</option>
|
||||||
|
<option value="E-book">E-book</option>
|
||||||
|
</>
|
||||||
|
) : newMedia.category === 'Games' ? (
|
||||||
|
<>
|
||||||
|
<option value="Game">Game</option>
|
||||||
|
</>
|
||||||
|
) : newMedia.category === 'Consoles' ? (
|
||||||
|
<>
|
||||||
|
<option value="Console">Console</option>
|
||||||
|
</>
|
||||||
|
) : newMedia.category === 'TV Series' || newMedia.category === 'Anime' ? (
|
||||||
|
<>
|
||||||
|
<option value="TV">TV</option>
|
||||||
|
<option value="OVA">OVA</option>
|
||||||
|
<option value="ONA">ONA</option>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<option value="Movie">Movie</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="status" className="text-sm font-black text-zinc-700">Status</Label>
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
value={newMedia.status}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
||||||
|
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="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 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)</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-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 className="grid gap-2">
|
||||||
|
<Label htmlFor="banner" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="description" className="text-sm font-black text-zinc-700">Description (Optional)</Label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
value={newMedia.description}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
placeholder="Brief description..."
|
||||||
|
className="bg-zinc-50 border-zinc-100 rounded-xl p-3 h-20 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="rating" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="runtime" className="text-sm font-black text-zinc-700">Runtime (min)</Label>
|
||||||
|
<Input
|
||||||
|
id="runtime"
|
||||||
|
type="number"
|
||||||
|
value={newMedia.runtime}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
|
||||||
|
placeholder="120"
|
||||||
|
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="releaseDate" className="text-sm font-black text-zinc-700">Release Date</Label>
|
||||||
|
<Input
|
||||||
|
id="releaseDate"
|
||||||
|
type="date"
|
||||||
|
value={newMedia.releaseDate}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
|
||||||
|
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="director" className="text-sm font-black text-zinc-700">Director</Label>
|
||||||
|
<Input
|
||||||
|
id="director"
|
||||||
|
value={newMedia.director}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
||||||
|
placeholder="Director name"
|
||||||
|
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="writer" className="text-sm font-black text-zinc-700">Writer</Label>
|
||||||
|
<Input
|
||||||
|
id="writer"
|
||||||
|
value={newMedia.writer}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
||||||
|
placeholder="Writer name"
|
||||||
|
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="genres" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="tags" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="studios" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,158 +1,30 @@
|
|||||||
import { Media, MediaCategory } from '@/types';
|
import { Media, MediaCategory } from '@/types';
|
||||||
import MediaCard from './MediaCard';
|
import MediaCard from './MediaCard';
|
||||||
import MediaListItem from './MediaListItem';
|
import MediaListItem from './MediaListItem';
|
||||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Plus, Search } from 'lucide-react';
|
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { createMedia, type CreateMediaInput } from '@/api';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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 { cn } from '@/lib/utils';
|
||||||
import { AnimatePresence } from 'motion/react';
|
import { AnimatePresence } from 'motion/react';
|
||||||
|
|
||||||
interface BrowseViewProps {
|
interface BrowseViewProps {
|
||||||
mediaList: Media[];
|
mediaList: Media[];
|
||||||
onMediaClick: (media: Media) => void;
|
onMediaClick: (media: Media) => void;
|
||||||
onAddMedia: (media: Media) => void;
|
|
||||||
activeCategory: MediaCategory;
|
activeCategory: MediaCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BrowseView({ mediaList, onMediaClick, onAddMedia, activeCategory }: BrowseViewProps) {
|
export default function BrowseView({ mediaList, onMediaClick, activeCategory }: BrowseViewProps) {
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
const [itemsPerPage, setItemsPerPage] = useState(12);
|
||||||
const [sortBy, setSortBy] = useState<string>('default');
|
const [sortBy, setSortBy] = useState<string>('default');
|
||||||
|
|
||||||
// Add Media Dialog State
|
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
|
||||||
const [newMedia, setNewMedia] = useState({
|
|
||||||
title: '',
|
|
||||||
year: '',
|
|
||||||
poster: '',
|
|
||||||
banner: '',
|
|
||||||
description: '',
|
|
||||||
rating: '',
|
|
||||||
category: activeCategory as MediaCategory,
|
|
||||||
type: 'Movie' as string,
|
|
||||||
status: 'Released' as string,
|
|
||||||
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1',
|
|
||||||
runtime: '',
|
|
||||||
director: '',
|
|
||||||
writer: '',
|
|
||||||
releaseDate: '',
|
|
||||||
genres: '' as string,
|
|
||||||
tags: '' as string,
|
|
||||||
studios: '' as string
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update category, default aspect ratio, and default type when activeCategory changes
|
|
||||||
useEffect(() => {
|
|
||||||
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
|
|
||||||
let defaultType = 'Movie';
|
|
||||||
|
|
||||||
if (activeCategory === 'Music') {
|
|
||||||
defaultAspect = '1/1';
|
|
||||||
defaultType = 'Album';
|
|
||||||
} else if (activeCategory === 'Games') {
|
|
||||||
defaultAspect = '16/9';
|
|
||||||
defaultType = 'Game';
|
|
||||||
} else if (activeCategory === 'Adult') {
|
|
||||||
defaultAspect = '16/9';
|
|
||||||
defaultType = 'Movie';
|
|
||||||
} else if (activeCategory === 'Anime') {
|
|
||||||
defaultType = 'TV';
|
|
||||||
} else if (activeCategory === 'Books') {
|
|
||||||
defaultType = 'Hardcover';
|
|
||||||
} else if (activeCategory === 'Consoles') {
|
|
||||||
defaultType = 'Console';
|
|
||||||
}
|
|
||||||
|
|
||||||
setNewMedia(prev => ({
|
|
||||||
...prev,
|
|
||||||
category: activeCategory,
|
|
||||||
aspectRatio: defaultAspect,
|
|
||||||
type: defaultType
|
|
||||||
}));
|
|
||||||
}, [activeCategory]);
|
|
||||||
|
|
||||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newMedia.title || !newMedia.poster) return;
|
|
||||||
|
|
||||||
// Convert category from plural to singular to match API format
|
|
||||||
const categoryMap: Record<string, string> = {
|
|
||||||
'Anime': 'Anime',
|
|
||||||
'Movies': 'Movie',
|
|
||||||
'Music': 'Music',
|
|
||||||
'Books': 'Book',
|
|
||||||
'Consoles': 'Console',
|
|
||||||
'Games': 'Game',
|
|
||||||
'Adult': 'Adult'
|
|
||||||
};
|
|
||||||
|
|
||||||
const mediaInput: CreateMediaInput = {
|
|
||||||
title: newMedia.title,
|
|
||||||
year: parseInt(newMedia.year) || new Date().getFullYear(),
|
|
||||||
poster: newMedia.poster,
|
|
||||||
banner: newMedia.banner || null,
|
|
||||||
description: newMedia.description || null,
|
|
||||||
rating: newMedia.rating ? parseFloat(newMedia.rating) : null,
|
|
||||||
category: categoryMap[newMedia.category] || newMedia.category,
|
|
||||||
type: newMedia.type,
|
|
||||||
status: newMedia.status,
|
|
||||||
aspectRatio: newMedia.aspectRatio,
|
|
||||||
runtime: newMedia.runtime ? parseInt(newMedia.runtime) : null,
|
|
||||||
director: newMedia.director || null,
|
|
||||||
writer: newMedia.writer || null,
|
|
||||||
releaseDate: newMedia.releaseDate || null,
|
|
||||||
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
|
|
||||||
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
|
|
||||||
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : []
|
|
||||||
};
|
|
||||||
|
|
||||||
const createdMedia = await createMedia(mediaInput);
|
|
||||||
|
|
||||||
if (createdMedia) {
|
|
||||||
onAddMedia(createdMedia);
|
|
||||||
}
|
|
||||||
|
|
||||||
setNewMedia({
|
|
||||||
title: '',
|
|
||||||
year: '',
|
|
||||||
poster: '',
|
|
||||||
banner: '',
|
|
||||||
description: '',
|
|
||||||
rating: '',
|
|
||||||
category: activeCategory,
|
|
||||||
type: 'Movie',
|
|
||||||
status: 'Released',
|
|
||||||
aspectRatio: '2/3',
|
|
||||||
runtime: '',
|
|
||||||
director: '',
|
|
||||||
writer: '',
|
|
||||||
releaseDate: '',
|
|
||||||
genres: '',
|
|
||||||
tags: '',
|
|
||||||
studios: ''
|
|
||||||
});
|
|
||||||
setIsAddDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter states
|
// Filter states
|
||||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||||
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
|
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
|
||||||
@@ -254,261 +126,6 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 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-4 py-6 max-h-[60vh] overflow-y-auto">
|
|
||||||
<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 grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="type" className="text-sm font-black text-zinc-700">Type</Label>
|
|
||||||
<select
|
|
||||||
id="type"
|
|
||||||
value={newMedia.type}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{newMedia.category === 'Music' ? (
|
|
||||||
<>
|
|
||||||
<option value="Album">Album</option>
|
|
||||||
<option value="Single">Single</option>
|
|
||||||
</>
|
|
||||||
) : newMedia.category === 'Books' ? (
|
|
||||||
<>
|
|
||||||
<option value="Hardcover">Hardcover</option>
|
|
||||||
<option value="E-book">E-book</option>
|
|
||||||
</>
|
|
||||||
) : newMedia.category === 'Games' ? (
|
|
||||||
<>
|
|
||||||
<option value="Game">Game</option>
|
|
||||||
</>
|
|
||||||
) : newMedia.category === 'Consoles' ? (
|
|
||||||
<>
|
|
||||||
<option value="Console">Console</option>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<option value="TV">TV</option>
|
|
||||||
<option value="Movie">Movie</option>
|
|
||||||
<option value="OVA">OVA</option>
|
|
||||||
<option value="ONA">ONA</option>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="status" className="text-sm font-black text-zinc-700">Status</Label>
|
|
||||||
<select
|
|
||||||
id="status"
|
|
||||||
value={newMedia.status}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
|
||||||
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="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 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)</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-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 className="grid gap-2">
|
|
||||||
<Label htmlFor="banner" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="description" className="text-sm font-black text-zinc-700">Description (Optional)</Label>
|
|
||||||
<textarea
|
|
||||||
id="description"
|
|
||||||
value={newMedia.description}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
|
||||||
placeholder="Brief description..."
|
|
||||||
className="bg-zinc-50 border-zinc-100 rounded-xl p-3 h-20 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="rating" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'Adult') && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="runtime" className="text-sm font-black text-zinc-700">Runtime (min)</Label>
|
|
||||||
<Input
|
|
||||||
id="runtime"
|
|
||||||
type="number"
|
|
||||||
value={newMedia.runtime}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
|
|
||||||
placeholder="120"
|
|
||||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="releaseDate" className="text-sm font-black text-zinc-700">Release Date</Label>
|
|
||||||
<Input
|
|
||||||
id="releaseDate"
|
|
||||||
type="date"
|
|
||||||
value={newMedia.releaseDate}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
|
|
||||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="director" className="text-sm font-black text-zinc-700">Director</Label>
|
|
||||||
<Input
|
|
||||||
id="director"
|
|
||||||
value={newMedia.director}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
|
||||||
placeholder="Director name"
|
|
||||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="writer" className="text-sm font-black text-zinc-700">Writer</Label>
|
|
||||||
<Input
|
|
||||||
id="writer"
|
|
||||||
value={newMedia.writer}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
|
||||||
placeholder="Writer name"
|
|
||||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="genres" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="tags" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="studios" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
|
||||||
/>
|
|
||||||
</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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 text-zinc-600 font-bold gap-2">
|
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 text-zinc-600 font-bold gap-2">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Search, User, X } from 'lucide-react';
|
import { Search, User, X, Plus } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { MediaCategory } from '@/types';
|
import { MediaCategory } from '@/types';
|
||||||
@@ -7,6 +7,7 @@ import LibrarySettings from './LibrarySettings';
|
|||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onBrowse: () => void;
|
onBrowse: () => void;
|
||||||
onCast: () => void;
|
onCast: () => void;
|
||||||
|
onAddMedia: () => void;
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
activeCategory: MediaCategory;
|
activeCategory: MediaCategory;
|
||||||
onCategoryChange: (category: MediaCategory) => void;
|
onCategoryChange: (category: MediaCategory) => void;
|
||||||
@@ -18,6 +19,7 @@ interface HeaderProps {
|
|||||||
export default function Header({
|
export default function Header({
|
||||||
onBrowse,
|
onBrowse,
|
||||||
onCast,
|
onCast,
|
||||||
|
onAddMedia,
|
||||||
onSearch,
|
onSearch,
|
||||||
activeCategory,
|
activeCategory,
|
||||||
onCategoryChange,
|
onCategoryChange,
|
||||||
@@ -101,6 +103,12 @@ export default function Header({
|
|||||||
>
|
>
|
||||||
{isSearchOpen ? <X size={20} /> : <Search size={20} />}
|
{isSearchOpen ? <X size={20} /> : <Search size={20} />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onAddMedia}
|
||||||
|
className="p-2 text-white/90 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
</button>
|
||||||
<LibrarySettings
|
<LibrarySettings
|
||||||
enabledCategories={enabledCategories}
|
enabledCategories={enabledCategories}
|
||||||
onToggleCategory={onToggleCategory}
|
onToggleCategory={onToggleCategory}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type MediaCategory = 'Anime' | 'Movies' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games';
|
export type MediaCategory = 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games';
|
||||||
|
|
||||||
export interface Media {
|
export interface Media {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user