import axios from 'axios'; import { AuthResponse, Story, Author, Tag, TagAlias, Series, SearchResult, PagedResult, Collection, CollectionSearchResult, StoryWithCollectionContext, CollectionStatistics } from '../types/api'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api'; // Create axios instance with default config const api = axios.create({ baseURL: API_BASE_URL, withCredentials: true, // Include cookies for JWT headers: { 'Content-Type': 'application/json', }, }); // Request interceptor to add JWT token api.interceptors.request.use((config) => { const token = localStorage.getItem('auth-token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // Global auth failure handler - can be set by AuthContext let globalAuthFailureHandler: (() => void) | null = null; export const setGlobalAuthFailureHandler = (handler: () => void) => { globalAuthFailureHandler = handler; }; // Response interceptor to handle auth errors api.interceptors.response.use( (response) => response, (error) => { // Handle authentication failures if (error.response?.status === 401 || error.response?.status === 403) { console.warn('Authentication failed, token may be expired or invalid'); // Clear invalid token localStorage.removeItem('auth-token'); // Use global handler if available (from AuthContext), otherwise fallback to direct redirect if (globalAuthFailureHandler) { globalAuthFailureHandler(); } else { // Fallback for cases where AuthContext isn't available window.location.href = '/login'; } // Return a more specific error for components to handle gracefully return Promise.reject(new Error('Authentication required')); } return Promise.reject(error); } ); // Auth endpoints export const authApi = { login: async (password: string): Promise => { const response = await api.post('/auth/login', { password }); // Store token in localStorage (httpOnly cookie is preferred but this is for backup) if (response.data.token) { localStorage.setItem('auth-token', response.data.token); } return response.data; }, logout: async (): Promise => { localStorage.removeItem('auth-token'); // Could call backend logout endpoint if implemented }, isAuthenticated: (): boolean => { return !!localStorage.getItem('auth-token'); }, }; // Story endpoints export const storyApi = { getStories: async (params?: { page?: number; size?: number; sortBy?: string; sortDir?: string; }): Promise> => { const response = await api.get('/stories', { params }); return response.data; }, getStory: async (id: string): Promise => { const response = await api.get(`/stories/${id}`); return response.data; }, createStory: async (storyData: { title: string; summary?: string; description?: string; contentHtml: string; sourceUrl?: string; volume?: number; authorId?: string; authorName?: string; seriesId?: string; seriesName?: string; tagNames?: string[]; }): Promise => { const response = await api.post('/stories', storyData); return response.data; }, updateStory: async (id: string, storyData: { title: string; summary?: string; description?: string; contentHtml: string; sourceUrl?: string; volume?: number; authorId?: string; seriesId?: string; seriesName?: string; tagNames?: string[]; }): Promise => { const response = await api.put(`/stories/${id}`, storyData); return response.data; }, deleteStory: async (id: string): Promise => { await api.delete(`/stories/${id}`); }, updateRating: async (id: string, rating: number): Promise => { await api.post(`/stories/${id}/rating`, { rating }); }, updateReadingProgress: async (id: string, position: number): Promise => { const response = await api.post(`/stories/${id}/reading-progress`, { position }); return response.data; }, uploadCover: async (id: string, coverImage: File): Promise<{ imagePath: string }> => { const formData = new FormData(); formData.append('file', coverImage); const response = await api.post(`/stories/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); return response.data; }, removeCover: async (id: string): Promise => { await api.delete(`/stories/${id}/cover`); }, processContentImages: async (id: string, htmlContent: string): Promise<{ processedContent: string; warnings?: string[]; downloadedImages: string[]; hasWarnings: boolean; }> => { const response = await api.post(`/stories/${id}/process-content-images`, { htmlContent }); return response.data; }, addTag: async (storyId: string, tagId: string): Promise => { const response = await api.post(`/stories/${storyId}/tags/${tagId}`); return response.data; }, removeTag: async (storyId: string, tagId: string): Promise => { const response = await api.delete(`/stories/${storyId}/tags/${tagId}`); return response.data; }, getStoryCollections: async (storyId: string): Promise => { const response = await api.get(`/stories/${storyId}/collections`); return response.data; }, reindexTypesense: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => { const response = await api.post('/stories/reindex-typesense'); return response.data; }, recreateTypesenseCollection: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => { const response = await api.post('/stories/recreate-typesense-collection'); return response.data; }, checkDuplicate: async (title: string, authorName: string): Promise<{ hasDuplicates: boolean; count: number; duplicates: Array<{ id: string; title: string; authorName: string; createdAt: string; }>; }> => { const response = await api.get('/stories/check-duplicate', { params: { title, authorName } }); return response.data; }, getRandomStory: async (filters?: { searchQuery?: string; tags?: string[]; minWordCount?: number; maxWordCount?: number; createdAfter?: string; createdBefore?: string; lastReadAfter?: string; lastReadBefore?: string; minRating?: number; maxRating?: number; unratedOnly?: boolean; readingStatus?: string; hasReadingProgress?: boolean; hasCoverImage?: boolean; sourceDomain?: string; seriesFilter?: string; minTagCount?: number; popularOnly?: boolean; hiddenGemsOnly?: boolean; }): Promise => { try { // Create URLSearchParams to properly handle array parameters like tags const searchParams = new URLSearchParams(); if (filters?.searchQuery) { searchParams.append('searchQuery', filters.searchQuery); } if (filters?.tags && filters.tags.length > 0) { filters.tags.forEach(tag => searchParams.append('tags', tag)); } // Advanced filters if (filters?.minWordCount !== undefined) searchParams.append('minWordCount', filters.minWordCount.toString()); if (filters?.maxWordCount !== undefined) searchParams.append('maxWordCount', filters.maxWordCount.toString()); if (filters?.createdAfter) searchParams.append('createdAfter', filters.createdAfter); if (filters?.createdBefore) searchParams.append('createdBefore', filters.createdBefore); if (filters?.lastReadAfter) searchParams.append('lastReadAfter', filters.lastReadAfter); if (filters?.lastReadBefore) searchParams.append('lastReadBefore', filters.lastReadBefore); if (filters?.minRating !== undefined) searchParams.append('minRating', filters.minRating.toString()); if (filters?.maxRating !== undefined) searchParams.append('maxRating', filters.maxRating.toString()); if (filters?.unratedOnly !== undefined) searchParams.append('unratedOnly', filters.unratedOnly.toString()); if (filters?.readingStatus) searchParams.append('readingStatus', filters.readingStatus); if (filters?.hasReadingProgress !== undefined) searchParams.append('hasReadingProgress', filters.hasReadingProgress.toString()); if (filters?.hasCoverImage !== undefined) searchParams.append('hasCoverImage', filters.hasCoverImage.toString()); if (filters?.sourceDomain) searchParams.append('sourceDomain', filters.sourceDomain); if (filters?.seriesFilter) searchParams.append('seriesFilter', filters.seriesFilter); if (filters?.minTagCount !== undefined) searchParams.append('minTagCount', filters.minTagCount.toString()); if (filters?.popularOnly !== undefined) searchParams.append('popularOnly', filters.popularOnly.toString()); if (filters?.hiddenGemsOnly !== undefined) searchParams.append('hiddenGemsOnly', filters.hiddenGemsOnly.toString()); const response = await api.get(`/stories/random?${searchParams.toString()}`); return response.data; } catch (error: any) { if (error.response?.status === 204) { // No content - no stories match filters return null; } throw error; } }, }; // Author endpoints export const authorApi = { getAuthors: async (params?: { page?: number; size?: number; sortBy?: string; sortDir?: string; }): Promise> => { const response = await api.get('/authors', { params }); return response.data; }, getAuthor: async (id: string): Promise => { const response = await api.get(`/authors/${id}`); return response.data; }, updateAuthor: async (id: string, formData: FormData): Promise => { const response = await api.put(`/authors/${id}`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); return response.data; }, uploadAvatar: async (id: string, avatarImage: File): Promise<{ imagePath: string }> => { const formData = new FormData(); formData.append('file', avatarImage); const response = await api.post(`/authors/${id}/avatar`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); return response.data; }, removeAvatar: async (id: string): Promise => { await api.delete(`/authors/${id}/avatar`); }, searchAuthorsTypesense: async (params?: { q?: string; page?: number; size?: number; sortBy?: string; sortOrder?: string; }): Promise<{ results: Author[]; totalHits: number; page: number; perPage: number; query: string; searchTimeMs: number; }> => { const response = await api.get('/authors/search-typesense', { params }); return response.data; }, reindexTypesense: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => { const response = await api.post('/authors/reindex-typesense'); return response.data; }, recreateTypesenseCollection: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => { const response = await api.post('/authors/recreate-typesense-collection'); return response.data; }, getTypesenseSchema: async (): Promise<{ success: boolean; schema?: any; error?: string }> => { const response = await api.get('/authors/typesense-schema'); return response.data; }, }; // Tag endpoints export const tagApi = { getTags: async (params?: { page?: number; size?: number; sortBy?: string; sortDir?: string; }): Promise> => { const response = await api.get('/tags', { params }); return response.data; }, getTag: async (id: string): Promise => { const response = await api.get(`/tags/${id}`); return response.data; }, createTag: async (tagData: { name: string; color?: string; description?: string; }): Promise => { const response = await api.post('/tags', tagData); return response.data; }, updateTag: async (id: string, tagData: { name?: string; color?: string; description?: string; }): Promise => { const response = await api.put(`/tags/${id}`, tagData); return response.data; }, deleteTag: async (id: string): Promise => { await api.delete(`/tags/${id}`); }, getTagAutocomplete: async (query: string): Promise => { const response = await api.get('/tags/autocomplete', { params: { query } }); // Backend returns TagDto[], extract just the names return response.data.map((tag: Tag) => tag.name); }, getCollectionTags: async (): Promise => { const response = await api.get('/tags/collections'); return response.data; }, // Alias operations addAlias: async (tagId: string, aliasName: string): Promise => { const response = await api.post(`/tags/${tagId}/aliases`, { aliasName }); return response.data; }, removeAlias: async (tagId: string, aliasId: string): Promise => { await api.delete(`/tags/${tagId}/aliases/${aliasId}`); }, resolveTag: async (name: string): Promise => { try { const response = await api.get(`/tags/resolve/${encodeURIComponent(name)}`); return response.data; } catch (error: any) { if (error.response?.status === 404) { return null; } throw error; } }, // Batch resolve multiple tag names to their canonical forms resolveTags: async (names: string[]): Promise => { const resolved = await Promise.all( names.map(async (name) => { const tag = await tagApi.resolveTag(name); return tag ? tag.name : name; // Return canonical name or original if not found }) ); return resolved; }, // Merge operations previewMerge: async (sourceTagIds: string[], targetTagId: string): Promise<{ targetTagName: string; targetStoryCount: number; totalResultStoryCount: number; aliasesToCreate: string[]; }> => { const response = await api.post('/tags/merge/preview', { sourceTagIds, targetTagId }); return response.data; }, mergeTags: async (sourceTagIds: string[], targetTagId: string): Promise => { const response = await api.post('/tags/merge', { sourceTagIds, targetTagId }); return response.data; }, // Tag suggestions suggestTags: async (title: string, content?: string, summary?: string, limit?: number): Promise<{ tagName: string; confidence: number; reason: string; }[]> => { const response = await api.post('/tags/suggest', { title, content, summary, limit: limit || 10 }); return response.data; }, }; // Series endpoints export const seriesApi = { getSeries: async (params?: { page?: number; size?: number; sortBy?: string; sortDir?: string; }): Promise> => { const response = await api.get('/series', { params }); return response.data; }, getSeriesStories: async (id: string): Promise => { const response = await api.get(`/stories/series/${id}`); return response.data; }, }; // Search endpoints export const searchApi = { search: async (params: { query: string; page?: number; size?: number; authors?: string[]; tags?: string[]; minRating?: number; maxRating?: number; sortBy?: string; sortDir?: string; facetBy?: string[]; // Advanced filters minWordCount?: number; maxWordCount?: number; createdAfter?: string; createdBefore?: string; lastReadAfter?: string; lastReadBefore?: string; unratedOnly?: boolean; readingStatus?: string; hasReadingProgress?: boolean; hasCoverImage?: boolean; sourceDomain?: string; seriesFilter?: string; minTagCount?: number; popularOnly?: boolean; hiddenGemsOnly?: boolean; }): Promise => { // Resolve tag aliases to canonical names for expanded search let resolvedTags = params.tags; if (params.tags && params.tags.length > 0) { try { resolvedTags = await tagApi.resolveTags(params.tags); } catch (error) { console.warn('Failed to resolve tag aliases during search:', error); // Fall back to original tags if resolution fails resolvedTags = params.tags; } } // Create URLSearchParams to properly handle array parameters const searchParams = new URLSearchParams(); // Add basic parameters searchParams.append('query', params.query); if (params.page !== undefined) searchParams.append('page', params.page.toString()); if (params.size !== undefined) searchParams.append('size', params.size.toString()); if (params.minRating !== undefined) searchParams.append('minRating', params.minRating.toString()); if (params.maxRating !== undefined) searchParams.append('maxRating', params.maxRating.toString()); if (params.sortBy) searchParams.append('sortBy', params.sortBy); if (params.sortDir) searchParams.append('sortDir', params.sortDir); // Advanced filters if (params.minWordCount !== undefined) searchParams.append('minWordCount', params.minWordCount.toString()); if (params.maxWordCount !== undefined) searchParams.append('maxWordCount', params.maxWordCount.toString()); if (params.createdAfter) searchParams.append('createdAfter', params.createdAfter); if (params.createdBefore) searchParams.append('createdBefore', params.createdBefore); if (params.lastReadAfter) searchParams.append('lastReadAfter', params.lastReadAfter); if (params.lastReadBefore) searchParams.append('lastReadBefore', params.lastReadBefore); if (params.unratedOnly !== undefined) searchParams.append('unratedOnly', params.unratedOnly.toString()); if (params.readingStatus) searchParams.append('readingStatus', params.readingStatus); if (params.hasReadingProgress !== undefined) searchParams.append('hasReadingProgress', params.hasReadingProgress.toString()); if (params.hasCoverImage !== undefined) searchParams.append('hasCoverImage', params.hasCoverImage.toString()); if (params.sourceDomain) searchParams.append('sourceDomain', params.sourceDomain); if (params.seriesFilter) searchParams.append('seriesFilter', params.seriesFilter); if (params.minTagCount !== undefined) searchParams.append('minTagCount', params.minTagCount.toString()); if (params.popularOnly !== undefined) searchParams.append('popularOnly', params.popularOnly.toString()); if (params.hiddenGemsOnly !== undefined) searchParams.append('hiddenGemsOnly', params.hiddenGemsOnly.toString()); // Add array parameters - each element gets its own parameter if (params.authors && params.authors.length > 0) { params.authors.forEach(author => searchParams.append('authors', author)); } if (resolvedTags && resolvedTags.length > 0) { resolvedTags.forEach(tag => searchParams.append('tags', tag)); } if (params.facetBy && params.facetBy.length > 0) { params.facetBy.forEach(facet => searchParams.append('facetBy', facet)); } const response = await api.get(`/stories/search?${searchParams.toString()}`); return response.data; }, }; // Configuration endpoints export const configApi = { getHtmlSanitizationConfig: async (): Promise<{ allowedTags: string[]; allowedAttributes: Record; allowedCssProperties: string[]; removedAttributes: Record; description: string; }> => { const response = await api.get('/config/html-sanitization'); return response.data; }, previewImageCleanup: async (): Promise<{ success: boolean; orphanedCount: number; totalSizeBytes: number; formattedSize: string; foldersToDelete: number; referencedImagesCount: number; errors: string[]; hasErrors: boolean; dryRun: boolean; error?: string; }> => { const response = await api.post('/config/cleanup/images/preview'); return response.data; }, executeImageCleanup: async (): Promise<{ success: boolean; deletedCount: number; totalSizeBytes: number; formattedSize: string; foldersDeleted: number; referencedImagesCount: number; errors: string[]; hasErrors: boolean; dryRun: boolean; error?: string; }> => { const response = await api.post('/config/cleanup/images/execute'); return response.data; }, }; // Collection endpoints export const collectionApi = { getCollections: async (params?: { page?: number; limit?: number; search?: string; tags?: string[]; archived?: boolean; }): Promise => { // Create URLSearchParams to properly handle array parameters const searchParams = new URLSearchParams(); if (params?.page !== undefined) searchParams.append('page', params.page.toString()); if (params?.limit !== undefined) searchParams.append('limit', params.limit.toString()); if (params?.search) searchParams.append('search', params.search); if (params?.archived !== undefined) searchParams.append('archived', params.archived.toString()); // Add array parameters - each element gets its own parameter if (params?.tags && params.tags.length > 0) { params.tags.forEach(tag => searchParams.append('tags', tag)); } const response = await api.get(`/collections?${searchParams.toString()}`); return response.data; }, getCollection: async (id: string): Promise => { const response = await api.get(`/collections/${id}`); return response.data; }, createCollection: async (collectionData: { name: string; description?: string; tagNames?: string[]; storyIds?: string[]; }): Promise => { const response = await api.post('/collections', collectionData); return response.data; }, createCollectionWithImage: async (collectionData: { name: string; description?: string; tags?: string[]; storyIds?: string[]; coverImage?: File; }): Promise => { const formData = new FormData(); formData.append('name', collectionData.name); if (collectionData.description) formData.append('description', collectionData.description); if (collectionData.tags) { collectionData.tags.forEach(tag => formData.append('tags', tag)); } if (collectionData.storyIds) { collectionData.storyIds.forEach(id => formData.append('storyIds', id)); } if (collectionData.coverImage) { formData.append('coverImage', collectionData.coverImage); } const response = await api.post('/collections', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); return response.data; }, updateCollection: async (id: string, collectionData: { name?: string; description?: string; tagNames?: string[]; rating?: number; }): Promise => { const response = await api.put(`/collections/${id}`, collectionData); return response.data; }, deleteCollection: async (id: string): Promise => { await api.delete(`/collections/${id}`); }, archiveCollection: async (id: string, archived: boolean): Promise => { const response = await api.put(`/collections/${id}/archive`, { archived }); return response.data; }, addStoriesToCollection: async (id: string, storyIds: string[], position?: number): Promise<{ added: number; skipped: number; totalStories: number; }> => { const response = await api.post(`/collections/${id}/stories`, { storyIds, position, }); return response.data; }, removeStoryFromCollection: async (collectionId: string, storyId: string): Promise => { await api.delete(`/collections/${collectionId}/stories/${storyId}`); }, reorderStories: async (collectionId: string, storyOrders: Array<{ storyId: string; position: number; }>): Promise => { await api.put(`/collections/${collectionId}/stories/order`, { storyOrders, }); }, getStoryWithCollectionContext: async (collectionId: string, storyId: string): Promise => { const response = await api.get(`/collections/${collectionId}/read/${storyId}`); return response.data; }, getCollectionStatistics: async (id: string): Promise => { const response = await api.get(`/collections/${id}/stats`); return response.data; }, uploadCover: async (id: string, coverImage: File): Promise<{ imagePath: string }> => { const formData = new FormData(); formData.append('file', coverImage); const response = await api.post(`/collections/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); return response.data; }, removeCover: async (id: string): Promise => { await api.delete(`/collections/${id}/cover`); }, }; // Database management endpoints export const databaseApi = { backup: async (): Promise => { const response = await api.post('/database/backup', {}, { responseType: 'blob' }); return response.data; }, restore: async (file: File): Promise<{ success: boolean; message: string }> => { const formData = new FormData(); formData.append('file', file); const response = await api.post('/database/restore', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); return response.data; }, clear: async (): Promise<{ success: boolean; message: string; deletedRecords?: number }> => { const response = await api.post('/database/clear'); return response.data; }, backupComplete: async (): Promise => { const response = await api.post('/database/backup-complete', {}, { responseType: 'blob' }); return response.data; }, restoreComplete: async (file: File): Promise<{ success: boolean; message: string }> => { const formData = new FormData(); formData.append('file', file); const response = await api.post('/database/restore-complete', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); return response.data; }, clearComplete: async (): Promise<{ success: boolean; message: string; deletedRecords?: number }> => { const response = await api.post('/database/clear-complete'); return response.data; }, }; // Library context for images - will be set by a React context provider let currentLibraryId: string | null = null; // Set the current library ID (called by library context or components) export const setCurrentLibraryId = (libraryId: string | null): void => { currentLibraryId = libraryId; }; // Get current library ID synchronously (fallback to 'default') export const getCurrentLibraryId = (): string => { return currentLibraryId || 'default'; }; // Clear library cache when switching libraries export const clearLibraryCache = (): void => { currentLibraryId = null; }; // Image utility - now library-aware export const getImageUrl = (path: string): string => { if (!path) return ''; // For compatibility during transition, handle both patterns if (path.startsWith('http')) { return path; // External URL } // Use library-aware API endpoint const libraryId = getCurrentLibraryId(); return `/api/files/images/${libraryId}/${path}`; };