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.
This commit is contained in:
198
.windsurf/skills/react/SKILL.md
Normal file
198
.windsurf/skills/react/SKILL.md
Normal file
@@ -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<Element> |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -25,7 +25,8 @@
|
|||||||
"shadcn": "^4.2.0",
|
"shadcn": "^4.2.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
@@ -7611,6 +7612,35 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.28 || ^4"
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
"shadcn": "^4.2.0",
|
"shadcn": "^4.2.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|||||||
311
src/App.tsx
311
src/App.tsx
@@ -16,53 +16,57 @@ 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 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 { 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';
|
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() {
|
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 { setTheme } = useTheme();
|
||||||
const [activeCategory, setActiveCategory] = useState<MediaCategory>(
|
|
||||||
(searchParams.get('category') as MediaCategory) || 'Anime'
|
// Zustand store
|
||||||
);
|
const {
|
||||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
apiMedia,
|
||||||
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
customMedia,
|
||||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
adultMedia,
|
||||||
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
mediaLoading,
|
||||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
selectedMedia,
|
||||||
const [customMedia, setCustomMedia] = useState<Media[]>([]);
|
selectedPerson,
|
||||||
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
|
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<Media[]>([]);
|
|
||||||
const [mediaLoading, setMediaLoading] = useState(true);
|
|
||||||
|
|
||||||
// Map URL paths to categories
|
|
||||||
const pathToCategory: Record<string, MediaCategory> = {
|
|
||||||
'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
|
// Set category from URL path on mount or location change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pathParts = location.pathname.split('/').filter(Boolean);
|
const pathParts = location.pathname.split('/').filter(Boolean);
|
||||||
if (pathParts.length === 1 && pathToCategory[pathParts[0]]) {
|
if (pathParts.length === 1 && PATH_TO_CATEGORY[pathParts[0]]) {
|
||||||
const category = pathToCategory[pathParts[0]];
|
const category = PATH_TO_CATEGORY[pathParts[0]];
|
||||||
if (enabledCategories.includes(category)) {
|
if (enabledCategories.includes(category)) {
|
||||||
setActiveCategory(category);
|
setActiveCategory(category);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [location.pathname, enabledCategories]);
|
}, [location.pathname, enabledCategories, setActiveCategory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettingsFromApi = async () => {
|
const loadSettingsFromApi = async () => {
|
||||||
@@ -116,57 +120,35 @@ function AppContent() {
|
|||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
const toggleCategory = async (category: MediaCategory) => {
|
const toggleCategory = async (category: MediaCategory) => {
|
||||||
setEnabledCategories(prev => {
|
const isEnabling = !enabledCategories.includes(category);
|
||||||
const isEnabling = !prev.includes(category);
|
const newList = isEnabling
|
||||||
const newList = isEnabling
|
? [...enabledCategories, category]
|
||||||
? [...prev, category]
|
: enabledCategories.filter(c => c !== category);
|
||||||
: prev.filter(c => c !== category);
|
|
||||||
|
// If we disable the current active category, switch to another enabled one
|
||||||
// If we disable the current active category, switch to another enabled one
|
if (!isEnabling && activeCategory === category) {
|
||||||
if (!isEnabling && activeCategory === category) {
|
const nextCategory = newList.find(c => c !== category) || 'Anime';
|
||||||
const nextCategory = newList.find(c => c !== category) || 'Anime';
|
setActiveCategory(nextCategory as MediaCategory);
|
||||||
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) => {
|
const handleCategoryChange = (category: MediaCategory) => {
|
||||||
setActiveCategory(category);
|
setActiveCategory(category);
|
||||||
// Map category names to URL-friendly paths
|
navigate(`/${CATEGORY_PATHS[category]}`);
|
||||||
const categoryPaths: Record<MediaCategory, string> = {
|
|
||||||
'Anime': 'anime',
|
|
||||||
'Movies': 'movies',
|
|
||||||
'TV Series': 'tv-series',
|
|
||||||
'Music': 'music',
|
|
||||||
'Books': 'books',
|
|
||||||
'Games': 'games',
|
|
||||||
'Consoles': 'consoles',
|
|
||||||
'Adult': 'adult'
|
|
||||||
};
|
|
||||||
navigate(`/${categoryPaths[category]}`);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -213,16 +195,7 @@ function AppContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGridItemSizeChange = async (size: number) => {
|
const handleGridItemSizeChange = async (size: number) => {
|
||||||
const baseSettings = settings || {
|
const baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories };
|
||||||
enabledCategories: enabledCategories,
|
|
||||||
itemsPerPage: 20,
|
|
||||||
gridItemSize: 5,
|
|
||||||
defaultView: 'grid',
|
|
||||||
showAdultContent: false,
|
|
||||||
autoPlayTrailers: false,
|
|
||||||
language: 'en',
|
|
||||||
theme: 'system',
|
|
||||||
};
|
|
||||||
const updatedSettings: UserSettings = {
|
const updatedSettings: UserSettings = {
|
||||||
...baseSettings,
|
...baseSettings,
|
||||||
gridItemSize: size,
|
gridItemSize: size,
|
||||||
@@ -365,88 +338,10 @@ function AppContent() {
|
|||||||
loading={mediaLoading}
|
loading={mediaLoading}
|
||||||
/>
|
/>
|
||||||
} />
|
} />
|
||||||
<Route path="/anime" element={
|
<Route path="/:category" element={
|
||||||
<BrowseView
|
<CategoryBrowseRoute
|
||||||
mediaList={filteredMedia}
|
mediaList={filteredMedia}
|
||||||
onMediaClick={handleMediaClick}
|
onMediaClick={handleMediaClick}
|
||||||
activeCategory="Anime"
|
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
|
||||||
gridItemSize={settings?.gridItemSize}
|
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
|
||||||
loading={mediaLoading}
|
|
||||||
/>
|
|
||||||
} />
|
|
||||||
<Route path="/movies" element={
|
|
||||||
<BrowseView
|
|
||||||
mediaList={filteredMedia}
|
|
||||||
onMediaClick={handleMediaClick}
|
|
||||||
activeCategory="Movies"
|
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
|
||||||
gridItemSize={settings?.gridItemSize}
|
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
|
||||||
loading={mediaLoading}
|
|
||||||
/>
|
|
||||||
} />
|
|
||||||
<Route path="/tv-series" element={
|
|
||||||
<BrowseView
|
|
||||||
mediaList={filteredMedia}
|
|
||||||
onMediaClick={handleMediaClick}
|
|
||||||
activeCategory="TV Series"
|
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
|
||||||
gridItemSize={settings?.gridItemSize}
|
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
|
||||||
loading={mediaLoading}
|
|
||||||
/>
|
|
||||||
} />
|
|
||||||
<Route path="/music" element={
|
|
||||||
<BrowseView
|
|
||||||
mediaList={filteredMedia}
|
|
||||||
onMediaClick={handleMediaClick}
|
|
||||||
activeCategory="Music"
|
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
|
||||||
gridItemSize={settings?.gridItemSize}
|
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
|
||||||
loading={mediaLoading}
|
|
||||||
/>
|
|
||||||
} />
|
|
||||||
<Route path="/books" element={
|
|
||||||
<BrowseView
|
|
||||||
mediaList={filteredMedia}
|
|
||||||
onMediaClick={handleMediaClick}
|
|
||||||
activeCategory="Books"
|
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
|
||||||
gridItemSize={settings?.gridItemSize}
|
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
|
||||||
loading={mediaLoading}
|
|
||||||
/>
|
|
||||||
} />
|
|
||||||
<Route path="/games" element={
|
|
||||||
<BrowseView
|
|
||||||
mediaList={filteredMedia}
|
|
||||||
onMediaClick={handleMediaClick}
|
|
||||||
activeCategory="Games"
|
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
|
||||||
gridItemSize={settings?.gridItemSize}
|
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
|
||||||
loading={mediaLoading}
|
|
||||||
/>
|
|
||||||
} />
|
|
||||||
<Route path="/consoles" element={
|
|
||||||
<BrowseView
|
|
||||||
mediaList={filteredMedia}
|
|
||||||
onMediaClick={handleMediaClick}
|
|
||||||
activeCategory="Consoles"
|
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
|
||||||
gridItemSize={settings?.gridItemSize}
|
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
|
||||||
loading={mediaLoading}
|
|
||||||
/>
|
|
||||||
} />
|
|
||||||
<Route path="/adult" element={
|
|
||||||
<BrowseView
|
|
||||||
mediaList={filteredMedia}
|
|
||||||
onMediaClick={handleMediaClick}
|
|
||||||
activeCategory="Adult"
|
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
itemsPerPage={settings?.itemsPerPage}
|
||||||
gridItemSize={settings?.gridItemSize}
|
gridItemSize={settings?.gridItemSize}
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
onGridItemSizeChange={handleGridItemSizeChange}
|
||||||
@@ -455,8 +350,6 @@ function AppContent() {
|
|||||||
} />
|
} />
|
||||||
<Route path="/media/:id" element={
|
<Route path="/media/:id" element={
|
||||||
<MediaDetailRoute
|
<MediaDetailRoute
|
||||||
selectedMedia={selectedMedia}
|
|
||||||
setSelectedMedia={setSelectedMedia}
|
|
||||||
allMedia={allMedia}
|
allMedia={allMedia}
|
||||||
onPersonClick={handlePersonClick}
|
onPersonClick={handlePersonClick}
|
||||||
/>
|
/>
|
||||||
@@ -469,10 +362,7 @@ function AppContent() {
|
|||||||
/>
|
/>
|
||||||
} />
|
} />
|
||||||
<Route path="/cast/:id" element={
|
<Route path="/cast/:id" element={
|
||||||
<CastDetailRoute
|
<CastDetailRoute />
|
||||||
selectedPerson={selectedPerson}
|
|
||||||
setSelectedPerson={setSelectedPerson}
|
|
||||||
/>
|
|
||||||
} />
|
} />
|
||||||
<Route path="/add" element={
|
<Route path="/add" element={
|
||||||
<AddMediaView
|
<AddMediaView
|
||||||
@@ -512,85 +402,6 @@ function AppContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper component for media detail route
|
|
||||||
function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadMedia = async () => {
|
|
||||||
if (id) {
|
|
||||||
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 <Loading message="Loading media details..." />;
|
|
||||||
if (!selectedMedia) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailView
|
|
||||||
media={selectedMedia}
|
|
||||||
onPersonClick={onPersonClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 <Loading message="Loading cast details..." />;
|
|
||||||
if (!selectedPerson) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CastDetailView
|
|
||||||
person={selectedPerson}
|
|
||||||
relatedMedia={[]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
774
src/api.ts
774
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;
|
// Legacy functions for compatibility
|
||||||
|
|
||||||
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<T> {
|
|
||||||
success: boolean;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
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<CreateMediaInput> {}
|
|
||||||
|
|
||||||
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<CreateCastInput> {}
|
|
||||||
|
|
||||||
|
|
||||||
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<Media[]> {
|
|
||||||
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<PaginatedResponse<ApiMediaItem>> = 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<Media | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${BASE_URL}/api/media/${id}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const data: ApiResponse<ApiMediaItem> = 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<Media | null> {
|
|
||||||
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<ApiMediaItem> = 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<Media | null> {
|
|
||||||
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<ApiMediaItem> = 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<boolean> {
|
|
||||||
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<Staff[]> {
|
|
||||||
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<PaginatedResponse<ApiCastItem>> = 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<ApiCastItem | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const data: ApiResponse<ApiCastItem> = 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<Media[]> {
|
|
||||||
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<PaginatedResponse<ApiMediaItem>> = 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<ApiCastItem | null> {
|
|
||||||
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<ApiCastItem> = 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<ApiCastItem | null> {
|
|
||||||
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<ApiCastItem> = 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<boolean> {
|
|
||||||
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<Array<{id: number, name: string, photo: string | null}>> {
|
|
||||||
try {
|
|
||||||
const media = await fetchAllMedia(1, 1000);
|
|
||||||
const actorMap = new Map<number, {id: number, name: string, photo: string | null}>();
|
|
||||||
|
|
||||||
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
|
|
||||||
export async function fetchAllTags(): Promise<string[]> {
|
export async function fetchAllTags(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
|
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||||
const media = await fetchAllMedia(1, 1000);
|
const media = await fetchAllMedia(1, 1000);
|
||||||
const tagSet = new Set<string>();
|
const tagSet = new Set<string>();
|
||||||
|
|
||||||
@@ -613,24 +24,9 @@ export async function fetchAllTags(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy function for compatibility - fetches media by actor name
|
export async function fetchMediaByTag(tag: string) {
|
||||||
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
|
|
||||||
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<Media[]> {
|
|
||||||
try {
|
try {
|
||||||
|
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||||
const media = await fetchAllMedia(1, 1000);
|
const media = await fetchAllMedia(1, 1000);
|
||||||
return media.filter(item =>
|
return media.filter(item =>
|
||||||
item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) ||
|
item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) ||
|
||||||
@@ -642,154 +38,12 @@ export async function fetchMediaByTag(tag: string): Promise<Media[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience function - fetch media from API (legacy compatibility)
|
export async function fetchMediaFromApi(apiUrl?: string) {
|
||||||
export async function fetchMediaFromApi(apiUrl?: string): Promise<Media[]> {
|
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||||
return fetchAllMedia();
|
return fetchAllMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience function - fetch media from local JSON (legacy compatibility)
|
export async function fetchMediaFromLocalJson() {
|
||||||
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
|
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||||
return fetchAllMedia();
|
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<CreateSettingsInput> {}
|
|
||||||
|
|
||||||
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<UserSettings | null> {
|
|
||||||
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<ApiSettingsItem> = 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<UserSettings | null> {
|
|
||||||
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<ApiSettingsItem> = 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<UserSettings | null> {
|
|
||||||
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<ApiSettingsItem> = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.data) {
|
|
||||||
return convertApiToSettings(data.data);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating settings:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { createMedia, type CreateMediaInput } from '@/api';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface AddMediaViewProps {
|
interface AddMediaViewProps {
|
||||||
@@ -180,8 +180,22 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCategoryIcon = (category: MediaCategory) => {
|
||||||
|
const icons: Record<MediaCategory, any> = {
|
||||||
|
'Anime': <Film size={18} />,
|
||||||
|
'Movies': <Film size={18} />,
|
||||||
|
'TV Series': <Film size={18} />,
|
||||||
|
'Music': <MusicIcon size={18} />,
|
||||||
|
'Books': <BookOpen size={18} />,
|
||||||
|
'Games': <Gamepad2 size={18} />,
|
||||||
|
'Consoles': <Monitor size={18} />,
|
||||||
|
'Adult': <Star size={18} />
|
||||||
|
};
|
||||||
|
return icons[category] || <Film size={18} />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
|
<div className="pt-24 pb-12 px-6">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
@@ -191,11 +205,18 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
Back to Browse
|
Back to Browse
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50">
|
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50 max-w-[1600px] mx-auto">
|
||||||
<h1 className="text-4xl font-black text-foreground mb-2">Add New Media</h1>
|
<div className="flex items-center gap-4 mb-8">
|
||||||
<p className="text-muted-foreground font-medium text-lg mb-8">
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] flex items-center justify-center shadow-lg shadow-[#6d28d9]/30">
|
||||||
Add a new item to your {activeCategory} library.
|
{getCategoryIcon(activeCategory)}
|
||||||
</p>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-black text-foreground mb-1">Add New Media</h1>
|
||||||
|
<p className="text-muted-foreground font-medium text-lg">
|
||||||
|
Add a new item to your {activeCategory} library.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{submitStatus === 'success' && (
|
{submitStatus === 'success' && (
|
||||||
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl backdrop-blur-sm">
|
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl backdrop-blur-sm">
|
||||||
@@ -209,53 +230,62 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleAddSubmit} className="space-y-6">
|
<form onSubmit={handleAddSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="grid gap-2">
|
{/* Basic Info Card */}
|
||||||
<Label htmlFor="title" className="text-sm font-black text-foreground">Title</Label>
|
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||||
<Input
|
<div className="flex items-center gap-3 mb-4">
|
||||||
id="title"
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||||
value={newMedia.title}
|
<FileText size={16} />
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
</div>
|
||||||
placeholder="e.g. Mob Psycho 100"
|
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
|
||||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="year" className="text-sm font-black text-foreground">Year</Label>
|
|
||||||
<Input
|
|
||||||
id="year"
|
|
||||||
value={newMedia.year}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
|
||||||
placeholder="2024"
|
|
||||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-4">
|
||||||
<Label htmlFor="category" className="text-sm font-black text-foreground">Category</Label>
|
<div className="grid gap-2">
|
||||||
<select
|
<Label htmlFor="title" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Title</Label>
|
||||||
id="category"
|
<Input
|
||||||
value={newMedia.category}
|
id="title"
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
value={newMedia.title}
|
||||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||||
>
|
placeholder="e.g. Mob Psycho 100"
|
||||||
{enabledCategories.map(cat => (
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
<option key={cat} value={cat}>{cat}</option>
|
required
|
||||||
))}
|
/>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
</div>
|
<div className="grid gap-2">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<Label htmlFor="year" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Year</Label>
|
||||||
<div className="grid gap-2">
|
<Input
|
||||||
<Label htmlFor="type" className="text-sm font-black text-foreground">Type</Label>
|
id="year"
|
||||||
<select
|
value={newMedia.year}
|
||||||
id="type"
|
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
||||||
value={newMedia.type}
|
placeholder="2024"
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
/>
|
||||||
>
|
</div>
|
||||||
{newMedia.category === 'Music' ? (
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="category" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Category</Label>
|
||||||
|
<select
|
||||||
|
id="category"
|
||||||
|
value={newMedia.category}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||||
|
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
||||||
|
>
|
||||||
|
{enabledCategories.map(cat => (
|
||||||
|
<option key={cat} value={cat}>{cat}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="type" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Type</Label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
value={newMedia.type}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
||||||
|
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
||||||
|
>
|
||||||
|
{newMedia.category === 'Music' ? (
|
||||||
<>
|
<>
|
||||||
<option value="Album">Album</option>
|
<option value="Album">Album</option>
|
||||||
<option value="Single">Single</option>
|
<option value="Single">Single</option>
|
||||||
@@ -284,287 +314,337 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
<option value="Movie">Movie</option>
|
<option value="Movie">Movie</option>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="status" className="text-sm font-black text-foreground">Status</Label>
|
<Label htmlFor="status" className="text-xs font-black text-muted-foreground uppercase tracking-wider">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-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
||||||
>
|
>
|
||||||
<option value="Released">Released</option>
|
<option value="Released">Released</option>
|
||||||
<option value="Ongoing">Ongoing</option>
|
<option value="Ongoing">Ongoing</option>
|
||||||
<option value="Upcoming">Upcoming</option>
|
<option value="Upcoming">Upcoming</option>
|
||||||
<option value="Completed">Completed</option>
|
<option value="Completed">Completed</option>
|
||||||
<option value="Watching">Watching</option>
|
<option value="Watching">Watching</option>
|
||||||
<option value="Reading">Reading</option>
|
<option value="Reading">Reading</option>
|
||||||
<option value="Listening">Listening</option>
|
<option value="Listening">Listening</option>
|
||||||
<option value="Playing">Playing</option>
|
<option value="Playing">Playing</option>
|
||||||
<option value="Dropped">Dropped</option>
|
<option value="Dropped">Dropped</option>
|
||||||
<option value="On Hold">On Hold</option>
|
<option value="On Hold">On Hold</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="aspectRatio" className="text-sm font-black text-foreground">Aspect Ratio (Format)</Label>
|
{/* Media Info Card */}
|
||||||
<select
|
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||||
id="aspectRatio"
|
<div className="flex items-center gap-3 mb-4">
|
||||||
value={newMedia.aspectRatio}
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
<Globe size={16} />
|
||||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
</div>
|
||||||
>
|
<h3 className="text-lg font-black text-foreground">Media Information</h3>
|
||||||
<option value="2/3">2:3 (Standard Poster)</option>
|
</div>
|
||||||
<option value="16/9">16:9 (Wide Thumbnail)</option>
|
<div className="grid gap-4">
|
||||||
<option value="1/1">1:1 (Square)</option>
|
<div className="grid gap-2">
|
||||||
</select>
|
<Label htmlFor="poster" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Poster URL</Label>
|
||||||
</div>
|
<Input
|
||||||
<div className="grid gap-2">
|
id="poster"
|
||||||
<Label htmlFor="poster" className="text-sm font-black text-foreground">Poster URL</Label>
|
value={newMedia.poster}
|
||||||
<Input
|
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||||
id="poster"
|
placeholder="https://example.com/poster.jpg"
|
||||||
value={newMedia.poster}
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
required
|
||||||
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"
|
</div>
|
||||||
required
|
<div className="grid gap-2">
|
||||||
/>
|
<Label htmlFor="banner" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Banner URL (optional)</Label>
|
||||||
</div>
|
<Input
|
||||||
<div className="grid gap-2">
|
id="banner"
|
||||||
<Label htmlFor="banner" className="text-sm font-black text-foreground">Banner URL (Optional)</Label>
|
value={newMedia.banner}
|
||||||
<Input
|
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
||||||
id="banner"
|
placeholder="https://example.com/banner.jpg"
|
||||||
value={newMedia.banner}
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
/>
|
||||||
placeholder="https://example.com/banner.jpg"
|
</div>
|
||||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="description" className="text-sm font-black text-foreground">Description (Optional)</Label>
|
|
||||||
<textarea
|
|
||||||
id="description"
|
|
||||||
value={newMedia.description}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
|
||||||
placeholder="Brief description..."
|
|
||||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl p-3 h-20 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="rating" className="text-sm font-black text-foreground">Rating (Optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="rating"
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
min="0"
|
|
||||||
max="10"
|
|
||||||
value={newMedia.rating}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
|
|
||||||
placeholder="8.5"
|
|
||||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="runtime" className="text-sm font-black text-foreground">Runtime (min)</Label>
|
<Label htmlFor="aspectRatio" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Aspect Ratio</Label>
|
||||||
|
<select
|
||||||
|
id="aspectRatio"
|
||||||
|
value={newMedia.aspectRatio}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
||||||
|
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
||||||
|
>
|
||||||
|
<option value="2/3">2:3 (Poster)</option>
|
||||||
|
<option value="16/9">16:9 (Banner)</option>
|
||||||
|
<option value="1/1">1:1 (Square)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="rating" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Rating (0-10)</Label>
|
||||||
|
<Input
|
||||||
|
id="rating"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="0.1"
|
||||||
|
value={newMedia.rating}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
|
||||||
|
placeholder="8.5"
|
||||||
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="description" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Description</Label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
value={newMedia.description}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
placeholder="Enter a description..."
|
||||||
|
rows={4}
|
||||||
|
className="bg-background border-border/50 rounded-xl p-3 text-sm focus:ring-2 focus:ring-[#6d28d9]/50 outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Production Details Card - for Movies/TV/Anime */}
|
||||||
|
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||||
|
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||||
|
<Clock size={16} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-black text-foreground">Production Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="runtime" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Runtime (minutes)</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-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="releaseDate" className="text-sm font-black text-foreground">Release Date</Label>
|
<Label htmlFor="releaseDate" className="text-xs font-black text-muted-foreground uppercase tracking-wider">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-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="director" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Director</Label>
|
||||||
|
<Input
|
||||||
|
id="director"
|
||||||
|
value={newMedia.director}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
||||||
|
placeholder="Director name"
|
||||||
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="writer" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Writer</Label>
|
||||||
|
<Input
|
||||||
|
id="writer"
|
||||||
|
value={newMedia.writer}
|
||||||
|
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
||||||
|
placeholder="Writer name"
|
||||||
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
</div>
|
||||||
<Label htmlFor="director" className="text-sm font-black text-foreground">Director</Label>
|
|
||||||
<Input
|
|
||||||
id="director"
|
|
||||||
value={newMedia.director}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
|
||||||
placeholder="Director name"
|
|
||||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="writer" className="text-sm font-black text-foreground">Writer</Label>
|
|
||||||
<Input
|
|
||||||
id="writer"
|
|
||||||
value={newMedia.writer}
|
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
|
||||||
placeholder="Writer name"
|
|
||||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-2">
|
{/* Classification Card */}
|
||||||
<Label htmlFor="genres" className="text-sm font-black text-foreground">Genres (comma-separated)</Label>
|
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||||
<Input
|
<div className="flex items-center gap-3 mb-4">
|
||||||
id="genres"
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||||
value={newMedia.genres}
|
<Tag size={16} />
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
</div>
|
||||||
placeholder="Action, Drama, Sci-Fi"
|
<h3 className="text-lg font-black text-foreground">Classification</h3>
|
||||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
</div>
|
||||||
/>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
</div>
|
<div className="grid gap-2">
|
||||||
<div className="grid gap-2">
|
<Label htmlFor="genres" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Genres (comma-separated)</Label>
|
||||||
<Label htmlFor="tags" className="text-sm font-black text-foreground">Tags (comma-separated)</Label>
|
<Input
|
||||||
<Input
|
id="genres"
|
||||||
id="tags"
|
value={newMedia.genres}
|
||||||
value={newMedia.tags}
|
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
placeholder="Action, Drama, Sci-Fi"
|
||||||
placeholder="Classic, Best-selling"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="grid gap-2">
|
||||||
<div className="grid gap-2">
|
<Label htmlFor="tags" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Tags (comma-separated)</Label>
|
||||||
<Label htmlFor="studios" className="text-sm font-black text-foreground">Studios (comma-separated)</Label>
|
<Input
|
||||||
<Input
|
id="tags"
|
||||||
id="studios"
|
value={newMedia.tags}
|
||||||
value={newMedia.studios}
|
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
placeholder="Classic, Best-selling"
|
||||||
placeholder="Studio A, Studio B"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="grid gap-2">
|
||||||
<div className="grid gap-2">
|
<Label htmlFor="studios" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Studios (comma-separated)</Label>
|
||||||
<Label htmlFor="source" className="text-sm font-black text-foreground">Source / Quelle</Label>
|
<Input
|
||||||
<Input
|
id="studios"
|
||||||
id="source"
|
value={newMedia.studios}
|
||||||
value={newMedia.source}
|
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
|
placeholder="Studio A, Studio B"
|
||||||
placeholder="e.g. username, xbvr, stashapp"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
/>
|
||||||
/>
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="source" className="text-xs font-black text-muted-foreground uppercase tracking-wider">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-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cast/Staff Section */}
|
{/* Cast/Staff Card */}
|
||||||
{(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="space-y-4">
|
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50 lg:col-span-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<Label className="text-sm font-black text-foreground">Cast & Crew</Label>
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||||
|
<Users size={16} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-black text-foreground">Cast & Crew</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Staff List */}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{staff.length > 0 && (
|
{/* Staff List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{staff.map((member, index) => (
|
{staff.length > 0 && (
|
||||||
<div key={index} className="flex items-center gap-3 p-3 bg-muted/50 backdrop-blur-sm rounded-xl border border-border/50">
|
<>
|
||||||
{member.photo && (
|
{staff.map((member, index) => (
|
||||||
<img
|
<div key={index} className="flex items-center gap-3 p-3 bg-background rounded-xl border border-border/50">
|
||||||
src={member.photo}
|
{member.photo && (
|
||||||
alt={member.name}
|
<img
|
||||||
className="w-12 h-12 rounded-xl object-cover border border-border/30"
|
src={member.photo}
|
||||||
referrerPolicy="no-referrer"
|
alt={member.name}
|
||||||
/>
|
className="w-12 h-12 rounded-xl object-cover border border-border/30"
|
||||||
)}
|
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 className="flex-1 min-w-0">
|
||||||
</div>
|
<p className="font-bold text-foreground truncate">{member.name}</p>
|
||||||
<Button
|
<p className="text-xs text-muted-foreground">{member.role}{member.characterName ? ` as ${member.characterName}` : ''}</p>
|
||||||
type="button"
|
</div>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
type="button"
|
||||||
onClick={() => setStaff(prev => prev.filter((_, i) => i !== index))}
|
variant="ghost"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-xl"
|
size="icon"
|
||||||
>
|
onClick={() => setStaff(prev => prev.filter((_, i) => i !== index))}
|
||||||
×
|
className="h-8 w-8 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-xl"
|
||||||
</Button>
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{staff.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
No cast members added yet
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add Staff Form */}
|
{/* Add Staff Form */}
|
||||||
<div className="grid gap-3 p-4 bg-muted/30 backdrop-blur-sm rounded-xl border border-border/50">
|
<div className="grid gap-3 p-4 bg-background rounded-xl border border-border/50">
|
||||||
<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/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
|
||||||
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">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="staffRole" className="text-xs font-black text-foreground">Role</Label>
|
<Label htmlFor="staffName" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="staffRole"
|
id="staffName"
|
||||||
placeholder="e.g. Actor, Director"
|
placeholder="Actor name"
|
||||||
className="bg-background border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const nameInput = document.getElementById('staffName') as HTMLInputElement;
|
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
|
||||||
if (input.value && nameInput?.value) {
|
if (input.value && roleInput?.value) {
|
||||||
addStaffMember();
|
addStaffMember();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="staffRole" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Role</Label>
|
||||||
|
<Input
|
||||||
|
id="staffRole"
|
||||||
|
placeholder="e.g. Actor, Director"
|
||||||
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
|
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-muted-foreground uppercase tracking-wider">Character (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="staffCharacter"
|
||||||
|
placeholder="Character name"
|
||||||
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="staffCharacter" className="text-xs font-black text-foreground">Character (optional)</Label>
|
<Label htmlFor="staffPhoto" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Photo URL (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="staffCharacter"
|
id="staffPhoto"
|
||||||
placeholder="Character name"
|
placeholder="https://example.com/photo.jpg"
|
||||||
className="bg-background border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={addStaffMember}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-border/50 text-sm font-bold hover:border-[#6d28d9]/50 hover:bg-[#6d28d9]/10 rounded-xl transition-all duration-300"
|
||||||
|
>
|
||||||
|
+ Add Cast Member
|
||||||
|
</Button>
|
||||||
</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/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={addStaffMember}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full border-border/50 text-sm font-bold hover:border-[#6d28d9]/50 hover:bg-[#6d28d9]/10 rounded-xl transition-all duration-300"
|
|
||||||
>
|
|
||||||
+ Add Cast Member
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
{/* Submit Button - Full Width */}
|
||||||
type="submit"
|
<div className="lg:col-span-2">
|
||||||
disabled={isSubmitting}
|
<Button
|
||||||
className="w-full bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/30 transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
type="submit"
|
||||||
>
|
disabled={isSubmitting}
|
||||||
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
|
className="w-full bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/30 transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||||
</Button>
|
>
|
||||||
|
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Menu,
|
Menu,
|
||||||
X
|
X,
|
||||||
|
Plus
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { MediaCategory } from '@/types';
|
import { MediaCategory } from '@/types';
|
||||||
|
import { CATEGORY_PATHS } from '@/constants';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
enabledCategories: MediaCategory[];
|
enabledCategories: MediaCategory[];
|
||||||
@@ -37,16 +39,6 @@ export default function Sidebar({ enabledCategories, onToggleCategory }: Sidebar
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const categoryPaths: Record<MediaCategory, string> = {
|
|
||||||
'Anime': 'anime',
|
|
||||||
'Movies': 'movies',
|
|
||||||
'TV Series': 'tv-series',
|
|
||||||
'Music': 'music',
|
|
||||||
'Books': 'books',
|
|
||||||
'Games': 'games',
|
|
||||||
'Consoles': 'consoles',
|
|
||||||
'Adult': 'adult'
|
|
||||||
};
|
|
||||||
|
|
||||||
const categoryIcons: Record<string, any> = {
|
const categoryIcons: Record<string, any> = {
|
||||||
'Audio Book': <BookOpen size={18} />,
|
'Audio Book': <BookOpen size={18} />,
|
||||||
@@ -84,7 +76,9 @@ export default function Sidebar({ enabledCategories, onToggleCategory }: Sidebar
|
|||||||
//{ icon: <Dumbbell size={18} />, label: 'Fitness', path: '/fitness' },
|
//{ icon: <Dumbbell size={18} />, label: 'Fitness', path: '/fitness' },
|
||||||
//{ icon: <Calendar size={18} />, label: 'Calendar', path: '/calendar' },
|
//{ icon: <Calendar size={18} />, label: 'Calendar', path: '/calendar' },
|
||||||
//{ icon: <FolderKanban size={18} />, label: 'Collections', path: '/collections' },
|
//{ icon: <FolderKanban size={18} />, label: 'Collections', path: '/collections' },
|
||||||
{ icon: <Settings size={18} />, label: 'Settings', path: '/settings' }
|
{ icon: <Plus size={18} />, label: 'Add Media', path: '/add' },
|
||||||
|
{ icon: <Settings size={18} />, label: 'Settings', path: '/settings' },
|
||||||
|
{ icon: <FolderKanban size={18} />, label: 'Import', path: '/import' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
|
|||||||
46
src/components/routes/CastDetailRoute.tsx
Normal file
46
src/components/routes/CastDetailRoute.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Staff } from '../../types';
|
||||||
|
import { fetchCastById, convertApiCastToStaff } from '../../api';
|
||||||
|
import CastDetailView from '../CastDetailView';
|
||||||
|
import Loading from '../ui/loading';
|
||||||
|
|
||||||
|
export default function CastDetailRoute() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
||||||
|
|
||||||
|
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, navigate]);
|
||||||
|
|
||||||
|
if (loading) return <Loading message="Loading cast details..." />;
|
||||||
|
if (!selectedPerson) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CastDetailView
|
||||||
|
person={selectedPerson}
|
||||||
|
relatedMedia={[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/routes/CategoryBrowseRoute.tsx
Normal file
49
src/components/routes/CategoryBrowseRoute.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Media, Staff, MediaCategory } from '../../types';
|
||||||
|
import BrowseView from '../BrowseView';
|
||||||
|
|
||||||
|
interface CategoryBrowseRouteProps {
|
||||||
|
mediaList: Media[];
|
||||||
|
onMediaClick: (media: Media) => void;
|
||||||
|
itemsPerPage?: number;
|
||||||
|
gridItemSize?: number;
|
||||||
|
onGridItemSizeChange: (size: number) => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryBrowseRoute({
|
||||||
|
mediaList,
|
||||||
|
onMediaClick,
|
||||||
|
itemsPerPage,
|
||||||
|
gridItemSize,
|
||||||
|
onGridItemSizeChange,
|
||||||
|
loading
|
||||||
|
}: CategoryBrowseRouteProps) {
|
||||||
|
const { category } = useParams<{ category: string }>();
|
||||||
|
|
||||||
|
// Map URL path to category
|
||||||
|
const categoryMap: Record<string, MediaCategory> = {
|
||||||
|
'anime': 'Anime',
|
||||||
|
'movies': 'Movies',
|
||||||
|
'tv-series': 'TV Series',
|
||||||
|
'music': 'Music',
|
||||||
|
'books': 'Books',
|
||||||
|
'games': 'Games',
|
||||||
|
'consoles': 'Consoles',
|
||||||
|
'adult': 'Adult'
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeCategory = category ? categoryMap[category] : 'Anime';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowseView
|
||||||
|
mediaList={mediaList}
|
||||||
|
onMediaClick={onMediaClick}
|
||||||
|
activeCategory={activeCategory}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
gridItemSize={gridItemSize}
|
||||||
|
onGridItemSizeChange={onGridItemSizeChange}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/routes/MediaDetailRoute.tsx
Normal file
50
src/components/routes/MediaDetailRoute.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Media, Staff } from '../../types';
|
||||||
|
import { fetchMediaById } from '../../api';
|
||||||
|
import DetailView from '../DetailView';
|
||||||
|
import Loading from '../ui/loading';
|
||||||
|
|
||||||
|
interface MediaDetailRouteProps {
|
||||||
|
allMedia: Media[];
|
||||||
|
onPersonClick: (person: Staff) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaDetailRoute({ allMedia, onPersonClick }: MediaDetailRouteProps) {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||||
|
|
||||||
|
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, navigate]);
|
||||||
|
|
||||||
|
if (loading) return <Loading message="Loading media details..." />;
|
||||||
|
if (!selectedMedia) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailView
|
||||||
|
media={selectedMedia}
|
||||||
|
onPersonClick={onPersonClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/constants.ts
Normal file
49
src/constants.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { MediaCategory } from './types';
|
||||||
|
|
||||||
|
// Category to URL path mapping
|
||||||
|
export const CATEGORY_PATHS: Record<MediaCategory, string> = {
|
||||||
|
'Anime': 'anime',
|
||||||
|
'Movies': 'movies',
|
||||||
|
'TV Series': 'tv-series',
|
||||||
|
'Music': 'music',
|
||||||
|
'Books': 'books',
|
||||||
|
'Games': 'games',
|
||||||
|
'Consoles': 'consoles',
|
||||||
|
'Adult': 'adult'
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL path to category mapping
|
||||||
|
export const PATH_TO_CATEGORY: Record<string, MediaCategory> = {
|
||||||
|
'anime': 'Anime',
|
||||||
|
'movies': 'Movies',
|
||||||
|
'tv-series': 'TV Series',
|
||||||
|
'music': 'Music',
|
||||||
|
'books': 'Books',
|
||||||
|
'games': 'Games',
|
||||||
|
'consoles': 'Consoles',
|
||||||
|
'adult': 'Adult'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default enabled categories
|
||||||
|
export const DEFAULT_ENABLED_CATEGORIES: MediaCategory[] = [
|
||||||
|
'Anime',
|
||||||
|
'Movies',
|
||||||
|
'TV Series',
|
||||||
|
'Music',
|
||||||
|
'Books',
|
||||||
|
'Consoles',
|
||||||
|
'Games',
|
||||||
|
'Adult'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Default settings
|
||||||
|
export const DEFAULT_SETTINGS = {
|
||||||
|
enabledCategories: DEFAULT_ENABLED_CATEGORIES,
|
||||||
|
itemsPerPage: 20,
|
||||||
|
gridItemSize: 5,
|
||||||
|
defaultView: 'grid' as const,
|
||||||
|
showAdultContent: false,
|
||||||
|
autoPlayTrailers: false,
|
||||||
|
language: 'en',
|
||||||
|
theme: 'system' as const,
|
||||||
|
};
|
||||||
@@ -127,7 +127,13 @@ export const MOCK_MEDIA: Media[] = [
|
|||||||
studios: ['Example Studio'],
|
studios: ['Example Studio'],
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
export const DETAIL_MEDIA: Media = {}
|
export const DETAIL_MEDIA: Media = {
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
year: '',
|
||||||
|
poster: '',
|
||||||
|
category: 'Movies'
|
||||||
|
}
|
||||||
/*
|
/*
|
||||||
export const DETAIL_MEDIA: Media = {
|
export const DETAIL_MEDIA: Media = {
|
||||||
id: 'mob-psycho',
|
id: 'mob-psycho',
|
||||||
|
|||||||
163
src/lib/api/castApi.ts
Normal file
163
src/lib/api/castApi.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Staff, Media } from '../../types';
|
||||||
|
import { ApiResponse, PaginatedResponse, ApiCastItem, CreateCastInput, UpdateCastInput } from './types';
|
||||||
|
import { convertApiCastToStaff, convertApiToMedia } from './converters';
|
||||||
|
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<Staff[]> {
|
||||||
|
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<PaginatedResponse<ApiCastItem>> = 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<ApiCastItem | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/cast/${id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data: ApiResponse<ApiCastItem> = 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<Media[]> {
|
||||||
|
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<PaginatedResponse<any>> = 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<ApiCastItem | null> {
|
||||||
|
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<ApiCastItem> = 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<ApiCastItem | null> {
|
||||||
|
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<ApiCastItem> = 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<boolean> {
|
||||||
|
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 functions for compatibility
|
||||||
|
export async function fetchAllActors(): Promise<Array<{id: number, name: string, photo: string | null}>> {
|
||||||
|
try {
|
||||||
|
const media = await (await import('./mediaApi')).fetchAllMedia(1, 1000);
|
||||||
|
const actorMap = new Map<number, {id: number, name: string, photo: string | null}>();
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
|
||||||
|
try {
|
||||||
|
const media = await (await import('./mediaApi')).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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
190
src/lib/api/converters.ts
Normal file
190
src/lib/api/converters.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { Media, Staff, UserSettings, MediaCategory } from '../../types';
|
||||||
|
import { ApiMediaItem, ApiStaff, ApiCastItem, ApiSettingsItem, CreateSettingsInput } from './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;
|
||||||
|
}
|
||||||
|
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
|
||||||
|
return `${BASE_URL}/${cleanPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
console.warn('Unknown category:', apiItem.category, 'defaulting to Movies');
|
||||||
|
mediaCategory = 'Movies';
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
105
src/lib/api/mediaApi.ts
Normal file
105
src/lib/api/mediaApi.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Media } from '../../types';
|
||||||
|
import { ApiResponse, PaginatedResponse, ApiMediaItem, CreateMediaInput, UpdateMediaInput } from './types';
|
||||||
|
import { convertApiToMedia } from './converters';
|
||||||
|
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
|
||||||
|
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<PaginatedResponse<ApiMediaItem>> = 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<Media | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/media/${id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data: ApiResponse<ApiMediaItem> = 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<Media | null> {
|
||||||
|
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<ApiMediaItem> = 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<Media | null> {
|
||||||
|
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<ApiMediaItem> = 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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/lib/api/settingsApi.ts
Normal file
83
src/lib/api/settingsApi.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { UserSettings } from '../../types';
|
||||||
|
import { ApiResponse, ApiSettingsItem, CreateSettingsInput, UpdateSettingsInput } from './types';
|
||||||
|
import { convertApiToSettings, convertSettingsToApi } from './converters';
|
||||||
|
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
export async function fetchSettings(): Promise<UserSettings | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/settings`);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data: ApiResponse<ApiSettingsItem> = 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<UserSettings | null> {
|
||||||
|
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<ApiSettingsItem> = 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<UserSettings | null> {
|
||||||
|
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 (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<ApiSettingsItem> = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.data) {
|
||||||
|
return convertApiToSettings(data.data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating settings:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
212
src/lib/api/types.ts
Normal file
212
src/lib/api/types.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// API Response Types
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
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<CreateMediaInput> {}
|
||||||
|
|
||||||
|
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<CreateCastInput> {}
|
||||||
|
|
||||||
|
// Settings 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;
|
||||||
|
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<CreateSettingsInput> {}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
// Import the source mapping
|
// Import the source mapping and types
|
||||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
import { SOURCE_CATEGORY_MAPPING, Media, Staff, Episode, Track } from '@/types';
|
||||||
|
|
||||||
export interface JellyfinConfig {
|
export interface JellyfinConfig {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -56,7 +56,7 @@ export interface JellyfinItem {
|
|||||||
Type: string;
|
Type: string;
|
||||||
Role?: string;
|
Role?: string;
|
||||||
PrimaryImageTag?: string;
|
PrimaryImageTag?: string;
|
||||||
ImageBlurHashes?: any;
|
ImageBlurHashes?: Record<string, Record<string, string>>;
|
||||||
}>;
|
}>;
|
||||||
ImageTags?: {
|
ImageTags?: {
|
||||||
Primary?: string;
|
Primary?: string;
|
||||||
@@ -96,7 +96,7 @@ export interface JellyfinPerson {
|
|||||||
Name: string;
|
Name: string;
|
||||||
Type: string;
|
Type: string;
|
||||||
PrimaryImageTag?: string;
|
PrimaryImageTag?: string;
|
||||||
ImageBlurHashes?: any;
|
ImageBlurHashes?: Record<string, Record<string, string>>;
|
||||||
PremiereDate?: string;
|
PremiereDate?: string;
|
||||||
ProductionYear?: number;
|
ProductionYear?: number;
|
||||||
Overview?: string;
|
Overview?: string;
|
||||||
@@ -105,6 +105,28 @@ export interface JellyfinPerson {
|
|||||||
PlaceOfBirth?: string;
|
PlaceOfBirth?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JellyfinEpisode {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
Overview?: string;
|
||||||
|
PremiereDate?: string;
|
||||||
|
RunTimeTicks?: number;
|
||||||
|
ParentIndexNumber?: number;
|
||||||
|
IndexNumber?: number;
|
||||||
|
ImageTags?: {
|
||||||
|
Primary?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyfinTrack {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
IndexNumber?: number;
|
||||||
|
RunTimeTicks?: number;
|
||||||
|
AlbumArtist?: string;
|
||||||
|
Artists?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export type LogCallback = (message: string) => void;
|
export type LogCallback = (message: string) => void;
|
||||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||||
|
|
||||||
@@ -575,10 +597,12 @@ async function convertJellyfinSeriesToMedia(
|
|||||||
const writers = item.People?.filter(p => p.Type === 'Writer').map(p => p.Name) || [];
|
const writers = item.People?.filter(p => p.Type === 'Writer').map(p => p.Name) || [];
|
||||||
|
|
||||||
// Fetch episodes for this series
|
// Fetch episodes for this series
|
||||||
let episodes: any[] = [];
|
let episodes: Episode[] = [];
|
||||||
try {
|
try {
|
||||||
const jellyfinEpisodes = await fetchJellyfinSeriesEpisodes(config, item.Id);
|
const jellyfinEpisodes = await fetchJellyfinSeriesEpisodes(config, item.Id);
|
||||||
episodes = jellyfinEpisodes.map(ep => ({
|
episodes = jellyfinEpisodes.map(ep => ({
|
||||||
|
id: parseInt(ep.Id),
|
||||||
|
media_id: parseInt(item.Id),
|
||||||
season: ep.ParentIndexNumber || 1,
|
season: ep.ParentIndexNumber || 1,
|
||||||
episode_number: ep.IndexNumber || 1,
|
episode_number: ep.IndexNumber || 1,
|
||||||
title: ep.Name,
|
title: ep.Name,
|
||||||
@@ -682,14 +706,16 @@ async function convertJellyfinAlbumToMedia(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Fetch tracks for this album
|
// Fetch tracks for this album
|
||||||
let tracks: any[] = [];
|
let tracks: Track[] = [];
|
||||||
try {
|
try {
|
||||||
const jellyfinTracks = await fetchJellyfinAlbumTracks(config, item.Id);
|
const jellyfinTracks = await fetchJellyfinAlbumTracks(config, item.Id);
|
||||||
tracks = jellyfinTracks.map((track, index) => ({
|
tracks = jellyfinTracks.map((track, index) => ({
|
||||||
|
id: parseInt(track.Id),
|
||||||
|
media_id: parseInt(item.Id),
|
||||||
track_number: track.IndexNumber || (index + 1),
|
track_number: track.IndexNumber || (index + 1),
|
||||||
title: track.Name,
|
title: track.Name,
|
||||||
duration: track.RunTimeTicks ? `${Math.floor(track.RunTimeTicks / 600000000 / 60)}:${String(Math.floor((track.RunTimeTicks / 600000000) % 60)).padStart(2, '0')}` : null,
|
duration: track.RunTimeTicks ? Math.floor(track.RunTimeTicks / 600000000) : null,
|
||||||
artist: track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown'
|
artist: (track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown') as string
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to fetch tracks for album ${item.Name}:`, error);
|
console.warn(`Failed to fetch tracks for album ${item.Name}:`, error);
|
||||||
@@ -721,18 +747,20 @@ async function convertJellyfinAlbumToMedia(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert Jellyfin person to API cast format
|
// Convert Jellyfin person to API cast format
|
||||||
function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinConfig): any {
|
function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinConfig): Staff {
|
||||||
const photo = person.PrimaryImageTag
|
const photo = person.PrimaryImageTag
|
||||||
? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary')
|
? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id: person.Id,
|
||||||
name: person.Name,
|
name: person.Name,
|
||||||
|
role: person.Type || 'Actor',
|
||||||
photo: photo,
|
photo: photo,
|
||||||
bio: person.Overview || null,
|
bio: person.Overview || null,
|
||||||
birthDate: person.BirthDate ? formatDate(person.BirthDate) : null,
|
birthDate: person.BirthDate ? formatDate(person.BirthDate) : null,
|
||||||
birthPlace: person.PlaceOfBirth || null,
|
birthPlace: person.PlaceOfBirth || null,
|
||||||
occupations: [person.Type === 'Actor' ? 'Actor' : person.Type || 'Person']
|
occupations: ['Actor']
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,7 +799,7 @@ export async function importFromJellyfin(
|
|||||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const existingMedia = new Map(
|
const existingMedia = new Map(
|
||||||
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
|
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
|
||||||
);
|
);
|
||||||
logCallback(`Found ${existingMedia.size} existing media items in database`);
|
logCallback(`Found ${existingMedia.size} existing media items in database`);
|
||||||
|
|
||||||
@@ -779,7 +807,7 @@ export async function importFromJellyfin(
|
|||||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const existingCast = new Map(
|
const existingCast = new Map(
|
||||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||||
);
|
);
|
||||||
logCallback(`Found ${existingCast.size} existing cast members in database`);
|
logCallback(`Found ${existingCast.size} existing cast members in database`);
|
||||||
|
|
||||||
@@ -1173,14 +1201,14 @@ export async function cleanupJellyfinMedia(
|
|||||||
logCallback('Fetching existing media from Kyoo API...');
|
logCallback('Fetching existing media from Kyoo API...');
|
||||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const jellyfinMedia = (existingMediaData.data?.items || []).filter((m: any) => m.source === 'jellyfin');
|
const jellyfinMedia = (existingMediaData.data?.items || []).filter((m: Media) => m.source === 'jellyfin');
|
||||||
logCallback(`Found ${jellyfinMedia.length} Jellyfin media items in database`);
|
logCallback(`Found ${jellyfinMedia.length} Jellyfin media items in database`);
|
||||||
|
|
||||||
// Fetch all existing cast from Kyoo API
|
// Fetch all existing cast from Kyoo API
|
||||||
logCallback('Fetching existing cast from Kyoo API...');
|
logCallback('Fetching existing cast from Kyoo API...');
|
||||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const jellyfinCast = (existingCastData.data?.items || []).filter((c: any) => c.photo && c.photo.includes(normalizeUrl(config.url)));
|
const jellyfinCast = (existingCastData.data?.items || []).filter((c: Staff) => c.photo && c.photo.includes(normalizeUrl(config.url)));
|
||||||
logCallback(`Found ${jellyfinCast.length} Jellyfin cast members in database`);
|
logCallback(`Found ${jellyfinCast.length} Jellyfin cast members in database`);
|
||||||
|
|
||||||
// Fetch current items from Jellyfin
|
// Fetch current items from Jellyfin
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
// Import the source mapping
|
// Import the source mapping and types
|
||||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||||
|
|
||||||
export interface PlayniteConfig {
|
export interface PlayniteConfig {
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -54,6 +54,9 @@ export interface PlayniteGame {
|
|||||||
lastPlayed?: string;
|
lastPlayed?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
isInstalled?: boolean;
|
isInstalled?: boolean;
|
||||||
|
coverBase64?: string;
|
||||||
|
backgroundBase64?: string;
|
||||||
|
iconBase64?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayniteGamesResponse {
|
export interface PlayniteGamesResponse {
|
||||||
@@ -65,7 +68,7 @@ export interface PlayniteGamesResponse {
|
|||||||
|
|
||||||
export type LogCallback = (message: string) => void;
|
export type LogCallback = (message: string) => void;
|
||||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||||
|
/*
|
||||||
async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const coverResponse = await fetch(`${baseUrl}/api/games/${gameId}/cover`, {
|
const coverResponse = await fetch(`${baseUrl}/api/games/${gameId}/cover`, {
|
||||||
@@ -89,6 +92,50 @@ async function fetchGameCover(baseUrl: string, headers: Record<string, string>,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchGameBackground(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const backgroundResponse = await fetch(`${baseUrl}/api/games/${gameId}/background`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backgroundResponse.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await backgroundResponse.blob();
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||||
|
|
||||||
|
const mimeType = blob.type || 'image/jpeg';
|
||||||
|
return `data:${mimeType};base64,${base64}`;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const iconResponse = await fetch(`${baseUrl}/api/games/${gameId}/icon`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!iconResponse.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await iconResponse.blob();
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||||
|
|
||||||
|
const mimeType = blob.type || 'image/png';
|
||||||
|
return `data:${mimeType};base64,${base64}`;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
export async function importFromPlaynite(
|
export async function importFromPlaynite(
|
||||||
config: PlayniteConfig,
|
config: PlayniteConfig,
|
||||||
logCallback: LogCallback,
|
logCallback: LogCallback,
|
||||||
@@ -117,7 +164,7 @@ export async function importFromPlaynite(
|
|||||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const existingMedia = new Map(
|
const existingMedia = new Map(
|
||||||
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
|
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
|
||||||
);
|
);
|
||||||
logCallback(`Found ${existingMedia.size} existing games in database`);
|
logCallback(`Found ${existingMedia.size} existing games in database`);
|
||||||
|
|
||||||
@@ -159,6 +206,18 @@ export async function importFromPlaynite(
|
|||||||
|
|
||||||
if (detailResponse.ok) {
|
if (detailResponse.ok) {
|
||||||
const detailData: PlayniteGame = await detailResponse.json();
|
const detailData: PlayniteGame = await detailResponse.json();
|
||||||
|
/*
|
||||||
|
// Fetch images
|
||||||
|
const [cover, background, icon] = await Promise.all([
|
||||||
|
fetchGameCover(baseUrl, headers, game.id),
|
||||||
|
fetchGameBackground(baseUrl, headers, game.id),
|
||||||
|
fetchGameIcon(baseUrl, headers, game.id)
|
||||||
|
]);
|
||||||
|
|
||||||
|
detailData.coverBase64 = cover;
|
||||||
|
detailData.backgroundBase64 = background;
|
||||||
|
detailData.iconBase64 = icon;
|
||||||
|
*/
|
||||||
detailedGames.push(detailData);
|
detailedGames.push(detailData);
|
||||||
logCallback(`✓ Fetched details for: ${game.name}`);
|
logCallback(`✓ Fetched details for: ${game.name}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -231,7 +290,7 @@ export async function importFromPlaynite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Staff is for actors/performers only - leave empty for games
|
// Staff is for actors/performers only - leave empty for games
|
||||||
const staff: any[] = [];
|
const staff: Staff[] = [];
|
||||||
// Determine type based on genres/features
|
// Determine type based on genres/features
|
||||||
let type = 'Game';
|
let type = 'Game';
|
||||||
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {
|
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
// Import the source mapping
|
// Import the source mapping and types
|
||||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||||
|
|
||||||
export interface StashAPPConfig {
|
export interface StashAPPConfig {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -81,7 +81,30 @@ export interface StashAPPScene {
|
|||||||
export interface StashAPPScenePerformer {
|
export interface StashAPPScenePerformer {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
disambiguation: string;
|
||||||
|
url: string;
|
||||||
|
gender: string;
|
||||||
|
birthdate: string;
|
||||||
|
ethnicity: string;
|
||||||
|
country: string;
|
||||||
|
eye_color: string;
|
||||||
|
height_cm: number;
|
||||||
|
measurements: string;
|
||||||
|
fake_tits: boolean;
|
||||||
|
career_length: string;
|
||||||
|
tattoos: string;
|
||||||
|
piercings: string;
|
||||||
|
alias_list: string[];
|
||||||
|
favorite: boolean;
|
||||||
|
ignore_auto_tag: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
details: string;
|
||||||
|
death_date: string;
|
||||||
|
hair_color: string;
|
||||||
|
weight: number;
|
||||||
image_path: string;
|
image_path: string;
|
||||||
|
scene_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StashAPPPerformer {
|
export interface StashAPPPerformer {
|
||||||
@@ -163,8 +186,8 @@ export async function updateActorsFromStashAPP(
|
|||||||
logCallback('Fetching existing cast from Kyoo API...');
|
logCallback('Fetching existing cast from Kyoo API...');
|
||||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const existingActors = new Map(
|
const existingActors = new Map<string, Staff>(
|
||||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||||
);
|
);
|
||||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||||
|
|
||||||
@@ -249,12 +272,12 @@ export async function updateActorsFromStashAPP(
|
|||||||
|
|
||||||
for (let i = 0; i < performers.length; i++) {
|
for (let i = 0; i < performers.length; i++) {
|
||||||
const performer = performers[i];
|
const performer = performers[i];
|
||||||
const existingActor: any = existingActors.get(performer.name);
|
const existingActor: Staff | undefined = existingActors.get(performer.name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (existingActor) {
|
if (existingActor) {
|
||||||
// Update existing actor
|
// Update existing actor
|
||||||
const updateData: any = {
|
const updateData: Partial<Staff> = {
|
||||||
name: performer.name,
|
name: performer.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -386,15 +409,15 @@ export async function importFromStashAPP(
|
|||||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const existingTitles = new Set(
|
const existingTitles = new Set(
|
||||||
existingMediaData.data?.items?.map((m: any) => m.title) || []
|
existingMediaData.data?.items?.map((m: Media) => m.title) || []
|
||||||
);
|
);
|
||||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||||
|
|
||||||
logCallback('Fetching existing cast from Kyoo API...');
|
logCallback('Fetching existing cast from Kyoo API...');
|
||||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const existingActors = new Map(
|
const existingActors = new Map<string, Staff>(
|
||||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||||
);
|
);
|
||||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||||
|
|
||||||
@@ -525,12 +548,12 @@ export async function importFromStashAPP(
|
|||||||
|
|
||||||
for (let i = 0; i < uniquePerformers.length; i++) {
|
for (let i = 0; i < uniquePerformers.length; i++) {
|
||||||
const performer = uniquePerformers[i];
|
const performer = uniquePerformers[i];
|
||||||
const existingActor: any = existingActors.get(performer.name);
|
const existingActor: Staff | undefined = existingActors.get(performer.name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (existingActor) {
|
if (existingActor) {
|
||||||
// Update existing actor
|
// Update existing actor
|
||||||
const updateData: any = {
|
const updateData: Partial<Staff> = {
|
||||||
name: performer.name,
|
name: performer.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
// Import the source mapping
|
// Import the source mapping and types
|
||||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||||
|
|
||||||
export interface XBVRConfig {
|
export interface XBVRConfig {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -83,7 +83,7 @@ export async function importFromXBVR(
|
|||||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const existingTitles = new Set(
|
const existingTitles = new Set(
|
||||||
existingMediaData.data?.items?.map((m: any) => m.title) || []
|
existingMediaData.data?.items?.map((m: Media) => m.title) || []
|
||||||
);
|
);
|
||||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ export async function importFromXBVR(
|
|||||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const existingActors = new Map(
|
const existingActors = new Map(
|
||||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||||
);
|
);
|
||||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||||
|
|
||||||
|
|||||||
70
src/store/appStore.ts
Normal file
70
src/store/appStore.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { Media, Staff, MediaCategory, UserSettings } from '../types';
|
||||||
|
import { DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from '../constants';
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
// Media state
|
||||||
|
apiMedia: Media[];
|
||||||
|
customMedia: Media[];
|
||||||
|
adultMedia: Media[];
|
||||||
|
mediaLoading: boolean;
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
selectedMedia: Media | null;
|
||||||
|
selectedPerson: Staff | null;
|
||||||
|
|
||||||
|
// Category state
|
||||||
|
activeCategory: MediaCategory;
|
||||||
|
enabledCategories: MediaCategory[];
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
searchQuery: string;
|
||||||
|
|
||||||
|
// Settings state
|
||||||
|
settings: UserSettings | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setApiMedia: (media: Media[]) => void;
|
||||||
|
setCustomMedia: (media: Media[]) => void;
|
||||||
|
setAdultMedia: (media: Media[]) => void;
|
||||||
|
setMediaLoading: (loading: boolean) => void;
|
||||||
|
setSelectedMedia: (media: Media | null) => void;
|
||||||
|
setSelectedPerson: (person: Staff | null) => void;
|
||||||
|
setActiveCategory: (category: MediaCategory) => void;
|
||||||
|
setEnabledCategories: (categories: MediaCategory[]) => void;
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
setSettings: (settings: UserSettings | null) => void;
|
||||||
|
resetMedia: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
|
// Initial state
|
||||||
|
apiMedia: [],
|
||||||
|
customMedia: [],
|
||||||
|
adultMedia: [],
|
||||||
|
mediaLoading: true,
|
||||||
|
selectedMedia: null,
|
||||||
|
selectedPerson: null,
|
||||||
|
activeCategory: 'Anime',
|
||||||
|
enabledCategories: DEFAULT_ENABLED_CATEGORIES,
|
||||||
|
searchQuery: '',
|
||||||
|
settings: null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setApiMedia: (media) => set({ apiMedia: media }),
|
||||||
|
setCustomMedia: (media) => set({ customMedia: media }),
|
||||||
|
setAdultMedia: (media) => set({ adultMedia: media }),
|
||||||
|
setMediaLoading: (loading) => set({ mediaLoading: loading }),
|
||||||
|
setSelectedMedia: (media) => set({ selectedMedia: media }),
|
||||||
|
setSelectedPerson: (person) => set({ selectedPerson: person }),
|
||||||
|
setActiveCategory: (category) => set({ activeCategory: category }),
|
||||||
|
setEnabledCategories: (categories) => set({ enabledCategories: categories }),
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
setSettings: (settings) => set({ settings }),
|
||||||
|
resetMedia: () => set({
|
||||||
|
apiMedia: [],
|
||||||
|
customMedia: [],
|
||||||
|
adultMedia: [],
|
||||||
|
mediaLoading: true
|
||||||
|
}),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user