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",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite": "^6.2.0"
|
||||
"vite": "^6.2.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
@@ -7611,6 +7612,35 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
|
||||
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"shadcn": "^4.2.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite": "^6.2.0"
|
||||
"vite": "^6.2.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
|
||||
273
src/App.tsx
273
src/App.tsx
@@ -16,53 +16,57 @@ import AddMediaView from './components/AddMediaView';
|
||||
import ImporterView from './components/ImporterView';
|
||||
import SettingsView from './components/SettingsView';
|
||||
import Loading from './components/ui/loading';
|
||||
import MediaDetailRoute from './components/routes/MediaDetailRoute';
|
||||
import CastDetailRoute from './components/routes/CastDetailRoute';
|
||||
import CategoryBrowseRoute from './components/routes/CategoryBrowseRoute';
|
||||
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
||||
import { Media, Staff, MediaCategory, UserSettings } from './types';
|
||||
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
|
||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
|
||||
import { CATEGORY_PATHS, PATH_TO_CATEGORY, DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from './constants';
|
||||
import { useAppStore } from './store/appStore';
|
||||
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
const [activeCategory, setActiveCategory] = useState<MediaCategory>(
|
||||
(searchParams.get('category') as MediaCategory) || 'Anime'
|
||||
);
|
||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [customMedia, setCustomMedia] = useState<Media[]>([]);
|
||||
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
|
||||
|
||||
// Load media from API on component mount (only when not on cast routes)
|
||||
const [apiMedia, setApiMedia] = useState<Media[]>([]);
|
||||
const [mediaLoading, setMediaLoading] = useState(true);
|
||||
// Zustand store
|
||||
const {
|
||||
apiMedia,
|
||||
customMedia,
|
||||
adultMedia,
|
||||
mediaLoading,
|
||||
selectedMedia,
|
||||
selectedPerson,
|
||||
activeCategory,
|
||||
enabledCategories,
|
||||
searchQuery,
|
||||
settings,
|
||||
setApiMedia,
|
||||
setCustomMedia,
|
||||
setAdultMedia,
|
||||
setMediaLoading,
|
||||
setSelectedMedia,
|
||||
setSelectedPerson,
|
||||
setActiveCategory,
|
||||
setEnabledCategories,
|
||||
setSearchQuery,
|
||||
setSettings,
|
||||
} = useAppStore();
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
const pathParts = location.pathname.split('/').filter(Boolean);
|
||||
if (pathParts.length === 1 && pathToCategory[pathParts[0]]) {
|
||||
const category = pathToCategory[pathParts[0]];
|
||||
if (pathParts.length === 1 && PATH_TO_CATEGORY[pathParts[0]]) {
|
||||
const category = PATH_TO_CATEGORY[pathParts[0]];
|
||||
if (enabledCategories.includes(category)) {
|
||||
setActiveCategory(category);
|
||||
}
|
||||
}
|
||||
}, [location.pathname, enabledCategories]);
|
||||
}, [location.pathname, enabledCategories, setActiveCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettingsFromApi = async () => {
|
||||
@@ -116,11 +120,10 @@ function AppContent() {
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggleCategory = async (category: MediaCategory) => {
|
||||
setEnabledCategories(prev => {
|
||||
const isEnabling = !prev.includes(category);
|
||||
const isEnabling = !enabledCategories.includes(category);
|
||||
const newList = isEnabling
|
||||
? [...prev, category]
|
||||
: prev.filter(c => c !== category);
|
||||
? [...enabledCategories, category]
|
||||
: enabledCategories.filter(c => c !== category);
|
||||
|
||||
// If we disable the current active category, switch to another enabled one
|
||||
if (!isEnabling && activeCategory === category) {
|
||||
@@ -128,17 +131,10 @@ function AppContent() {
|
||||
setActiveCategory(nextCategory as MediaCategory);
|
||||
}
|
||||
|
||||
setEnabledCategories(newList);
|
||||
|
||||
// Save to API
|
||||
const baseSettings = settings || {
|
||||
enabledCategories: prev,
|
||||
itemsPerPage: 20,
|
||||
gridItemSize: 5,
|
||||
defaultView: 'grid',
|
||||
showAdultContent: false,
|
||||
autoPlayTrailers: false,
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
};
|
||||
const baseSettings = settings || DEFAULT_SETTINGS;
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
enabledCategories: newList,
|
||||
@@ -148,25 +144,11 @@ function AppContent() {
|
||||
setSettings(saved);
|
||||
}
|
||||
});
|
||||
|
||||
return newList;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: MediaCategory) => {
|
||||
setActiveCategory(category);
|
||||
// Map category names to URL-friendly paths
|
||||
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]}`);
|
||||
navigate(`/${CATEGORY_PATHS[category]}`);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
@@ -213,16 +195,7 @@ function AppContent() {
|
||||
};
|
||||
|
||||
const handleGridItemSizeChange = async (size: number) => {
|
||||
const baseSettings = settings || {
|
||||
enabledCategories: enabledCategories,
|
||||
itemsPerPage: 20,
|
||||
gridItemSize: 5,
|
||||
defaultView: 'grid',
|
||||
showAdultContent: false,
|
||||
autoPlayTrailers: false,
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
};
|
||||
const baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories };
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
gridItemSize: size,
|
||||
@@ -365,88 +338,10 @@ function AppContent() {
|
||||
loading={mediaLoading}
|
||||
/>
|
||||
} />
|
||||
<Route path="/anime" element={
|
||||
<BrowseView
|
||||
<Route path="/:category" element={
|
||||
<CategoryBrowseRoute
|
||||
mediaList={filteredMedia}
|
||||
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}
|
||||
gridItemSize={settings?.gridItemSize}
|
||||
onGridItemSizeChange={handleGridItemSizeChange}
|
||||
@@ -455,8 +350,6 @@ function AppContent() {
|
||||
} />
|
||||
<Route path="/media/:id" element={
|
||||
<MediaDetailRoute
|
||||
selectedMedia={selectedMedia}
|
||||
setSelectedMedia={setSelectedMedia}
|
||||
allMedia={allMedia}
|
||||
onPersonClick={handlePersonClick}
|
||||
/>
|
||||
@@ -469,10 +362,7 @@ function AppContent() {
|
||||
/>
|
||||
} />
|
||||
<Route path="/cast/:id" element={
|
||||
<CastDetailRoute
|
||||
selectedPerson={selectedPerson}
|
||||
setSelectedPerson={setSelectedPerson}
|
||||
/>
|
||||
<CastDetailRoute />
|
||||
} />
|
||||
<Route path="/add" element={
|
||||
<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() {
|
||||
return (
|
||||
<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;
|
||||
|
||||
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
|
||||
// Legacy functions for compatibility
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
try {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
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 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[]> {
|
||||
export async function fetchMediaByTag(tag: string) {
|
||||
try {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
return media.filter(item =>
|
||||
item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) ||
|
||||
@@ -642,154 +38,12 @@ export async function fetchMediaByTag(tag: string): Promise<Media[]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience function - fetch media from API (legacy compatibility)
|
||||
export async function fetchMediaFromApi(apiUrl?: string): Promise<Media[]> {
|
||||
export async function fetchMediaFromApi(apiUrl?: string) {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
// Convenience function - fetch media from local JSON (legacy compatibility)
|
||||
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
|
||||
export async function fetchMediaFromLocalJson() {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
// Settings API Types
|
||||
export interface ApiSettingsItem {
|
||||
id?: number;
|
||||
enabled_categories: string[];
|
||||
items_per_page: number;
|
||||
grid_item_size?: number;
|
||||
default_view: string;
|
||||
show_adult_content: boolean;
|
||||
auto_play_trailers: boolean;
|
||||
language: string;
|
||||
theme: string;
|
||||
jellyfin_library_mappings?: string; // JSON string of LibraryMapping[]
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateSettingsInput {
|
||||
enabled_categories: string[];
|
||||
items_per_page?: number;
|
||||
grid_item_size?: number;
|
||||
default_view?: string;
|
||||
show_adult_content?: boolean;
|
||||
auto_play_trailers?: boolean;
|
||||
language?: string;
|
||||
theme?: string;
|
||||
jellyfin_library_mappings?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSettingsInput extends Partial<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 { useNavigate } from 'react-router-dom';
|
||||
import { createMedia, type CreateMediaInput } from '@/api';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { ArrowLeft, Film, Calendar, Star, User, BookOpen, Music as MusicIcon, Gamepad2, Monitor, Hash, Tag, Users, FileText, Globe, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AddMediaViewProps {
|
||||
@@ -180,8 +180,22 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: MediaCategory) => {
|
||||
const icons: Record<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 (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
|
||||
<div className="pt-24 pb-12 px-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/')}
|
||||
@@ -191,11 +205,18 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
Back to Browse
|
||||
</Button>
|
||||
|
||||
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50">
|
||||
<h1 className="text-4xl font-black text-foreground mb-2">Add New Media</h1>
|
||||
<p className="text-muted-foreground font-medium text-lg mb-8">
|
||||
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50 max-w-[1600px] mx-auto">
|
||||
<div className="flex items-center gap-4 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">
|
||||
{getCategoryIcon(activeCategory)}
|
||||
</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' && (
|
||||
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl backdrop-blur-sm">
|
||||
@@ -209,31 +230,40 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddSubmit} className="space-y-6">
|
||||
<form onSubmit={handleAddSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Info Card */}
|
||||
<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">
|
||||
<FileText size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title" className="text-sm font-black text-foreground">Title</Label>
|
||||
<Label htmlFor="title" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newMedia.title}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="e.g. Mob Psycho 100"
|
||||
className="bg-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"
|
||||
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>
|
||||
<Label htmlFor="year" className="text-xs font-black text-muted-foreground uppercase tracking-wider">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"
|
||||
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="category" className="text-sm font-black text-foreground">Category</Label>
|
||||
<Label htmlFor="category" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Category</Label>
|
||||
<select
|
||||
id="category"
|
||||
value={newMedia.category}
|
||||
@@ -248,7 +278,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type" className="text-sm font-black text-foreground">Type</Label>
|
||||
<Label htmlFor="type" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Type</Label>
|
||||
<select
|
||||
id="type"
|
||||
value={newMedia.type}
|
||||
@@ -287,7 +317,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
</select>
|
||||
</div>
|
||||
<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
|
||||
id="status"
|
||||
value={newMedia.status}
|
||||
@@ -307,164 +337,204 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media Info Card */}
|
||||
<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">
|
||||
<Globe size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Media Information</h3>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="aspectRatio" className="text-sm font-black text-foreground">Aspect Ratio (Format)</Label>
|
||||
<Label htmlFor="poster" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Poster URL</Label>
|
||||
<Input
|
||||
id="poster"
|
||||
value={newMedia.poster}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||
placeholder="https://example.com/poster.jpg"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="banner" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Banner URL (optional)</Label>
|
||||
<Input
|
||||
id="banner"
|
||||
value={newMedia.banner}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
||||
placeholder="https://example.com/banner.jpg"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<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 (Standard Poster)</option>
|
||||
<option value="16/9">16:9 (Wide Thumbnail)</option>
|
||||
<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="poster" className="text-sm font-black text-foreground">Poster URL</Label>
|
||||
<Label htmlFor="rating" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Rating (0-10)</Label>
|
||||
<Input
|
||||
id="poster"
|
||||
value={newMedia.poster}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||
placeholder="https://example.com/poster.jpg"
|
||||
className="bg-muted/50 backdrop-blur-sm border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||
required
|
||||
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 className="grid gap-2">
|
||||
<Label htmlFor="banner" className="text-sm font-black text-foreground">Banner URL (Optional)</Label>
|
||||
<Input
|
||||
id="banner"
|
||||
value={newMedia.banner}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
||||
placeholder="https://example.com/banner.jpg"
|
||||
className="bg-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>
|
||||
<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="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"
|
||||
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 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>
|
||||
</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-sm font-black text-foreground">Runtime (min)</Label>
|
||||
<Label htmlFor="runtime" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Runtime (minutes)</Label>
|
||||
<Input
|
||||
id="runtime"
|
||||
type="number"
|
||||
value={newMedia.runtime}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
|
||||
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 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
|
||||
id="releaseDate"
|
||||
type="date"
|
||||
value={newMedia.releaseDate}
|
||||
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>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="director" className="text-sm font-black text-foreground">Director</Label>
|
||||
<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-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="writer" className="text-sm font-black text-foreground">Writer</Label>
|
||||
<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-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>
|
||||
)}
|
||||
{/* Classification Card */}
|
||||
<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">
|
||||
<Tag size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Classification</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="genres" className="text-sm font-black text-foreground">Genres (comma-separated)</Label>
|
||||
<Label htmlFor="genres" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Genres (comma-separated)</Label>
|
||||
<Input
|
||||
id="genres"
|
||||
value={newMedia.genres}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
||||
placeholder="Action, Drama, Sci-Fi"
|
||||
className="bg-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="tags" className="text-sm font-black text-foreground">Tags (comma-separated)</Label>
|
||||
<Label htmlFor="tags" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Tags (comma-separated)</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={newMedia.tags}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
||||
placeholder="Classic, Best-selling"
|
||||
className="bg-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="studios" className="text-sm font-black text-foreground">Studios (comma-separated)</Label>
|
||||
<Label htmlFor="studios" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Studios (comma-separated)</Label>
|
||||
<Input
|
||||
id="studios"
|
||||
value={newMedia.studios}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
||||
placeholder="Studio A, Studio B"
|
||||
className="bg-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="source" className="text-sm font-black text-foreground">Source / Quelle</Label>
|
||||
<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-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>
|
||||
|
||||
{/* Cast/Staff Section */}
|
||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-black text-foreground">Cast & Crew</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cast/Staff Card */}
|
||||
{(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 lg:col-span-2">
|
||||
<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">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Cast & Crew</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Staff List */}
|
||||
{staff.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{staff.length > 0 && (
|
||||
<>
|
||||
{staff.map((member, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-muted/50 backdrop-blur-sm rounded-xl border border-border/50">
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-background rounded-xl border border-border/50">
|
||||
{member.photo && (
|
||||
<img
|
||||
src={member.photo}
|
||||
@@ -488,17 +558,23 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{staff.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
No cast members added yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<Label htmlFor="staffName" className="text-xs font-black text-muted-foreground uppercase tracking-wider">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"
|
||||
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();
|
||||
@@ -513,11 +589,11 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffRole" className="text-xs font-black text-foreground">Role</Label>
|
||||
<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-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) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -531,20 +607,20 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffCharacter" className="text-xs font-black text-foreground">Character (optional)</Label>
|
||||
<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-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>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffPhoto" className="text-xs font-black text-foreground">Photo URL (optional)</Label>
|
||||
<Label htmlFor="staffPhoto" className="text-xs font-black text-muted-foreground uppercase tracking-wider">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"
|
||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -557,7 +633,10 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Submit Button - Full Width */}
|
||||
<div className="lg:col-span-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
@@ -565,6 +644,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
>
|
||||
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,11 +20,13 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
X
|
||||
X,
|
||||
Plus
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { MediaCategory } from '@/types';
|
||||
import { CATEGORY_PATHS } from '@/constants';
|
||||
|
||||
interface SidebarProps {
|
||||
enabledCategories: MediaCategory[];
|
||||
@@ -37,16 +39,6 @@ export default function Sidebar({ enabledCategories, onToggleCategory }: Sidebar
|
||||
const { theme, setTheme } = useTheme();
|
||||
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> = {
|
||||
'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: <Calendar size={18} />, label: 'Calendar', path: '/calendar' },
|
||||
//{ 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 = () => {
|
||||
|
||||
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'],
|
||||
}
|
||||
];
|
||||
export const DETAIL_MEDIA: Media = {}
|
||||
export const DETAIL_MEDIA: Media = {
|
||||
id: '',
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: 'Movies'
|
||||
}
|
||||
/*
|
||||
export const DETAIL_MEDIA: Media = {
|
||||
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;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff, Episode, Track } from '@/types';
|
||||
|
||||
export interface JellyfinConfig {
|
||||
url: string;
|
||||
@@ -56,7 +56,7 @@ export interface JellyfinItem {
|
||||
Type: string;
|
||||
Role?: string;
|
||||
PrimaryImageTag?: string;
|
||||
ImageBlurHashes?: any;
|
||||
ImageBlurHashes?: Record<string, Record<string, string>>;
|
||||
}>;
|
||||
ImageTags?: {
|
||||
Primary?: string;
|
||||
@@ -96,7 +96,7 @@ export interface JellyfinPerson {
|
||||
Name: string;
|
||||
Type: string;
|
||||
PrimaryImageTag?: string;
|
||||
ImageBlurHashes?: any;
|
||||
ImageBlurHashes?: Record<string, Record<string, string>>;
|
||||
PremiereDate?: string;
|
||||
ProductionYear?: number;
|
||||
Overview?: string;
|
||||
@@ -105,6 +105,28 @@ export interface JellyfinPerson {
|
||||
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 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) || [];
|
||||
|
||||
// Fetch episodes for this series
|
||||
let episodes: any[] = [];
|
||||
let episodes: Episode[] = [];
|
||||
try {
|
||||
const jellyfinEpisodes = await fetchJellyfinSeriesEpisodes(config, item.Id);
|
||||
episodes = jellyfinEpisodes.map(ep => ({
|
||||
id: parseInt(ep.Id),
|
||||
media_id: parseInt(item.Id),
|
||||
season: ep.ParentIndexNumber || 1,
|
||||
episode_number: ep.IndexNumber || 1,
|
||||
title: ep.Name,
|
||||
@@ -682,14 +706,16 @@ async function convertJellyfinAlbumToMedia(
|
||||
}));
|
||||
|
||||
// Fetch tracks for this album
|
||||
let tracks: any[] = [];
|
||||
let tracks: Track[] = [];
|
||||
try {
|
||||
const jellyfinTracks = await fetchJellyfinAlbumTracks(config, item.Id);
|
||||
tracks = jellyfinTracks.map((track, index) => ({
|
||||
id: parseInt(track.Id),
|
||||
media_id: parseInt(item.Id),
|
||||
track_number: track.IndexNumber || (index + 1),
|
||||
title: track.Name,
|
||||
duration: track.RunTimeTicks ? `${Math.floor(track.RunTimeTicks / 600000000 / 60)}:${String(Math.floor((track.RunTimeTicks / 600000000) % 60)).padStart(2, '0')}` : null,
|
||||
artist: track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown'
|
||||
duration: track.RunTimeTicks ? Math.floor(track.RunTimeTicks / 600000000) : null,
|
||||
artist: (track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown') as string
|
||||
}));
|
||||
} catch (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
|
||||
function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinConfig): any {
|
||||
function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinConfig): Staff {
|
||||
const photo = person.PrimaryImageTag
|
||||
? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary')
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: person.Id,
|
||||
name: person.Name,
|
||||
role: person.Type || 'Actor',
|
||||
photo: photo,
|
||||
bio: person.Overview || null,
|
||||
birthDate: person.BirthDate ? formatDate(person.BirthDate) : 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 existingMediaData = await existingMediaResponse.json();
|
||||
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`);
|
||||
|
||||
@@ -779,7 +807,7 @@ export async function importFromJellyfin(
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
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`);
|
||||
|
||||
@@ -1173,14 +1201,14 @@ export async function cleanupJellyfinMedia(
|
||||
logCallback('Fetching existing media from Kyoo API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||
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`);
|
||||
|
||||
// Fetch all existing cast from Kyoo API
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||
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`);
|
||||
|
||||
// Fetch current items from Jellyfin
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||
|
||||
export interface PlayniteConfig {
|
||||
ip: string;
|
||||
@@ -54,6 +54,9 @@ export interface PlayniteGame {
|
||||
lastPlayed?: string;
|
||||
source?: string;
|
||||
isInstalled?: boolean;
|
||||
coverBase64?: string;
|
||||
backgroundBase64?: string;
|
||||
iconBase64?: string;
|
||||
}
|
||||
|
||||
export interface PlayniteGamesResponse {
|
||||
@@ -65,7 +68,7 @@ export interface PlayniteGamesResponse {
|
||||
|
||||
export type LogCallback = (message: string) => void;
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
|
||||
/*
|
||||
async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
||||
try {
|
||||
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(
|
||||
config: PlayniteConfig,
|
||||
logCallback: LogCallback,
|
||||
@@ -117,7 +164,7 @@ export async function importFromPlaynite(
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
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`);
|
||||
|
||||
@@ -159,6 +206,18 @@ export async function importFromPlaynite(
|
||||
|
||||
if (detailResponse.ok) {
|
||||
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);
|
||||
logCallback(`✓ Fetched details for: ${game.name}`);
|
||||
} else {
|
||||
@@ -231,7 +290,7 @@ export async function importFromPlaynite(
|
||||
}
|
||||
|
||||
// Staff is for actors/performers only - leave empty for games
|
||||
const staff: any[] = [];
|
||||
const staff: Staff[] = [];
|
||||
// Determine type based on genres/features
|
||||
let type = 'Game';
|
||||
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||
|
||||
export interface StashAPPConfig {
|
||||
url: string;
|
||||
@@ -81,7 +81,30 @@ export interface StashAPPScene {
|
||||
export interface StashAPPScenePerformer {
|
||||
id: 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;
|
||||
scene_count: number;
|
||||
}
|
||||
|
||||
export interface StashAPPPerformer {
|
||||
@@ -163,8 +186,8 @@ export async function updateActorsFromStashAPP(
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const existingActors = new Map(
|
||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||
const existingActors = new Map<string, Staff>(
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||
|
||||
@@ -249,12 +272,12 @@ export async function updateActorsFromStashAPP(
|
||||
|
||||
for (let i = 0; i < performers.length; i++) {
|
||||
const performer = performers[i];
|
||||
const existingActor: any = existingActors.get(performer.name);
|
||||
const existingActor: Staff | undefined = existingActors.get(performer.name);
|
||||
|
||||
try {
|
||||
if (existingActor) {
|
||||
// Update existing actor
|
||||
const updateData: any = {
|
||||
const updateData: Partial<Staff> = {
|
||||
name: performer.name,
|
||||
};
|
||||
|
||||
@@ -386,15 +409,15 @@ export async function importFromStashAPP(
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
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('Fetching existing cast from Kyoo API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const existingActors = new Map(
|
||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||
const existingActors = new Map<string, Staff>(
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||
|
||||
@@ -525,12 +548,12 @@ export async function importFromStashAPP(
|
||||
|
||||
for (let i = 0; i < uniquePerformers.length; i++) {
|
||||
const performer = uniquePerformers[i];
|
||||
const existingActor: any = existingActors.get(performer.name);
|
||||
const existingActor: Staff | undefined = existingActors.get(performer.name);
|
||||
|
||||
try {
|
||||
if (existingActor) {
|
||||
// Update existing actor
|
||||
const updateData: any = {
|
||||
const updateData: Partial<Staff> = {
|
||||
name: performer.name,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||
|
||||
export interface XBVRConfig {
|
||||
url: string;
|
||||
@@ -83,7 +83,7 @@ export async function importFromXBVR(
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
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`);
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function importFromXBVR(
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
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`);
|
||||
|
||||
|
||||
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