From 432416cfc5f87de5783110da1b462893efa8248e Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Thu, 16 Apr 2026 14:53:46 +0200 Subject: [PATCH] Use Zustand store; modularize API & routes Introduce a centralized Zustand store and refactor app state out of App.tsx into src/store/appStore.ts. Modularize API surface by moving media/cast/settings/converters/types into src/lib/api/* and re-exporting from src/api.ts for backward compatibility. Replace inline route helpers with dedicated route components (MediaDetailRoute, CastDetailRoute, CategoryBrowseRoute) and wire CATEGORY_PATHS/PATH_TO_CATEGORY constants. Update AddMediaView UI (icons, layout) and adjust settings/category handling to use DEFAULT_SETTINGS and the store. Add zustand to package.json/package-lock.json and include a new React SKILL.md. Overall changes improve state management, API organization, and route/component separation for better maintainability and code-splitting. --- .windsurf/skills/react/SKILL.md | 198 +++++ package-lock.json | 32 +- package.json | 3 +- src/App.tsx | 311 ++----- src/api.ts | 774 +----------------- src/components/AddMediaView.tsx | 646 ++++++++------- src/components/Sidebar.tsx | 18 +- src/components/routes/CastDetailRoute.tsx | 46 ++ src/components/routes/CategoryBrowseRoute.tsx | 49 ++ src/components/routes/MediaDetailRoute.tsx | 50 ++ src/constants.ts | 49 ++ src/data.ts | 8 +- src/lib/api/castApi.ts | 163 ++++ src/lib/api/converters.ts | 190 +++++ src/lib/api/mediaApi.ts | 105 +++ src/lib/api/settingsApi.ts | 83 ++ src/lib/api/types.ts | 212 +++++ src/lib/jellyfinImporter.ts | 56 +- src/lib/playniteImporter.ts | 69 +- src/lib/stashappImporter.ts | 45 +- src/lib/xbvrImporter.ts | 8 +- src/store/appStore.ts | 70 ++ 22 files changed, 1843 insertions(+), 1342 deletions(-) create mode 100644 .windsurf/skills/react/SKILL.md create mode 100644 src/components/routes/CastDetailRoute.tsx create mode 100644 src/components/routes/CategoryBrowseRoute.tsx create mode 100644 src/components/routes/MediaDetailRoute.tsx create mode 100644 src/constants.ts create mode 100644 src/lib/api/castApi.ts create mode 100644 src/lib/api/converters.ts create mode 100644 src/lib/api/mediaApi.ts create mode 100644 src/lib/api/settingsApi.ts create mode 100644 src/lib/api/types.ts create mode 100644 src/store/appStore.ts diff --git a/.windsurf/skills/react/SKILL.md b/.windsurf/skills/react/SKILL.md new file mode 100644 index 0000000..385e0e6 --- /dev/null +++ b/.windsurf/skills/react/SKILL.md @@ -0,0 +1,198 @@ +--- +name: react +description: Modern React patterns and principles. Hooks, composition, performance, TypeScript best practices. +allowed-tools: Read, Write, Edit, Glob, Grep +--- + +# React Patterns + +> Principles for building production-ready React applications. + +--- + +## 1. Component Design Principles + +### Component Types + +| Type | Use | State | +|------|-----|-------| +| **Server** | Data fetching, static | None | +| **Client** | Interactivity | useState, effects | +| **Presentational** | UI display | Props only | +| **Container** | Logic/state | Heavy state | + +### Design Rules + +- One responsibility per component +- Props down, events up +- Composition over inheritance +- Prefer small, focused components + +--- + +## 2. Hook Patterns + +### When to Extract Hooks + +| Pattern | Extract When | +|---------|-------------| +| **useLocalStorage** | Same storage logic needed | +| **useDebounce** | Multiple debounced values | +| **useFetch** | Repeated fetch patterns | +| **useForm** | Complex form state | + +### Hook Rules + +- Hooks at top level only +- Same order every render +- Custom hooks start with "use" +- Clean up effects on unmount + +--- + +## 3. State Management Selection + +| Complexity | Solution | +|------------|----------| +| Simple | useState, useReducer | +| Shared local | Context | +| Server state | React Query, SWR | +| Complex global | Zustand, Redux Toolkit | + +### State Placement + +| Scope | Where | +|-------|-------| +| Single component | useState | +| Parent-child | Lift state up | +| Subtree | Context | +| App-wide | Global store | + +--- + +## 4. React 19 Patterns + +### New Hooks + +| Hook | Purpose | +|------|---------| +| **useActionState** | Form submission state | +| **useOptimistic** | Optimistic UI updates | +| **use** | Read resources in render | + +### Compiler Benefits + +- Automatic memoization +- Less manual useMemo/useCallback +- Focus on pure components + +--- + +## 5. Composition Patterns + +### Compound Components + +- Parent provides context +- Children consume context +- Flexible slot-based composition +- Example: Tabs, Accordion, Dropdown + +### Render Props vs Hooks + +| Use Case | Prefer | +|----------|--------| +| Reusable logic | Custom hook | +| Render flexibility | Render props | +| Cross-cutting | Higher-order component | + +--- + +## 6. Performance Principles + +### When to Optimize + +| Signal | Action | +|--------|--------| +| Slow renders | Profile first | +| Large lists | Virtualize | +| Expensive calc | useMemo | +| Stable callbacks | useCallback | + +### Optimization Order + +1. Check if actually slow +2. Profile with DevTools +3. Identify bottleneck +4. Apply targeted fix + +--- + +## 7. Error Handling + +### Error Boundary Usage + +| Scope | Placement | +|-------|-----------| +| App-wide | Root level | +| Feature | Route/feature level | +| Component | Around risky component | + +### Error Recovery + +- Show fallback UI +- Log error +- Offer retry option +- Preserve user data + +--- + +## 8. TypeScript Patterns + +### Props Typing + +| Pattern | Use | +|---------|-----| +| Interface | Component props | +| Type | Unions, complex | +| Generic | Reusable components | + +### Common Types + +| Need | Type | +|------|------| +| Children | ReactNode | +| Event handler | MouseEventHandler | +| Ref | RefObject | + +--- + +## 9. Testing Principles + +| Level | Focus | +|-------|-------| +| Unit | Pure functions, hooks | +| Integration | Component behavior | +| E2E | User flows | + +### Test Priorities + +- User-visible behavior +- Edge cases +- Error states +- Accessibility + +--- + +## 10. Anti-Patterns + +| ❌ Don't | ✅ Do | +|----------|-------| +| Prop drilling deep | Use context | +| Giant components | Split smaller | +| useEffect for everything | Server components | +| Premature optimization | Profile first | +| Index as key | Stable unique ID | + +--- + +> **Remember:** React is about composition. Build small, combine thoughtfully. diff --git a/package-lock.json b/package-lock.json index 98a5464..c1c4fde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "shadcn": "^4.2.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", - "vite": "^6.2.0" + "vite": "^6.2.0", + "zustand": "^5.0.12" }, "devDependencies": { "@types/express": "^4.17.21", @@ -7611,6 +7612,35 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 4a218a1..5c8e400 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "shadcn": "^4.2.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", - "vite": "^6.2.0" + "vite": "^6.2.0", + "zustand": "^5.0.12" }, "devDependencies": { "@types/express": "^4.17.21", diff --git a/src/App.tsx b/src/App.tsx index 975a964..9161a18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,53 +16,57 @@ import AddMediaView from './components/AddMediaView'; import ImporterView from './components/ImporterView'; import SettingsView from './components/SettingsView'; import Loading from './components/ui/loading'; +import MediaDetailRoute from './components/routes/MediaDetailRoute'; +import CastDetailRoute from './components/routes/CastDetailRoute'; +import CategoryBrowseRoute from './components/routes/CategoryBrowseRoute'; import { MOCK_MEDIA, DETAIL_MEDIA } from './data'; import { Media, Staff, MediaCategory, UserSettings } from './types'; import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api'; import { ThemeProvider, useTheme } from './contexts/ThemeContext'; +import { CATEGORY_PATHS, PATH_TO_CATEGORY, DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from './constants'; +import { useAppStore } from './store/appStore'; function AppContent() { const navigate = useNavigate(); const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); const { setTheme } = useTheme(); - const [activeCategory, setActiveCategory] = useState( - (searchParams.get('category') as MediaCategory) || 'Anime' - ); - const [selectedMedia, setSelectedMedia] = useState(null); - const [selectedPerson, setSelectedPerson] = useState(null); - const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || ''); - const [enabledCategories, setEnabledCategories] = useState(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']); - const [settings, setSettings] = useState(null); - const [customMedia, setCustomMedia] = useState([]); - const [adultMedia, setAdultMedia] = useState([]); + + // Zustand store + const { + apiMedia, + customMedia, + adultMedia, + mediaLoading, + selectedMedia, + selectedPerson, + activeCategory, + enabledCategories, + searchQuery, + settings, + setApiMedia, + setCustomMedia, + setAdultMedia, + setMediaLoading, + setSelectedMedia, + setSelectedPerson, + setActiveCategory, + setEnabledCategories, + setSearchQuery, + setSettings, + } = useAppStore(); - // Load media from API on component mount (only when not on cast routes) - const [apiMedia, setApiMedia] = useState([]); - const [mediaLoading, setMediaLoading] = useState(true); - - // Map URL paths to categories - const pathToCategory: Record = { - 'anime': 'Anime', - 'movies': 'Movies', - 'tv-series': 'TV Series', - 'music': 'Music', - 'books': 'Books', - 'games': 'Games', - 'consoles': 'Consoles', - 'adult': 'Adult' - }; // Set category from URL path on mount or location change useEffect(() => { const pathParts = location.pathname.split('/').filter(Boolean); - if (pathParts.length === 1 && pathToCategory[pathParts[0]]) { - const category = pathToCategory[pathParts[0]]; + if (pathParts.length === 1 && PATH_TO_CATEGORY[pathParts[0]]) { + const category = PATH_TO_CATEGORY[pathParts[0]]; if (enabledCategories.includes(category)) { setActiveCategory(category); } } - }, [location.pathname, enabledCategories]); + }, [location.pathname, enabledCategories, setActiveCategory]); useEffect(() => { const loadSettingsFromApi = async () => { @@ -116,57 +120,35 @@ function AppContent() { }, [location.pathname]); const toggleCategory = async (category: MediaCategory) => { - setEnabledCategories(prev => { - const isEnabling = !prev.includes(category); - const newList = isEnabling - ? [...prev, category] - : prev.filter(c => c !== category); - - // If we disable the current active category, switch to another enabled one - if (!isEnabling && activeCategory === category) { - const nextCategory = newList.find(c => c !== category) || 'Anime'; - setActiveCategory(nextCategory as MediaCategory); + const isEnabling = !enabledCategories.includes(category); + const newList = isEnabling + ? [...enabledCategories, category] + : enabledCategories.filter(c => c !== category); + + // If we disable the current active category, switch to another enabled one + if (!isEnabling && activeCategory === category) { + const nextCategory = newList.find(c => c !== category) || 'Anime'; + setActiveCategory(nextCategory as MediaCategory); + } + + setEnabledCategories(newList); + + // Save to API + const baseSettings = settings || DEFAULT_SETTINGS; + const updatedSettings: UserSettings = { + ...baseSettings, + enabledCategories: newList, + }; + updateSettings(updatedSettings).then(saved => { + if (saved) { + setSettings(saved); } - - // Save to API - const baseSettings = settings || { - enabledCategories: prev, - itemsPerPage: 20, - gridItemSize: 5, - defaultView: 'grid', - showAdultContent: false, - autoPlayTrailers: false, - language: 'en', - theme: 'system', - }; - const updatedSettings: UserSettings = { - ...baseSettings, - enabledCategories: newList, - }; - updateSettings(updatedSettings).then(saved => { - if (saved) { - setSettings(saved); - } - }); - - return newList; }); }; const handleCategoryChange = (category: MediaCategory) => { setActiveCategory(category); - // Map category names to URL-friendly paths - const categoryPaths: Record = { - 'Anime': 'anime', - 'Movies': 'movies', - 'TV Series': 'tv-series', - 'Music': 'music', - 'Books': 'books', - 'Games': 'games', - 'Consoles': 'consoles', - 'Adult': 'adult' - }; - navigate(`/${categoryPaths[category]}`); + navigate(`/${CATEGORY_PATHS[category]}`); window.scrollTo({ top: 0, behavior: 'smooth' }); }; @@ -213,16 +195,7 @@ 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 baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories }; const updatedSettings: UserSettings = { ...baseSettings, gridItemSize: size, @@ -365,88 +338,10 @@ function AppContent() { loading={mediaLoading} /> } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - @@ -469,10 +362,7 @@ function AppContent() { /> } /> + } /> (); - const navigate = useNavigate(); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const loadMedia = async () => { - if (id) { - setLoading(true); - try { - const fetchedMedia = await fetchMediaById(id); - if (fetchedMedia) { - setSelectedMedia(fetchedMedia); - } else { - navigate('/'); - } - } catch (error) { - console.error('Failed to fetch media:', error); - navigate('/'); - } finally { - setLoading(false); - } - } - }; - loadMedia(); - }, [id]); - - if (loading) return ; - if (!selectedMedia) return null; - - return ( - - ); -} - -// Helper component for cast detail route -function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const loadCast = async () => { - if (id) { - setLoading(true); - try { - const castData = await fetchCastById(id); - if (castData) { - const person = convertApiCastToStaff(castData); - setSelectedPerson(person); - } else { - navigate('/cast'); - } - } catch (error) { - console.error('Failed to load cast:', error); - navigate('/cast'); - } finally { - setLoading(false); - } - } - }; - loadCast(); - }, [id]); - - if (loading) return ; - if (!selectedPerson) return null; - - return ( - - ); -} - export default function App() { return ( diff --git a/src/api.ts b/src/api.ts index 31e92bf..2141b96 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,603 +1,14 @@ -import { Media, Staff, UserSettings, MediaCategory } from './types'; +// Re-export all API functions for backward compatibility +export * from './lib/api/mediaApi'; +export * from './lib/api/castApi'; +export * from './lib/api/settingsApi'; +export * from './lib/api/converters'; +export * from './lib/api/types'; -const BASE_URL = import.meta.env.VITE_API_URL; - -function normalizeUrl(url: string | null): string { - if (!url) return ''; - if (url.startsWith('http://') || url.startsWith('https://')) { - return url; - } - // Remove leading slash if present and add base URL - const cleanPath = url.startsWith('/') ? url.slice(1) : url; - return `${BASE_URL}/${cleanPath}`; -} - -// API Response Types -export interface ApiResponse { - success: boolean; - data: T; -} - -export interface PaginatedResponse { - items: T[]; - total: number; - page: number; - limit: number; - totalPages?: number; -} - -// Media Types -export interface ApiEpisode { - id: number; - media_id: number; - season: number; - episode_number: number; - title: string; - description: string; - air_date: string; - duration: number; - thumbnail: string; -} - -export interface ApiTrack { - id: number; - media_id: number; - track_number: number; - title: string; - duration: number | null; - artist: string; -} - -export interface ApiMediaItem { - id: number; - title: string; - year: number; - poster: string | null; - banner: string | null; - description: string | null; - rating: number | null; - category: string | null; - type: string; - status: string; - aspectRatio: string | null; - runtime: number | null; - director: string | null; - writer: string | null; - releaseDate: string | null; - source?: string | null; - createdAt: string; - updatedAt: string; - genres?: string[]; - tags?: string[]; - studios?: string[]; - staff?: ApiStaff[]; - categories?: string[]; - platforms?: string[]; - developers?: string[]; - completionStatus?: string; - playCount?: number; - lastActivity?: string | null; - playtime?: number; - episodes?: ApiEpisode[]; - tracks?: ApiTrack[]; -} - -export interface ApiStaff { - id: number; - name: string; - photo: string | null; - bio: string | null; - birthDate: string | null; - birthPlace: string | null; - role: string; - characterName: string | null; - characterImage: string | null; - occupations?: string[]; -} - -export interface CreateMediaInput { - title: string; - year: number; - poster?: string | null; - banner?: string | null; - description?: string | null; - rating?: number | null; - category?: string | null; - type?: string; - status?: string; - aspectRatio?: string | null; - runtime?: number | null; - director?: string | null; - writer?: string | null; - releaseDate?: string | null; - source?: string | null; - genres?: string[]; - tags?: string[]; - studios?: string[]; - staff?: CreateStaffInput[]; -} - -export interface UpdateMediaInput extends Partial {} - -export interface CreateStaffInput { - name: string; - photo?: string | null; - bio?: string | null; - birthDate?: string | null; - birthPlace?: string | null; - role: string; - characterName?: string | null; - characterImage?: string | null; - occupations?: string[]; -} - -// Cast Types -export interface ApiCastItem { - id: number; - name: string; - cleanname?: string; - photo: string | null; - bio: string | null; - birthDate: string | null; - birthPlace: string | null; - createdAt: string; - updatedAt: string; - occupations?: string[]; - filmography?: ApiCastMediaItem[]; - media_types?: string[]; - bust_size?: number | null; - cup_size?: string | null; - waist_size?: number | null; - hip_size?: number | null; - height?: number | null; - weight?: number | null; - hair_color?: string | null; - eye_color?: string | null; - ethnicity?: string | null; - adult_specifics?: { - id: number; - cast_id: number; - bust_size?: number | null; - cup_size?: string | null; - waist_size?: number | null; - hip_size?: number | null; - height?: number | null; - weight?: number | null; - hair_color?: string | null; - eye_color?: string | null; - ethnicity?: string | null; - tattoos?: string | null; - piercings?: string | null; - measurements?: string | null; - shoe_size?: number | null; - }; -} - -export interface ApiCastMediaItem { - id: number; - title: string; - year: number; - poster: string | null; - category: string | null; - type: string; - role: string; - characterName?: string | null; -} - -export interface CreateCastInput { - name: string; - photo?: string | null; - bio?: string | null; - birthDate?: string | null; - birthPlace?: string | null; - occupations?: string[]; -} - -export interface UpdateCastInput extends Partial {} - - -export function convertApiCastToStaff(apiItem: ApiCastItem): Staff { - return { - id: apiItem.id.toString(), - name: apiItem.name, - cleanname: apiItem.cleanname, - role: apiItem.occupations?.[0] || 'Actor', - photo: normalizeUrl(apiItem.photo) || `https://picsum.photos/seed/cast-${apiItem.id}/200/200`, - bio: apiItem.bio || undefined, - birthDate: apiItem.birthDate || undefined, - birthPlace: apiItem.birthPlace || undefined, - occupations: apiItem.occupations || ['Actor'], - createdAt: apiItem.createdAt, - updatedAt: apiItem.updatedAt, - bust_size: apiItem.bust_size, - cup_size: apiItem.cup_size, - waist_size: apiItem.waist_size, - hip_size: apiItem.hip_size, - height: apiItem.height, - weight: apiItem.weight, - hair_color: apiItem.hair_color, - eye_color: apiItem.eye_color, - ethnicity: apiItem.ethnicity, - filmography: apiItem.filmography?.map(item => ({ - id: item.id, - title: item.title, - year: item.year, - poster: normalizeUrl(item.poster) || `https://picsum.photos/seed/${item.id}/400/600`, - category: item.category, - type: item.type, - role: item.role, - characterName: item.characterName - })), - media_types: apiItem.media_types, - adult_specifics: apiItem.adult_specifics - }; -} - -export function convertApiToMedia(apiItem: ApiMediaItem): Media { - // Convert staff from API to Media staff format - const staff: Staff[] = (apiItem.staff || []).map((staffMember) => ({ - id: staffMember.id.toString(), - name: staffMember.name, - role: staffMember.role, - photo: normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`, - characterName: staffMember.characterName || staffMember.name, - characterImage: normalizeUrl(staffMember.characterImage) || normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`, - })); - - // Determine aspect ratio from API format - let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3'; - if (apiItem.aspectRatio) { - const ratio = apiItem.aspectRatio.toLowerCase(); - if (ratio.includes('16:9') || ratio.includes('16/9') || ratio.includes('1.78') || ratio.includes('2.39')) { - aspectRatio = '16/9'; - } else if (ratio.includes('1:1') || ratio.includes('1/1') || ratio.includes('1.00')) { - aspectRatio = '1/1'; - } else if (ratio.includes('2/3')) { - aspectRatio = '2/3'; - } - } - - // Map API type to Media type allowed values - let mediaType: 'TV' | 'Movie' | 'OVA' | 'ONA' | 'Album' | 'Single' | 'Hardcover' | 'E-book' | 'Console' | 'Game' = 'Movie'; - const apiType = apiItem.type?.toLowerCase(); - if (apiType === 'tv' || apiType === 'episode') { - mediaType = 'TV'; - } else if (apiType === 'album' || apiType === 'single') { - mediaType = apiType === 'album' ? 'Album' : 'Single'; - } else if (apiType === 'game' || apiType === 'console') { - mediaType = apiType === 'game' ? 'Game' : 'Console'; - } else if (apiType === 'ova') { - mediaType = 'OVA'; - } else if (apiType === 'ona') { - mediaType = 'ONA'; - } else if (apiType === 'hardcover' || apiType === 'e-book') { - mediaType = apiType === 'hardcover' ? 'Hardcover' : 'E-book'; - } - - // Map API category to MediaCategory - let mediaCategory: 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games' = 'Movies'; - const apiCategory = apiItem.category?.toLowerCase(); - - if (apiCategory === 'anime') { - mediaCategory = 'Anime'; - } else if (apiCategory === 'movie' || apiCategory === 'movies') { - mediaCategory = 'Movies'; - } else if (apiCategory === 'tv' || apiCategory === 'series' || apiCategory === 'tv series' || apiType === 'tv' || apiType === 'episode') { - mediaCategory = 'TV Series'; - } else if (apiCategory === 'music' || apiType === 'album' || apiType === 'single') { - mediaCategory = 'Music'; - } else if (apiCategory === 'book' || apiCategory === 'books' || apiType === 'hardcover' || apiType === 'e-book') { - mediaCategory = 'Books'; - } else if (apiCategory === 'adult') { - mediaCategory = 'Adult'; - } else if (apiCategory === 'console' || apiCategory === 'consoles' || apiType === 'console') { - mediaCategory = 'Consoles'; - } else if (apiCategory === 'game' || apiCategory === 'games' || apiType === 'game') { - mediaCategory = 'Games'; - } else { - // If category doesn't match any known category, use the original value capitalized - // This handles cases where the API returns unexpected category values - console.warn('Unknown category:', apiItem.category, 'defaulting to Movies'); - mediaCategory = 'Movies'; - } - - // Map API status to Media status allowed values - let mediaStatus: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold' = 'completed'; - const apiStatus = apiItem.status?.toLowerCase(); - if (apiStatus === 'ongoing' || apiStatus === 'watching') { - mediaStatus = 'watching'; - } else if (apiStatus === 'upcoming' || apiStatus === 'planned') { - mediaStatus = 'planned'; - } else if (apiStatus === 'dropped') { - mediaStatus = 'dropped'; - } else if (apiStatus === 'reading') { - mediaStatus = 'reading'; - } else if (apiStatus === 'listening') { - mediaStatus = 'listening'; - } else if (apiStatus === 'playing') { - mediaStatus = 'playing'; - } else if (apiStatus === 'on-hold') { - mediaStatus = 'on-hold'; - } - - return { - id: apiItem.id.toString(), - title: apiItem.title, - year: apiItem.year?.toString() || 'Unknown', - poster: normalizeUrl(apiItem.poster) || `https://picsum.photos/seed/${apiItem.id}/400/600`, - category: mediaCategory, - banner: normalizeUrl(apiItem.banner) || undefined, - description: apiItem.description || undefined, - rating: apiItem.rating || undefined, - genres: apiItem.genres || [], - tags: apiItem.tags || [], - studios: apiItem.studios, - type: mediaType, - source: apiItem.source || undefined, - status: mediaStatus, - staff: staff.length > 0 ? staff : undefined, - aspectRatio: aspectRatio, - categories: apiItem.categories, - platforms: apiItem.platforms, - developers: apiItem.developers, - completionStatus: apiItem.completionStatus, - playCount: apiItem.playCount, - lastActivity: apiItem.lastActivity, - playtime: apiItem.playtime, - episodes: apiItem.episodes, - tracks: apiItem.tracks - }; -} - -// Media API Functions -export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise { - try { - const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse> = await response.json(); - - if (data.success && data.data.items) { - return data.data.items.map(convertApiToMedia); - } - return []; - } catch (error) { - console.error('Error fetching media from API:', error); - return []; - } -} - -export async function fetchMediaById(id: number | string): Promise { - try { - const response = await fetch(`${BASE_URL}/api/media/${id}`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse = await response.json(); - - if (data.success && data.data) { - return convertApiToMedia(data.data); - } - return null; - } catch (error) { - console.error('Error fetching media by ID:', error); - return null; - } -} - -export async function createMedia(media: CreateMediaInput): Promise { - try { - const response = await fetch(`${BASE_URL}/api/media`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(media), - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse = await response.json(); - - if (data.success && data.data) { - return convertApiToMedia(data.data); - } - return null; - } catch (error) { - console.error('Error creating media:', error); - return null; - } -} - -export async function updateMedia(id: number | string, media: UpdateMediaInput): Promise { - try { - const response = await fetch(`${BASE_URL}/api/media/${id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(media), - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse = await response.json(); - - if (data.success && data.data) { - return convertApiToMedia(data.data); - } - return null; - } catch (error) { - console.error('Error updating media:', error); - return null; - } -} - -export async function deleteMedia(id: number | string): Promise { - try { - const response = await fetch(`${BASE_URL}/api/media/${id}`, { - method: 'DELETE', - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse<{ message: string }> = await response.json(); - return data.success; - } catch (error) { - console.error('Error deleting media:', error); - return false; - } -} - -// Cast API Functions -export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise { - try { - const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse> = await response.json(); - - if (data.success && data.data.items) { - return data.data.items.map(convertApiCastToStaff); - } - return []; - } catch (error) { - console.error('Error fetching cast from API:', error); - return []; - } -} - -export async function fetchCastById(id: number | string): Promise { - try { - const response = await fetch(`${BASE_URL}/api/cast/${id}`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse = await response.json(); - - if (data.success && data.data) { - return data.data; - } - return null; - } catch (error) { - console.error('Error fetching cast by ID:', error); - return null; - } -} - -export async function fetchCastMedia(castId: number | string): Promise { - try { - const response = await fetch(`${BASE_URL}/api/cast/${castId}/media`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse> = await response.json(); - - if (data.success && data.data.items) { - return data.data.items.map(convertApiToMedia); - } - return []; - } catch (error) { - console.error('Error fetching cast media:', error); - return []; - } -} - -export async function createCast(cast: CreateCastInput): Promise { - try { - const response = await fetch(`${BASE_URL}/api/cast`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(cast), - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse = await response.json(); - - if (data.success && data.data) { - return data.data; - } - return null; - } catch (error) { - console.error('Error creating cast:', error); - return null; - } -} - -export async function updateCast(id: number | string, cast: UpdateCastInput): Promise { - try { - const response = await fetch(`${BASE_URL}/api/cast/${id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(cast), - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse = await response.json(); - - if (data.success && data.data) { - return data.data; - } - return null; - } catch (error) { - console.error('Error updating cast:', error); - return null; - } -} - -export async function deleteCast(id: number | string): Promise { - try { - const response = await fetch(`${BASE_URL}/api/cast/${id}`, { - method: 'DELETE', - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse<{ message: string }> = await response.json(); - return data.success; - } catch (error) { - console.error('Error deleting cast:', error); - return false; - } -} - -// Legacy function for compatibility - fetches all unique staff members from media -export async function fetchAllActors(): Promise> { - try { - const media = await fetchAllMedia(1, 1000); - const actorMap = new Map(); - - media.forEach(item => { - item.staff?.forEach(staffMember => { - const id = parseInt(staffMember.id); - if (!actorMap.has(id)) { - actorMap.set(id, { - id: id, - name: staffMember.name, - photo: staffMember.photo - }); - } - }); - }); - - return Array.from(actorMap.values()); - } catch (error) { - console.error('Error fetching all actors:', error); - return []; - } -} - -// Legacy function for compatibility - fetches all unique tags from media +// Legacy functions for compatibility export async function fetchAllTags(): Promise { try { + const { fetchAllMedia } = await import('./lib/api/mediaApi'); const media = await fetchAllMedia(1, 1000); const tagSet = new Set(); @@ -613,24 +24,9 @@ export async function fetchAllTags(): Promise { } } -// Legacy function for compatibility - fetches media by actor name -export async function fetchMediaByActor(actorName: string): Promise { - try { - const media = await fetchAllMedia(1, 1000); - return media.filter(item => - item.staff?.some(staffMember => - staffMember.name.toLowerCase().includes(actorName.toLowerCase()) - ) - ); - } catch (error) { - console.error('Error fetching media by actor:', error); - return []; - } -} - -// Legacy function for compatibility - fetches media by tag -export async function fetchMediaByTag(tag: string): Promise { +export async function fetchMediaByTag(tag: string) { try { + const { fetchAllMedia } = await import('./lib/api/mediaApi'); const media = await fetchAllMedia(1, 1000); return media.filter(item => item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) || @@ -642,154 +38,12 @@ export async function fetchMediaByTag(tag: string): Promise { } } -// Convenience function - fetch media from API (legacy compatibility) -export async function fetchMediaFromApi(apiUrl?: string): Promise { +export async function fetchMediaFromApi(apiUrl?: string) { + const { fetchAllMedia } = await import('./lib/api/mediaApi'); return fetchAllMedia(); } -// Convenience function - fetch media from local JSON (legacy compatibility) -export async function fetchMediaFromLocalJson(): Promise { +export async function fetchMediaFromLocalJson() { + const { fetchAllMedia } = await import('./lib/api/mediaApi'); return fetchAllMedia(); } - -// Settings API Types -export interface ApiSettingsItem { - id?: number; - enabled_categories: string[]; - items_per_page: number; - grid_item_size?: number; - default_view: string; - show_adult_content: boolean; - auto_play_trailers: boolean; - language: string; - theme: string; - jellyfin_library_mappings?: string; // JSON string of LibraryMapping[] - created_at?: string; - updated_at?: string; -} - -export interface CreateSettingsInput { - enabled_categories: string[]; - items_per_page?: number; - grid_item_size?: number; - default_view?: string; - show_adult_content?: boolean; - auto_play_trailers?: boolean; - language?: string; - theme?: string; - jellyfin_library_mappings?: string; -} - -export interface UpdateSettingsInput extends Partial {} - -export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings { - return { - id: apiItem.id, - enabledCategories: apiItem.enabled_categories as MediaCategory[], - itemsPerPage: apiItem.items_per_page || 20, - gridItemSize: apiItem.grid_item_size || 5, - defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid', - showAdultContent: apiItem.show_adult_content || false, - autoPlayTrailers: apiItem.auto_play_trailers || false, - language: apiItem.language || 'en', - theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system', - jellyfinLibraryMappings: apiItem.jellyfin_library_mappings, - createdAt: apiItem.created_at, - updatedAt: apiItem.updated_at, - }; -} - -export function convertSettingsToApi(settings: UserSettings): CreateSettingsInput { - return { - enabled_categories: settings.enabledCategories, - items_per_page: settings.itemsPerPage, - grid_item_size: settings.gridItemSize, - default_view: settings.defaultView, - show_adult_content: settings.showAdultContent, - auto_play_trailers: settings.autoPlayTrailers, - language: settings.language, - theme: settings.theme, - jellyfin_library_mappings: settings.jellyfinLibraryMappings, - }; -} - -// Settings API Functions -export async function fetchSettings(): Promise { - try { - const response = await fetch(`${BASE_URL}/api/settings`); - if (!response.ok) { - // If settings don't exist (404), return null to use defaults - if (response.status === 404) { - return null; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse = await response.json(); - - if (data.success && data.data) { - return convertApiToSettings(data.data); - } - return null; - } catch (error) { - console.error('Error fetching settings:', error); - return null; - } -} - -export async function createSettings(settings: UserSettings): Promise { - try { - const apiSettings = convertSettingsToApi(settings); - const response = await fetch(`${BASE_URL}/api/settings`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(apiSettings), - }); - if (!response.ok) { - const errorText = await response.text(); - console.error('Create settings error response:', errorText); - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse = await response.json(); - - if (data.success && data.data) { - return convertApiToSettings(data.data); - } - return null; - } catch (error) { - console.error('Error creating settings:', error); - return null; - } -} - -export async function updateSettings(settings: UserSettings): Promise { - try { - const apiSettings = convertSettingsToApi(settings); - const response = await fetch(`${BASE_URL}/api/settings`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(apiSettings), - }); - if (!response.ok) { - // If settings don't exist (404), try creating them instead - if (response.status === 404) { - return createSettings(settings); - } - const errorText = await response.text(); - console.error('Update settings error response:', errorText); - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: ApiResponse = await response.json(); - - if (data.success && data.data) { - return convertApiToSettings(data.data); - } - return null; - } catch (error) { - console.error('Error updating settings:', error); - return null; - } -} diff --git a/src/components/AddMediaView.tsx b/src/components/AddMediaView.tsx index 406cae1..9be471e 100644 --- a/src/components/AddMediaView.tsx +++ b/src/components/AddMediaView.tsx @@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label'; import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { createMedia, type CreateMediaInput } from '@/api'; -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, Film, Calendar, Star, User, BookOpen, Music as MusicIcon, Gamepad2, Monitor, Hash, Tag, Users, FileText, Globe, Clock } from 'lucide-react'; import { cn } from '@/lib/utils'; interface AddMediaViewProps { @@ -180,8 +180,22 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC } }; + const getCategoryIcon = (category: MediaCategory) => { + const icons: Record = { + 'Anime': , + 'Movies': , + 'TV Series': , + 'Music': , + 'Books': , + 'Games': , + 'Consoles': , + 'Adult': + }; + return icons[category] || ; + }; + return ( -
+
-
-

Add New Media

-

- Add a new item to your {activeCategory} library. -

+
+
+
+ {getCategoryIcon(activeCategory)} +
+
+

Add New Media

+

+ Add a new item to your {activeCategory} library. +

+
+
{submitStatus === 'success' && (
@@ -209,53 +230,62 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
)} -
-
- - setNewMedia(prev => ({ ...prev, title: e.target.value }))} - placeholder="e.g. Mob Psycho 100" - className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50" - required - /> -
-
-
- - setNewMedia(prev => ({ ...prev, year: e.target.value }))} - placeholder="2024" - className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50" - /> + + {/* Basic Info Card */} +
+
+
+ +
+

Basic Information

-
- - -
-
-
-
- - setNewMedia(prev => ({ ...prev, title: e.target.value }))} + placeholder="e.g. Mob Psycho 100" + className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50" + required + /> +
+
+
+ + setNewMedia(prev => ({ ...prev, year: e.target.value }))} + placeholder="2024" + className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50" + /> +
+
+ + +
+
+
+
+ + -
-
- - + +
+
+ + +
+
-
- - -
-
- - setNewMedia(prev => ({ ...prev, poster: e.target.value }))} - placeholder="https://example.com/poster.jpg" - className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50" - required - /> -
-
- - setNewMedia(prev => ({ ...prev, banner: e.target.value }))} - placeholder="https://example.com/banner.jpg" - className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50" - /> -
-
- -