Archived
feat: Integrate Expo mobile application into monorepo (#16)
- Add mobile/ directory with Expo React Native project - Create API client with JWT auth and token refresh using AsyncStorage - Implement AuthContext for login/register/logout flow - Add screens: Login, Register, Library, Search, Settings - Set up React Navigation with AuthStack and MainTabs - Create packages/shared/ with shared types and utilities - Add shared validation utilities (email, password strength) - Update root package.json workspaces to include mobile + shared - Add spec document docs/backend/009-expo-integration.md
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import api from "./client";
|
||||
import type {
|
||||
Bookmark,
|
||||
Note,
|
||||
CreateBookmarkPayload,
|
||||
CreateNotePayload,
|
||||
PaginatedResponse,
|
||||
} from "@cloud-reader/shared";
|
||||
|
||||
export function fetchBookmarks(
|
||||
bookId?: string,
|
||||
): Promise<PaginatedResponse<Bookmark>> {
|
||||
const params = bookId ? { book: bookId } : {};
|
||||
return api
|
||||
.get<PaginatedResponse<Bookmark>>("/api/annotations/bookmarks/", { params })
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function createBookmark(
|
||||
payload: CreateBookmarkPayload,
|
||||
): Promise<Bookmark> {
|
||||
return api
|
||||
.post<Bookmark>("/api/annotations/bookmarks/", payload)
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function deleteBookmark(id: string): Promise<void> {
|
||||
return api.delete(`/api/annotations/bookmarks/${id}/`).then(() => {});
|
||||
}
|
||||
|
||||
export function fetchNotes(bookId?: string): Promise<PaginatedResponse<Note>> {
|
||||
const params = bookId ? { book: bookId } : {};
|
||||
return api
|
||||
.get<PaginatedResponse<Note>>("/api/annotations/notes/", { params })
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function createNote(payload: CreateNotePayload): Promise<Note> {
|
||||
return api
|
||||
.post<Note>("/api/annotations/notes/", payload)
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function updateNote(
|
||||
id: string,
|
||||
content: string,
|
||||
): Promise<Note> {
|
||||
return api
|
||||
.patch<Note>(`/api/annotations/notes/${id}/`, { content })
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function deleteNote(id: string): Promise<void> {
|
||||
return api.delete(`/api/annotations/notes/${id}/`).then(() => {});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import api from "./client";
|
||||
import type { Book, PaginatedResponse } from "@cloud-reader/shared";
|
||||
|
||||
export function fetchBooks(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PaginatedResponse<Book>> {
|
||||
return api
|
||||
.get<PaginatedResponse<Book>>("/api/books/", {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function fetchBook(id: string): Promise<Book> {
|
||||
return api.get<Book>(`/api/books/${id}/`).then((res) => res.data);
|
||||
}
|
||||
|
||||
export function searchBooks(
|
||||
query: string,
|
||||
): Promise<PaginatedResponse<Book>> {
|
||||
return api
|
||||
.get<PaginatedResponse<Book>>("/api/books/search/", {
|
||||
params: { q: query },
|
||||
})
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function deleteBook(id: string): Promise<void> {
|
||||
return api.delete(`/api/books/${id}/`).then(() => {});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
ACCESS_TOKEN: "access_token",
|
||||
REFRESH_TOKEN: "refresh_token",
|
||||
} as const;
|
||||
|
||||
interface RetryConfig extends InternalAxiosRequestConfig {
|
||||
_retry?: boolean;
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:8000",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// ── Token helpers ────────────────────────────────────────────────────
|
||||
|
||||
async function getAccessToken(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
async function getRefreshToken(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
async function setTokens(access: string, refresh: string): Promise<void> {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access);
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh);
|
||||
}
|
||||
|
||||
async function clearTokens(): Promise<void> {
|
||||
await AsyncStorage.multiRemove([
|
||||
STORAGE_KEYS.ACCESS_TOKEN,
|
||||
STORAGE_KEYS.REFRESH_TOKEN,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Request interceptor ─────────────────────────────────────────────
|
||||
|
||||
api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||
const token = await getAccessToken();
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// ── Response interceptor: auto-refresh on 401 ───────────────────────
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
function processQueue(error: unknown, token: string | null = null): void {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else if (token) {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as RetryConfig | undefined;
|
||||
|
||||
if (!originalRequest) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (
|
||||
error.response?.status !== 401 ||
|
||||
originalRequest._retry ||
|
||||
originalRequest.url?.includes("/api/auth/token/refresh/") ||
|
||||
originalRequest.url?.includes("/api/auth/login/") ||
|
||||
originalRequest.url?.includes("/api/auth/register/") ||
|
||||
originalRequest.url?.includes("/api/auth/logout/")
|
||||
) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return api(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
const refreshToken = await getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
isRefreshing = false;
|
||||
await clearTokens();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${api.defaults.baseURL}/api/auth/token/refresh/`,
|
||||
{ refresh: refreshToken },
|
||||
);
|
||||
|
||||
const newAccess = response.data.access as string;
|
||||
const newRefresh = response.data.refresh as string;
|
||||
await setTokens(newAccess, newRefresh);
|
||||
|
||||
processQueue(null, newAccess);
|
||||
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
|
||||
}
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
await clearTokens();
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export { getAccessToken, getRefreshToken, setTokens, clearTokens };
|
||||
export default api;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { apiClient } from "./client";
|
||||
import type {
|
||||
EBookListItem,
|
||||
EBookDetail,
|
||||
ReadingProgress,
|
||||
ReadingSettings,
|
||||
TocResponse,
|
||||
ContentResponse,
|
||||
PaginatedResponse,
|
||||
} from "@cloud-reader/shared";
|
||||
|
||||
export const ebooksApi = {
|
||||
/** List uploaded e-books */
|
||||
list() {
|
||||
return apiClient.get<PaginatedResponse<EBookListItem>>("/api/ebooks/");
|
||||
},
|
||||
|
||||
/** Get e-book detail */
|
||||
get(id: number) {
|
||||
return apiClient.get<EBookDetail>(`/api/ebooks/${id}/`);
|
||||
},
|
||||
|
||||
/** Get table of contents */
|
||||
getToc(id: number) {
|
||||
return apiClient.get<TocResponse>(`/api/ebooks/${id}/toc/`);
|
||||
},
|
||||
|
||||
/** Get page content */
|
||||
getContent(id: number, page: number) {
|
||||
return apiClient.get<ContentResponse>(
|
||||
`/api/ebooks/${id}/content/?page=${page}`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Update reading progress */
|
||||
updateProgress(id: number, data: Partial<ReadingProgress>) {
|
||||
return apiClient.patch<ReadingProgress>(
|
||||
`/api/ebooks/${id}/progress/`,
|
||||
data,
|
||||
);
|
||||
},
|
||||
|
||||
/** Get or update reading settings */
|
||||
getSettings(id: number) {
|
||||
return apiClient.get<ReadingSettings>(`/api/ebooks/${id}/settings/`);
|
||||
},
|
||||
|
||||
updateSettings(id: number, data: Partial<ReadingSettings>) {
|
||||
return apiClient.patch<ReadingSettings>(
|
||||
`/api/ebooks/${id}/settings/`,
|
||||
data,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { apiClient, saveTokens, loadTokens, clearTokens } from "./client";
|
||||
export { booksApi } from "./books";
|
||||
export { ebooksApi } from "./ebooks";
|
||||
export { annotationsApi } from "./annotations";
|
||||
Reference in New Issue
Block a user