Compare commits
13 Commits
07c3270e12
...
6250164656
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6250164656 | ||
|
|
9c7e5a2b19 | ||
|
|
dff599e5af | ||
|
|
6c316fbf84 | ||
|
|
0d530ea99c | ||
|
|
555209ed4b | ||
|
|
52d272c701 | ||
|
|
b36b72b8e0 | ||
|
|
53c6f5c555 | ||
|
|
f482807387 | ||
|
|
444c908449 | ||
|
|
b29732a653 | ||
|
|
96593a6235 |
91
src/App.tsx
91
src/App.tsx
@@ -14,14 +14,17 @@ import CastDetailView from './components/CastDetailView';
|
||||
import AddMediaView from './components/AddMediaView';
|
||||
import ImporterView from './components/ImporterView';
|
||||
import SettingsView from './components/SettingsView';
|
||||
import Loading from './components/ui/loading';
|
||||
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
||||
import { Media, Staff, MediaCategory, UserSettings } from './types';
|
||||
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
|
||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
|
||||
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
const [activeCategory, setActiveCategory] = useState<MediaCategory>(
|
||||
(searchParams.get('category') as MediaCategory) || 'Anime'
|
||||
);
|
||||
@@ -35,6 +38,7 @@ function AppContent() {
|
||||
|
||||
// Load media from API on component mount (only when not on cast routes)
|
||||
const [apiMedia, setApiMedia] = useState<Media[]>([]);
|
||||
const [mediaLoading, setMediaLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettingsFromApi = async () => {
|
||||
@@ -43,6 +47,8 @@ function AppContent() {
|
||||
if (loadedSettings) {
|
||||
setSettings(loadedSettings);
|
||||
setEnabledCategories(loadedSettings.enabledCategories);
|
||||
// Sync theme with theme context
|
||||
setTheme(loadedSettings.theme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings from API:', error);
|
||||
@@ -50,7 +56,7 @@ function AppContent() {
|
||||
};
|
||||
|
||||
loadSettingsFromApi();
|
||||
}, []);
|
||||
}, [setTheme]);
|
||||
|
||||
const reloadSettings = async () => {
|
||||
try {
|
||||
@@ -58,6 +64,8 @@ function AppContent() {
|
||||
if (loadedSettings) {
|
||||
setSettings(loadedSettings);
|
||||
setEnabledCategories(loadedSettings.enabledCategories);
|
||||
// Sync theme with theme context
|
||||
setTheme(loadedSettings.theme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reload settings from API:', error);
|
||||
@@ -66,11 +74,14 @@ function AppContent() {
|
||||
|
||||
useEffect(() => {
|
||||
const loadMediaFromApi = async () => {
|
||||
setMediaLoading(true);
|
||||
try {
|
||||
const media = await fetchAllMedia();
|
||||
setApiMedia(media);
|
||||
} catch (error) {
|
||||
console.error('Failed to load media from API:', error);
|
||||
} finally {
|
||||
setMediaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,6 +108,7 @@ function AppContent() {
|
||||
const baseSettings = settings || {
|
||||
enabledCategories: prev,
|
||||
itemsPerPage: 20,
|
||||
gridItemSize: 5,
|
||||
defaultView: 'grid',
|
||||
showAdultContent: false,
|
||||
autoPlayTrailers: false,
|
||||
@@ -166,6 +178,28 @@ function AppContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGridItemSizeChange = async (size: number) => {
|
||||
const baseSettings = settings || {
|
||||
enabledCategories: enabledCategories,
|
||||
itemsPerPage: 20,
|
||||
gridItemSize: 5,
|
||||
defaultView: 'grid',
|
||||
showAdultContent: false,
|
||||
autoPlayTrailers: false,
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
};
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
gridItemSize: size,
|
||||
};
|
||||
updateSettings(updatedSettings).then(saved => {
|
||||
if (saved) {
|
||||
setSettings(saved);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const allStaff = useMemo(() => {
|
||||
const staff: Staff[] = [];
|
||||
// Use API data if available, otherwise fall back to mock data
|
||||
@@ -270,7 +304,7 @@ function AppContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
|
||||
<div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
activeCategory={activeCategory}
|
||||
@@ -289,6 +323,9 @@ function AppContent() {
|
||||
onMediaClick={handleMediaClick}
|
||||
activeCategory={activeCategory}
|
||||
itemsPerPage={settings?.itemsPerPage}
|
||||
gridItemSize={settings?.gridItemSize}
|
||||
onGridItemSizeChange={handleGridItemSizeChange}
|
||||
loading={mediaLoading}
|
||||
/>
|
||||
} />
|
||||
<Route path="/media/:id" element={
|
||||
@@ -315,6 +352,7 @@ function AppContent() {
|
||||
<Route path="/add" element={
|
||||
<AddMediaView
|
||||
activeCategory={activeCategory}
|
||||
enabledCategories={enabledCategories}
|
||||
onAddComplete={handleAddMedia}
|
||||
/>
|
||||
} />
|
||||
@@ -329,18 +367,18 @@ function AppContent() {
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-6 border-t border-zinc-100 bg-zinc-50">
|
||||
<footer className="py-12 px-6 border-t border-border bg-muted/50">
|
||||
<div className="max-w-[1600px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2 text-xl font-black text-zinc-400">
|
||||
<div className="w-5 h-5 bg-zinc-300 rounded-full" />
|
||||
<div className="flex items-center gap-2 text-xl font-black text-muted-foreground">
|
||||
<div className="w-5 h-5 bg-muted rounded-full" />
|
||||
kyoo
|
||||
</div>
|
||||
<div className="flex items-center gap-8 text-sm font-bold text-zinc-400">
|
||||
<div className="flex items-center gap-8 text-sm font-bold text-muted-foreground">
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors">Terms</a>
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors">Privacy</a>
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors">Contact</a>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-zinc-400">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
© 2026 Kyoo Media Discovery. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
@@ -353,33 +391,31 @@ function AppContent() {
|
||||
function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMedia = async () => {
|
||||
if (id) {
|
||||
// First check if media is in allMedia
|
||||
const media = allMedia.find(m => m.id === id);
|
||||
if (media) {
|
||||
setSelectedMedia(media);
|
||||
} else {
|
||||
// If not found, fetch from API
|
||||
try {
|
||||
const fetchedMedia = await fetchMediaById(id);
|
||||
if (fetchedMedia) {
|
||||
setSelectedMedia(fetchedMedia);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch media:', error);
|
||||
setLoading(true);
|
||||
try {
|
||||
const fetchedMedia = await fetchMediaById(id);
|
||||
if (fetchedMedia) {
|
||||
setSelectedMedia(fetchedMedia);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch media:', error);
|
||||
navigate('/');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadMedia();
|
||||
}, [id, allMedia]);
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Loading message="Loading media details..." />;
|
||||
if (!selectedMedia) return null;
|
||||
|
||||
return (
|
||||
@@ -394,10 +430,12 @@ function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonC
|
||||
function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCast = async () => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const castData = await fetchCastById(id);
|
||||
if (castData) {
|
||||
@@ -409,12 +447,15 @@ function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to load cast:', error);
|
||||
navigate('/cast');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadCast();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Loading message="Loading cast details..." />;
|
||||
if (!selectedPerson) return null;
|
||||
|
||||
return (
|
||||
@@ -428,7 +469,9 @@ function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppContent />
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/api.ts
47
src/api.ts
@@ -27,6 +27,27 @@ export interface PaginatedResponse<T> {
|
||||
}
|
||||
|
||||
// Media Types
|
||||
export interface ApiEpisode {
|
||||
id: number;
|
||||
media_id: number;
|
||||
season: number;
|
||||
episode_number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
air_date: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ApiTrack {
|
||||
id: number;
|
||||
media_id: number;
|
||||
track_number: number;
|
||||
title: string;
|
||||
duration: number | null;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export interface ApiMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -43,6 +64,7 @@ export interface ApiMediaItem {
|
||||
director: string | null;
|
||||
writer: string | null;
|
||||
releaseDate: string | null;
|
||||
source?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
genres?: string[];
|
||||
@@ -53,10 +75,11 @@ export interface ApiMediaItem {
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
source?: string;
|
||||
playCount?: number;
|
||||
lastActivity?: string | null;
|
||||
playtime?: number;
|
||||
episodes?: ApiEpisode[];
|
||||
tracks?: ApiTrack[];
|
||||
}
|
||||
|
||||
export interface ApiStaff {
|
||||
@@ -87,6 +110,7 @@ export interface CreateMediaInput {
|
||||
director?: string | null;
|
||||
writer?: string | null;
|
||||
releaseDate?: string | null;
|
||||
source?: string | null;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
studios?: string[];
|
||||
@@ -309,6 +333,7 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
tags: apiItem.tags || [],
|
||||
studios: apiItem.studios,
|
||||
type: mediaType,
|
||||
source: apiItem.source || undefined,
|
||||
status: mediaStatus,
|
||||
staff: staff.length > 0 ? staff : undefined,
|
||||
aspectRatio: aspectRatio,
|
||||
@@ -316,10 +341,11 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
platforms: apiItem.platforms,
|
||||
developers: apiItem.developers,
|
||||
completionStatus: apiItem.completionStatus,
|
||||
source: apiItem.source,
|
||||
playCount: apiItem.playCount,
|
||||
lastActivity: apiItem.lastActivity,
|
||||
playtime: apiItem.playtime
|
||||
playtime: apiItem.playtime,
|
||||
episodes: apiItem.episodes,
|
||||
tracks: apiItem.tracks
|
||||
};
|
||||
}
|
||||
|
||||
@@ -631,11 +657,13 @@ export interface ApiSettingsItem {
|
||||
id?: number;
|
||||
enabled_categories: string[];
|
||||
items_per_page: number;
|
||||
grid_item_size?: number;
|
||||
default_view: string;
|
||||
show_adult_content: boolean;
|
||||
auto_play_trailers: boolean;
|
||||
language: string;
|
||||
theme: string;
|
||||
jellyfin_library_mappings?: string; // JSON string of LibraryMapping[]
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -643,11 +671,13 @@ export interface ApiSettingsItem {
|
||||
export interface CreateSettingsInput {
|
||||
enabled_categories: string[];
|
||||
items_per_page?: number;
|
||||
grid_item_size?: number;
|
||||
default_view?: string;
|
||||
show_adult_content?: boolean;
|
||||
auto_play_trailers?: boolean;
|
||||
language?: string;
|
||||
theme?: string;
|
||||
jellyfin_library_mappings?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
|
||||
@@ -657,11 +687,13 @@ export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
|
||||
id: apiItem.id,
|
||||
enabledCategories: apiItem.enabled_categories as MediaCategory[],
|
||||
itemsPerPage: apiItem.items_per_page || 20,
|
||||
gridItemSize: apiItem.grid_item_size || 5,
|
||||
defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid',
|
||||
showAdultContent: apiItem.show_adult_content || false,
|
||||
autoPlayTrailers: apiItem.auto_play_trailers || false,
|
||||
language: apiItem.language || 'en',
|
||||
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
|
||||
jellyfinLibraryMappings: apiItem.jellyfin_library_mappings,
|
||||
createdAt: apiItem.created_at,
|
||||
updatedAt: apiItem.updated_at,
|
||||
};
|
||||
@@ -671,11 +703,13 @@ export function convertSettingsToApi(settings: UserSettings): CreateSettingsInpu
|
||||
return {
|
||||
enabled_categories: settings.enabledCategories,
|
||||
items_per_page: settings.itemsPerPage,
|
||||
grid_item_size: settings.gridItemSize,
|
||||
default_view: settings.defaultView,
|
||||
show_adult_content: settings.showAdultContent,
|
||||
auto_play_trailers: settings.autoPlayTrailers,
|
||||
language: settings.language,
|
||||
theme: settings.theme,
|
||||
jellyfin_library_mappings: settings.jellyfinLibraryMappings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -705,7 +739,6 @@ export async function fetchSettings(): Promise<UserSettings | null> {
|
||||
export async function createSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||
try {
|
||||
const apiSettings = convertSettingsToApi(settings);
|
||||
console.log('Creating settings:', apiSettings);
|
||||
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -713,14 +746,12 @@ export async function createSettings(settings: UserSettings): Promise<UserSettin
|
||||
},
|
||||
body: JSON.stringify(apiSettings),
|
||||
});
|
||||
console.log('Create settings response status:', response.status);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Create settings error response:', errorText);
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||
console.log('Create settings response:', data);
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToSettings(data.data);
|
||||
@@ -735,7 +766,6 @@ export async function createSettings(settings: UserSettings): Promise<UserSettin
|
||||
export async function updateSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||
try {
|
||||
const apiSettings = convertSettingsToApi(settings);
|
||||
console.log('Updating settings:', apiSettings);
|
||||
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -743,11 +773,9 @@ export async function updateSettings(settings: UserSettings): Promise<UserSettin
|
||||
},
|
||||
body: JSON.stringify(apiSettings),
|
||||
});
|
||||
console.log('Update settings response status:', response.status);
|
||||
if (!response.ok) {
|
||||
// If settings don't exist (404), try creating them instead
|
||||
if (response.status === 404) {
|
||||
console.log('Settings not found, attempting to create...');
|
||||
return createSettings(settings);
|
||||
}
|
||||
const errorText = await response.text();
|
||||
@@ -755,7 +783,6 @@ export async function updateSettings(settings: UserSettings): Promise<UserSettin
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||
console.log('Update settings response:', data);
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToSettings(data.data);
|
||||
|
||||
@@ -10,10 +10,11 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
interface AddMediaViewProps {
|
||||
activeCategory: MediaCategory;
|
||||
enabledCategories: MediaCategory[];
|
||||
onAddComplete: () => void;
|
||||
}
|
||||
|
||||
export default function AddMediaView({ activeCategory, onAddComplete }: AddMediaViewProps) {
|
||||
export default function AddMediaView({ activeCategory, enabledCategories, onAddComplete }: AddMediaViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [newMedia, setNewMedia] = useState({
|
||||
title: '',
|
||||
@@ -30,6 +31,7 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
|
||||
director: '',
|
||||
writer: '',
|
||||
releaseDate: '',
|
||||
source: '' as string,
|
||||
genres: '' as string,
|
||||
tags: '' as string,
|
||||
studios: '' as string
|
||||
@@ -37,6 +39,29 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [staff, setStaff] = useState<Array<{ name: string; role: string; characterName?: string; photo?: string }>>([]);
|
||||
|
||||
const addStaffMember = () => {
|
||||
const nameInput = document.getElementById('staffName') as HTMLInputElement;
|
||||
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
|
||||
const characterInput = document.getElementById('staffCharacter') as HTMLInputElement;
|
||||
const photoInput = document.getElementById('staffPhoto') as HTMLInputElement;
|
||||
|
||||
if (nameInput?.value && roleInput?.value) {
|
||||
setStaff(prev => [...prev, {
|
||||
name: nameInput.value,
|
||||
role: roleInput.value,
|
||||
characterName: characterInput?.value || undefined,
|
||||
photo: photoInput?.value || undefined
|
||||
}]);
|
||||
|
||||
// Clear the form
|
||||
if (nameInput) nameInput.value = '';
|
||||
if (roleInput) roleInput.value = '';
|
||||
if (characterInput) characterInput.value = '';
|
||||
if (photoInput) photoInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Update category, default aspect ratio, and default type when activeCategory changes
|
||||
useEffect(() => {
|
||||
@@ -105,9 +130,16 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
|
||||
director: newMedia.director || null,
|
||||
writer: newMedia.writer || null,
|
||||
releaseDate: newMedia.releaseDate || null,
|
||||
source: newMedia.source || 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()) : []
|
||||
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : [],
|
||||
staff: staff.length > 0 ? staff.map(s => ({
|
||||
name: s.name,
|
||||
role: s.role,
|
||||
characterName: s.characterName || null,
|
||||
photo: s.photo || null
|
||||
})) : undefined
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -133,10 +165,12 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
|
||||
director: '',
|
||||
writer: '',
|
||||
releaseDate: '',
|
||||
source: '',
|
||||
genres: '',
|
||||
tags: '',
|
||||
studios: ''
|
||||
});
|
||||
setStaff([]);
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitStatus('error');
|
||||
@@ -151,62 +185,62 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/')}
|
||||
className="mb-6 gap-2 text-zinc-600 hover:text-zinc-900"
|
||||
className="mb-6 gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<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">
|
||||
<div className="bg-card rounded-3xl shadow-xl p-8 border border-border">
|
||||
<h1 className="text-3xl font-black text-foreground mb-2">Add New Media</h1>
|
||||
<p className="text-muted-foreground 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 className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl">
|
||||
<p className="text-green-500 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 className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl">
|
||||
<p className="text-red-500 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>
|
||||
<Label htmlFor="title" className="text-sm font-black text-foreground">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newMedia.title}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="e.g. Mob Psycho 100"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-muted border-border 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>
|
||||
<Label htmlFor="year" className="text-sm font-black text-foreground">Year</Label>
|
||||
<Input
|
||||
id="year"
|
||||
value={newMedia.year}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
||||
placeholder="2024"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-muted border-border 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>
|
||||
<Label htmlFor="category" className="text-sm font-black text-foreground">Category</Label>
|
||||
<select
|
||||
id="category"
|
||||
value={newMedia.category}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||
className="bg-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"
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'].map(cat => (
|
||||
{enabledCategories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -214,12 +248,12 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
|
||||
</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>
|
||||
<Label htmlFor="type" className="text-sm font-black text-foreground">Type</Label>
|
||||
<select
|
||||
id="type"
|
||||
value={newMedia.type}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="bg-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"
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{newMedia.category === 'Music' ? (
|
||||
<>
|
||||
@@ -253,12 +287,12 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status" className="text-sm font-black text-zinc-700">Status</Label>
|
||||
<Label htmlFor="status" className="text-sm font-black text-foreground">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
value={newMedia.status}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
||||
className="bg-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"
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="Released">Released</option>
|
||||
<option value="Ongoing">Ongoing</option>
|
||||
@@ -274,12 +308,12 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">Aspect Ratio (Format)</Label>
|
||||
<Label htmlFor="aspectRatio" className="text-sm font-black text-foreground">Aspect Ratio (Format)</Label>
|
||||
<select
|
||||
id="aspectRatio"
|
||||
value={newMedia.aspectRatio}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
||||
className="bg-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"
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground 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>
|
||||
@@ -287,38 +321,38 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="poster" className="text-sm font-black text-zinc-700">Poster URL</Label>
|
||||
<Label htmlFor="poster" className="text-sm font-black text-foreground">Poster URL</Label>
|
||||
<Input
|
||||
id="poster"
|
||||
value={newMedia.poster}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||
placeholder="https://example.com/poster.jpg"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-muted border-border 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>
|
||||
<Label htmlFor="banner" className="text-sm font-black text-foreground">Banner URL (Optional)</Label>
|
||||
<Input
|
||||
id="banner"
|
||||
value={newMedia.banner}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
||||
placeholder="https://example.com/banner.jpg"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-muted border-border 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>
|
||||
<Label htmlFor="description" className="text-sm font-black text-foreground">Description (Optional)</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={newMedia.description}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Brief description..."
|
||||
className="bg-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"
|
||||
className="bg-muted border-border rounded-xl p-3 h-20 text-sm font-bold text-foreground 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>
|
||||
<Label htmlFor="rating" className="text-sm font-black text-foreground">Rating (Optional)</Label>
|
||||
<Input
|
||||
id="rating"
|
||||
type="number"
|
||||
@@ -328,86 +362,202 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
|
||||
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]"
|
||||
className="bg-muted border-border 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>
|
||||
<Label htmlFor="runtime" className="text-sm font-black text-foreground">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]"
|
||||
className="bg-muted border-border 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>
|
||||
<Label htmlFor="releaseDate" className="text-sm font-black text-foreground">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]"
|
||||
className="bg-muted border-border 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>
|
||||
<Label htmlFor="director" className="text-sm font-black text-foreground">Director</Label>
|
||||
<Input
|
||||
id="director"
|
||||
value={newMedia.director}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
||||
placeholder="Director name"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-muted border-border 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>
|
||||
<Label htmlFor="writer" className="text-sm font-black text-foreground">Writer</Label>
|
||||
<Input
|
||||
id="writer"
|
||||
value={newMedia.writer}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
||||
placeholder="Writer name"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-muted border-border 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>
|
||||
<Label htmlFor="genres" className="text-sm font-black text-foreground">Genres (comma-separated)</Label>
|
||||
<Input
|
||||
id="genres"
|
||||
value={newMedia.genres}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
||||
placeholder="Action, Drama, Sci-Fi"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-muted border-border 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>
|
||||
<Label htmlFor="tags" className="text-sm font-black text-foreground">Tags (comma-separated)</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={newMedia.tags}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
||||
placeholder="Classic, Best-selling"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-muted border-border 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>
|
||||
<Label htmlFor="studios" className="text-sm font-black text-foreground">Studios (comma-separated)</Label>
|
||||
<Input
|
||||
id="studios"
|
||||
value={newMedia.studios}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
||||
placeholder="Studio A, Studio B"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="source" className="text-sm font-black text-foreground">Source / Quelle</Label>
|
||||
<Input
|
||||
id="source"
|
||||
value={newMedia.source}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
|
||||
placeholder="e.g. username, xbvr, stashapp"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cast/Staff Section */}
|
||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-black text-foreground">Cast & Crew</Label>
|
||||
</div>
|
||||
|
||||
{/* Staff List */}
|
||||
{staff.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{staff.map((member, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-muted/50 rounded-xl border border-border">
|
||||
{member.photo && (
|
||||
<img
|
||||
src={member.photo}
|
||||
alt={member.name}
|
||||
className="w-12 h-12 rounded-lg object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-foreground truncate">{member.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.role}{member.characterName ? ` as ${member.characterName}` : ''}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setStaff(prev => prev.filter((_, i) => i !== index))}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-red-500"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Staff Form */}
|
||||
<div className="grid gap-3 p-4 bg-muted/30 rounded-xl border border-border">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffName" className="text-xs font-black text-foreground">Name</Label>
|
||||
<Input
|
||||
id="staffName"
|
||||
placeholder="Actor name"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const input = e.target as HTMLInputElement;
|
||||
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
|
||||
if (input.value && roleInput?.value) {
|
||||
addStaffMember();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffRole" className="text-xs font-black text-foreground">Role</Label>
|
||||
<Input
|
||||
id="staffRole"
|
||||
placeholder="e.g. Actor, Director"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const input = e.target as HTMLInputElement;
|
||||
const nameInput = document.getElementById('staffName') as HTMLInputElement;
|
||||
if (input.value && nameInput?.value) {
|
||||
addStaffMember();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffCharacter" className="text-xs font-black text-foreground">Character (optional)</Label>
|
||||
<Input
|
||||
id="staffCharacter"
|
||||
placeholder="Character name"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffPhoto" className="text-xs font-black text-foreground">Photo URL (optional)</Label>
|
||||
<Input
|
||||
id="staffPhoto"
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addStaffMember}
|
||||
variant="outline"
|
||||
className="w-full border-border text-sm font-bold"
|
||||
>
|
||||
+ Add Cast Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import MediaCard from './MediaCard';
|
||||
import MediaListItem from './MediaListItem';
|
||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree } from 'lucide-react';
|
||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree, Tag } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Loading from '@/components/ui/loading';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -18,20 +19,39 @@ interface BrowseViewProps {
|
||||
onMediaClick: (media: Media) => void;
|
||||
activeCategory: MediaCategory;
|
||||
itemsPerPage?: number;
|
||||
gridItemSize?: number;
|
||||
onGridItemSizeChange?: (size: number) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12 }: BrowseViewProps) {
|
||||
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12, gridItemSize: initialGridItemSize = 5, onGridItemSizeChange, loading = false }: BrowseViewProps) {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||
const [sortBy, setSortBy] = useState<string>('default');
|
||||
|
||||
const [gridItemSize, setGridItemSize] = useState<number>(initialGridItemSize);
|
||||
|
||||
// Sync itemsPerPage with prop when API settings are loaded
|
||||
useEffect(() => {
|
||||
if (initialItemsPerPage) {
|
||||
setItemsPerPage(initialItemsPerPage);
|
||||
}
|
||||
}, [initialItemsPerPage]);
|
||||
|
||||
// Sync gridItemSize with prop when API settings are loaded
|
||||
useEffect(() => {
|
||||
if (initialGridItemSize !== undefined) {
|
||||
setGridItemSize(initialGridItemSize);
|
||||
}
|
||||
}, [initialGridItemSize]);
|
||||
|
||||
// Filter states
|
||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
|
||||
// Extract unique values for filters
|
||||
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
|
||||
@@ -39,6 +59,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]);
|
||||
const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]);
|
||||
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]);
|
||||
const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]);
|
||||
|
||||
const filteredMedia = useMemo(() => {
|
||||
return mediaList.filter(media => {
|
||||
@@ -47,9 +68,10 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
|
||||
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
|
||||
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
|
||||
if (selectedSource && media.source !== selectedSource) return false;
|
||||
return true;
|
||||
});
|
||||
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory]);
|
||||
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory, selectedSource]);
|
||||
|
||||
// Reset to first page when mediaList or filters change
|
||||
useEffect(() => {
|
||||
@@ -67,6 +89,23 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
return list;
|
||||
}, [filteredMedia, sortBy]);
|
||||
|
||||
const gridColsClass = useMemo(() => {
|
||||
// Map slider value (1-10) to grid columns
|
||||
const colsMap: Record<number, string> = {
|
||||
1: 'grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
2: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
3: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
|
||||
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6',
|
||||
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
|
||||
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
|
||||
7: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
|
||||
8: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
|
||||
9: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
|
||||
10: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-12',
|
||||
};
|
||||
return `grid ${colsMap[gridItemSize] || colsMap[5]}`;
|
||||
}, [gridItemSize]);
|
||||
|
||||
const totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
|
||||
|
||||
const paginatedMedia = useMemo(() => {
|
||||
@@ -92,7 +131,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
{/* Genre Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("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 font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<button type="button" className={cn("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 font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<Star size={16} />
|
||||
{selectedGenre || 'Genres'}
|
||||
</button>
|
||||
@@ -108,7 +147,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
{/* Studio Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("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 font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<button type="button" className={cn("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 font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
Studios
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -124,7 +163,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("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 font-bold gap-2", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<button type="button" className={cn("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 font-bold gap-2", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<Monitor size={16} />
|
||||
{selectedPlatform || 'Platforms'}
|
||||
</button>
|
||||
@@ -142,7 +181,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("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 font-bold gap-2", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<button type="button" className={cn("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 font-bold gap-2", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<Users size={16} />
|
||||
{selectedDeveloper || 'Developers'}
|
||||
</button>
|
||||
@@ -160,7 +199,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("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 font-bold gap-2", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<button type="button" className={cn("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 font-bold gap-2", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<FolderTree size={16} />
|
||||
{selectedCategory || 'Categories'}
|
||||
</button>
|
||||
@@ -174,17 +213,36 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory) && (
|
||||
{/* Source Filter */}
|
||||
{allSources.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("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 font-bold gap-2", selectedSource ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<Tag size={16} />
|
||||
{selectedSource || 'Source'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedSource(null)}>All Sources</DropdownMenuItem>
|
||||
{allSources.sort().map(source => (
|
||||
<DropdownMenuItem key={source} onClick={() => setSelectedSource(source)}>{source}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory || selectedSource) && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-zinc-400 font-bold"
|
||||
className="text-muted-foreground font-bold"
|
||||
onClick={() => {
|
||||
setSelectedGenre(null);
|
||||
setSelectedStudio(null);
|
||||
setSelectedPlatform(null);
|
||||
setSelectedDeveloper(null);
|
||||
setSelectedCategory(null);
|
||||
setSelectedSource(null);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
@@ -193,9 +251,27 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Grid item size slider */}
|
||||
<div className="flex items-center gap-3 bg-muted rounded-md px-3 py-2">
|
||||
<span className="text-xs font-bold text-muted-foreground">Size</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={gridItemSize}
|
||||
onChange={(e) => {
|
||||
const newSize = Number(e.target.value);
|
||||
setGridItemSize(newSize);
|
||||
onGridItemSizeChange?.(newSize);
|
||||
}}
|
||||
className="w-24 h-2 bg-background rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
|
||||
/>
|
||||
<span className="text-xs font-bold text-[#6d28d9] w-5 text-center">{gridItemSize}</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<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-muted-foreground font-bold gap-2">
|
||||
<ArrowUpDown size={16} />
|
||||
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
|
||||
</button>
|
||||
@@ -207,13 +283,13 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center bg-zinc-100 rounded-md p-1">
|
||||
<div className="flex items-center bg-muted 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"
|
||||
viewMode === 'grid' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
@@ -224,7 +300,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all",
|
||||
viewMode === 'list' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400"
|
||||
viewMode === 'list' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
@@ -235,9 +311,11 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
</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">
|
||||
{loading ? (
|
||||
<Loading message="Loading media..." />
|
||||
) : mediaList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<Search size={32} />
|
||||
</div>
|
||||
<p className="text-lg font-bold">No results found</p>
|
||||
@@ -245,8 +323,8 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
</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"
|
||||
viewMode === 'grid'
|
||||
? cn(gridColsClass, "gap-x-4 gap-y-8")
|
||||
: "flex flex-col gap-2"
|
||||
)}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
@@ -271,16 +349,16 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
|
||||
{/* 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="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border pt-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-zinc-500 font-medium">Items per page:</span>
|
||||
<span className="text-sm text-muted-foreground 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"
|
||||
className="bg-muted border-none rounded-md px-2 py-1 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{[12, 20, 36, 48, 60].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
@@ -294,7 +372,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
className="gap-2 font-bold border-border"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
@@ -302,8 +380,8 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
|
||||
<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>
|
||||
<span className="text-sm text-muted-foreground font-medium">of</span>
|
||||
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -311,7 +389,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
className="gap-2 font-bold border-border"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Staff, Media } from '@/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'motion/react';
|
||||
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye } from 'lucide-react';
|
||||
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye, ChevronDown, ListFilter } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CastDetailViewProps {
|
||||
person: Staff;
|
||||
@@ -12,12 +13,26 @@ interface CastDetailViewProps {
|
||||
|
||||
export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [sortBy, setSortBy] = useState<'year' | 'title' | 'role'>('role');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const handleMediaClick = (mediaId: string) => {
|
||||
navigate(`/media/${mediaId}`);
|
||||
};
|
||||
|
||||
const sortedFilmography = [...(person.filmography || [])].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
if (sortBy === 'year') {
|
||||
comparison = (a.year || 0) - (b.year || 0);
|
||||
} else if (sortBy === 'title') {
|
||||
comparison = (a.title || '').localeCompare(b.title || '');
|
||||
} else if (sortBy === 'role') {
|
||||
comparison = (a.role || '').localeCompare(b.role || '');
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-20">
|
||||
<div className="min-h-screen bg-background pb-20">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-[40vh] md:h-[50vh] overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
@@ -26,14 +41,14 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
className="w-full h-full object-cover opacity-40 blur-xl scale-110"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent" />
|
||||
|
||||
<div className="absolute inset-0 flex items-end px-6 pb-12">
|
||||
<div className="max-w-[1200px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-48 h-48 md:w-64 md:h-64 rounded-2xl overflow-hidden border-4 border-white shadow-2xl shrink-0"
|
||||
className="h-48 md:h-64 rounded-2xl overflow-hidden border-4 border-background shadow-2xl shrink-0"
|
||||
>
|
||||
<img
|
||||
src={person.photo}
|
||||
@@ -49,7 +64,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<h1 className="text-4xl md:text-6xl font-black text-zinc-900 mb-4 drop-shadow-sm">
|
||||
<h1 className="text-4xl md:text-6xl font-black text-foreground mb-4 drop-shadow-sm">
|
||||
{person.name}
|
||||
</h1>
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-3">
|
||||
@@ -58,6 +73,11 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
{occ}
|
||||
</Badge>
|
||||
))}
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold px-4 py-1">
|
||||
{person.filmography.length} Role{person.filmography.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -78,90 +98,90 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
<div className="max-w-[1200px] mx-auto px-6 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{/* Sidebar Info */}
|
||||
<div className="space-y-8">
|
||||
<div className="bg-zinc-50 rounded-3xl p-8 space-y-6">
|
||||
<h3 className="text-xl font-black text-zinc-900">Personal Info</h3>
|
||||
<div className="bg-muted/50 rounded-3xl p-8 space-y-6 border border-border">
|
||||
<h3 className="text-xl font-black text-foreground">Personal Info</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Date</p>
|
||||
<p className="font-bold text-zinc-700">{person.birthDate || 'Unknown'}</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Date</p>
|
||||
<p className="font-bold text-foreground">{person.birthDate || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<MapPin size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Place</p>
|
||||
<p className="font-bold text-zinc-700">{person.birthPlace || 'Unknown'}</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Place</p>
|
||||
<p className="font-bold text-foreground">{person.birthPlace || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Briefcase size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Known For</p>
|
||||
<p className="font-bold text-zinc-700">{person.role}</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Known For</p>
|
||||
<p className="font-bold text-foreground">{person.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(person.ethnicity || person.adult_specifics?.ethnicity) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<User size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Ethnicity</p>
|
||||
<p className="font-bold text-zinc-700">{person.adult_specifics?.ethnicity || person.ethnicity}</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Ethnicity</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.ethnicity || person.ethnicity}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-50 rounded-3xl p-8 space-y-6">
|
||||
<h3 className="text-xl font-black text-zinc-900">Measurements</h3>
|
||||
<div className="bg-muted/50 rounded-3xl p-8 space-y-6 border border-border">
|
||||
<h3 className="text-xl font-black text-foreground">Measurements</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Ruler size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Height</p>
|
||||
<p className="font-bold text-zinc-700">{person.adult_specifics?.height || person.height} cm</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Height</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.height || person.height} cm</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{(person.weight || person.adult_specifics?.weight) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Ruler size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Weight</p>
|
||||
<p className="font-bold text-zinc-700">{person.adult_specifics?.weight || person.weight} kg</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Weight</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.weight || person.weight} kg</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Ruler size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Measurements</p>
|
||||
<p className="font-bold text-zinc-700">
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Measurements</p>
|
||||
<p className="font-bold text-foreground">
|
||||
{person.adult_specifics?.measurements || (
|
||||
<>
|
||||
{person.bust_size && `${person.bust_size}`}
|
||||
@@ -179,48 +199,48 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
|
||||
{(person.hair_color || person.adult_specifics?.hair_color) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Palette size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Hair Color</p>
|
||||
<p className="font-bold text-zinc-700">{person.adult_specifics?.hair_color || person.hair_color}</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Hair Color</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.hair_color || person.hair_color}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(person.eye_color || person.adult_specifics?.eye_color) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Eye size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Eye Color</p>
|
||||
<p className="font-bold text-zinc-700">{person.adult_specifics?.eye_color || person.eye_color}</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Eye Color</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.eye_color || person.eye_color}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{person.adult_specifics?.tattoos && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Palette size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Tattoos</p>
|
||||
<p className="font-bold text-zinc-700">{person.adult_specifics.tattoos}</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Tattoos</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics.tattoos}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{person.adult_specifics?.piercings && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Palette size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Piercings</p>
|
||||
<p className="font-bold text-zinc-700">{person.adult_specifics.piercings}</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Piercings</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics.piercings}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -232,10 +252,10 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
<div className="lg:col-span-2 space-y-12">
|
||||
{person.bio && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
Biography
|
||||
</h2>
|
||||
<p className="text-zinc-600 leading-relaxed text-lg">
|
||||
<p className="text-foreground leading-relaxed text-lg">
|
||||
{person.bio}
|
||||
</p>
|
||||
</section>
|
||||
@@ -243,7 +263,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<User className="text-[#6d28d9]" />
|
||||
Characters
|
||||
</h2>
|
||||
@@ -251,9 +271,9 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
{person.filmography.map(item => (
|
||||
<div
|
||||
key={`${item.id}-char`}
|
||||
className="flex items-center gap-4 p-4 rounded-2xl bg-zinc-50 border border-zinc-100"
|
||||
className="flex items-center gap-4 p-4 rounded-2xl bg-muted/50 border border-border"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-white">
|
||||
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-background">
|
||||
<img
|
||||
src={item.poster || person.photo}
|
||||
alt={item.title}
|
||||
@@ -262,14 +282,19 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Character</p>
|
||||
<h4 className="font-black text-zinc-900 truncate">{item.characterName || item.role}</h4>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1">Character</p>
|
||||
<h4 className="font-black text-foreground truncate">{item.characterName || item.role}</h4>
|
||||
<button
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left"
|
||||
>
|
||||
in {item.title}
|
||||
</button>
|
||||
{item.category && (
|
||||
<Badge variant="secondary" className="text-[10px] font-bold mt-2 bg-muted text-muted-foreground border-none">
|
||||
{item.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -279,16 +304,37 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
|
||||
<Film className="text-[#6d28d9]" />
|
||||
Filmography
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-black text-foreground flex items-center gap-3">
|
||||
<Film className="text-[#6d28d9]" />
|
||||
Filmography
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
className="rounded-full border-border"
|
||||
>
|
||||
<ListFilter size={16} />
|
||||
</Button>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as 'year' | 'title' | 'role')}
|
||||
className="bg-muted border border-border rounded-full px-3 py-1.5 text-sm font-bold text-foreground focus:outline-none focus:ring-2 focus:ring-[#6d28d9]"
|
||||
>
|
||||
<option value="year">Year</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="role">Role</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{person.filmography.map(item => (
|
||||
{sortedFilmography.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
className="group flex items-center gap-4 p-4 rounded-2xl bg-white border border-zinc-100 hover:border-[#6d28d9]/30 hover:shadow-lg transition-all cursor-pointer"
|
||||
className="group flex items-center gap-4 p-4 rounded-2xl bg-card border border-border hover:border-[#6d28d9]/30 hover:shadow-lg transition-all cursor-pointer"
|
||||
>
|
||||
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 shadow-sm">
|
||||
<img
|
||||
@@ -299,16 +345,21 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
<h4 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-1">
|
||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1">
|
||||
{item.year || 'Unknown'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-zinc-200">
|
||||
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-border">
|
||||
{item.role}
|
||||
</Badge>
|
||||
{item.category && (
|
||||
<Badge variant="secondary" className="text-[10px] font-bold py-0 h-5 bg-muted text-muted-foreground border-none">
|
||||
{item.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter } from
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import Loading from '@/components/ui/loading';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { fetchAllCast } from '@/api';
|
||||
@@ -22,11 +23,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
const [searchQuery, setSearchQuery] = useState(() => {
|
||||
return localStorage.getItem('castSearchQuery') || '';
|
||||
});
|
||||
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height'>(() => {
|
||||
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height') || 'name';
|
||||
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height' | 'roleCount'>(() => {
|
||||
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height' | 'roleCount') || 'roleCount';
|
||||
});
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => {
|
||||
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'asc';
|
||||
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
|
||||
});
|
||||
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
|
||||
return localStorage.getItem('castFilterOccupation') || '';
|
||||
@@ -38,6 +39,13 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Sync itemsPerPage with prop when API settings are loaded
|
||||
useEffect(() => {
|
||||
if (initialItemsPerPage) {
|
||||
setItemsPerPage(initialItemsPerPage);
|
||||
}
|
||||
}, [initialItemsPerPage]);
|
||||
|
||||
// Persist filters and sorts
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castSearchQuery', searchQuery);
|
||||
@@ -61,13 +69,13 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSortBy('name');
|
||||
setSortOrder('asc');
|
||||
setSortBy('roleCount');
|
||||
setSortOrder('desc');
|
||||
setFilterOccupation('');
|
||||
setFilterMediaType('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'name' || sortOrder !== 'asc';
|
||||
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'roleCount' || sortOrder !== 'desc';
|
||||
|
||||
useEffect(() => {
|
||||
const loadCast = async () => {
|
||||
@@ -130,6 +138,10 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
const heightA = a.height || 0;
|
||||
const heightB = b.height || 0;
|
||||
comparison = heightA - heightB;
|
||||
} else if (sortBy === 'roleCount') {
|
||||
const roleCountA = a.filmography?.length || 0;
|
||||
const roleCountB = b.filmography?.length || 0;
|
||||
comparison = roleCountA - roleCountB;
|
||||
}
|
||||
|
||||
return sortOrder === 'desc' ? -comparison : comparison;
|
||||
@@ -177,24 +189,24 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black text-zinc-900 mb-2">Cast & Staff</h1>
|
||||
<p className="text-zinc-500 font-medium">Discover the people behind your favorite media</p>
|
||||
<h1 className="text-4xl font-black text-foreground mb-2">Cast & Staff</h1>
|
||||
<p className="text-muted-foreground font-medium">Discover the people behind your favorite media</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
|
||||
<Input
|
||||
placeholder="Search cast..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 w-full md:w-[300px] bg-zinc-100 border-none rounded-full h-11"
|
||||
className="pl-10 w-full md:w-[300px] bg-muted border-none rounded-full h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={showFilters ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
className={`rounded-full h-11 w-11 ${showFilters ? 'bg-[#6d28d9] text-white border-[#6d28d9]' : 'border-zinc-200'}`}
|
||||
className={`rounded-full h-11 w-11 ${showFilters ? 'bg-[#6d28d9] text-white border-[#6d28d9]' : 'border-border'}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter size={20} />
|
||||
@@ -202,7 +214,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full h-11 w-11 border-zinc-200"
|
||||
className="rounded-full h-11 w-11 border-border"
|
||||
onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||
>
|
||||
<ArrowUpDown size={20} />
|
||||
@@ -211,7 +223,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full h-11 w-11 text-zinc-400 hover:text-zinc-900"
|
||||
className="rounded-full h-11 w-11 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleResetFilters}
|
||||
title="Reset filters"
|
||||
>
|
||||
@@ -226,28 +238,29 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="bg-zinc-50 rounded-2xl p-6 mb-6"
|
||||
className="bg-muted/50 rounded-2xl p-6 mb-6 border border-border"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="text-sm font-bold text-zinc-700 mb-2 block">Sort By</label>
|
||||
<label className="text-sm font-bold text-foreground mb-2 block">Sort By</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="role">Role</option>
|
||||
<option value="birthDate">Birth Date</option>
|
||||
<option value="height">Height</option>
|
||||
<option value="roleCount">Role Count</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-bold text-zinc-700 mb-2 block">Occupation</label>
|
||||
<label className="text-sm font-bold text-foreground mb-2 block">Occupation</label>
|
||||
<select
|
||||
value={filterOccupation}
|
||||
onChange={(e) => setFilterOccupation(e.target.value)}
|
||||
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="">All Occupations</option>
|
||||
{uniqueOccupations.map(occ => (
|
||||
@@ -256,11 +269,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-bold text-zinc-700 mb-2 block">Media Type</label>
|
||||
<label className="text-sm font-bold text-foreground mb-2 block">Media Type</label>
|
||||
<select
|
||||
value={filterMediaType}
|
||||
onChange={(e) => setFilterMediaType(e.target.value)}
|
||||
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="">All Media Types</option>
|
||||
{uniqueMediaTypes.map(type => (
|
||||
@@ -273,7 +286,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
{searchQuery && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
Search: {searchQuery}
|
||||
<button onClick={() => setSearchQuery('')} className="hover:text-zinc-900">
|
||||
<button onClick={() => setSearchQuery('')} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
@@ -281,7 +294,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
{filterOccupation && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
Occupation: {filterOccupation}
|
||||
<button onClick={() => setFilterOccupation('')} className="hover:text-zinc-900">
|
||||
<button onClick={() => setFilterOccupation('')} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
@@ -289,7 +302,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
{filterMediaType && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
Media Type: {filterMediaType}
|
||||
<button onClick={() => setFilterMediaType('')} className="hover:text-zinc-900">
|
||||
<button onClick={() => setFilterMediaType('')} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
@@ -297,7 +310,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
{(sortBy !== 'name' || sortOrder !== 'asc') && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
Sort: {sortBy} ({sortOrder})
|
||||
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-zinc-900">
|
||||
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
@@ -307,12 +320,9 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#6d28d9] mb-4" />
|
||||
<p className="text-lg font-bold">Loading cast...</p>
|
||||
</div>
|
||||
<Loading message="Loading cast..." />
|
||||
) : filteredStaff.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<User size={48} className="mb-4 opacity-20" />
|
||||
<p className="text-lg font-bold">No cast members found</p>
|
||||
</div>
|
||||
@@ -326,11 +336,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="group bg-white rounded-2xl p-4 shadow-sm border border-zinc-100 hover:shadow-xl hover:border-[#6d28d9]/20 transition-all duration-300 cursor-pointer"
|
||||
className="group bg-card rounded-2xl p-4 shadow-sm border border-border hover:shadow-xl hover:border-[#6d28d9]/20 transition-all duration-300 cursor-pointer"
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-zinc-100 group-hover:border-[#6d28d9] transition-colors">
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-border group-hover:border-[#6d28d9] transition-colors">
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
@@ -338,19 +348,24 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
{person.name}
|
||||
</h3>
|
||||
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider">
|
||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{person.role}
|
||||
</p>
|
||||
</div>
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold text-[10px] px-2 py-0.5 shrink-0">
|
||||
{person.filmography.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<div className="bg-zinc-50 rounded-xl p-3 flex items-center gap-3">
|
||||
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-white">
|
||||
<div className="bg-muted/50 rounded-xl p-3 flex items-center gap-3">
|
||||
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background">
|
||||
<img
|
||||
src={person.filmography[0].poster || person.photo}
|
||||
alt={person.filmography[0].title}
|
||||
@@ -359,8 +374,8 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest leading-none mb-1">Latest Role</p>
|
||||
<p className="text-xs font-bold text-zinc-700 truncate">{person.filmography[0].title}</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest leading-none mb-1">Latest Role</p>
|
||||
<p className="text-xs font-bold text-foreground truncate">{person.filmography[0].title}</p>
|
||||
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">{person.filmography[0].role}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,15 +388,15 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{filteredStaff.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="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border pt-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-zinc-500 font-medium">Items per page:</span>
|
||||
<span className="text-sm text-muted-foreground font-medium">Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
}}
|
||||
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"
|
||||
className="bg-muted border-none rounded-md px-2 py-1 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{[12, 20, 36, 48, 60].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
@@ -395,7 +410,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
className="gap-2 font-bold border-border"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
@@ -403,8 +418,8 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
|
||||
<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>
|
||||
<span className="text-sm text-muted-foreground font-medium">of</span>
|
||||
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -412,7 +427,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
className="gap-2 font-bold border-border"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Media, Staff } from '@/types';
|
||||
import { Media, Staff, Track } from '@/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Bookmark,
|
||||
@@ -8,7 +9,8 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
ListFilter
|
||||
ListFilter,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -23,8 +25,51 @@ interface DetailViewProps {
|
||||
|
||||
export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [castLimit, setCastLimit] = useState(6);
|
||||
const [showAllCast, setShowAllCast] = useState(false);
|
||||
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
|
||||
|
||||
// Group episodes by season
|
||||
const episodesBySeason = useMemo(() => {
|
||||
if (!media.episodes) return {};
|
||||
const grouped: Record<number, typeof media.episodes> = {};
|
||||
media.episodes.forEach(episode => {
|
||||
if (!grouped[episode.season]) {
|
||||
grouped[episode.season] = [];
|
||||
}
|
||||
grouped[episode.season].push(episode);
|
||||
});
|
||||
// Sort episodes within each season by episode number
|
||||
Object.keys(grouped).forEach(season => {
|
||||
grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number);
|
||||
});
|
||||
return grouped;
|
||||
}, [media.episodes]);
|
||||
|
||||
// Expand first season by default on mount
|
||||
useEffect(() => {
|
||||
const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b);
|
||||
if (seasons.length > 0) {
|
||||
setExpandedSeasons(new Set([seasons[0]]));
|
||||
}
|
||||
}, [episodesBySeason]);
|
||||
|
||||
const toggleSeason = (season: number) => {
|
||||
setExpandedSeasons(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(season)) {
|
||||
newSet.delete(season);
|
||||
} else {
|
||||
newSet.add(season);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []);
|
||||
const hasMoreCast = (media.staff?.length || 0) > castLimit;
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50">
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Banner */}
|
||||
<div className="relative h-[400px] w-full overflow-hidden">
|
||||
<img
|
||||
@@ -33,7 +78,7 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-zinc-50 via-zinc-50/40 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/40 to-transparent" />
|
||||
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
@@ -45,12 +90,12 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-[1400px] mx-auto px-6 -mt-32 relative z-10 pb-24">
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{/* Left Column: Poster */}
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Left Column: Poster + Metadata */}
|
||||
<div className="w-full md:w-[300px] shrink-0">
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className={`rounded-xl overflow-hidden shadow-2xl bg-zinc-800 ${
|
||||
className={`rounded-xl overflow-hidden shadow-2xl bg-card ${
|
||||
media.aspectRatio === '16/9' ? 'aspect-video' :
|
||||
media.aspectRatio === '1/1' ? 'aspect-square' :
|
||||
'aspect-[2/3]'
|
||||
@@ -63,28 +108,103 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Compact metadata under poster */}
|
||||
<div className="mt-4 space-y-2">
|
||||
{media.studios && media.studios.length > 0 && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Studios:</span>
|
||||
{media.studios.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{media.developers && media.developers.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Developers:</span>
|
||||
{media.developers.map(dev => (
|
||||
<Badge key={dev} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
|
||||
{dev}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.platforms && media.platforms.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Platforms:</span>
|
||||
{media.platforms.map(platform => (
|
||||
<Badge key={platform} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
|
||||
{platform}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.categories && media.categories.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Categories:</span>
|
||||
{media.categories.map(category => (
|
||||
<Badge key={category} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.completionStatus && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Completion:</span>
|
||||
{media.completionStatus}
|
||||
</p>
|
||||
)}
|
||||
{media.source && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Source:</span>
|
||||
{media.source}
|
||||
</p>
|
||||
)}
|
||||
{media.playCount !== undefined && media.playCount !== null && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Play Count:</span>
|
||||
{media.playCount}
|
||||
</p>
|
||||
)}
|
||||
{media.playtime !== undefined && media.playtime !== null && media.playtime > 0 && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Playtime:</span>
|
||||
{media.playtime}h
|
||||
</p>
|
||||
)}
|
||||
{media.lastActivity && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Last Activity:</span>
|
||||
{media.lastActivity}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Links:</span>
|
||||
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">Tvdb</Button>
|
||||
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">AniDb</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Info */}
|
||||
<div className="flex-1 pt-32 md:pt-40">
|
||||
<div className="flex-1 pt-4 md:pt-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black text-zinc-900 mb-2">
|
||||
{media.title} <span className="text-zinc-400 font-medium">({media.year})</span>
|
||||
<h1 className="text-4xl font-black text-foreground mb-2">
|
||||
{media.title} <span className="text-muted-foreground font-medium">({media.year})</span>
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" className="rounded-full bg-[#6d28d9] hover:bg-[#5b21b6]">
|
||||
<Play size={20} fill="currentColor" />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="rounded-full border-zinc-300">
|
||||
<Button size="icon" variant="outline" className="rounded-full border-border">
|
||||
<Bookmark size={20} />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="rounded-full border-zinc-300">
|
||||
<Button size="icon" variant="outline" className="rounded-full border-border">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-zinc-600 font-bold">
|
||||
<div className="flex items-center gap-1 text-foreground font-bold">
|
||||
<Star size={18} className="text-yellow-500" fill="currentColor" />
|
||||
{media.rating} / 10
|
||||
</div>
|
||||
@@ -95,7 +215,7 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
<h3 className="text-xs font-black text-[#6d28d9] uppercase tracking-wider mb-2">Genres</h3>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{media.genres?.map(genre => (
|
||||
<span key={genre} className="text-sm font-bold text-zinc-600 hover:text-[#6d28d9] cursor-pointer transition-colors">
|
||||
<span key={genre} className="text-sm font-bold text-foreground hover:text-[#6d28d9] cursor-pointer transition-colors">
|
||||
• {genre}
|
||||
</span>
|
||||
))}
|
||||
@@ -103,92 +223,19 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-zinc-600 leading-relaxed mb-8 max-w-3xl">
|
||||
{media.description}
|
||||
</p>
|
||||
<div
|
||||
className="text-foreground leading-relaxed mb-6 max-w-3xl prose prose-sm dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: media.description || '' }}
|
||||
/>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{media.tags?.map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20 border-none px-3 py-1 font-bold text-[10px] uppercase tracking-wider">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{media.studios && media.studios.length > 0 && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Studios:</span>
|
||||
{media.studios.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{media.developers && media.developers.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Developers:</span>
|
||||
{media.developers.map(dev => (
|
||||
<Badge key={dev} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||
{dev}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.platforms && media.platforms.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Platforms:</span>
|
||||
{media.platforms.map(platform => (
|
||||
<Badge key={platform} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||
{platform}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.categories && media.categories.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Categories:</span>
|
||||
{media.categories.map(category => (
|
||||
<Badge key={category} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.completionStatus && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Completion:</span>
|
||||
{media.completionStatus}
|
||||
</p>
|
||||
)}
|
||||
{media.source && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Source:</span>
|
||||
{media.source}
|
||||
</p>
|
||||
)}
|
||||
{media.playCount !== undefined && media.playCount !== null && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Play Count:</span>
|
||||
{media.playCount}
|
||||
</p>
|
||||
)}
|
||||
{media.playtime !== undefined && media.playtime !== null && media.playtime > 0 && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Playtime:</span>
|
||||
{media.playtime}h
|
||||
</p>
|
||||
)}
|
||||
{media.lastActivity && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Last Activity:</span>
|
||||
{media.lastActivity}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Links:</span>
|
||||
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">Tvdb</Button>
|
||||
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">AniDb</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,31 +243,39 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
{media.staff && media.staff.length > 0 && (
|
||||
<section className="mt-20">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-2xl font-black text-zinc-900">Cast & Crew</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" className="rounded-full border-zinc-200">
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="rounded-full border-zinc-200">
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
<h2 className="text-2xl font-black text-foreground">Cast & Crew</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-bold text-muted-foreground">
|
||||
{showAllCast ? media.staff.length : displayedCast.length} / {media.staff.length}
|
||||
</span>
|
||||
{hasMoreCast && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAllCast(!showAllCast)}
|
||||
className="rounded-full border-border font-bold"
|
||||
>
|
||||
{showAllCast ? 'Show Less' : 'Show All'}
|
||||
<ChevronDown size={16} className={`ml-2 transition-transform ${showAllCast ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{media.staff.map(person => (
|
||||
{displayedCast.map(person => (
|
||||
<div
|
||||
key={person.id}
|
||||
className="flex items-center gap-4 bg-white p-3 rounded-xl shadow-sm border border-zinc-100 hover:shadow-md transition-shadow cursor-pointer group"
|
||||
className="flex items-center gap-4 bg-card p-3 rounded-xl shadow-sm border border-border hover:shadow-md transition-shadow cursor-pointer group"
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0">
|
||||
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-bold text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4>
|
||||
<p className="text-xs text-zinc-500 truncate">{person.role}</p>
|
||||
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4>
|
||||
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
|
||||
</div>
|
||||
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 bg-zinc-50">
|
||||
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 bg-muted">
|
||||
<img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,44 +292,130 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-xl">
|
||||
<span className="opacity-40">{media.episodes.length}</span> Episode{media.episodes.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="text-sm font-bold text-muted-foreground">
|
||||
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||
<Input placeholder="Search" className="pl-10 w-[200px] bg-zinc-100 border-none rounded-full h-9 text-sm" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted border-none rounded-full h-9 text-sm" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="text-zinc-400">
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-zinc-400">
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
||||
<ListFilter size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{media.episodes.map(episode => (
|
||||
<div key={episode.id} className="group cursor-pointer">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-xl overflow-hidden shadow-sm relative">
|
||||
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 py-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-black text-zinc-900 group-hover:text-[#6d28d9] transition-colors">
|
||||
S1:E{episode.number} • {episode.title}
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-zinc-400">{episode.date} • {episode.duration}</span>
|
||||
<div className="space-y-4">
|
||||
{Object.keys(episodesBySeason)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(season => (
|
||||
<div key={season} className="border border-border rounded-2xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSeason(season)}
|
||||
className="w-full flex items-center justify-between p-6 bg-card hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-2xl font-black text-foreground">Season {season}</h3>
|
||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold">
|
||||
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 leading-relaxed line-clamp-3">
|
||||
{episode.description}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
className={`transition-transform duration-300 text-muted-foreground ${
|
||||
expandedSeasons.has(season) ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{expandedSeasons.has(season) && (
|
||||
<div className="p-6 pt-0 space-y-6">
|
||||
{episodesBySeason[season].map(episode => (
|
||||
<div key={episode.id} className="group cursor-pointer">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-xl overflow-hidden shadow-sm relative">
|
||||
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 py-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors">
|
||||
E{episode.episode_number} • {episode.title}
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-muted-foreground">{episode.air_date} • {episode.duration}m</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{episode.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="mt-6 bg-border" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="mt-6 bg-zinc-200" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tracks Section - Only show if tracks data exists (Music) */}
|
||||
{media.tracks && media.tracks.length > 0 && (
|
||||
<section className="mt-20">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-xl">
|
||||
<span className="opacity-40">{media.tracks.length}</span> Track{media.tracks.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted border-none rounded-full h-9 text-sm" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
||||
<ListFilter size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-2xl overflow-hidden">
|
||||
<div className="divide-y divide-border">
|
||||
{media.tracks
|
||||
.sort((a, b) => a.track_number - b.track_number)
|
||||
.map((track, index) => (
|
||||
<div key={track.id} className="group cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground group-hover:bg-[#6d28d9] group-hover:text-white transition-colors">
|
||||
{track.track_number}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-foreground group-hover:text-[#6d28d9] transition-colors truncate">
|
||||
{track.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{track.artist}</p>
|
||||
</div>
|
||||
{track.duration && (
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{track.duration}s
|
||||
</span>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Play size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Search, User, X, Plus, Download, Settings } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { MediaCategory } from '@/types';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
interface HeaderProps {
|
||||
onSearch: (query: string) => void;
|
||||
@@ -23,6 +24,17 @@ export default function Header({
|
||||
}: HeaderProps) {
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 10);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
@@ -39,41 +51,61 @@ export default function Header({
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
<header
|
||||
className={cn(
|
||||
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-300",
|
||||
transparent ? "bg-transparent" : "bg-[#6d28d9]"
|
||||
transparent && !scrolled
|
||||
? "bg-transparent"
|
||||
: transparent && scrolled
|
||||
? "backdrop-blur-md bg-background/80 border-b border-border/50"
|
||||
: "bg-[#6d28d9]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-8">
|
||||
<Link
|
||||
<Link
|
||||
to="/"
|
||||
className="text-2xl font-black text-white flex items-center gap-1"
|
||||
className={cn(
|
||||
"text-2xl font-black flex items-center gap-1",
|
||||
(transparent && !scrolled) || !transparent ? "text-white" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="w-6 h-6 bg-white rounded-full flex items-center justify-center">
|
||||
<div className="w-3 h-3 bg-[#6d28d9] rounded-full" />
|
||||
<div className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white" : "bg-[#6d28d9]"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"w-3 h-3 rounded-full",
|
||||
(transparent && !scrolled) || !transparent ? "bg-[#6d28d9]" : "bg-white"
|
||||
)} />
|
||||
</div>
|
||||
kyoo
|
||||
</Link>
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
{enabledCategories.map(cat => (
|
||||
<button
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => onCategoryChange(cat)}
|
||||
className={cn(
|
||||
"text-sm font-bold transition-colors uppercase tracking-wider",
|
||||
activeCategory === cat ? "text-white" : "text-white/60 hover:text-white"
|
||||
(transparent && !scrolled) || !transparent
|
||||
? activeCategory === cat ? "text-white" : "text-white/60 hover:text-white"
|
||||
: activeCategory === cat ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
<div className="w-px h-4 bg-white/20 mx-2" />
|
||||
<NavLink
|
||||
<div className={cn(
|
||||
"w-px h-4 mx-2",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white/20" : "bg-border"
|
||||
)} />
|
||||
<NavLink
|
||||
to="/cast"
|
||||
className={({ isActive }) => cn(
|
||||
"text-sm font-bold transition-colors uppercase tracking-wider",
|
||||
isActive ? "text-white" : "text-white/60 hover:text-white"
|
||||
(transparent && !scrolled) || !transparent
|
||||
? isActive ? "text-white" : "text-white/60 hover:text-white"
|
||||
: isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
CAST
|
||||
@@ -83,45 +115,74 @@ export default function Header({
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn(
|
||||
"flex items-center transition-all duration-300 overflow-hidden",
|
||||
isSearchOpen ? "w-48 md:w-64 bg-white/10 rounded-full px-3 py-1" : "w-0"
|
||||
isSearchOpen ? "w-48 md:w-64 rounded-full px-3 py-1" : "w-0",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white/10" : "bg-muted"
|
||||
)}>
|
||||
<input
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="bg-transparent border-none outline-none text-white text-sm w-full placeholder:text-white/50"
|
||||
className={cn(
|
||||
"bg-transparent border-none outline-none text-sm w-full",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white placeholder:text-white/50"
|
||||
: "text-foreground placeholder:text-muted-foreground"
|
||||
)}
|
||||
autoFocus={isSearchOpen}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onClick={toggleSearch}
|
||||
className="p-2 text-white/90 hover:text-white transition-colors"
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{isSearchOpen ? <X size={20} /> : <Search size={20} />}
|
||||
</button>
|
||||
<Link
|
||||
to="/add"
|
||||
className="p-2 text-white/90 hover:text-white transition-colors"
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</Link>
|
||||
<Link
|
||||
to="/import"
|
||||
className="p-2 text-white/90 hover:text-white transition-colors"
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Download size={20} />
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="p-2 text-white/90 hover:text-white transition-colors"
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Settings size={20} />
|
||||
</Link>
|
||||
<button className="w-8 h-8 rounded-full overflow-hidden border-2 border-white/20">
|
||||
<img
|
||||
src="https://picsum.photos/seed/user/100/100"
|
||||
alt="User"
|
||||
<button className={cn(
|
||||
"w-8 h-8 rounded-full overflow-hidden border-2",
|
||||
(transparent && !scrolled) || !transparent ? "border-white/20" : "border-border"
|
||||
)}>
|
||||
<img
|
||||
src="https://picsum.photos/seed/user/100/100"
|
||||
alt="User"
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
|
||||
@@ -6,21 +6,92 @@ import { cn } from '@/lib/utils';
|
||||
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
|
||||
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
|
||||
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
|
||||
import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions, LibraryMapping, fetchJellyfinLibraries } from '@/lib/jellyfinImporter';
|
||||
import { fetchSettings, updateSettings } from '@/api';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:3000';
|
||||
|
||||
export default function ImporterView() {
|
||||
const navigate = useNavigate();
|
||||
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: import.meta.env.VITE_XBVR_URL || BASE_URL });
|
||||
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: import.meta.env.VITE_XBVR_URL || BASE_URL, updateExisting: true });
|
||||
const [stashappConfig, setStashappConfig] = useState<StashAPPConfig>({
|
||||
url: import.meta.env.VITE_STASHAPP_URL || '',
|
||||
apiKey: import.meta.env.VITE_STASHAPP_API_KEY || ''
|
||||
apiKey: import.meta.env.VITE_STASHAPP_API_KEY || '',
|
||||
updateExisting: true
|
||||
});
|
||||
const [playniteConfig, setPlayniteConfig] = useState<PlayniteConfig>({
|
||||
ip: import.meta.env.VITE_PLAYNITE_IP || '',
|
||||
apiToken: import.meta.env.VITE_PLAYNITE_API_TOKEN || '',
|
||||
port: parseInt(import.meta.env.VITE_PLAYNITE_PORT || '19821')
|
||||
apiToken: import.meta.env.VITE_PLAYNITE_API_TOKEN || '',
|
||||
port: import.meta.env.VITE_PLAYNITE_PORT ? parseInt(import.meta.env.VITE_PLAYNITE_PORT) : undefined,
|
||||
updateExisting: true
|
||||
});
|
||||
const [jellyfinConfig, setJellyfinConfig] = useState<JellyfinConfig>({
|
||||
url: import.meta.env.VITE_JELLYFIN_URL || '',
|
||||
apiKey: import.meta.env.VITE_JELLYFIN_API_KEY || ''
|
||||
});
|
||||
const [jellyfinOptions, setJellyfinOptions] = useState<JellyfinImportOptions>({
|
||||
importMovies: true,
|
||||
importSeries: true,
|
||||
importMusic: true,
|
||||
importCast: true,
|
||||
limit: undefined,
|
||||
libraryMappings: [],
|
||||
updateExisting: true
|
||||
});
|
||||
const [jellyfinLibraries, setJellyfinLibraries] = useState<Array<{ Id: string; Name: string; CollectionType: string }>>([]);
|
||||
const [libraryMappings, setLibraryMappings] = useState<LibraryMapping[]>([]);
|
||||
const [showLibraryMapping, setShowLibraryMapping] = useState(false);
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
|
||||
// Load library mappings from API on mount
|
||||
useEffect(() => {
|
||||
const loadMappings = async () => {
|
||||
try {
|
||||
const settings = await fetchSettings();
|
||||
if (settings?.jellyfinLibraryMappings) {
|
||||
const mappings = JSON.parse(settings.jellyfinLibraryMappings);
|
||||
setLibraryMappings(mappings);
|
||||
setShowLibraryMapping(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load library mappings from API:', error);
|
||||
// Fallback to localStorage
|
||||
const savedMappings = localStorage.getItem('jellyfinLibraryMappings');
|
||||
if (savedMappings) {
|
||||
try {
|
||||
setLibraryMappings(JSON.parse(savedMappings));
|
||||
setShowLibraryMapping(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse saved library mappings:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsInitialLoad(false);
|
||||
};
|
||||
loadMappings();
|
||||
}, []);
|
||||
|
||||
// Save library mappings to API and localStorage when they change
|
||||
useEffect(() => {
|
||||
if (libraryMappings.length > 0 && !isInitialLoad) {
|
||||
// Save to localStorage as fallback
|
||||
localStorage.setItem('jellyfinLibraryMappings', JSON.stringify(libraryMappings));
|
||||
|
||||
// Save to API
|
||||
const saveMappings = async () => {
|
||||
try {
|
||||
const settings = await fetchSettings();
|
||||
if (settings) {
|
||||
settings.jellyfinLibraryMappings = JSON.stringify(libraryMappings);
|
||||
await updateSettings(settings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save library mappings to API:', error);
|
||||
}
|
||||
};
|
||||
saveMappings();
|
||||
}
|
||||
}, [libraryMappings, isInitialLoad]);
|
||||
const [progress, setProgress] = useState<ImportProgress>({
|
||||
current: 0,
|
||||
total: 0,
|
||||
@@ -137,6 +208,120 @@ export default function ImporterView() {
|
||||
setProgress(result);
|
||||
};
|
||||
|
||||
const handleJellyfinImport = async () => {
|
||||
setProgress({
|
||||
current: 0,
|
||||
total: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Connecting to Jellyfin API...',
|
||||
videosImported: 0,
|
||||
actorsImported: 0,
|
||||
errors: []
|
||||
});
|
||||
setImportLog([]);
|
||||
|
||||
// Update options with current library mappings
|
||||
const optionsWithMappings = {
|
||||
...jellyfinOptions,
|
||||
libraryMappings: libraryMappings
|
||||
};
|
||||
|
||||
const result = await importFromJellyfin(
|
||||
jellyfinConfig,
|
||||
optionsWithMappings,
|
||||
addLog,
|
||||
(progressUpdate) => {
|
||||
setProgress(prev => ({ ...prev, ...progressUpdate }));
|
||||
}
|
||||
);
|
||||
|
||||
setProgress(result);
|
||||
};
|
||||
|
||||
const handleJellyfinCleanup = async () => {
|
||||
setProgress({
|
||||
current: 0,
|
||||
total: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Connecting to Jellyfin API for cleanup...',
|
||||
videosImported: 0,
|
||||
actorsImported: 0,
|
||||
errors: []
|
||||
});
|
||||
setImportLog([]);
|
||||
|
||||
const result = await cleanupJellyfinMedia(
|
||||
jellyfinConfig,
|
||||
jellyfinOptions,
|
||||
addLog,
|
||||
(progressUpdate) => {
|
||||
setProgress(prev => ({ ...prev, ...progressUpdate }));
|
||||
}
|
||||
);
|
||||
|
||||
setProgress(result);
|
||||
};
|
||||
|
||||
const handleFetchJellyfinLibraries = async () => {
|
||||
try {
|
||||
const libraries = await fetchJellyfinLibraries(jellyfinConfig);
|
||||
setJellyfinLibraries(libraries);
|
||||
|
||||
// Merge existing mappings with new libraries
|
||||
const newMappings: LibraryMapping[] = libraries.map(lib => {
|
||||
// Check if mapping already exists
|
||||
const existing = libraryMappings.find(m => m.libraryName === lib.Name);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new mapping with default category
|
||||
let defaultCategory: 'TV Series' | 'Anime' | 'Movies' | 'Music' = 'TV Series';
|
||||
if (lib.CollectionType === 'movies') {
|
||||
defaultCategory = 'Movies';
|
||||
} else if (lib.CollectionType === 'music') {
|
||||
defaultCategory = 'Music';
|
||||
} else if (lib.CollectionType === 'tvshows') {
|
||||
defaultCategory = 'TV Series';
|
||||
}
|
||||
|
||||
return {
|
||||
libraryName: lib.Name,
|
||||
category: defaultCategory
|
||||
};
|
||||
});
|
||||
|
||||
setLibraryMappings(newMappings);
|
||||
setShowLibraryMapping(true);
|
||||
addLog(`Fetched ${libraries.length} libraries from Jellyfin`);
|
||||
} catch (error) {
|
||||
addLog(`Failed to fetch libraries: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLibraryMappingChange = (libraryName: string, category: 'TV Series' | 'Anime' | 'Movies' | 'Music' | 'skip') => {
|
||||
setLibraryMappings(prev => {
|
||||
const existing = prev.find(m => m.libraryName === libraryName);
|
||||
if (existing) {
|
||||
return prev.map(m => m.libraryName === libraryName ? { ...m, category } : m);
|
||||
} else {
|
||||
return [...prev, { libraryName, category }];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleLibraryPathSegmentsChange = (libraryName: string, value: string) => {
|
||||
const segments = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
setLibraryMappings(prev => {
|
||||
const existing = prev.find(m => m.libraryName === libraryName);
|
||||
if (existing) {
|
||||
return prev.map(m => m.libraryName === libraryName ? { ...m, pathSegments: segments } : m);
|
||||
} else {
|
||||
return [...prev, { libraryName, category: 'TV Series', pathSegments: segments }];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetImport = () => {
|
||||
setProgress({
|
||||
current: 0,
|
||||
@@ -164,13 +349,13 @@ export default function ImporterView() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate('/')}
|
||||
className="text-zinc-600 hover:text-[#6d28d9]"
|
||||
className="text-muted-foreground hover:text-[#6d28d9]"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-zinc-900">Media Importers</h1>
|
||||
<p className="text-sm text-zinc-500 font-medium">Import media from external platforms</p>
|
||||
<h1 className="text-2xl font-black text-foreground">Media Importers</h1>
|
||||
<p className="text-sm text-muted-foreground font-medium">Import media from external platforms</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,41 +364,52 @@ export default function ImporterView() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
{/* XBVR Importer Card */}
|
||||
{xbvrConfig.url && (
|
||||
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<Film className="text-purple-600" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900">XBVR</h3>
|
||||
<p className="text-xs text-zinc-500 font-medium">Adult Video Manager</p>
|
||||
<h3 className="font-bold text-foreground">XBVR</h3>
|
||||
<p className="text-xs text-muted-foreground font-medium">Adult Video Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 border-zinc-200"
|
||||
className="h-8 w-8 border-border"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600 mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Import adult videos and actors from your XBVR database.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">XBVR URL</label>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">XBVR URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={xbvrConfig.url}
|
||||
onChange={(e) => setXbvrConfig({ ...xbvrConfig, url: e.target.value })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="http://192.168.1.102:10001"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="xbvr-update-existing"
|
||||
checked={xbvrConfig.updateExisting}
|
||||
onChange={(e) => setXbvrConfig({ ...xbvrConfig, updateExisting: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<label htmlFor="xbvr-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleXBVRImport}
|
||||
disabled={progress.stage !== 'idle' || !xbvrConfig.url}
|
||||
@@ -237,52 +433,63 @@ export default function ImporterView() {
|
||||
|
||||
{/* StashAPP Importer Card */}
|
||||
{stashappConfig.url && (
|
||||
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Film className="text-blue-600" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900">StashAPP</h3>
|
||||
<p className="text-xs text-zinc-500 font-medium">Adult Content Manager</p>
|
||||
<h3 className="font-bold text-foreground">StashAPP</h3>
|
||||
<p className="text-xs text-muted-foreground font-medium">Adult Content Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 border-zinc-200"
|
||||
className="h-8 w-8 border-border"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600 mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Import adult videos and performers from your StashAPP database.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">StashAPP URL</label>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">StashAPP URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stashappConfig.url}
|
||||
onChange={(e) => setStashappConfig({ ...stashappConfig, url: e.target.value })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="http://192.168.1.102:10001"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Key (optional)</label>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Key (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={stashappConfig.apiKey || ''}
|
||||
onChange={(e) => setStashappConfig({ ...stashappConfig, apiKey: e.target.value })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="Enter API key if required"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="stashapp-update-existing"
|
||||
checked={stashappConfig.updateExisting}
|
||||
onChange={(e) => setStashappConfig({ ...stashappConfig, updateExisting: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<label htmlFor="stashapp-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleStashAPPImport}
|
||||
disabled={progress.stage !== 'idle' || !stashappConfig.url}
|
||||
@@ -306,38 +513,38 @@ export default function ImporterView() {
|
||||
|
||||
{/* StashAPP Actor Updater Card */}
|
||||
{stashappConfig.url && (
|
||||
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<Users className="text-green-600" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900">StashAPP Actor Updater</h3>
|
||||
<p className="text-xs text-zinc-500 font-medium">Update existing actors</p>
|
||||
<h3 className="font-bold text-foreground">StashAPP Actor Updater</h3>
|
||||
<p className="text-xs text-muted-foreground font-medium">Update existing actors</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 border-zinc-200"
|
||||
className="h-8 w-8 border-border"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600 mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Update existing actors with fresh data from StashAPP and create missing ones.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Key (optional)</label>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Key (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={stashappConfig.apiKey || ''}
|
||||
onChange={(e) => setStashappConfig({ ...stashappConfig, apiKey: e.target.value })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="Enter API key if required"
|
||||
/>
|
||||
</div>
|
||||
@@ -364,63 +571,74 @@ export default function ImporterView() {
|
||||
|
||||
{/* Playnite Importer Card */}
|
||||
{playniteConfig.ip && playniteConfig.apiToken && (
|
||||
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<Film className="text-orange-600" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900">Playnite</h3>
|
||||
<p className="text-xs text-zinc-500 font-medium">Game Library Manager</p>
|
||||
<h3 className="font-bold text-foreground">Playnite</h3>
|
||||
<p className="text-xs text-muted-foreground font-medium">Game Library Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 border-zinc-200"
|
||||
className="h-8 w-8 border-border"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600 mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Import games from your Playnite library via Bridge API.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">IP Address</label>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">IP Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={playniteConfig.ip}
|
||||
onChange={(e) => setPlayniteConfig({ ...playniteConfig, ip: e.target.value })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="localhost"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">Port</label>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={playniteConfig.port || 19821}
|
||||
onChange={(e) => setPlayniteConfig({ ...playniteConfig, port: parseInt(e.target.value) || 19821 })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="19821"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Token</label>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={playniteConfig.apiToken}
|
||||
onChange={(e) => setPlayniteConfig({ ...playniteConfig, apiToken: e.target.value })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="pb_your_token_here"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="playnite-update-existing"
|
||||
checked={playniteConfig.updateExisting}
|
||||
onChange={(e) => setPlayniteConfig({ ...playniteConfig, updateExisting: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<label htmlFor="playnite-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePlayniteImport}
|
||||
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
|
||||
@@ -441,11 +659,285 @@ export default function ImporterView() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jellyfin Importer Card */}
|
||||
{jellyfinConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||
<Film className="text-indigo-600" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground">Jellyfin</h3>
|
||||
<p className="text-xs text-muted-foreground font-medium">Media Server</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 border-border"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Import movies, series, music and cast from your Jellyfin server.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">Jellyfin URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={jellyfinConfig.url}
|
||||
onChange={(e) => setJellyfinConfig({ ...jellyfinConfig, url: e.target.value })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="http://192.168.1.102:8096"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={jellyfinConfig.apiKey || ''}
|
||||
onChange={(e) => setJellyfinConfig({ ...jellyfinConfig, apiKey: e.target.value })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-2 block">Import Options</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jellyfinOptions.importMovies}
|
||||
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMovies: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-muted-foreground">Movies</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jellyfinOptions.importSeries}
|
||||
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importSeries: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-muted-foreground">Series</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jellyfinOptions.importMusic}
|
||||
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMusic: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-muted-foreground">Music</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jellyfinOptions.importCast}
|
||||
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importCast: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-muted-foreground">Cast</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">Limit (optional, for testing)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={jellyfinOptions.limit || ''}
|
||||
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, limit: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="e.g. 10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="jellyfin-update-existing"
|
||||
checked={jellyfinOptions.updateExisting}
|
||||
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, updateExisting: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<label htmlFor="jellyfin-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-2 block">Library Category Mapping</label>
|
||||
<Button
|
||||
onClick={handleFetchJellyfinLibraries}
|
||||
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
|
||||
variant="outline"
|
||||
className="w-full mb-3 font-bold border-border"
|
||||
>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Fetch Libraries
|
||||
</Button>
|
||||
{showLibraryMapping && libraryMappings.length > 0 && (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{libraryMappings.map(mapping => (
|
||||
<div key={mapping.libraryName} className="space-y-1 p-2 border border-border rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-muted-foreground flex-1 truncate">{mapping.libraryName}</span>
|
||||
<select
|
||||
value={mapping.category}
|
||||
onChange={(e) => handleLibraryMappingChange(mapping.libraryName, e.target.value as any)}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="text-xs px-2 py-1 border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="TV Series">TV Series</option>
|
||||
<option value="Anime">Anime</option>
|
||||
<option value="Movies">Movies</option>
|
||||
<option value="Music">Music</option>
|
||||
<option value="skip">Nicht importieren</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Pfad-Segmente (kommagetrennt):</span>
|
||||
<input
|
||||
type="text"
|
||||
value={mapping.pathSegments?.join(', ') || ''}
|
||||
onChange={(e) => handleLibraryPathSegmentsChange(mapping.libraryName, e.target.value)}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
placeholder="z.B. Serien, Animes"
|
||||
className="text-xs px-2 py-1 border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleJellyfinImport}
|
||||
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold"
|
||||
>
|
||||
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
||||
<>
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={16} className="mr-2" />
|
||||
Import from Jellyfin
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jellyfin Cleanup Card */}
|
||||
{jellyfinConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<RefreshCw className="text-red-600" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground">Jellyfin Cleanup</h3>
|
||||
<p className="text-xs text-muted-foreground font-medium">Remove deleted media</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 border-border"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Remove Jellyfin media and cast that no longer exist in your Jellyfin server.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-2 block">Cleanup Options</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jellyfinOptions.importMovies}
|
||||
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMovies: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-muted-foreground">Movies</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jellyfinOptions.importSeries}
|
||||
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importSeries: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-muted-foreground">Series</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jellyfinOptions.importMusic}
|
||||
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMusic: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-muted-foreground">Music</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jellyfinOptions.importCast}
|
||||
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importCast: e.target.checked })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-muted-foreground">Cast</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleJellyfinCleanup}
|
||||
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white font-bold"
|
||||
>
|
||||
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
||||
<>
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
Cleaning up...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Cleanup Jellyfin Media
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
{progress.stage !== 'idle' && (
|
||||
<div className="bg-white border border-zinc-200 rounded-xl p-6">
|
||||
<div className="bg-card border border-border rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{progress.stage === 'complete' ? (
|
||||
@@ -458,12 +950,12 @@ export default function ImporterView() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Loader2 className="text-purple-600 animate-spin" size={20} />
|
||||
<Loader2 className="text-muted-foreground animate-spin" size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900">{progress.message}</h3>
|
||||
<p className="text-xs text-zinc-500 font-medium">
|
||||
<h3 className="font-bold text-foreground">{progress.message}</h3>
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
{progress.stage === 'fetching' && 'Connecting to external service...'}
|
||||
{progress.stage === 'importing' && `Processing items... ${getProgressPercentage()}%`}
|
||||
{progress.stage === 'complete' && 'Import finished'}
|
||||
@@ -476,7 +968,7 @@ export default function ImporterView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetImport}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
className="gap-2 font-bold border-border"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Reset
|
||||
@@ -487,7 +979,7 @@ export default function ImporterView() {
|
||||
{/* Progress Bar */}
|
||||
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
||||
<div className="mb-6">
|
||||
<div className="h-2 bg-zinc-100 rounded-full overflow-hidden">
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-300 ease-out",
|
||||
@@ -496,7 +988,7 @@ export default function ImporterView() {
|
||||
style={{ width: `${getProgressPercentage()}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-zinc-500 font-medium">
|
||||
<div className="flex justify-between mt-2 text-xs text-muted-foreground font-medium">
|
||||
<span>{progress.current} / {progress.total} items</span>
|
||||
<span>{getProgressPercentage()}%</span>
|
||||
</div>
|
||||
@@ -505,26 +997,36 @@ export default function ImporterView() {
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-zinc-50 rounded-lg p-4">
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Film size={16} className="text-zinc-400" />
|
||||
<span className="text-xs font-bold text-zinc-500">{(progress as any).gamesImported !== undefined ? 'Games' : 'Videos'}</span>
|
||||
<Film size={16} className="text-muted-foreground" />
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{(progress as any).gamesImported !== undefined ? 'Games' :
|
||||
(progress as any).moviesImported !== undefined ? 'Movies' :
|
||||
(progress as any).seriesImported !== undefined ? 'Series' :
|
||||
(progress as any).musicImported !== undefined ? 'Music' : 'Videos'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black text-zinc-900">{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported : progress.videosImported}</p>
|
||||
<p className="text-2xl font-black text-foreground">
|
||||
{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported :
|
||||
(progress as any).moviesImported !== undefined ? (progress as any).moviesImported :
|
||||
(progress as any).seriesImported !== undefined ? (progress as any).seriesImported :
|
||||
(progress as any).musicImported !== undefined ? (progress as any).musicImported : progress.videosImported}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-zinc-50 rounded-lg p-4">
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users size={16} className="text-zinc-400" />
|
||||
<span className="text-xs font-bold text-zinc-500">Actors</span>
|
||||
<Users size={16} className="text-muted-foreground" />
|
||||
<span className="text-xs font-bold text-muted-foreground">{(progress as any).castImported !== undefined ? 'Cast' : 'Actors'}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black text-zinc-900">{progress.actorsImported}</p>
|
||||
<p className="text-2xl font-black text-foreground">{(progress as any).castImported !== undefined ? (progress as any).castImported : progress.actorsImported}</p>
|
||||
</div>
|
||||
<div className="bg-zinc-50 rounded-lg p-4">
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle size={16} className="text-zinc-400" />
|
||||
<span className="text-xs font-bold text-zinc-500">Errors</span>
|
||||
<AlertCircle size={16} className="text-muted-foreground" />
|
||||
<span className="text-xs font-bold text-muted-foreground">Errors</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black text-zinc-900">{progress.errors.length}</p>
|
||||
<p className="text-2xl font-black text-foreground">{progress.errors.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function MediaCard({ media, onClick }: MediaCardProps) {
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className={cn(
|
||||
"relative rounded-lg overflow-hidden shadow-lg bg-zinc-800 transition-all duration-300",
|
||||
"relative rounded-lg overflow-hidden shadow-lg bg-card transition-all duration-300",
|
||||
aspectRatioClass
|
||||
)}>
|
||||
<img
|
||||
@@ -70,10 +70,10 @@ export default function MediaCard({ media, onClick }: MediaCardProps) {
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<h3 className="text-sm font-bold text-zinc-900 line-clamp-1 group-hover:text-[#6d28d9] transition-colors">
|
||||
<h3 className="text-sm font-bold text-foreground line-clamp-1 group-hover:text-[#6d28d9] transition-colors">
|
||||
{media.title}
|
||||
</h3>
|
||||
<p className="text-xs font-medium text-zinc-500">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{media.year}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -44,11 +44,11 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="group flex items-center gap-6 p-4 rounded-xl hover:bg-zinc-50 transition-colors cursor-pointer border border-transparent hover:border-zinc-200"
|
||||
className="group flex items-center gap-6 p-4 rounded-xl hover:bg-muted/50 transition-colors cursor-pointer border border-transparent hover:border-border"
|
||||
onClick={() => onClick(media)}
|
||||
>
|
||||
<div className={cn(
|
||||
"relative rounded-lg overflow-hidden shrink-0 shadow-md bg-zinc-800 transition-all duration-300",
|
||||
"relative rounded-lg overflow-hidden shrink-0 shadow-md bg-card transition-all duration-300",
|
||||
aspectRatioClass
|
||||
)}>
|
||||
<img
|
||||
@@ -67,32 +67,32 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="text-lg font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
<h3 className="text-lg font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
{media.title}
|
||||
</h3>
|
||||
<span className="text-sm font-bold text-zinc-400">({media.year})</span>
|
||||
<span className="text-sm font-bold text-muted-foreground">({media.year})</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className="flex items-center gap-1 text-xs font-bold text-zinc-500">
|
||||
<div className="flex items-center gap-1 text-xs font-bold text-muted-foreground">
|
||||
<Star size={14} className="text-yellow-500" fill="currentColor" />
|
||||
{media.rating || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs font-bold text-zinc-400 uppercase tracking-wider">
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{media.genres?.slice(0, 3).join(' • ') || 'Anime'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500 line-clamp-2 max-w-2xl">
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 max-w-2xl">
|
||||
{media.description || "No description available for this title."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Button size="icon" variant="ghost" className="rounded-full text-zinc-400 hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
|
||||
<Button size="icon" variant="ghost" className="rounded-full text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
|
||||
<Play size={18} fill="currentColor" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="rounded-full text-zinc-400 hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
|
||||
<Button size="icon" variant="ghost" className="rounded-full text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
|
||||
<Bookmark size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Film, Music, Book, Tv, Gamepad2, ShieldAlert, LayoutGrid, List, Globe, Monitor, Sun, Moon, Save, ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { fetchSettings, updateSettings } from '@/api';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
|
||||
Anime: <Tv size={18} />,
|
||||
@@ -31,9 +32,11 @@ interface SettingsViewProps {
|
||||
}
|
||||
|
||||
export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
const { setTheme } = useTheme();
|
||||
const [settings, setSettings] = useState<UserSettings>({
|
||||
enabledCategories: ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'],
|
||||
itemsPerPage: 20,
|
||||
gridItemSize: 5,
|
||||
defaultView: 'grid',
|
||||
showAdultContent: false,
|
||||
autoPlayTrailers: false,
|
||||
@@ -69,6 +72,8 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
if (savedSettings) {
|
||||
setSettings(savedSettings);
|
||||
setSaveStatus('success');
|
||||
// Sync theme with theme context
|
||||
setTheme(savedSettings.theme);
|
||||
onSettingsSaved?.();
|
||||
} else {
|
||||
setSaveStatus('error');
|
||||
@@ -93,26 +98,26 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-zinc-400 font-medium">Loading settings...</div>
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-muted-foreground font-medium">Loading settings...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pt-20">
|
||||
<div className="min-h-screen bg-background pt-20">
|
||||
{/* Content */}
|
||||
<div className="max-w-[1600px] mx-auto px-6 py-12">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 text-sm font-bold text-zinc-400 hover:text-[#6d28d9] transition-colors mb-2"
|
||||
className="inline-flex items-center gap-2 text-sm font-bold text-muted-foreground hover:text-[#6d28d9] transition-colors mb-2"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Back to home
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black text-zinc-900">Settings</h1>
|
||||
<h1 className="text-3xl font-black text-foreground">Settings</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
@@ -144,23 +149,23 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
<div className="grid gap-8">
|
||||
{/* Library Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-zinc-900 mb-6">Library Settings</h2>
|
||||
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100">
|
||||
<p className="text-sm font-medium text-zinc-500 mb-4">
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Library Settings</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-4">
|
||||
Toggle which media areas you want to see in your library.
|
||||
</p>
|
||||
<div className="grid gap-4">
|
||||
{(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => (
|
||||
<div key={category} className="flex items-center justify-between p-4 rounded-xl bg-white border border-zinc-100 transition-all hover:border-[#6d28d9]/20">
|
||||
<div key={category} className="flex items-center justify-between p-4 rounded-xl bg-background border border-border transition-all hover:border-[#6d28d9]/20">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-zinc-50 flex items-center justify-center text-[#6d28d9]">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center text-[#6d28d9]">
|
||||
{CATEGORY_ICONS[category]}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={category} className="text-sm font-black text-zinc-900 cursor-pointer">
|
||||
<Label htmlFor={category} className="text-sm font-black text-foreground cursor-pointer">
|
||||
{category}
|
||||
</Label>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
{settings.enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -178,11 +183,11 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
|
||||
{/* Display Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-zinc-900 mb-6">Display Settings</h2>
|
||||
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100 space-y-6">
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Display Settings</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border space-y-6">
|
||||
{/* Items per page */}
|
||||
<div>
|
||||
<Label className="text-sm font-black text-zinc-900 mb-2 block">Items per page</Label>
|
||||
<Label className="text-sm font-black text-foreground mb-2 block">Items per page</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{ITEMS_PER_PAGE_OPTIONS.map((option) => (
|
||||
<button
|
||||
@@ -191,7 +196,7 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${
|
||||
settings.itemsPerPage === option
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
@@ -202,14 +207,14 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
|
||||
{/* Default view */}
|
||||
<div>
|
||||
<Label className="text-sm font-black text-zinc-900 mb-2 block">Default view</Label>
|
||||
<Label className="text-sm font-black text-foreground mb-2 block">Default view</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||
settings.defaultView === 'grid'
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid size={18} />
|
||||
@@ -220,7 +225,7 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||
settings.defaultView === 'list'
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
<List size={18} />
|
||||
@@ -229,9 +234,27 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid item size */}
|
||||
<div>
|
||||
<Label className="text-sm font-black text-foreground mb-2 block">Grid item size</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-bold text-muted-foreground">Small</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={settings.gridItemSize}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, gridItemSize: Number(e.target.value) }))}
|
||||
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
|
||||
/>
|
||||
<span className="text-xs font-bold text-muted-foreground">Large</span>
|
||||
<span className="text-sm font-bold text-[#6d28d9] w-8 text-center">{settings.gridItemSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme */}
|
||||
<div>
|
||||
<Label className="text-sm font-black text-zinc-900 mb-2 block">Theme</Label>
|
||||
<Label className="text-sm font-black text-foreground mb-2 block">Theme</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['light', 'dark', 'system'] as const).map((theme) => (
|
||||
<button
|
||||
@@ -240,7 +263,7 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||
settings.theme === theme
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
{theme === 'light' && <Sun size={18} />}
|
||||
@@ -256,15 +279,15 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
|
||||
{/* Content Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-zinc-900 mb-6">Content Settings</h2>
|
||||
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100 space-y-4">
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Content Settings</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border space-y-4">
|
||||
{/* Show adult content */}
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white border border-zinc-100">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-background border border-border">
|
||||
<div>
|
||||
<Label htmlFor="showAdult" className="text-sm font-black text-zinc-900 cursor-pointer">
|
||||
<Label htmlFor="showAdult" className="text-sm font-black text-foreground cursor-pointer">
|
||||
Show adult content
|
||||
</Label>
|
||||
<p className="text-xs font-medium text-zinc-500 mt-1">
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">
|
||||
Display adult media in your library
|
||||
</p>
|
||||
</div>
|
||||
@@ -276,12 +299,12 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
</div>
|
||||
|
||||
{/* Auto-play trailers */}
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white border border-zinc-100">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-background border border-border">
|
||||
<div>
|
||||
<Label htmlFor="autoPlay" className="text-sm font-black text-zinc-900 cursor-pointer">
|
||||
<Label htmlFor="autoPlay" className="text-sm font-black text-foreground cursor-pointer">
|
||||
Auto-play trailers
|
||||
</Label>
|
||||
<p className="text-xs font-medium text-zinc-500 mt-1">
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">
|
||||
Automatically play trailers when viewing media
|
||||
</p>
|
||||
</div>
|
||||
@@ -296,11 +319,11 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
|
||||
{/* Language Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-zinc-900 mb-6">Language</h2>
|
||||
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100">
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Language</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Globe size={18} className="text-[#6d28d9]" />
|
||||
<Label className="text-sm font-black text-zinc-900">Interface language</Label>
|
||||
<Label className="text-sm font-black text-foreground">Interface language</Label>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
@@ -310,7 +333,7 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${
|
||||
settings.language === option.value
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
14
src/components/ui/loading.tsx
Normal file
14
src/components/ui/loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function Loading({ message = 'Loading...' }: LoadingProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="animate-spin h-12 w-12 text-[#6d28d9] mb-4" />
|
||||
<p className="text-lg font-bold">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/contexts/ThemeContext.tsx
Normal file
74
src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
effectiveTheme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem('theme') as Theme;
|
||||
return stored || 'system';
|
||||
});
|
||||
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
const applyTheme = () => {
|
||||
let resolved: 'light' | 'dark';
|
||||
|
||||
if (theme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
} else {
|
||||
resolved = theme;
|
||||
}
|
||||
|
||||
setEffectiveTheme(resolved);
|
||||
|
||||
if (resolved === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
|
||||
// Listen for system theme changes when in system mode
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
if (theme === 'system') {
|
||||
applyTheme();
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, effectiveTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
1328
src/lib/jellyfinImporter.ts
Normal file
1328
src/lib/jellyfinImporter.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
|
||||
export interface PlayniteConfig {
|
||||
ip: string;
|
||||
apiToken: string;
|
||||
port?: number;
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
export interface ImportProgress {
|
||||
@@ -191,6 +195,15 @@ export async function importFromPlaynite(
|
||||
const existingGame = existingMedia.get(game.name);
|
||||
const isUpdate = existingGame !== undefined;
|
||||
|
||||
// Skip if updateExisting is false and item already exists
|
||||
if (!config.updateExisting && isUpdate) {
|
||||
logCallback(`⊘ Skipped game: ${game.name} (already exists, updateExisting is false)`);
|
||||
progressCallback({
|
||||
current: i + 1
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse release date
|
||||
let year = new Date().getFullYear();
|
||||
@@ -241,7 +254,7 @@ export async function importFromPlaynite(
|
||||
series: game.series ? [game.series] : [],
|
||||
ageRatings: game.ageRatings || [],
|
||||
regions: game.regions || [],
|
||||
source: game.source || null,
|
||||
source: SOURCE_CATEGORY_MAPPING['playnite']?.includes('Games') ? (game.source || 'playnite') : null,
|
||||
gameId: game.id,
|
||||
pluginId: null,
|
||||
completionStatus: game.completionStatus || 'Not Played',
|
||||
@@ -267,7 +280,8 @@ export async function importFromPlaynite(
|
||||
achievements: [],
|
||||
year: year.toString(),
|
||||
poster: game.coverBase64 || null,
|
||||
banner: null,
|
||||
banner: game.backgroundBase64 || null,
|
||||
icon: game.iconBase64 || null,
|
||||
rating: rating,
|
||||
category: 'Game',
|
||||
status: game.completionStatus === 'Completed' ? 'completed' :
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
|
||||
export interface StashAPPConfig {
|
||||
url: string;
|
||||
apiKey?: string;
|
||||
blacklist?: ['/AI/', 'temp', 'backup'];
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
export interface ImportProgress {
|
||||
@@ -642,11 +646,14 @@ export async function importFromStashAPP(
|
||||
|
||||
// Check for duplicate
|
||||
if (existingTitles.has(scene.title)) {
|
||||
logCallback(`⊘ Skipped duplicate: ${scene.title}`);
|
||||
progressCallback({
|
||||
current: uniquePerformers.length + i + 1
|
||||
});
|
||||
continue;
|
||||
if (!config.updateExisting) {
|
||||
logCallback(`⊘ Skipped duplicate: ${scene.title} (updateExisting is false)`);
|
||||
progressCallback({
|
||||
current: uniquePerformers.length + i + 1
|
||||
});
|
||||
continue;
|
||||
}
|
||||
logCallback(`→ Updating existing: ${scene.title}`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -702,6 +709,7 @@ export async function importFromStashAPP(
|
||||
director: null,
|
||||
writer: null,
|
||||
releaseDate: releaseDate,
|
||||
source: SOURCE_CATEGORY_MAPPING['stashapp']?.includes('Adult') ? 'stashapp' : null,
|
||||
genres: [],
|
||||
tags: [],
|
||||
studios: [],
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
|
||||
export interface XBVRConfig {
|
||||
url: string;
|
||||
apiKey?: string;
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
export interface ImportProgress {
|
||||
@@ -255,11 +259,14 @@ export async function importFromXBVR(
|
||||
|
||||
// Check for duplicate
|
||||
if (existingTitles.has(video.title)) {
|
||||
logCallback(`⊘ Skipped duplicate: ${video.title}`);
|
||||
progressCallback({
|
||||
current: uniqueActors.length + i + 1
|
||||
});
|
||||
continue;
|
||||
if (!config.updateExisting) {
|
||||
logCallback(`⊘ Skipped duplicate: ${video.title} (updateExisting is false)`);
|
||||
progressCallback({
|
||||
current: uniqueActors.length + i + 1
|
||||
});
|
||||
continue;
|
||||
}
|
||||
logCallback(`→ Updating existing: ${video.title}`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -308,6 +315,7 @@ export async function importFromXBVR(
|
||||
director: null,
|
||||
writer: null,
|
||||
releaseDate: releaseDate,
|
||||
source: SOURCE_CATEGORY_MAPPING['xbvr']?.includes('Adult') ? 'xbvr' : null,
|
||||
genres: categories,
|
||||
tags: categories,
|
||||
studios: video.paysite?.name ? [video.paysite.name] : [],
|
||||
|
||||
30
src/types.ts
30
src/types.ts
@@ -16,6 +16,7 @@ export interface Media {
|
||||
studios?: string[];
|
||||
status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold';
|
||||
episodes?: Episode[];
|
||||
tracks?: Track[];
|
||||
staff?: Staff[];
|
||||
categories?: string[];
|
||||
platforms?: string[];
|
||||
@@ -28,15 +29,26 @@ export interface Media {
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
id: string;
|
||||
number: number;
|
||||
id: number;
|
||||
media_id: number;
|
||||
season: number;
|
||||
episode_number: number;
|
||||
title: string;
|
||||
date: string;
|
||||
duration: string;
|
||||
description: string;
|
||||
air_date: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: number;
|
||||
media_id: number;
|
||||
track_number: number;
|
||||
title: string;
|
||||
duration: number | null;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export interface Staff {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -100,11 +112,21 @@ export interface UserSettings {
|
||||
id?: number;
|
||||
enabledCategories: MediaCategory[];
|
||||
itemsPerPage: number;
|
||||
gridItemSize: number; // 1-10 scale
|
||||
defaultView: 'grid' | 'list';
|
||||
showAdultContent: boolean;
|
||||
autoPlayTrailers: boolean;
|
||||
language: string;
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
jellyfinLibraryMappings?: string; // JSON string of LibraryMapping[]
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// Source to Category mapping - ensures sources are only used with appropriate categories
|
||||
export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = {
|
||||
'xbvr': ['Adult'],
|
||||
'stashapp': ['Adult'],
|
||||
'playnite': ['Games'],
|
||||
'manual': ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Adult', 'Consoles', 'Games'],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user