Compare commits

..

13 Commits

Author SHA1 Message Date
Lars Behrends
6250164656 Add updateExisting option to importers & UI
Introduce an updateExisting flag across importers and the Importer UI to control whether existing items should be updated or only new items imported. Changes: added updateExisting to XBVR, StashAPP, Playnite, and Jellyfin config types; added checkboxes in ImporterView (enabled by default) to toggle the behavior; import logic now skips existing items when updateExisting is false and logs/skips appropriately (XBVR, StashAPP, Playnite, Jellyfin). Also: minor Playnite env parsing tweak for port (undefined when not provided) and small logging/cleanup in the Jellyfin album handling.
2026-04-12 02:57:34 +02:00
Lars Behrends
9c7e5a2b19 Add Jellyfin library mapping support
Add support for Jellyfin library-to-category mappings used during import. Key changes:

- UI: ImporterView now lets users fetch Jellyfin libraries, configure per-library category (TV/Anime/Movies/Music/skip) and optional path segments, and persists mappings to API settings and localStorage.
- API/types: Add jellyfin_library_mappings to ApiSettingsItem/CreateSettingsInput and UserSettings (JSON string of LibraryMapping[]), and wire conversion helpers in src/api.ts and src/types.ts.
- Jellyfin importer: Introduce LibraryMapping type, fetchJellyfinLibraries, helper functions to resolve library from ParentId or Path (extractLibraryFromPath, findLibraryForItem), and update item conversion (movies/series/albums) to apply mappings and skip items marked 'skip'. Import flow now fetches libraries to build id->name map and passes mappings through to converters.

This enables category-aware imports and allows skipping libraries during Jellyfin imports.
2026-04-12 02:08:31 +02:00
Lars Behrends
dff599e5af Add track support and UI list in DetailView
Introduce Track and ApiTrack types and add tracks to ApiMediaItem/Media. Map ApiMediaItem.tracks into Media in convertApiToMedia. Implement a conditional Tracks section in DetailView that lists sorted tracks with search input, track number, title, artist, duration and a play button (only shown when tracks exist). Files changed: src/types.ts, src/api.ts, src/components/DetailView.tsx.
2026-04-11 01:42:45 +02:00
Lars Behrends
6c316fbf84 Update DetailView.tsx 2026-04-11 01:27:58 +02:00
Lars Behrends
0d530ea99c Add Loading component and use across views
Introduce a reusable Loading component (src/components/ui/loading.tsx) that shows a spinning Loader2 icon and an optional message. Replace ad-hoc loading UIs by importing and using Loading in BrowseView and CastView. In App.tsx, add mediaLoading state (set around fetchAllMedia) and pass it to BrowseView; also add local loading states to MediaDetailRoute and CastDetailRoute to show Loading while fetching details. These changes centralize loading UX and remove duplicated spinner markup.
2026-04-11 01:26:41 +02:00
Lars Behrends
555209ed4b Add Jellyfin importer and UI improvements
Introduce a full Jellyfin importer and related UI enhancements.

- Add new lib/jellyfinImporter.ts: implements Jellyfin API clients, conversion helpers, and import/cleanup flows (movies, series, music, cast) with progress/log callbacks.
- Wire Jellyfin integration into ImporterView: add config/options state, import and cleanup handlers, and two new UI cards for importing and cleaning up Jellyfin media; adjust progress display to support different media types and cast naming.
- Update API types (src/api.ts) to include ApiEpisode and episodes on ApiMediaItem and propagate episodes through convertApiToMedia.
- Improve DetailView: add cast show/hide controls, display counts, use characterName when available, and format episode season/episode, air date and duration.
- Enhance Header: theme/scroll-aware styling, scroll listener, themed search/input/avatar styling, and improved nav color handling.
- Simplify MediaDetailRoute in App.tsx: always fetch media by id and remove allMedia dependency to avoid stale resolution.
- Update src/types.ts to support source/category mapping required by the Jellyfin importer.

These changes add Jellyfin as an import source and polish the app UI and detail handling for better UX and more complete media metadata.
2026-04-11 01:24:50 +02:00
Lars Behrends
52d272c701 Add filmography sorting and role-count UI
Introduce filmography sorting controls and role-count indicators across cast list and detail views. In CastDetailView: add sort state (sortBy/sortOrder), compute sortedFilmography, add UI for choosing sort key and toggling order, and show a filmography count badge; import new icons and useState. In CastView: add a new 'roleCount' sort option (default sort is now roleCount desc), persist/restore it from localStorage, adjust reset/default filter logic and hasActiveFilters check, render role-count badges for each person, and tweak layout (flex-1) for better truncation. These changes make it easier to surface prolific cast members and sort filmography entries by year, title, or role.
2026-04-11 00:47:04 +02:00
Lars Behrends
b36b72b8e0 Update DetailView.tsx 2026-04-11 00:39:36 +02:00
Lars Behrends
53c6f5c555 Add source field, UI filter, and import mapping
Introduce a 'source' property across types and API models, include it in convertApiToMedia, and add a Source input to AddMediaView. Add source-based filtering in BrowseView (dropdown + tag icon) and ensure Clear Filters resets source. Update playnite, stashapp, and xbvr importers to set source conditionally using a new SOURCE_CATEGORY_MAPPING constant (added to types) so sources are only applied for appropriate media categories.
2026-04-11 00:28:43 +02:00
Lars Behrends
f482807387 Add grid item size setting and UI
Introduce a persistent gridItemSize user setting (1-10) across the app. Updates include: types (UserSettings.gridItemSize), API mappings (grid_item_size in ApiSettingsItem, CreateSettingsInput, convertApiToSettings, convertSettingsToApi), default setting values, and the App handler (handleGridItemSizeChange) to save changes. UI additions: slider control in SettingsView, slider and value in BrowseView (with syncing to incoming API settings), passing the prop and change callback from App, and a mapping from slider values to responsive Tailwind grid column classes so the grid layout adapts to the chosen size. Also added syncing of itemsPerPage in BrowseView and CastView with API-loaded settings.
2026-04-10 23:31:24 +02:00
Lars Behrends
444c908449 Update AddMediaView.tsx 2026-04-10 15:06:20 +02:00
Lars Behrends
b29732a653 Introduce ThemeContext and apply theme tokens
Add a ThemeContext and provider, wrap the app with ThemeProvider, and sync user settings' theme into the context. Replace hardcoded color classes with design token classes (background, muted, foreground, border, card, etc.) across multiple UI components to centralize theming and enable consistent light/dark styling. Files updated include App.tsx (useTheme, setTheme, ThemeProvider, footer/background tokens), several views and components (AddMediaView, BrowseView, CastDetailView, CastView, MediaCard, MediaListItem, SettingsView, ImporterView) to use tokenized classes, and add new src/contexts/ThemeContext.tsx.
2026-04-10 14:59:40 +02:00
Lars Behrends
96593a6235 Update playniteImporter.ts 2026-04-10 14:50:18 +02:00
19 changed files with 3042 additions and 483 deletions

View File

