Files
storycove/frontend/src/lib/api.ts
2025-07-26 12:05:54 +02:00

497 lines
15 KiB
TypeScript

import axios from 'axios';
import { AuthResponse, Story, Author, Tag, 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 });
},
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`);
},
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;
},
};
// 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;
},
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;
},
};
// 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;
}): Promise<SearchResult> => {
// 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);
// 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 (params.tags && params.tags.length > 0) {
params.tags.forEach(tag => searchParams.append('tags', tag));
}
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;
},
};
// 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`);
},
};
// Image utility
export const getImageUrl = (path: string): string => {
if (!path) return '';
// Images are served directly by nginx at /images/
return `/images/${path}`;
};