inital working version
This commit is contained in:
236
frontend/src/lib/api.ts
Normal file
236
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import axios from 'axios';
|
||||
import { AuthResponse, Story, Author, Tag, Series, SearchResult, PagedResult } 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;
|
||||
});
|
||||
|
||||
// Response interceptor to handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear invalid token and redirect to login
|
||||
localStorage.removeItem('auth-token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
// 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`);
|
||||
},
|
||||
};
|
||||
|
||||
// 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);
|
||||
},
|
||||
};
|
||||
|
||||
// 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> => {
|
||||
const response = await api.get('/stories/search', { params });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Image utility
|
||||
export const getImageUrl = (path: string): string => {
|
||||
if (!path) return '';
|
||||
// Images are served directly by nginx at /images/
|
||||
return `/images/${path}`;
|
||||
};
|
||||
37
frontend/src/lib/theme.ts
Normal file
37
frontend/src/lib/theme.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>('light');
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage for saved preference
|
||||
const savedTheme = localStorage.getItem('storycove-theme') as Theme;
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
} else {
|
||||
// Check system preference
|
||||
const systemPreference = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
setTheme(systemPreference);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Apply theme to document
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('storycove-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return { theme, setTheme, toggleTheme };
|
||||
}
|
||||
Reference in New Issue
Block a user