@@ -14,14 +14,17 @@ import CastDetailView from './components/CastDetailView';
import AddMediaView from './components/AddMediaView'; import AddMediaView from './components/AddMediaView';
import ImporterView from './components/ImporterView'; import ImporterView from './components/ImporterView';
import SettingsView from './components/SettingsView'; import SettingsView from './components/SettingsView';
import Loading from './components/ui/loading';
import { MOCK_MEDIA, DETAIL_MEDIA } from './data'; import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory, UserSettings } from './types'; import { Media, Staff, MediaCategory, UserSettings } from './types';
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api'; import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
function AppContent() { function AppContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { setTheme } = useTheme();
const [activeCategory, setActiveCategory] = useState<MediaCategory>( const [activeCategory, setActiveCategory] = useState<MediaCategory>(
(searchParams.get('category') as MediaCategory) || 'Anime' (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) // Load media from API on component mount (only when not on cast routes)
const [apiMedia, setApiMedia] = useState<Media[]>([]); const [apiMedia, setApiMedia] = useState<Media[]>([]);
const [mediaLoading, setMediaLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadSettingsFromApi = async () => { const loadSettingsFromApi = async () => {
@@ -43,6 +47,8 @@ function AppContent() {
if (loadedSettings) { if (loadedSettings) {
setSettings(loadedSettings); setSettings(loadedSettings);
setEnabledCategories(loadedSettings.enabledCategories); setEnabledCategories(loadedSettings.enabledCategories);
// Sync theme with theme context
setTheme(loadedSettings.theme);
} }
} catch (error) { } catch (error) {
console.error('Failed to load settings from API:', error); console.error('Failed to load settings from API:', error);
@@ -50,7 +56,7 @@ function AppContent() {
}; };
loadSettingsFromApi(); loadSettingsFromApi();
}, []); }, [setTheme]);
const reloadSettings = async () => { const reloadSettings = async () => {
try { try {
@@ -58,6 +64,8 @@ function AppContent() {
if (loadedSettings) { if (loadedSettings) {
setSettings(loadedSettings); setSettings(loadedSettings);
setEnabledCategories(loadedSettings.enabledCategories); setEnabledCategories(loadedSettings.enabledCategories);
// Sync theme with theme context
setTheme(loadedSettings.theme);
} }
} catch (error) { } catch (error) {
console.error('Failed to reload settings from API:', error); console.error('Failed to reload settings from API:', error);
@@ -66,11 +74,14 @@ function AppContent() {
useEffect(() => { useEffect(() => {
const loadMediaFromApi = async () => { const loadMediaFromApi = async () => {
setMediaLoading(true);
try { try {
const media = await fetchAllMedia(); const media = await fetchAllMedia();
setApiMedia(media); setApiMedia(media);
} catch (error) { } catch (error) {
console.error('Failed to load media from API:', error); console.error('Failed to load media from API:', error);
} finally {
setMediaLoading(false);
} }
}; };
@@ -97,6 +108,7 @@ function AppContent() {
const baseSettings = settings || { const baseSettings = settings || {
enabledCategories: prev, enabledCategories: prev,
itemsPerPage: 20, itemsPerPage: 20,
gridItemSize: 5,
defaultView: 'grid', defaultView: 'grid',
showAdultContent: false, showAdultContent: false,
autoPlayTrailers: 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 allStaff = useMemo(() => {
const staff: Staff[] = []; const staff: Staff[] = [];
// Use API data if available, otherwise fall back to mock data // Use API data if available, otherwise fall back to mock data
@@ -270,7 +304,7 @@ function AppContent() {
}; };
return ( 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 <Header
onSearch={handleSearch} onSearch={handleSearch}
activeCategory={activeCategory} activeCategory={activeCategory}
@@ -289,6 +323,9 @@ function AppContent() {
onMediaClick={handleMediaClick} onMediaClick={handleMediaClick}
activeCategory={activeCategory} activeCategory={activeCategory}
itemsPerPage={settings?.itemsPerPage} itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/> />
} /> } />
<Route path="/media/:id" element={ <Route path="/media/:id" element={
@@ -315,6 +352,7 @@ function AppContent() {
<Route path="/add" element={ <Route path="/add" element={
<AddMediaView <AddMediaView
activeCategory={activeCategory} activeCategory={activeCategory}
enabledCategories={enabledCategories}
onAddComplete={handleAddMedia} onAddComplete={handleAddMedia}
/> />
} /> } />
@@ -329,18 +367,18 @@ function AppContent() {
</main> </main>
{/* Footer */} {/* 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="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="flex items-center gap-2 text-xl font-black text-muted-foreground">
<div className="w-5 h-5 bg-zinc-300 rounded-full" /> <div className="w-5 h-5 bg-muted rounded-full" />
kyoo kyoo
</div> </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">Terms</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors">Privacy</a> <a href="#" className="hover:text-[#6d28d9] transition-colors">Privacy</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors">Contact</a> <a href="#" className="hover:text-[#6d28d9] transition-colors">Contact</a>
</div> </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. © 2026 Kyoo Media Discovery. All rights reserved.
</p> </p>
</div> </div>
@@ -353,33 +391,31 @@ function AppContent() {
function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) { function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadMedia = async () => { const loadMedia = async () => {
if (id) { if (id) {
// First check if media is in allMedia setLoading(true);
const media = allMedia.find(m => m.id === id); try {
if (media) { const fetchedMedia = await fetchMediaById(id);
setSelectedMedia(media); if (fetchedMedia) {
} else { setSelectedMedia(fetchedMedia);
// If not found, fetch from API } else {
try {
const fetchedMedia = await fetchMediaById(id);
if (fetchedMedia) {
setSelectedMedia(fetchedMedia);
} else {
navigate('/');
}
} catch (error) {
console.error('Failed to fetch media:', error);
navigate('/'); navigate('/');
} }
} catch (error) {
console.error('Failed to fetch media:', error);
navigate('/');
} finally {
setLoading(false);
} }
} }
}; };
loadMedia(); loadMedia();
}, [id, allMedia]); }, [id]);
if (loading) return <Loading message="Loading media details..." />;
if (!selectedMedia) return null; if (!selectedMedia) return null;
return ( return (
@@ -394,10 +430,12 @@ function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonC
function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) { function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadCast = async () => { const loadCast = async () => {
if (id) { if (id) {
setLoading(true);
try { try {
const castData = await fetchCastById(id); const castData = await fetchCastById(id);
if (castData) { if (castData) {
@@ -409,12 +447,15 @@ function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
} catch (error) { } catch (error) {
console.error('Failed to load cast:', error); console.error('Failed to load cast:', error);
navigate('/cast'); navigate('/cast');
} finally {
setLoading(false);
} }
} }
}; };
loadCast(); loadCast();
}, [id]); }, [id]);
if (loading) return <Loading message="Loading cast details..." />;
if (!selectedPerson) return null; if (!selectedPerson) return null;
return ( return (
@@ -428,7 +469,9 @@ function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AppContent /> <ThemeProvider>
<AppContent />
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
); );
} }

View File

@@ -27,6 +27,27 @@ export interface PaginatedResponse<T> {
} }
// Media Types // 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 { export interface ApiMediaItem {
id: number; id: number;
title: string; title: string;
@@ -43,6 +64,7 @@ export interface ApiMediaItem {
director: string | null; director: string | null;
writer: string | null; writer: string | null;
releaseDate: string | null; releaseDate: string | null;
source?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
genres?: string[]; genres?: string[];
@@ -53,10 +75,11 @@ export interface ApiMediaItem {
platforms?: string[]; platforms?: string[];
developers?: string[]; developers?: string[];
completionStatus?: string; completionStatus?: string;
source?: string;
playCount?: number; playCount?: number;
lastActivity?: string | null; lastActivity?: string | null;
playtime?: number; playtime?: number;
episodes?: ApiEpisode[];
tracks?: ApiTrack[];
} }
export interface ApiStaff { export interface ApiStaff {
@@ -87,6 +110,7 @@ export interface CreateMediaInput {
director?: string | null; director?: string | null;
writer?: string | null; writer?: string | null;
releaseDate?: string | null; releaseDate?: string | null;
source?: string | null;
genres?: string[]; genres?: string[];
tags?: string[]; tags?: string[];
studios?: string[]; studios?: string[];
@@ -309,6 +333,7 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
tags: apiItem.tags || [], tags: apiItem.tags || [],
studios: apiItem.studios, studios: apiItem.studios,
type: mediaType, type: mediaType,
source: apiItem.source || undefined,
status: mediaStatus, status: mediaStatus,
staff: staff.length > 0 ? staff : undefined, staff: staff.length > 0 ? staff : undefined,
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
@@ -316,10 +341,11 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
platforms: apiItem.platforms, platforms: apiItem.platforms,
developers: apiItem.developers, developers: apiItem.developers,
completionStatus: apiItem.completionStatus, completionStatus: apiItem.completionStatus,
source: apiItem.source,
playCount: apiItem.playCount, playCount: apiItem.playCount,
lastActivity: apiItem.lastActivity, lastActivity: apiItem.lastActivity,
playtime: apiItem.playtime playtime: apiItem.playtime,
episodes: apiItem.episodes,
tracks: apiItem.tracks
}; };
} }
@@ -631,11 +657,13 @@ export interface ApiSettingsItem {
id?: number; id?: number;
enabled_categories: string[]; enabled_categories: string[];
items_per_page: number; items_per_page: number;
grid_item_size?: number;
default_view: string; default_view: string;
show_adult_content: boolean; show_adult_content: boolean;
auto_play_trailers: boolean; auto_play_trailers: boolean;
language: string; language: string;
theme: string; theme: string;
jellyfin_library_mappings?: string; // JSON string of LibraryMapping[]
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
@@ -643,11 +671,13 @@ export interface ApiSettingsItem {
export interface CreateSettingsInput { export interface CreateSettingsInput {
enabled_categories: string[]; enabled_categories: string[];
items_per_page?: number; items_per_page?: number;
grid_item_size?: number;
default_view?: string; default_view?: string;
show_adult_content?: boolean; show_adult_content?: boolean;
auto_play_trailers?: boolean; auto_play_trailers?: boolean;
language?: string; language?: string;
theme?: string; theme?: string;
jellyfin_library_mappings?: string;
} }
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {} export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
@@ -657,11 +687,13 @@ export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
id: apiItem.id, id: apiItem.id,
enabledCategories: apiItem.enabled_categories as MediaCategory[], enabledCategories: apiItem.enabled_categories as MediaCategory[],
itemsPerPage: apiItem.items_per_page || 20, itemsPerPage: apiItem.items_per_page || 20,
gridItemSize: apiItem.grid_item_size || 5,
defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid', defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid',
showAdultContent: apiItem.show_adult_content || false, showAdultContent: apiItem.show_adult_content || false,
autoPlayTrailers: apiItem.auto_play_trailers || false, autoPlayTrailers: apiItem.auto_play_trailers || false,
language: apiItem.language || 'en', language: apiItem.language || 'en',
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system', theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
jellyfinLibraryMappings: apiItem.jellyfin_library_mappings,
createdAt: apiItem.created_at, createdAt: apiItem.created_at,
updatedAt: apiItem.updated_at, updatedAt: apiItem.updated_at,
}; };
@@ -671,11 +703,13 @@ export function convertSettingsToApi(settings: UserSettings): CreateSettingsInpu
return { return {
enabled_categories: settings.enabledCategories, enabled_categories: settings.enabledCategories,
items_per_page: settings.itemsPerPage, items_per_page: settings.itemsPerPage,
grid_item_size: settings.gridItemSize,
default_view: settings.defaultView, default_view: settings.defaultView,
show_adult_content: settings.showAdultContent, show_adult_content: settings.showAdultContent,
auto_play_trailers: settings.autoPlayTrailers, auto_play_trailers: settings.autoPlayTrailers,
language: settings.language, language: settings.language,
theme: settings.theme, 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> { export async function createSettings(settings: UserSettings): Promise<UserSettings | null> {
try { try {
const apiSettings = convertSettingsToApi(settings); const apiSettings = convertSettingsToApi(settings);
console.log('Creating settings:', apiSettings);
const response = await fetch(`${BASE_URL}/api/settings`, { const response = await fetch(`${BASE_URL}/api/settings`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -713,14 +746,12 @@ export async function createSettings(settings: UserSettings): Promise<UserSettin
}, },
body: JSON.stringify(apiSettings), body: JSON.stringify(apiSettings),
}); });
console.log('Create settings response status:', response.status);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error('Create settings error response:', errorText); console.error('Create settings error response:', errorText);
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const data: ApiResponse<ApiSettingsItem> = await response.json(); const data: ApiResponse<ApiSettingsItem> = await response.json();
console.log('Create settings response:', data);
if (data.success && data.data) { if (data.success && data.data) {
return convertApiToSettings(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> { export async function updateSettings(settings: UserSettings): Promise<UserSettings | null> {
try { try {
const apiSettings = convertSettingsToApi(settings); const apiSettings = convertSettingsToApi(settings);
console.log('Updating settings:', apiSettings);
const response = await fetch(`${BASE_URL}/api/settings`, { const response = await fetch(`${BASE_URL}/api/settings`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -743,11 +773,9 @@ export async function updateSettings(settings: UserSettings): Promise<UserSettin
}, },
body: JSON.stringify(apiSettings), body: JSON.stringify(apiSettings),
}); });
console.log('Update settings response status:', response.status);
if (!response.ok) { if (!response.ok) {
// If settings don't exist (404), try creating them instead // If settings don't exist (404), try creating them instead
if (response.status === 404) { if (response.status === 404) {
console.log('Settings not found, attempting to create...');
return createSettings(settings); return createSettings(settings);
} }
const errorText = await response.text(); 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}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const data: ApiResponse<ApiSettingsItem> = await response.json(); const data: ApiResponse<ApiSettingsItem> = await response.json();
console.log('Update settings response:', data);
if (data.success && data.data) { if (data.success && data.data) {
return convertApiToSettings(data.data); return convertApiToSettings(data.data);

View File

@@ -10,10 +10,11 @@ import { cn } from '@/lib/utils';
interface AddMediaViewProps { interface AddMediaViewProps {
activeCategory: MediaCategory; activeCategory: MediaCategory;
enabledCategories: MediaCategory[];
onAddComplete: () => void; onAddComplete: () => void;
} }
export default function AddMediaView({ activeCategory, onAddComplete }: AddMediaViewProps) { export default function AddMediaView({ activeCategory, enabledCategories, onAddComplete }: AddMediaViewProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [newMedia, setNewMedia] = useState({ const [newMedia, setNewMedia] = useState({
title: '', title: '',
@@ -30,6 +31,7 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
director: '', director: '',
writer: '', writer: '',
releaseDate: '', releaseDate: '',
source: '' as string,
genres: '' as string, genres: '' as string,
tags: '' as string, tags: '' as string,
studios: '' as string studios: '' as string
@@ -37,6 +39,29 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState(''); 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 // Update category, default aspect ratio, and default type when activeCategory changes
useEffect(() => { useEffect(() => {
@@ -105,9 +130,16 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
director: newMedia.director || null, director: newMedia.director || null,
writer: newMedia.writer || null, writer: newMedia.writer || null,
releaseDate: newMedia.releaseDate || null, releaseDate: newMedia.releaseDate || null,
source: newMedia.source || null,
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [], genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.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 { try {
@@ -133,10 +165,12 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
director: '', director: '',
writer: '', writer: '',
releaseDate: '', releaseDate: '',
source: '',
genres: '', genres: '',
tags: '', tags: '',
studios: '' studios: ''
}); });
setStaff([]);
} }
} catch (error) { } catch (error) {
setSubmitStatus('error'); setSubmitStatus('error');
@@ -151,62 +185,62 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
<Button <Button
variant="ghost" variant="ghost"
onClick={() => navigate('/')} 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} /> <ArrowLeft size={20} />
Back to Browse Back to Browse
</Button> </Button>
<div className="bg-white rounded-3xl shadow-xl p-8"> <div className="bg-card rounded-3xl shadow-xl p-8 border border-border">
<h1 className="text-3xl font-black text-zinc-900 mb-2">Add New Media</h1> <h1 className="text-3xl font-black text-foreground mb-2">Add New Media</h1>
<p className="text-zinc-500 font-medium mb-8"> <p className="text-muted-foreground font-medium mb-8">
Add a new item to your {activeCategory} library. Add a new item to your {activeCategory} library.
</p> </p>
{submitStatus === 'success' && ( {submitStatus === 'success' && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl"> <div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl">
<p className="text-green-800 font-bold"> Successfully added to library!</p> <p className="text-green-500 font-bold"> Successfully added to library!</p>
</div> </div>
)} )}
{submitStatus === 'error' && ( {submitStatus === 'error' && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl"> <div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl">
<p className="text-red-800 font-bold"> Error: {errorMessage}</p> <p className="text-red-500 font-bold"> Error: {errorMessage}</p>
</div> </div>
)} )}
<form onSubmit={handleAddSubmit} className="space-y-6"> <form onSubmit={handleAddSubmit} className="space-y-6">
<div className="grid gap-2"> <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 <Input
id="title" id="title"
value={newMedia.title} value={newMedia.title}
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
placeholder="e.g. Mob Psycho 100" 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 required
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <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 <Input
id="year" id="year"
value={newMedia.year} value={newMedia.year}
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
placeholder="2024" 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>
<div className="grid gap-2"> <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 <select
id="category" id="category"
value={newMedia.category} value={newMedia.category}
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))} 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> <option key={cat} value={cat}>{cat}</option>
))} ))}
</select> </select>
@@ -214,12 +248,12 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <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 <select
id="type" id="type"
value={newMedia.type} value={newMedia.type}
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))} 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' ? ( {newMedia.category === 'Music' ? (
<> <>
@@ -253,12 +287,12 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
</select> </select>
</div> </div>
<div className="grid gap-2"> <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 <select
id="status" id="status"
value={newMedia.status} value={newMedia.status}
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))} 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="Released">Released</option>
<option value="Ongoing">Ongoing</option> <option value="Ongoing">Ongoing</option>
@@ -274,12 +308,12 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
</div> </div>
</div> </div>
<div className="grid gap-2"> <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 <select
id="aspectRatio" id="aspectRatio"
value={newMedia.aspectRatio} value={newMedia.aspectRatio}
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))} 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="2/3">2:3 (Standard Poster)</option>
<option value="16/9">16:9 (Wide Thumbnail)</option> <option value="16/9">16:9 (Wide Thumbnail)</option>
@@ -287,38 +321,38 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
</select> </select>
</div> </div>
<div className="grid gap-2"> <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 <Input
id="poster" id="poster"
value={newMedia.poster} value={newMedia.poster}
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
placeholder="https://example.com/poster.jpg" 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 required
/> />
</div> </div>
<div className="grid gap-2"> <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 <Input
id="banner" id="banner"
value={newMedia.banner} value={newMedia.banner}
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
placeholder="https://example.com/banner.jpg" 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>
<div className="grid gap-2"> <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 <textarea
id="description" id="description"
value={newMedia.description} value={newMedia.description}
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
placeholder="Brief description..." 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>
<div className="grid gap-2"> <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 <Input
id="rating" id="rating"
type="number" type="number"
@@ -328,86 +362,202 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
value={newMedia.rating} value={newMedia.rating}
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
placeholder="8.5" 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> </div>
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && ( {(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
<> <>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <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 <Input
id="runtime" id="runtime"
type="number" type="number"
value={newMedia.runtime} value={newMedia.runtime}
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
placeholder="120" 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>
<div className="grid gap-2"> <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 <Input
id="releaseDate" id="releaseDate"
type="date" type="date"
value={newMedia.releaseDate} value={newMedia.releaseDate}
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))} 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> </div>
<div className="grid gap-2"> <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 <Input
id="director" id="director"
value={newMedia.director} value={newMedia.director}
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
placeholder="Director name" 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>
<div className="grid gap-2"> <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 <Input
id="writer" id="writer"
value={newMedia.writer} value={newMedia.writer}
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
placeholder="Writer name" 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>
</> </>
)} )}
<div className="grid gap-2"> <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 <Input
id="genres" id="genres"
value={newMedia.genres} value={newMedia.genres}
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
placeholder="Action, Drama, Sci-Fi" 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>
<div className="grid gap-2"> <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 <Input
id="tags" id="tags"
value={newMedia.tags} value={newMedia.tags}
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
placeholder="Classic, Best-selling" 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>
<div className="grid gap-2"> <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 <Input
id="studios" id="studios"
value={newMedia.studios} value={newMedia.studios}
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
placeholder="Studio A, Studio B" 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>
<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 <Button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}

View File

@@ -1,8 +1,9 @@
import { Media, MediaCategory } from '@/types'; import { Media, MediaCategory } from '@/types';
import MediaCard from './MediaCard'; import MediaCard from './MediaCard';
import MediaListItem from './MediaListItem'; import MediaListItem from './MediaListItem';
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, 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 { Button } from '@/components/ui/button';
import Loading from '@/components/ui/loading';
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { import {
DropdownMenu, DropdownMenu,
@@ -18,20 +19,39 @@ interface BrowseViewProps {
onMediaClick: (media: Media) => void; onMediaClick: (media: Media) => void;
activeCategory: MediaCategory; activeCategory: MediaCategory;
itemsPerPage?: number; 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 [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage); const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
const [sortBy, setSortBy] = useState<string>('default'); 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 // Filter states
const [selectedGenre, setSelectedGenre] = useState<string | null>(null); const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
const [selectedStudio, setSelectedStudio] = useState<string | null>(null); const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null); const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null); const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
// Extract unique values for filters // Extract unique values for filters
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]); 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 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 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 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(() => { const filteredMedia = useMemo(() => {
return mediaList.filter(media => { 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 (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false; if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false; if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
if (selectedSource && media.source !== selectedSource) return false;
return true; return true;
}); });
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory]); }, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory, selectedSource]);
// Reset to first page when mediaList or filters change // Reset to first page when mediaList or filters change
useEffect(() => { useEffect(() => {
@@ -67,6 +89,23 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
return list; return list;
}, [filteredMedia, sortBy]); }, [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 totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
const paginatedMedia = useMemo(() => { const paginatedMedia = useMemo(() => {
@@ -92,7 +131,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
{/* Genre Filter */} {/* Genre Filter */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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} /> <Star size={16} />
{selectedGenre || 'Genres'} {selectedGenre || 'Genres'}
</button> </button>
@@ -108,7 +147,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
{/* Studio Filter */} {/* Studio Filter */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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 Studios
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -124,7 +163,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
{activeCategory === 'Games' && ( {activeCategory === 'Games' && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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} /> <Monitor size={16} />
{selectedPlatform || 'Platforms'} {selectedPlatform || 'Platforms'}
</button> </button>
@@ -142,7 +181,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
{activeCategory === 'Games' && ( {activeCategory === 'Games' && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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} /> <Users size={16} />
{selectedDeveloper || 'Developers'} {selectedDeveloper || 'Developers'}
</button> </button>
@@ -160,7 +199,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
{activeCategory === 'Games' && ( {activeCategory === 'Games' && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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} /> <FolderTree size={16} />
{selectedCategory || 'Categories'} {selectedCategory || 'Categories'}
</button> </button>
@@ -174,17 +213,36 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
</DropdownMenu> </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 <Button
variant="link" variant="link"
size="sm" size="sm"
className="text-zinc-400 font-bold" className="text-muted-foreground font-bold"
onClick={() => { onClick={() => {
setSelectedGenre(null); setSelectedGenre(null);
setSelectedStudio(null); setSelectedStudio(null);
setSelectedPlatform(null); setSelectedPlatform(null);
setSelectedDeveloper(null); setSelectedDeveloper(null);
setSelectedCategory(null); setSelectedCategory(null);
setSelectedSource(null);
}} }}
> >
Clear Filters Clear Filters
@@ -193,9 +251,27 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
</div> </div>
<div className="flex items-center gap-4"> <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> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 text-zinc-600 font-bold gap-2"> <button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 text-muted-foreground font-bold gap-2">
<ArrowUpDown size={16} /> <ArrowUpDown size={16} />
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'} {sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
</button> </button>
@@ -207,13 +283,13 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 transition-all", "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')} onClick={() => setViewMode('grid')}
> >
@@ -224,7 +300,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 transition-all", "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')} onClick={() => setViewMode('list')}
> >
@@ -235,9 +311,11 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
</div> </div>
{/* Content */} {/* Content */}
{mediaList.length === 0 ? ( {loading ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400"> <Loading message="Loading media..." />
<div className="w-16 h-16 bg-zinc-100 rounded-full flex items-center justify-center mb-4"> ) : 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} /> <Search size={32} />
</div> </div>
<p className="text-lg font-bold">No results found</p> <p className="text-lg font-bold">No results found</p>
@@ -245,8 +323,8 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
</div> </div>
) : ( ) : (
<div className={cn( <div className={cn(
viewMode === 'grid' 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" ? cn(gridColsClass, "gap-x-4 gap-y-8")
: "flex flex-col gap-2" : "flex flex-col gap-2"
)}> )}>
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
@@ -271,16 +349,16 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
{/* Pagination Controls */} {/* Pagination Controls */}
{mediaList.length > 0 && ( {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"> <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 <select
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => { onChange={(e) => {
setItemsPerPage(Number(e.target.value)); setItemsPerPage(Number(e.target.value));
setCurrentPage(1); 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 => ( {[12, 20, 36, 48, 60].map(size => (
<option key={size} value={size}>{size}</option> <option key={size} value={size}>{size}</option>
@@ -294,7 +372,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
size="sm" size="sm"
onClick={handlePrevPage} onClick={handlePrevPage}
disabled={currentPage === 1} disabled={currentPage === 1}
className="gap-2 font-bold border-zinc-200" className="gap-2 font-bold border-border"
> >
<ChevronLeft size={16} /> <ChevronLeft size={16} />
Previous Previous
@@ -302,8 +380,8 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span> <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 text-muted-foreground font-medium">of</span>
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span> <span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
</div> </div>
<Button <Button
@@ -311,7 +389,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
size="sm" size="sm"
onClick={handleNextPage} onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0} disabled={currentPage === totalPages || totalPages === 0}
className="gap-2 font-bold border-zinc-200" className="gap-2 font-bold border-border"
> >
Next Next
<ChevronRight size={16} /> <ChevronRight size={16} />

View File

@@ -1,9 +1,10 @@
import { Staff, Media } from '@/types'; import { Staff, Media } from '@/types';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { motion } from 'motion/react'; 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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useState } from 'react';
interface CastDetailViewProps { interface CastDetailViewProps {
person: Staff; person: Staff;
@@ -12,12 +13,26 @@ interface CastDetailViewProps {
export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) { export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [sortBy, setSortBy] = useState<'year' | 'title' | 'role'>('role');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const handleMediaClick = (mediaId: string) => { const handleMediaClick = (mediaId: string) => {
navigate(`/media/${mediaId}`); 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 ( return (
<div className="min-h-screen bg-white pb-20"> <div className="min-h-screen bg-background pb-20">
{/* Hero Section */} {/* Hero Section */}
<div className="relative h-[40vh] md:h-[50vh] overflow-hidden bg-zinc-900"> <div className="relative h-[40vh] md:h-[50vh] overflow-hidden bg-zinc-900">
<img <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" className="w-full h-full object-cover opacity-40 blur-xl scale-110"
referrerPolicy="no-referrer" 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="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"> <div className="max-w-[1200px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-8">
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} 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 <img
src={person.photo} src={person.photo}
@@ -49,7 +64,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }} 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} {person.name}
</h1> </h1>
<div className="flex flex-wrap justify-center md:justify-start gap-3"> <div className="flex flex-wrap justify-center md:justify-start gap-3">
@@ -58,6 +73,11 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{occ} {occ}
</Badge> </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> </div>
</motion.div> </motion.div>
</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"> <div className="max-w-[1200px] mx-auto px-6 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Sidebar Info */} {/* Sidebar Info */}
<div className="space-y-8"> <div className="space-y-8">
<div className="bg-zinc-50 rounded-3xl p-8 space-y-6"> <div className="bg-muted/50 rounded-3xl p-8 space-y-6 border border-border">
<h3 className="text-xl font-black text-zinc-900">Personal Info</h3> <h3 className="text-xl font-black text-foreground">Personal Info</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start gap-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} /> <Calendar size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Date</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Date</p>
<p className="font-bold text-zinc-700">{person.birthDate || 'Unknown'}</p> <p className="font-bold text-foreground">{person.birthDate || 'Unknown'}</p>
</div> </div>
</div> </div>
<div className="flex items-start gap-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">
<MapPin size={20} /> <MapPin size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Place</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Place</p>
<p className="font-bold text-zinc-700">{person.birthPlace || 'Unknown'}</p> <p className="font-bold text-foreground">{person.birthPlace || 'Unknown'}</p>
</div> </div>
</div> </div>
<div className="flex items-start gap-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">
<Briefcase size={20} /> <Briefcase size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Known For</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Known For</p>
<p className="font-bold text-zinc-700">{person.role}</p> <p className="font-bold text-foreground">{person.role}</p>
</div> </div>
</div> </div>
{(person.ethnicity || person.adult_specifics?.ethnicity) && ( {(person.ethnicity || person.adult_specifics?.ethnicity) && (
<div className="flex items-start gap-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">
<User size={20} /> <User size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Ethnicity</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Ethnicity</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.ethnicity || person.ethnicity}</p> <p className="font-bold text-foreground">{person.adult_specifics?.ethnicity || person.ethnicity}</p>
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="bg-zinc-50 rounded-3xl p-8 space-y-6"> <div className="bg-muted/50 rounded-3xl p-8 space-y-6 border border-border">
<h3 className="text-xl font-black text-zinc-900">Measurements</h3> <h3 className="text-xl font-black text-foreground">Measurements</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start gap-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} /> <Ruler size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Height</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Height</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.height || person.height} cm</p> <p className="font-bold text-foreground">{person.adult_specifics?.height || person.height} cm</p>
</div> </div>
</div> </div>
{(person.weight || person.adult_specifics?.weight) && ( {(person.weight || person.adult_specifics?.weight) && (
<div className="flex items-start gap-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} /> <Ruler size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Weight</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Weight</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.weight || person.weight} kg</p> <p className="font-bold text-foreground">{person.adult_specifics?.weight || person.weight} kg</p>
</div> </div>
</div> </div>
)} )}
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && ( {(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="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} /> <Ruler size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Measurements</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Measurements</p>
<p className="font-bold text-zinc-700"> <p className="font-bold text-foreground">
{person.adult_specifics?.measurements || ( {person.adult_specifics?.measurements || (
<> <>
{person.bust_size && `${person.bust_size}`} {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) && ( {(person.hair_color || person.adult_specifics?.hair_color) && (
<div className="flex items-start gap-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">
<Palette size={20} /> <Palette size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Hair Color</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Hair Color</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.hair_color || person.hair_color}</p> <p className="font-bold text-foreground">{person.adult_specifics?.hair_color || person.hair_color}</p>
</div> </div>
</div> </div>
)} )}
{(person.eye_color || person.adult_specifics?.eye_color) && ( {(person.eye_color || person.adult_specifics?.eye_color) && (
<div className="flex items-start gap-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">
<Eye size={20} /> <Eye size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Eye Color</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Eye Color</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.eye_color || person.eye_color}</p> <p className="font-bold text-foreground">{person.adult_specifics?.eye_color || person.eye_color}</p>
</div> </div>
</div> </div>
)} )}
{person.adult_specifics?.tattoos && ( {person.adult_specifics?.tattoos && (
<div className="flex items-start gap-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">
<Palette size={20} /> <Palette size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Tattoos</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Tattoos</p>
<p className="font-bold text-zinc-700">{person.adult_specifics.tattoos}</p> <p className="font-bold text-foreground">{person.adult_specifics.tattoos}</p>
</div> </div>
</div> </div>
)} )}
{person.adult_specifics?.piercings && ( {person.adult_specifics?.piercings && (
<div className="flex items-start gap-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">
<Palette size={20} /> <Palette size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Piercings</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Piercings</p>
<p className="font-bold text-zinc-700">{person.adult_specifics.piercings}</p> <p className="font-bold text-foreground">{person.adult_specifics.piercings}</p>
</div> </div>
</div> </div>
)} )}
@@ -232,10 +252,10 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
<div className="lg:col-span-2 space-y-12"> <div className="lg:col-span-2 space-y-12">
{person.bio && ( {person.bio && (
<section> <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 Biography
</h2> </h2>
<p className="text-zinc-600 leading-relaxed text-lg"> <p className="text-foreground leading-relaxed text-lg">
{person.bio} {person.bio}
</p> </p>
</section> </section>
@@ -243,7 +263,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{person.filmography && person.filmography.length > 0 && ( {person.filmography && person.filmography.length > 0 && (
<section> <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]" /> <User className="text-[#6d28d9]" />
Characters Characters
</h2> </h2>
@@ -251,9 +271,9 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{person.filmography.map(item => ( {person.filmography.map(item => (
<div <div
key={`${item.id}-char`} 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 <img
src={item.poster || person.photo} src={item.poster || person.photo}
alt={item.title} alt={item.title}
@@ -262,14 +282,19 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
/> />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Character</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1">Character</p>
<h4 className="font-black text-zinc-900 truncate">{item.characterName || item.role}</h4> <h4 className="font-black text-foreground truncate">{item.characterName || item.role}</h4>
<button <button
onClick={() => handleMediaClick(item.id.toString())} onClick={() => handleMediaClick(item.id.toString())}
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left" className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left"
> >
in {item.title} in {item.title}
</button> </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>
</div> </div>
))} ))}
@@ -279,16 +304,37 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{person.filmography && person.filmography.length > 0 && ( {person.filmography && person.filmography.length > 0 && (
<section> <section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3"> <div className="flex items-center justify-between mb-6">
<Film className="text-[#6d28d9]" /> <h2 className="text-2xl font-black text-foreground flex items-center gap-3">
Filmography <Film className="text-[#6d28d9]" />
</h2> 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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{person.filmography.map(item => ( {sortedFilmography.map(item => (
<div <div
key={item.id} key={item.id}
onClick={() => handleMediaClick(item.id.toString())} 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"> <div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 shadow-sm">
<img <img
@@ -299,16 +345,21 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
/> />
</div> </div>
<div className="min-w-0"> <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} {item.title}
</h4> </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'} {item.year || 'Unknown'}
</p> </p>
<div className="flex items-center gap-2"> <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} {item.role}
</Badge> </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> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter } from
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import Loading from '@/components/ui/loading';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { fetchAllCast } from '@/api'; import { fetchAllCast } from '@/api';
@@ -22,11 +23,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
const [searchQuery, setSearchQuery] = useState(() => { const [searchQuery, setSearchQuery] = useState(() => {
return localStorage.getItem('castSearchQuery') || ''; return localStorage.getItem('castSearchQuery') || '';
}); });
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height'>(() => { const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height' | 'roleCount'>(() => {
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height') || 'name'; return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height' | 'roleCount') || 'roleCount';
}); });
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => { 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>(() => { const [filterOccupation, setFilterOccupation] = useState<string>(() => {
return localStorage.getItem('castFilterOccupation') || ''; return localStorage.getItem('castFilterOccupation') || '';
@@ -38,6 +39,13 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage); const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
// Sync itemsPerPage with prop when API settings are loaded
useEffect(() => {
if (initialItemsPerPage) {
setItemsPerPage(initialItemsPerPage);
}
}, [initialItemsPerPage]);
// Persist filters and sorts // Persist filters and sorts
useEffect(() => { useEffect(() => {
localStorage.setItem('castSearchQuery', searchQuery); localStorage.setItem('castSearchQuery', searchQuery);
@@ -61,13 +69,13 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
const handleResetFilters = () => { const handleResetFilters = () => {
setSearchQuery(''); setSearchQuery('');
setSortBy('name'); setSortBy('roleCount');
setSortOrder('asc'); setSortOrder('desc');
setFilterOccupation(''); setFilterOccupation('');
setFilterMediaType(''); setFilterMediaType('');
}; };
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'name' || sortOrder !== 'asc'; const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'roleCount' || sortOrder !== 'desc';
useEffect(() => { useEffect(() => {
const loadCast = async () => { const loadCast = async () => {
@@ -130,6 +138,10 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
const heightA = a.height || 0; const heightA = a.height || 0;
const heightB = b.height || 0; const heightB = b.height || 0;
comparison = heightA - heightB; 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; 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="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 className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
<div> <div>
<h1 className="text-4xl font-black text-zinc-900 mb-2">Cast & Staff</h1> <h1 className="text-4xl font-black text-foreground mb-2">Cast & Staff</h1>
<p className="text-zinc-500 font-medium">Discover the people behind your favorite media</p> <p className="text-muted-foreground font-medium">Discover the people behind your favorite media</p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative"> <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 <Input
placeholder="Search cast..." placeholder="Search cast..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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> </div>
<Button <Button
variant={showFilters ? 'default' : 'outline'} variant={showFilters ? 'default' : 'outline'}
size="icon" 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)} onClick={() => setShowFilters(!showFilters)}
> >
<Filter size={20} /> <Filter size={20} />
@@ -202,7 +214,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
<Button <Button
variant="outline" variant="outline"
size="icon" 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')} onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
> >
<ArrowUpDown size={20} /> <ArrowUpDown size={20} />
@@ -211,7 +223,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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} onClick={handleResetFilters}
title="Reset filters" title="Reset filters"
> >
@@ -226,28 +238,29 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
initial={{ opacity: 0, height: 0 }} initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }} animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }} 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 className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div> <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 <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)} 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="name">Name</option>
<option value="role">Role</option> <option value="role">Role</option>
<option value="birthDate">Birth Date</option> <option value="birthDate">Birth Date</option>
<option value="height">Height</option> <option value="height">Height</option>
<option value="roleCount">Role Count</option>
</select> </select>
</div> </div>
<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 <select
value={filterOccupation} value={filterOccupation}
onChange={(e) => setFilterOccupation(e.target.value)} 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> <option value="">All Occupations</option>
{uniqueOccupations.map(occ => ( {uniqueOccupations.map(occ => (
@@ -256,11 +269,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
</select> </select>
</div> </div>
<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 <select
value={filterMediaType} value={filterMediaType}
onChange={(e) => setFilterMediaType(e.target.value)} 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> <option value="">All Media Types</option>
{uniqueMediaTypes.map(type => ( {uniqueMediaTypes.map(type => (
@@ -273,7 +286,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
{searchQuery && ( {searchQuery && (
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="gap-1">
Search: {searchQuery} Search: {searchQuery}
<button onClick={() => setSearchQuery('')} className="hover:text-zinc-900"> <button onClick={() => setSearchQuery('')} className="hover:text-foreground">
<X size={12} /> <X size={12} />
</button> </button>
</Badge> </Badge>
@@ -281,7 +294,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
{filterOccupation && ( {filterOccupation && (
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="gap-1">
Occupation: {filterOccupation} Occupation: {filterOccupation}
<button onClick={() => setFilterOccupation('')} className="hover:text-zinc-900"> <button onClick={() => setFilterOccupation('')} className="hover:text-foreground">
<X size={12} /> <X size={12} />
</button> </button>
</Badge> </Badge>
@@ -289,7 +302,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
{filterMediaType && ( {filterMediaType && (
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="gap-1">
Media Type: {filterMediaType} Media Type: {filterMediaType}
<button onClick={() => setFilterMediaType('')} className="hover:text-zinc-900"> <button onClick={() => setFilterMediaType('')} className="hover:text-foreground">
<X size={12} /> <X size={12} />
</button> </button>
</Badge> </Badge>
@@ -297,7 +310,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
{(sortBy !== 'name' || sortOrder !== 'asc') && ( {(sortBy !== 'name' || sortOrder !== 'asc') && (
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="gap-1">
Sort: {sortBy} ({sortOrder}) 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} /> <X size={12} />
</button> </button>
</Badge> </Badge>
@@ -307,12 +320,9 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
)} )}
{loading ? ( {loading ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400"> <Loading message="Loading cast..." />
<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>
) : filteredStaff.length === 0 ? ( ) : 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" /> <User size={48} className="mb-4 opacity-20" />
<p className="text-lg font-bold">No cast members found</p> <p className="text-lg font-bold">No cast members found</p>
</div> </div>
@@ -326,11 +336,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} 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)} onClick={() => onPersonClick(person)}
> >
<div className="flex items-center gap-4 mb-4"> <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 <img
src={person.photo} src={person.photo}
alt={person.name} alt={person.name}
@@ -338,19 +348,24 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</div> </div>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<h3 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors"> <h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
{person.name} {person.name}
</h3> </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} {person.role}
</p> </p>
</div> </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> </div>
{person.filmography && person.filmography.length > 0 && ( {person.filmography && person.filmography.length > 0 && (
<div className="bg-zinc-50 rounded-xl p-3 flex items-center gap-3"> <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-white"> <div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background">
<img <img
src={person.filmography[0].poster || person.photo} src={person.filmography[0].poster || person.photo}
alt={person.filmography[0].title} alt={person.filmography[0].title}
@@ -359,8 +374,8 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
/> />
</div> </div>
<div className="min-w-0"> <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-[10px] font-black text-muted-foreground 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-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> <p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">{person.filmography[0].role}</p>
</div> </div>
</div> </div>
@@ -373,15 +388,15 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
{/* Pagination Controls */} {/* Pagination Controls */}
{filteredStaff.length > 0 && ( {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"> <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 <select
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => { onChange={(e) => {
setItemsPerPage(Number(e.target.value)); 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 => ( {[12, 20, 36, 48, 60].map(size => (
<option key={size} value={size}>{size}</option> <option key={size} value={size}>{size}</option>
@@ -395,7 +410,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
size="sm" size="sm"
onClick={handlePrevPage} onClick={handlePrevPage}
disabled={currentPage === 1} disabled={currentPage === 1}
className="gap-2 font-bold border-zinc-200" className="gap-2 font-bold border-border"
> >
<ChevronLeft size={16} /> <ChevronLeft size={16} />
Previous Previous
@@ -403,8 +418,8 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span> <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 text-muted-foreground font-medium">of</span>
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span> <span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
</div> </div>
<Button <Button
@@ -412,7 +427,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
size="sm" size="sm"
onClick={handleNextPage} onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0} disabled={currentPage === totalPages || totalPages === 0}
className="gap-2 font-bold border-zinc-200" className="gap-2 font-bold border-border"
> >
Next Next
<ChevronRight size={16} /> <ChevronRight size={16} />

View File

@@ -1,5 +1,6 @@
import { Media, Staff } from '@/types'; import { Media, Staff, Track } from '@/types';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useState, useMemo, useEffect } from 'react';
import { import {
Play, Play,
Bookmark, Bookmark,
@@ -8,7 +9,8 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Search, Search,
ListFilter ListFilter,
ChevronDown
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -23,8 +25,51 @@ interface DetailViewProps {
export default function DetailView({ media, onPersonClick }: DetailViewProps) { export default function DetailView({ media, onPersonClick }: DetailViewProps) {
const navigate = useNavigate(); 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 ( return (
<div className="min-h-screen bg-zinc-50"> <div className="min-h-screen bg-background">
{/* Banner */} {/* Banner */}
<div className="relative h-[400px] w-full overflow-hidden"> <div className="relative h-[400px] w-full overflow-hidden">
<img <img
@@ -33,7 +78,7 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
className="w-full h-full object-cover" className="w-full h-full object-cover"
referrerPolicy="no-referrer" 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 <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
@@ -45,12 +90,12 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
{/* Content */} {/* Content */}
<div className="max-w-[1400px] mx-auto px-6 -mt-32 relative z-10 pb-24"> <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"> <div className="flex flex-col md:flex-row gap-6">
{/* Left Column: Poster */} {/* Left Column: Poster + Metadata */}
<div className="w-full md:w-[300px] shrink-0"> <div className="w-full md:w-[300px] shrink-0">
<motion.div <motion.div
layoutId={`media-${media.id}`} 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 === '16/9' ? 'aspect-video' :
media.aspectRatio === '1/1' ? 'aspect-square' : media.aspectRatio === '1/1' ? 'aspect-square' :
'aspect-[2/3]' 'aspect-[2/3]'
@@ -63,28 +108,103 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</motion.div> </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> </div>
{/* Right Column: Info */} {/* 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 className="flex flex-wrap items-end justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-4xl font-black text-zinc-900 mb-2"> <h1 className="text-4xl font-black text-foreground mb-2">
{media.title} <span className="text-zinc-400 font-medium">({media.year})</span> {media.title} <span className="text-muted-foreground font-medium">({media.year})</span>
</h1> </h1>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="icon" className="rounded-full bg-[#6d28d9] hover:bg-[#5b21b6]"> <Button size="icon" className="rounded-full bg-[#6d28d9] hover:bg-[#5b21b6]">
<Play size={20} fill="currentColor" /> <Play size={20} fill="currentColor" />
</Button> </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} /> <Bookmark size={20} />
</Button> </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} /> <MoreHorizontal size={20} />
</Button> </Button>
</div> </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" /> <Star size={18} className="text-yellow-500" fill="currentColor" />
{media.rating} / 10 {media.rating} / 10
</div> </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> <h3 className="text-xs font-black text-[#6d28d9] uppercase tracking-wider mb-2">Genres</h3>
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
{media.genres?.map(genre => ( {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} {genre}
</span> </span>
))} ))}
@@ -103,92 +223,19 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
</div> </div>
</div> </div>
<p className="text-zinc-600 leading-relaxed mb-8 max-w-3xl"> <div
{media.description} className="text-foreground leading-relaxed mb-6 max-w-3xl prose prose-sm dark:prose-invert"
</p> dangerouslySetInnerHTML={{ __html: media.description || '' }}
/>
{/* Tags */} {/* Tags */}
<div className="flex flex-wrap gap-2 mb-8"> <div className="flex flex-wrap gap-2 mb-4">
{media.tags?.map(tag => ( {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"> <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} {tag}
</Badge> </Badge>
))} ))}
</div> </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>
</div> </div>
@@ -196,31 +243,39 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
{media.staff && media.staff.length > 0 && ( {media.staff && media.staff.length > 0 && (
<section className="mt-20"> <section className="mt-20">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-black text-zinc-900">Cast & Crew</h2> <h2 className="text-2xl font-black text-foreground">Cast & Crew</h2>
<div className="flex gap-2"> <div className="flex items-center gap-4">
<Button variant="outline" size="icon" className="rounded-full border-zinc-200"> <span className="text-sm font-bold text-muted-foreground">
<ChevronLeft size={18} /> {showAllCast ? media.staff.length : displayedCast.length} / {media.staff.length}
</Button> </span>
<Button variant="outline" size="icon" className="rounded-full border-zinc-200"> {hasMoreCast && (
<ChevronRight size={18} /> <Button
</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> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{media.staff.map(person => ( {displayedCast.map(person => (
<div <div
key={person.id} 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)} onClick={() => onPersonClick(person)}
> >
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0"> <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" /> <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>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="font-bold text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4> <h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4>
<p className="text-xs text-zinc-500 truncate">{person.role}</p> <p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
</div> </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" /> <img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" />
</div> </div>
</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"> <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' : ''} <span className="opacity-40">{media.episodes.length}</span> Episode{media.episodes.length !== 1 ? 's' : ''}
</div> </div>
<div className="text-sm font-bold text-muted-foreground">
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} /> <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-zinc-100 border-none rounded-full h-9 text-sm" /> <Input placeholder="Search" className="pl-10 w-[200px] bg-muted border-none rounded-full h-9 text-sm" />
</div> </div>
<Button variant="ghost" size="icon" className="text-zinc-400"> <Button variant="ghost" size="icon" className="text-muted-foreground">
<MoreHorizontal size={20} /> <MoreHorizontal size={20} />
</Button> </Button>
<Button variant="ghost" size="icon" className="text-zinc-400"> <Button variant="ghost" size="icon" className="text-muted-foreground">
<ListFilter size={20} /> <ListFilter size={20} />
</Button> </Button>
</div> </div>
</div> </div>
<div className="space-y-6"> <div className="space-y-4">
{media.episodes.map(episode => ( {Object.keys(episodesBySeason)
<div key={episode.id} className="group cursor-pointer"> .map(Number)
<div className="flex flex-col md:flex-row gap-6"> .sort((a, b) => a - b)
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-xl overflow-hidden shadow-sm relative"> .map(season => (
<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 key={season} className="border border-border rounded-2xl overflow-hidden">
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" /> <button
</div> onClick={() => toggleSeason(season)}
<div className="flex-1 py-1"> className="w-full flex items-center justify-between p-6 bg-card hover:bg-muted/50 transition-colors"
<div className="flex items-center justify-between mb-2"> >
<h3 className="font-black text-zinc-900 group-hover:text-[#6d28d9] transition-colors"> <div className="flex items-center gap-4">
S1:E{episode.number} {episode.title} <h3 className="text-2xl font-black text-foreground">Season {season}</h3>
</h3> <Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold">
<span className="text-xs font-bold text-zinc-400">{episode.date} {episode.duration}</span> {episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
</Badge>
</div> </div>
<p className="text-sm text-zinc-500 leading-relaxed line-clamp-3"> <ChevronDown
{episode.description} size={24}
</p> className={`transition-transform duration-300 text-muted-foreground ${
</div> 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> </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>
<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> </div>
</section> </section>
)} )}

View File

@@ -1,8 +1,9 @@
import { Search, User, X, Plus, Download, Settings } from 'lucide-react'; import { Search, User, X, Plus, Download, Settings } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink } from 'react-router-dom';
import { MediaCategory } from '@/types'; import { MediaCategory } from '@/types';
import { useTheme } from '@/contexts/ThemeContext';
interface HeaderProps { interface HeaderProps {
onSearch: (query: string) => void; onSearch: (query: string) => void;
@@ -23,6 +24,17 @@ export default function Header({
}: HeaderProps) { }: HeaderProps) {
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); 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 handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value; const query = e.target.value;
@@ -39,41 +51,61 @@ export default function Header({
}; };
return ( return (
<header <header
className={cn( className={cn(
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-300", "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"> <div className="flex items-center gap-8">
<Link <Link
to="/" 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={cn(
<div className="w-3 h-3 bg-[#6d28d9] rounded-full" /> "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> </div>
kyoo kyoo
</Link> </Link>
<nav className="hidden md:flex items-center gap-6"> <nav className="hidden md:flex items-center gap-6">
{enabledCategories.map(cat => ( {enabledCategories.map(cat => (
<button <button
key={cat} key={cat}
onClick={() => onCategoryChange(cat)} onClick={() => onCategoryChange(cat)}
className={cn( className={cn(
"text-sm font-bold transition-colors uppercase tracking-wider", "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} {cat}
</button> </button>
))} ))}
<div className="w-px h-4 bg-white/20 mx-2" /> <div className={cn(
<NavLink "w-px h-4 mx-2",
(transparent && !scrolled) || !transparent ? "bg-white/20" : "bg-border"
)} />
<NavLink
to="/cast" to="/cast"
className={({ isActive }) => cn( className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider", "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 CAST
@@ -83,45 +115,74 @@ export default function Header({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className={cn( <div className={cn(
"flex items-center transition-all duration-300 overflow-hidden", "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" type="text"
placeholder="Search..." placeholder="Search..."
value={searchQuery} value={searchQuery}
onChange={handleSearchChange} 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} autoFocus={isSearchOpen}
/> />
</div> </div>
<button <button
onClick={toggleSearch} 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} />} {isSearchOpen ? <X size={20} /> : <Search size={20} />}
</button> </button>
<Link <Link
to="/add" 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} /> <Plus size={20} />
</Link> </Link>
<Link <Link
to="/import" 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} /> <Download size={20} />
</Link> </Link>
<Link <Link
to="/settings" 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} /> <Settings size={20} />
</Link> </Link>
<button className="w-8 h-8 rounded-full overflow-hidden border-2 border-white/20"> <button className={cn(
<img "w-8 h-8 rounded-full overflow-hidden border-2",
src="https://picsum.photos/seed/user/100/100" (transparent && !scrolled) || !transparent ? "border-white/20" : "border-border"
alt="User" )}>
<img
src="https://picsum.photos/seed/user/100/100"
alt="User"
className="w-full h-full object-cover" className="w-full h-full object-cover"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />

View File

@@ -6,21 +6,92 @@ import { cn } from '@/lib/utils';
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter'; import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter'; import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter'; 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'; const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:3000';
export default function ImporterView() { export default function ImporterView() {
const navigate = useNavigate(); 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>({ const [stashappConfig, setStashappConfig] = useState<StashAPPConfig>({
url: import.meta.env.VITE_STASHAPP_URL || '', 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>({ const [playniteConfig, setPlayniteConfig] = useState<PlayniteConfig>({
ip: import.meta.env.VITE_PLAYNITE_IP || '', ip: import.meta.env.VITE_PLAYNITE_IP || '',
apiToken: import.meta.env.VITE_PLAYNITE_API_TOKEN || '', apiToken: import.meta.env.VITE_PLAYNITE_API_TOKEN || '',
port: parseInt(import.meta.env.VITE_PLAYNITE_PORT || '19821') 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>({ const [progress, setProgress] = useState<ImportProgress>({
current: 0, current: 0,
total: 0, total: 0,
@@ -137,6 +208,120 @@ export default function ImporterView() {
setProgress(result); 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 = () => { const resetImport = () => {
setProgress({ setProgress({
current: 0, current: 0,
@@ -164,13 +349,13 @@ export default function ImporterView() {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate('/')} onClick={() => navigate('/')}
className="text-zinc-600 hover:text-[#6d28d9]" className="text-muted-foreground hover:text-[#6d28d9]"
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} />
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-black text-zinc-900">Media Importers</h1> <h1 className="text-2xl font-black text-foreground">Media Importers</h1>
<p className="text-sm text-zinc-500 font-medium">Import media from external platforms</p> <p className="text-sm text-muted-foreground font-medium">Import media from external platforms</p>
</div> </div>
</div> </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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{/* XBVR Importer Card */} {/* XBVR Importer Card */}
{xbvrConfig.url && ( {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-start justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<Film className="text-purple-600" size={24} /> <Film className="text-purple-600" size={24} />
</div> </div>
<div> <div>
<h3 className="font-bold text-zinc-900">XBVR</h3> <h3 className="font-bold text-foreground">XBVR</h3>
<p className="text-xs text-zinc-500 font-medium">Adult Video Manager</p> <p className="text-xs text-muted-foreground font-medium">Adult Video Manager</p>
</div> </div>
</div> </div>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8 border-zinc-200" className="h-8 w-8 border-border"
onClick={() => {}} onClick={() => {}}
> >
<Settings size={16} /> <Settings size={16} />
</Button> </Button>
</div> </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. Import adult videos and actors from your XBVR database.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<div> <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 <input
type="text" type="text"
value={xbvrConfig.url} value={xbvrConfig.url}
onChange={(e) => setXbvrConfig({ ...xbvrConfig, url: e.target.value })} onChange={(e) => setXbvrConfig({ ...xbvrConfig, url: e.target.value })}
disabled={progress.stage !== 'idle'} 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" placeholder="http://192.168.1.102:10001"
/> />
</div> </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 <Button
onClick={handleXBVRImport} onClick={handleXBVRImport}
disabled={progress.stage !== 'idle' || !xbvrConfig.url} disabled={progress.stage !== 'idle' || !xbvrConfig.url}
@@ -237,52 +433,63 @@ export default function ImporterView() {
{/* StashAPP Importer Card */} {/* StashAPP Importer Card */}
{stashappConfig.url && ( {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-start justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<Film className="text-blue-600" size={24} /> <Film className="text-blue-600" size={24} />
</div> </div>
<div> <div>
<h3 className="font-bold text-zinc-900">StashAPP</h3> <h3 className="font-bold text-foreground">StashAPP</h3>
<p className="text-xs text-zinc-500 font-medium">Adult Content Manager</p> <p className="text-xs text-muted-foreground font-medium">Adult Content Manager</p>
</div> </div>
</div> </div>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8 border-zinc-200" className="h-8 w-8 border-border"
onClick={() => {}} onClick={() => {}}
> >
<Settings size={16} /> <Settings size={16} />
</Button> </Button>
</div> </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. Import adult videos and performers from your StashAPP database.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<div> <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 <input
type="text" type="text"
value={stashappConfig.url} value={stashappConfig.url}
onChange={(e) => setStashappConfig({ ...stashappConfig, url: e.target.value })} onChange={(e) => setStashappConfig({ ...stashappConfig, url: e.target.value })}
disabled={progress.stage !== 'idle'} 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" placeholder="http://192.168.1.102:10001"
/> />
</div> </div>
<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 <input
type="password" type="password"
value={stashappConfig.apiKey || ''} value={stashappConfig.apiKey || ''}
onChange={(e) => setStashappConfig({ ...stashappConfig, apiKey: e.target.value })} onChange={(e) => setStashappConfig({ ...stashappConfig, apiKey: e.target.value })}
disabled={progress.stage !== 'idle'} 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" placeholder="Enter API key if required"
/> />
</div> </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 <Button
onClick={handleStashAPPImport} onClick={handleStashAPPImport}
disabled={progress.stage !== 'idle' || !stashappConfig.url} disabled={progress.stage !== 'idle' || !stashappConfig.url}
@@ -306,38 +513,38 @@ export default function ImporterView() {
{/* StashAPP Actor Updater Card */} {/* StashAPP Actor Updater Card */}
{stashappConfig.url && ( {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-start justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<Users className="text-green-600" size={24} /> <Users className="text-green-600" size={24} />
</div> </div>
<div> <div>
<h3 className="font-bold text-zinc-900">StashAPP Actor Updater</h3> <h3 className="font-bold text-foreground">StashAPP Actor Updater</h3>
<p className="text-xs text-zinc-500 font-medium">Update existing actors</p> <p className="text-xs text-muted-foreground font-medium">Update existing actors</p>
</div> </div>
</div> </div>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8 border-zinc-200" className="h-8 w-8 border-border"
onClick={() => {}} onClick={() => {}}
> >
<Settings size={16} /> <Settings size={16} />
</Button> </Button>
</div> </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. Update existing actors with fresh data from StashAPP and create missing ones.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<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 <input
type="password" type="password"
value={stashappConfig.apiKey || ''} value={stashappConfig.apiKey || ''}
onChange={(e) => setStashappConfig({ ...stashappConfig, apiKey: e.target.value })} onChange={(e) => setStashappConfig({ ...stashappConfig, apiKey: e.target.value })}
disabled={progress.stage !== 'idle'} 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" placeholder="Enter API key if required"
/> />
</div> </div>
@@ -364,63 +571,74 @@ export default function ImporterView() {
{/* Playnite Importer Card */} {/* Playnite Importer Card */}
{playniteConfig.ip && playniteConfig.apiToken && ( {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-start justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
<Film className="text-orange-600" size={24} /> <Film className="text-orange-600" size={24} />
</div> </div>
<div> <div>
<h3 className="font-bold text-zinc-900">Playnite</h3> <h3 className="font-bold text-foreground">Playnite</h3>
<p className="text-xs text-zinc-500 font-medium">Game Library Manager</p> <p className="text-xs text-muted-foreground font-medium">Game Library Manager</p>
</div> </div>
</div> </div>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8 border-zinc-200" className="h-8 w-8 border-border"
onClick={() => {}} onClick={() => {}}
> >
<Settings size={16} /> <Settings size={16} />
</Button> </Button>
</div> </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. Import games from your Playnite library via Bridge API.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<div> <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 <input
type="text" type="text"
value={playniteConfig.ip} value={playniteConfig.ip}
onChange={(e) => setPlayniteConfig({ ...playniteConfig, ip: e.target.value })} onChange={(e) => setPlayniteConfig({ ...playniteConfig, ip: e.target.value })}
disabled={progress.stage !== 'idle'} 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" placeholder="localhost"
/> />
</div> </div>
<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 <input
type="number" type="number"
value={playniteConfig.port || 19821} value={playniteConfig.port || 19821}
onChange={(e) => setPlayniteConfig({ ...playniteConfig, port: parseInt(e.target.value) || 19821 })} onChange={(e) => setPlayniteConfig({ ...playniteConfig, port: parseInt(e.target.value) || 19821 })}
disabled={progress.stage !== 'idle'} 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" placeholder="19821"
/> />
</div> </div>
<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 <input
type="password" type="password"
value={playniteConfig.apiToken} value={playniteConfig.apiToken}
onChange={(e) => setPlayniteConfig({ ...playniteConfig, apiToken: e.target.value })} onChange={(e) => setPlayniteConfig({ ...playniteConfig, apiToken: e.target.value })}
disabled={progress.stage !== 'idle'} 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" placeholder="pb_your_token_here"
/> />
</div> </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 <Button
onClick={handlePlayniteImport} onClick={handlePlayniteImport}
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken} disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
@@ -441,11 +659,285 @@ export default function ImporterView() {
</div> </div>
</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> </div>
{/* Progress Section */} {/* Progress Section */}
{progress.stage !== 'idle' && ( {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 justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{progress.stage === 'complete' ? ( {progress.stage === 'complete' ? (
@@ -458,12 +950,12 @@ export default function ImporterView() {
</div> </div>
) : ( ) : (
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center"> <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>
)} )}
<div> <div>
<h3 className="font-bold text-zinc-900">{progress.message}</h3> <h3 className="font-bold text-foreground">{progress.message}</h3>
<p className="text-xs text-zinc-500 font-medium"> <p className="text-xs text-muted-foreground font-medium">
{progress.stage === 'fetching' && 'Connecting to external service...'} {progress.stage === 'fetching' && 'Connecting to external service...'}
{progress.stage === 'importing' && `Processing items... ${getProgressPercentage()}%`} {progress.stage === 'importing' && `Processing items... ${getProgressPercentage()}%`}
{progress.stage === 'complete' && 'Import finished'} {progress.stage === 'complete' && 'Import finished'}
@@ -476,7 +968,7 @@ export default function ImporterView() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={resetImport} onClick={resetImport}
className="gap-2 font-bold border-zinc-200" className="gap-2 font-bold border-border"
> >
<RefreshCw size={16} /> <RefreshCw size={16} />
Reset Reset
@@ -487,7 +979,7 @@ export default function ImporterView() {
{/* Progress Bar */} {/* Progress Bar */}
{progress.stage === 'fetching' || progress.stage === 'importing' ? ( {progress.stage === 'fetching' || progress.stage === 'importing' ? (
<div className="mb-6"> <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 <div
className={cn( className={cn(
"h-full transition-all duration-300 ease-out", "h-full transition-all duration-300 ease-out",
@@ -496,7 +988,7 @@ export default function ImporterView() {
style={{ width: `${getProgressPercentage()}%` }} style={{ width: `${getProgressPercentage()}%` }}
/> />
</div> </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>{progress.current} / {progress.total} items</span>
<span>{getProgressPercentage()}%</span> <span>{getProgressPercentage()}%</span>
</div> </div>
@@ -505,26 +997,36 @@ export default function ImporterView() {
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6"> <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"> <div className="flex items-center gap-2 mb-2">
<Film size={16} className="text-zinc-400" /> <Film size={16} className="text-muted-foreground" />
<span className="text-xs font-bold text-zinc-500">{(progress as any).gamesImported !== undefined ? 'Games' : 'Videos'}</span> <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> </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>
<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"> <div className="flex items-center gap-2 mb-2">
<Users size={16} className="text-zinc-400" /> <Users size={16} className="text-muted-foreground" />
<span className="text-xs font-bold text-zinc-500">Actors</span> <span className="text-xs font-bold text-muted-foreground">{(progress as any).castImported !== undefined ? 'Cast' : 'Actors'}</span>
</div> </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>
<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"> <div className="flex items-center gap-2 mb-2">
<AlertCircle size={16} className="text-zinc-400" /> <AlertCircle size={16} className="text-muted-foreground" />
<span className="text-xs font-bold text-zinc-500">Errors</span> <span className="text-xs font-bold text-muted-foreground">Errors</span>
</div> </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>
</div> </div>

View File

@@ -52,7 +52,7 @@ export default function MediaCard({ media, onClick }: MediaCardProps) {
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className={cn( <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 aspectRatioClass
)}> )}>
<img <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 className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
</div> </div>
<div className="mt-3 space-y-1"> <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} {media.title}
</h3> </h3>
<p className="text-xs font-medium text-zinc-500"> <p className="text-xs font-medium text-muted-foreground">
{media.year} {media.year}
</p> </p>
</div> </div>

View File

@@ -44,11 +44,11 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} 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)} onClick={() => onClick(media)}
> >
<div className={cn( <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 aspectRatioClass
)}> )}>
<img <img
@@ -67,32 +67,32 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1"> <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} {media.title}
</h3> </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>
<div className="flex items-center gap-4 mb-3"> <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" /> <Star size={14} className="text-yellow-500" fill="currentColor" />
{media.rating || 'N/A'} {media.rating || 'N/A'}
</div> </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'} {media.genres?.slice(0, 3).join(' • ') || 'Anime'}
</div> </div>
</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."} {media.description || "No description available for this title."}
</p> </p>
</div> </div>
<div className="hidden md:flex items-center gap-2"> <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" /> <Play size={18} fill="currentColor" />
</Button> </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} /> <Bookmark size={18} />
</Button> </Button>
</div> </div>

View File

@@ -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 { Film, Music, Book, Tv, Gamepad2, ShieldAlert, LayoutGrid, List, Globe, Monitor, Sun, Moon, Save, ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { fetchSettings, updateSettings } from '@/api'; import { fetchSettings, updateSettings } from '@/api';
import { useTheme } from '@/contexts/ThemeContext';
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = { const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
Anime: <Tv size={18} />, Anime: <Tv size={18} />,
@@ -31,9 +32,11 @@ interface SettingsViewProps {
} }
export default function SettingsView({ onSettingsSaved }: SettingsViewProps) { export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
const { setTheme } = useTheme();
const [settings, setSettings] = useState<UserSettings>({ const [settings, setSettings] = useState<UserSettings>({
enabledCategories: ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'], enabledCategories: ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'],
itemsPerPage: 20, itemsPerPage: 20,
gridItemSize: 5,
defaultView: 'grid', defaultView: 'grid',
showAdultContent: false, showAdultContent: false,
autoPlayTrailers: false, autoPlayTrailers: false,
@@ -69,6 +72,8 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
if (savedSettings) { if (savedSettings) {
setSettings(savedSettings); setSettings(savedSettings);
setSaveStatus('success'); setSaveStatus('success');
// Sync theme with theme context
setTheme(savedSettings.theme);
onSettingsSaved?.(); onSettingsSaved?.();
} else { } else {
setSaveStatus('error'); setSaveStatus('error');
@@ -93,26 +98,26 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-white flex items-center justify-center"> <div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-zinc-400 font-medium">Loading settings...</div> <div className="text-muted-foreground font-medium">Loading settings...</div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-white pt-20"> <div className="min-h-screen bg-background pt-20">
{/* Content */} {/* Content */}
<div className="max-w-[1600px] mx-auto px-6 py-12"> <div className="max-w-[1600px] mx-auto px-6 py-12">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div> <div>
<Link <Link
to="/" 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} /> <ArrowLeft size={16} />
Back to home Back to home
</Link> </Link>
<h1 className="text-3xl font-black text-zinc-900">Settings</h1> <h1 className="text-3xl font-black text-foreground">Settings</h1>
</div> </div>
<button <button
onClick={handleSave} onClick={handleSave}
@@ -144,23 +149,23 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
<div className="grid gap-8"> <div className="grid gap-8">
{/* Library Settings */} {/* Library Settings */}
<section> <section>
<h2 className="text-xl font-black text-zinc-900 mb-6">Library Settings</h2> <h2 className="text-xl font-black text-foreground mb-6">Library Settings</h2>
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100"> <div className="bg-muted/50 rounded-2xl p-6 border border-border">
<p className="text-sm font-medium text-zinc-500 mb-4"> <p className="text-sm font-medium text-muted-foreground mb-4">
Toggle which media areas you want to see in your library. Toggle which media areas you want to see in your library.
</p> </p>
<div className="grid gap-4"> <div className="grid gap-4">
{(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => ( {(['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="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]} {CATEGORY_ICONS[category]}
</div> </div>
<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} {category}
</Label> </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'} {settings.enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
</p> </p>
</div> </div>
@@ -178,11 +183,11 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
{/* Display Settings */} {/* Display Settings */}
<section> <section>
<h2 className="text-xl font-black text-zinc-900 mb-6">Display Settings</h2> <h2 className="text-xl font-black text-foreground mb-6">Display Settings</h2>
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100 space-y-6"> <div className="bg-muted/50 rounded-2xl p-6 border border-border space-y-6">
{/* Items per page */} {/* Items per page */}
<div> <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"> <div className="flex gap-2 flex-wrap">
{ITEMS_PER_PAGE_OPTIONS.map((option) => ( {ITEMS_PER_PAGE_OPTIONS.map((option) => (
<button <button
@@ -191,7 +196,7 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${ className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${
settings.itemsPerPage === option settings.itemsPerPage === option
? 'bg-[#6d28d9] text-white' ? '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} {option}
@@ -202,14 +207,14 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
{/* Default view */} {/* Default view */}
<div> <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"> <div className="flex gap-2">
<button <button
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))} 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 ${ 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' settings.defaultView === 'grid'
? 'bg-[#6d28d9] text-white' ? '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} /> <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 ${ 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' settings.defaultView === 'list'
? 'bg-[#6d28d9] text-white' ? '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} /> <List size={18} />
@@ -229,9 +234,27 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
</div> </div>
</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 */} {/* Theme */}
<div> <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"> <div className="flex gap-2">
{(['light', 'dark', 'system'] as const).map((theme) => ( {(['light', 'dark', 'system'] as const).map((theme) => (
<button <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 ${ 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 settings.theme === theme
? 'bg-[#6d28d9] text-white' ? '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} />} {theme === 'light' && <Sun size={18} />}
@@ -256,15 +279,15 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
{/* Content Settings */} {/* Content Settings */}
<section> <section>
<h2 className="text-xl font-black text-zinc-900 mb-6">Content Settings</h2> <h2 className="text-xl font-black text-foreground mb-6">Content Settings</h2>
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100 space-y-4"> <div className="bg-muted/50 rounded-2xl p-6 border border-border space-y-4">
{/* Show adult content */} {/* 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> <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 Show adult content
</Label> </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 Display adult media in your library
</p> </p>
</div> </div>
@@ -276,12 +299,12 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
</div> </div>
{/* Auto-play trailers */} {/* 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> <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 Auto-play trailers
</Label> </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 Automatically play trailers when viewing media
</p> </p>
</div> </div>
@@ -296,11 +319,11 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
{/* Language Settings */} {/* Language Settings */}
<section> <section>
<h2 className="text-xl font-black text-zinc-900 mb-6">Language</h2> <h2 className="text-xl font-black text-foreground mb-6">Language</h2>
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100"> <div className="bg-muted/50 rounded-2xl p-6 border border-border">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Globe size={18} className="text-[#6d28d9]" /> <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>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{LANGUAGE_OPTIONS.map((option) => ( {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 ${ className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${
settings.language === option.value settings.language === option.value
? 'bg-[#6d28d9] text-white' ? '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} {option.label}

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

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,13 @@
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping
import { SOURCE_CATEGORY_MAPPING } from '@/types';
export interface PlayniteConfig { export interface PlayniteConfig {
ip: string; ip: string;
apiToken: string; apiToken: string;
port?: number; port?: number;
updateExisting?: boolean;
} }
export interface ImportProgress { export interface ImportProgress {
@@ -191,6 +195,15 @@ export async function importFromPlaynite(
const existingGame = existingMedia.get(game.name); const existingGame = existingMedia.get(game.name);
const isUpdate = existingGame !== undefined; 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 { try {
// Parse release date // Parse release date
let year = new Date().getFullYear(); let year = new Date().getFullYear();
@@ -241,7 +254,7 @@ export async function importFromPlaynite(
series: game.series ? [game.series] : [], series: game.series ? [game.series] : [],
ageRatings: game.ageRatings || [], ageRatings: game.ageRatings || [],
regions: game.regions || [], regions: game.regions || [],
source: game.source || null, source: SOURCE_CATEGORY_MAPPING['playnite']?.includes('Games') ? (game.source || 'playnite') : null,
gameId: game.id, gameId: game.id,
pluginId: null, pluginId: null,
completionStatus: game.completionStatus || 'Not Played', completionStatus: game.completionStatus || 'Not Played',
@@ -267,7 +280,8 @@ export async function importFromPlaynite(
achievements: [], achievements: [],
year: year.toString(), year: year.toString(),
poster: game.coverBase64 || null, poster: game.coverBase64 || null,
banner: null, banner: game.backgroundBase64 || null,
icon: game.iconBase64 || null,
rating: rating, rating: rating,
category: 'Game', category: 'Game',
status: game.completionStatus === 'Completed' ? 'completed' : status: game.completionStatus === 'Completed' ? 'completed' :

View File

@@ -1,9 +1,13 @@
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping
import { SOURCE_CATEGORY_MAPPING } from '@/types';
export interface StashAPPConfig { export interface StashAPPConfig {
url: string; url: string;
apiKey?: string; apiKey?: string;
blacklist?: ['/AI/', 'temp', 'backup']; blacklist?: ['/AI/', 'temp', 'backup'];
updateExisting?: boolean;
} }
export interface ImportProgress { export interface ImportProgress {
@@ -642,11 +646,14 @@ export async function importFromStashAPP(
// Check for duplicate // Check for duplicate
if (existingTitles.has(scene.title)) { if (existingTitles.has(scene.title)) {
logCallback(`⊘ Skipped duplicate: ${scene.title}`); if (!config.updateExisting) {
progressCallback({ logCallback(`⊘ Skipped duplicate: ${scene.title} (updateExisting is false)`);
current: uniquePerformers.length + i + 1 progressCallback({
}); current: uniquePerformers.length + i + 1
continue; });
continue;
}
logCallback(`→ Updating existing: ${scene.title}`);
} }
try { try {
@@ -702,6 +709,7 @@ export async function importFromStashAPP(
director: null, director: null,
writer: null, writer: null,
releaseDate: releaseDate, releaseDate: releaseDate,
source: SOURCE_CATEGORY_MAPPING['stashapp']?.includes('Adult') ? 'stashapp' : null,
genres: [], genres: [],
tags: [], tags: [],
studios: [], studios: [],

View File

@@ -1,8 +1,12 @@
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping
import { SOURCE_CATEGORY_MAPPING } from '@/types';
export interface XBVRConfig { export interface XBVRConfig {
url: string; url: string;
apiKey?: string; apiKey?: string;
updateExisting?: boolean;
} }
export interface ImportProgress { export interface ImportProgress {
@@ -255,11 +259,14 @@ export async function importFromXBVR(
// Check for duplicate // Check for duplicate
if (existingTitles.has(video.title)) { if (existingTitles.has(video.title)) {
logCallback(`⊘ Skipped duplicate: ${video.title}`); if (!config.updateExisting) {
progressCallback({ logCallback(`⊘ Skipped duplicate: ${video.title} (updateExisting is false)`);
current: uniqueActors.length + i + 1 progressCallback({
}); current: uniqueActors.length + i + 1
continue; });
continue;
}
logCallback(`→ Updating existing: ${video.title}`);
} }
try { try {
@@ -308,6 +315,7 @@ export async function importFromXBVR(
director: null, director: null,
writer: null, writer: null,
releaseDate: releaseDate, releaseDate: releaseDate,
source: SOURCE_CATEGORY_MAPPING['xbvr']?.includes('Adult') ? 'xbvr' : null,
genres: categories, genres: categories,
tags: categories, tags: categories,
studios: video.paysite?.name ? [video.paysite.name] : [], studios: video.paysite?.name ? [video.paysite.name] : [],

View File

@@ -16,6 +16,7 @@ export interface Media {
studios?: string[]; studios?: string[];
status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold'; status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold';
episodes?: Episode[]; episodes?: Episode[];
tracks?: Track[];
staff?: Staff[]; staff?: Staff[];
categories?: string[]; categories?: string[];
platforms?: string[]; platforms?: string[];
@@ -28,15 +29,26 @@ export interface Media {
} }
export interface Episode { export interface Episode {
id: string; id: number;
number: number; media_id: number;
season: number;
episode_number: number;
title: string; title: string;
date: string;
duration: string;
description: string; description: string;
air_date: string;
duration: number;
thumbnail: string; thumbnail: string;
} }
export interface Track {
id: number;
media_id: number;
track_number: number;
title: string;
duration: number | null;
artist: string;
}
export interface Staff { export interface Staff {
id: string; id: string;
name: string; name: string;
@@ -100,11 +112,21 @@ export interface UserSettings {
id?: number; id?: number;
enabledCategories: MediaCategory[]; enabledCategories: MediaCategory[];
itemsPerPage: number; itemsPerPage: number;
gridItemSize: number; // 1-10 scale
defaultView: 'grid' | 'list'; defaultView: 'grid' | 'list';
showAdultContent: boolean; showAdultContent: boolean;
autoPlayTrailers: boolean; autoPlayTrailers: boolean;
language: string; language: string;
theme: 'light' | 'dark' | 'system'; theme: 'light' | 'dark' | 'system';
jellyfinLibraryMappings?: string; // JSON string of LibraryMapping[]
createdAt?: string; createdAt?: string;
updatedAt?: 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'],
};