Archived
- Backend: Django REST Framework API with Bookmark and Note models - ViewSets with user-scoped querysets and select_related for N+1 prevention - Create/List/Detail/Update/Delete endpoints - Batch delete operations - Unique constraint on user+book+page for bookmarks - IsOwner permission class for object-level access control - Full serializer validation (page > 0, non-empty content, duplicate check) - 30+ pytest-django tests covering CRUD, auth, filtering, edge cases - Frontend: React TypeScript components - AnnotationsContext with useReducer for state management - BookmarkList, NoteList, AddAnnotationForm, AnnotationsDashboard - Inline note editing with immediate save - Batch delete support - API client with JWT auto-refresh interceptors - Paginated query hook for infinite scroll support - Responsive CSS with loading/empty states - Infrastructure: Django project with custom User model, JWT auth, CORS - PostgreSQL database models with proper FK and indexes - Django admin configuration for all models
67 lines
1.8 KiB
TypeScript
67 lines
1.8 KiB
TypeScript
import axios from "axios";
|
|
|
|
const api = axios.create({
|
|
baseURL: "/api",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
// Attach JWT token to every request
|
|
api.interceptors.request.use((config) => {
|
|
const token = localStorage.getItem("access_token");
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// Attempt token refresh on 401
|
|
let isRefreshing = false;
|
|
let pendingRequests: Array<(token: string) => void> = [];
|
|
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
const originalRequest = error.config;
|
|
if (error.response?.status !== 401 || originalRequest._retry) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
if (isRefreshing) {
|
|
return new Promise((resolve) => {
|
|
pendingRequests.push((token: string) => {
|
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
resolve(api(originalRequest));
|
|
});
|
|
});
|
|
}
|
|
|
|
originalRequest._retry = true;
|
|
isRefreshing = true;
|
|
|
|
try {
|
|
const refreshToken = localStorage.getItem("refresh_token");
|
|
if (!refreshToken) {
|
|
throw new Error("No refresh token");
|
|
}
|
|
const { data } = await axios.post("/api/auth/token/refresh/", {
|
|
refresh: refreshToken,
|
|
});
|
|
localStorage.setItem("access_token", data.access);
|
|
pendingRequests.forEach((cb) => cb(data.access));
|
|
pendingRequests = [];
|
|
originalRequest.headers.Authorization = `Bearer ${data.access}`;
|
|
return api(originalRequest);
|
|
} catch {
|
|
localStorage.removeItem("access_token");
|
|
localStorage.removeItem("refresh_token");
|
|
window.location.href = "/login";
|
|
return Promise.reject(error);
|
|
} finally {
|
|
isRefreshing = false;
|
|
}
|
|
}
|
|
);
|
|
|
|
export default api; |