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:
Lars Behrends
2026-04-16 14:53:46 +02:00
parent a407b57006
commit 432416cfc5
22 changed files with 1843 additions and 1342 deletions

View File

@@ -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;
}
}