824 lines
27 KiB
TypeScript
824 lines
27 KiB
TypeScript
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<AuthResponse> => {
|
|
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<void> => {
|
|
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<PagedResult<Story>> => {
|
|
const response = await api.get('/stories', { params });
|
|
return response.data;
|
|
},
|
|
|
|
getStory: async (id: string): Promise<Story> => {
|
|
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<Story> => {
|
|
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<Story> => {
|
|
const response = await api.put(`/stories/${id}`, storyData);
|
|
return response.data;
|
|
},
|
|
|
|
deleteStory: async (id: string): Promise<void> => {
|
|
await api.delete(`/stories/${id}`);
|
|
},
|
|
|
|
updateRating: async (id: string, rating: number): Promise<void> => {
|
|
await api.post(`/stories/${id}/rating`, { rating });
|
|
},
|
|
|
|
updateReadingProgress: async (id: string, position: number): Promise<Story> => {
|
|
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<void> => {
|
|
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<Story> => {
|
|
const response = await api.post(`/stories/${storyId}/tags/${tagId}`);
|
|
return response.data;
|
|
},
|
|
|
|
removeTag: async (storyId: string, tagId: string): Promise<Story> => {
|
|
const response = await api.delete(`/stories/${storyId}/tags/${tagId}`);
|
|
return response.data;
|
|
},
|
|
|
|
getStoryCollections: async (storyId: string): Promise<Collection[]> => {
|
|
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<Story | null> => {
|
|
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<PagedResult<Author>> => {
|
|
const response = await api.get('/authors', { params });
|
|
return response.data;
|
|
},
|
|
|
|
getAuthor: async (id: string): Promise<Author> => {
|
|
const response = await api.get(`/authors/${id}`);
|
|
return response.data;
|
|
},
|
|
|
|
updateAuthor: async (id: string, formData: FormData): Promise<Author> => {
|
|
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<void> => {
|
|
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<PagedResult<Tag>> => {
|
|
const response = await api.get('/tags', { params });
|
|
return response.data;
|
|
},
|
|
|
|
getTag: async (id: string): Promise<Tag> => {
|
|
const response = await api.get(`/tags/${id}`);
|
|
return response.data;
|
|
},
|
|
|
|
createTag: async (tagData: {
|
|
name: string;
|
|
color?: string;
|
|
description?: string;
|
|
}): Promise<Tag> => {
|
|
const response = await api.post('/tags', tagData);
|
|
return response.data;
|
|
},
|
|
|
|
updateTag: async (id: string, tagData: {
|
|
name?: string;
|
|
color?: string;
|
|
description?: string;
|
|
}): Promise<Tag> => {
|
|
const response = await api.put(`/tags/${id}`, tagData);
|
|
return response.data;
|
|
},
|
|
|
|
deleteTag: async (id: string): Promise<void> => {
|
|
await api.delete(`/tags/${id}`);
|
|
},
|
|
|
|
getTagAutocomplete: async (query: string): Promise<string[]> => {
|
|
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<Tag[]> => {
|
|
const response = await api.get('/tags/collections');
|
|
return response.data;
|
|
},
|
|
|
|
// Alias operations
|
|
addAlias: async (tagId: string, aliasName: string): Promise<TagAlias> => {
|
|
const response = await api.post(`/tags/${tagId}/aliases`, { aliasName });
|
|
return response.data;
|
|
},
|
|
|
|
removeAlias: async (tagId: string, aliasId: string): Promise<void> => {
|
|
await api.delete(`/tags/${tagId}/aliases/${aliasId}`);
|
|
},
|
|
|
|
resolveTag: async (name: string): Promise<Tag | null> => {
|
|
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<string[]> => {
|
|
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<Tag> => {
|
|
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<PagedResult<Series>> => {
|
|
const response = await api.get('/series', { params });
|
|
return response.data;
|
|
},
|
|
|
|
getSeriesStories: async (id: string): Promise<Story[]> => {
|
|
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<SearchResult> => {
|
|
// 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<string, string[]>;
|
|
allowedCssProperties: string[];
|
|
removedAttributes: Record<string, string[]>;
|
|
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<CollectionSearchResult> => {
|
|
// 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<Collection> => {
|
|
const response = await api.get(`/collections/${id}`);
|
|
return response.data;
|
|
},
|
|
|
|
createCollection: async (collectionData: {
|
|
name: string;
|
|
description?: string;
|
|
tagNames?: string[];
|
|
storyIds?: string[];
|
|
}): Promise<Collection> => {
|
|
const response = await api.post('/collections', collectionData);
|
|
return response.data;
|
|
},
|
|
|
|
createCollectionWithImage: async (collectionData: {
|
|
name: string;
|
|
description?: string;
|
|
tags?: string[];
|
|
storyIds?: string[];
|
|
coverImage?: File;
|
|
}): Promise<Collection> => {
|
|
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<Collection> => {
|
|
const response = await api.put(`/collections/${id}`, collectionData);
|
|
return response.data;
|
|
},
|
|
|
|
deleteCollection: async (id: string): Promise<void> => {
|
|
await api.delete(`/collections/${id}`);
|
|
},
|
|
|
|
archiveCollection: async (id: string, archived: boolean): Promise<Collection> => {
|
|
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<void> => {
|
|
await api.delete(`/collections/${collectionId}/stories/${storyId}`);
|
|
},
|
|
|
|
reorderStories: async (collectionId: string, storyOrders: Array<{
|
|
storyId: string;
|
|
position: number;
|
|
}>): Promise<void> => {
|
|
await api.put(`/collections/${collectionId}/stories/order`, {
|
|
storyOrders,
|
|
});
|
|
},
|
|
|
|
getStoryWithCollectionContext: async (collectionId: string, storyId: string): Promise<StoryWithCollectionContext> => {
|
|
const response = await api.get(`/collections/${collectionId}/read/${storyId}`);
|
|
return response.data;
|
|
},
|
|
|
|
getCollectionStatistics: async (id: string): Promise<CollectionStatistics> => {
|
|
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<void> => {
|
|
await api.delete(`/collections/${id}/cover`);
|
|
},
|
|
};
|
|
|
|
// Database management endpoints
|
|
export const databaseApi = {
|
|
backup: async (): Promise<Blob> => {
|
|
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<Blob> => {
|
|
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}`;
|
|
}; |