From fa82fab44a39f7b20d37e65a0572d6c5e851e20d Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Fri, 29 May 2026 02:50:09 +0000 Subject: [PATCH] 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 --- README.md | 37 +++- docs/backend/009-expo-integration.md | 96 +++++++++++ mobile/App.tsx | 16 ++ mobile/app.json | 28 +++ mobile/assets/adaptive-icon.png | Bin 0 -> 69 bytes mobile/assets/favicon.png | Bin 0 -> 69 bytes mobile/assets/icon.png | Bin 0 -> 69 bytes mobile/assets/splash.png | Bin 0 -> 69 bytes mobile/babel.config.js | 7 + mobile/package.json | 35 ++++ mobile/src/api/annotations.ts | 55 ++++++ mobile/src/api/books.ts | 31 ++++ mobile/src/api/client.ts | 140 +++++++++++++++ mobile/src/api/ebooks.ts | 54 ++++++ mobile/src/api/index.ts | 4 + mobile/src/context/AuthContext.tsx | 104 +++++++++++ mobile/src/hooks/index.ts | 2 + mobile/src/hooks/useAsyncData.ts | 33 ++++ mobile/src/navigation/AppNavigator.tsx | 45 +++++ mobile/src/navigation/AuthNavigator.tsx | 24 +++ mobile/src/navigation/MainNavigator.tsx | 68 ++++++++ mobile/src/navigation/MainTabs.tsx | 55 ++++++ mobile/src/navigation/RootNavigator.tsx | 23 +++ mobile/src/screens/LibraryScreen.tsx | 181 ++++++++++++++++++++ mobile/src/screens/LoginScreen.tsx | 173 +++++++++++++++++++ mobile/src/screens/RegisterScreen.tsx | 212 +++++++++++++++++++++++ mobile/src/screens/SearchScreen.tsx | 130 ++++++++++++++ mobile/src/screens/SettingsScreen.tsx | 91 ++++++++++ mobile/src/types/index.ts | 8 + mobile/tsconfig.json | 20 +++ package.json | 13 +- packages/shared/package.json | 14 ++ packages/shared/src/index.ts | 2 + packages/shared/src/types.ts | 218 ++++++++++++++++++++++++ packages/shared/src/utils.ts | 117 +++++++++++++ packages/shared/tsconfig.json | 19 +++ 36 files changed, 2051 insertions(+), 4 deletions(-) create mode 100644 docs/backend/009-expo-integration.md create mode 100644 mobile/App.tsx create mode 100644 mobile/app.json create mode 100644 mobile/assets/adaptive-icon.png create mode 100644 mobile/assets/favicon.png create mode 100644 mobile/assets/icon.png create mode 100644 mobile/assets/splash.png create mode 100644 mobile/babel.config.js create mode 100644 mobile/package.json create mode 100644 mobile/src/api/annotations.ts create mode 100644 mobile/src/api/books.ts create mode 100644 mobile/src/api/client.ts create mode 100644 mobile/src/api/ebooks.ts create mode 100644 mobile/src/api/index.ts create mode 100644 mobile/src/context/AuthContext.tsx create mode 100644 mobile/src/hooks/index.ts create mode 100644 mobile/src/hooks/useAsyncData.ts create mode 100644 mobile/src/navigation/AppNavigator.tsx create mode 100644 mobile/src/navigation/AuthNavigator.tsx create mode 100644 mobile/src/navigation/MainNavigator.tsx create mode 100644 mobile/src/navigation/MainTabs.tsx create mode 100644 mobile/src/navigation/RootNavigator.tsx create mode 100644 mobile/src/screens/LibraryScreen.tsx create mode 100644 mobile/src/screens/LoginScreen.tsx create mode 100644 mobile/src/screens/RegisterScreen.tsx create mode 100644 mobile/src/screens/SearchScreen.tsx create mode 100644 mobile/src/screens/SettingsScreen.tsx create mode 100644 mobile/src/types/index.ts create mode 100644 mobile/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/src/utils.ts create mode 100644 packages/shared/tsconfig.json diff --git a/README.md b/README.md index 1ab0a40..16cbd76 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ cloud-reader/ │ │ └── annotations/ # Bookmarks and notes │ ├── manage.py │ └── requirements.txt -├── frontend/ # React + Vite + TypeScript (canonical frontend) +├── frontend/ # React + Vite + TypeScript (web frontend) │ ├── src/ │ │ ├── api/ # API client (axios with JWT refresh) │ │ ├── components/ # Reusable components @@ -23,6 +23,23 @@ cloud-reader/ │ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings) │ │ └── types/ # TypeScript type definitions │ └── package.json +├── mobile/ # Expo React Native app (mobile frontend) +│ ├── src/ +│ │ ├── api/ # API client (axios with JWT refresh via AsyncStorage) +│ │ ├── components/ # Reusable UI components +│ │ ├── context/ # Auth context +│ │ ├── hooks/ # Custom hooks +│ │ ├── navigation/ # React Navigation (Auth stack + Main tabs) +│ │ ├── screens/ # Screen-level components (Login, Library, etc.) +│ │ └── types/ # Mobile-specific types +│ ├── App.tsx +│ └── app.json +├── packages/ +│ └── shared/ # @cloud-reader/shared — domain types & utilities +│ └── src/ +│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note, etc.) +│ └── utils.ts # Date formatting, validation, API endpoint constants +├── package.json # Root — yarn workspaces config └── docker-compose.yml ``` @@ -50,8 +67,24 @@ yarn install yarn dev ``` +### Mobile (Expo) +```bash +# From monorepo root — installs all workspaces including mobile +yarn install + +# Start Expo dev server +yarn workspace @cloud-reader/mobile start + +# Or cd into mobile and run directly +cd mobile +npx expo start +``` + +> The mobile app requires the backend to be running. Set `EXPO_PUBLIC_API_URL` environment variable in your shell or `.env` file to point to the backend (defaults to `http://10.0.2.2:8000` for Android emulator). + ## Migration Notes Consolidated from duplicate `api/` + `web/` into single `backend/` + `frontend/` canonical structure. - `backend/` kept as canonical; `api/` features (e-book uploads, reading progress, reading settings) merged in. - `frontend/` kept as canonical; `web/` pages (Library, Reader, AddBook, Auth, Settings) merged in. -- `api/` and `web/` directories removed. \ No newline at end of file +- `api/` and `web/` directories removed. +- `mobile/` added as Expo React Native app with shared `@cloud-reader/shared` package. \ No newline at end of file diff --git a/docs/backend/009-expo-integration.md b/docs/backend/009-expo-integration.md new file mode 100644 index 0000000..44a5760 --- /dev/null +++ b/docs/backend/009-expo-integration.md @@ -0,0 +1,96 @@ +# 009 — Expo Mobile Application Integration + +**Issue:** #16 +**Status:** Draft +**Created:** 2026-05-29 + +## Objective + +Integrate an Expo-based React Native mobile application into the `cloud-reader` monorepo, sharing types, API client patterns, and configuration with the existing web frontend. + +## Directory Structure + +``` +cloud-reader/ +├── mobile/ # Expo React Native app +│ ├── package.json +│ ├── app.json +│ ├── tsconfig.json +│ ├── babel.config.js +│ ├── App.tsx # Root component +│ ├── src/ +│ │ ├── api/ # API client (mirrors frontend/src/api/ pattern) +│ │ │ ├── client.ts # Axios instance + JWT interceptor +│ │ │ ├── books.ts # Book API calls +│ │ │ └── annotations.ts +│ │ ├── screens/ # Screen-level components +│ │ ├── components/ # Reusable UI components +│ │ ├── navigation/ # React Navigation setup +│ │ ├── context/ # Auth context, etc. +│ │ ├── hooks/ # Custom hooks +│ │ └── types/ # Mobile-specific types +│ └── assets/ +├── packages/ +│ └── shared/ +│ ├── package.json +│ ├── tsconfig.json +│ └── src/ +│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note) +│ └── utils.ts # Shared utility functions +└── package.json # Root — updated workspace config +``` + +## Monorepo Workspace Config + +Root `package.json` workspaces array updated to include `"mobile"`, `"packages/shared"` alongside existing `"frontend"` and `"backend"`. + +## Shared `packages/shared` + +- `@cloud-reader/shared` package published within the monorepo +- Exports: + - All domain types (`Book`, `BookSummary`, `Bookmark`, `Note`, `User`, `AnnotationEntry`, `PaginatedResponse`, `TokenResponse`) + - API endpoint constants + - Date formatting helpers + - Validation utilities (email regex, password strength check) + +## Mobile App Structure + +### API Client (`mobile/src/api/client.ts`) +- Axios instance configured with: + - Base URL from environment variable (`EXPO_PUBLIC_API_URL`) + - JWT token attachment via request interceptor + - Token refresh response interceptor on 401 + - Uses `AsyncStorage` for token persistence (instead of `localStorage`) + +### Navigation (`mobile/src/navigation/`) +- React Navigation stack: + 1. `AuthStack` — Login, Register screens + 2. `MainTabs` — Library, Search, Settings tabs + 3. `BookReader` — Full-screen reading view + +### Key Screens +| Screen | Route | Purpose | +|--------|-------|---------| +| Login | `Auth/Login` | Email/password login | +| Register | `Auth/Register` | User registration | +| Library | `Main/Library` | Book list with filtering | +| BookDetail | `Main/BookDetail` | Book metadata + actions | +| Reader | `Reader/View` | EPUB/PDF rendering | +| Search | `Main/Search` | Book discovery | +| Settings | `Main/Settings` | Profile, theme, download mgmt | + +## Backend Changes Required + +None. The existing Django REST API already serves all endpoints needed by the mobile app. The mobile app communicates with the same backend via the shared API base URL. + +## Docker + +No changes to `docker-compose.yml` needed — the mobile app runs on-device or via Expo Go, not inside Docker. + +## CI/CD Considerations + +The monorepo structure supports a single pipeline that can: +- `yarn install` at root (installs all workspaces) +- `yarn workspace @cloud-reader/shared build` +- `yarn workspace @cloud-reader/mobile build` (Expo EAS for mobile builds) +- `yarn workspace @cloud-reader/frontend build` (Vite for web builds) \ No newline at end of file diff --git a/mobile/App.tsx b/mobile/App.tsx new file mode 100644 index 0000000..9b3eeea --- /dev/null +++ b/mobile/App.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { NavigationContainer } from "@react-navigation/native"; +import { StatusBar } from "expo-status-bar"; +import { AuthProvider } from "./src/context/AuthContext"; +import { RootNavigator } from "./src/navigation/RootNavigator"; + +export default function App() { + return ( + + + + + + + ); +} \ No newline at end of file diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..7ccd6b0 --- /dev/null +++ b/mobile/app.json @@ -0,0 +1,28 @@ +{ + "expo": { + "name": "Cloud Reader", + "slug": "cloud-reader", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "splash": { + "backgroundColor": "#1a1a2e" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.cloudreader.app" + }, + "android": { + "adaptiveIcon": { + "backgroundColor": "#1a1a2e" + }, + "package": "com.cloudreader.app" + }, + "plugins": [ + "expo-document-picker", + "expo-file-system" + ] + } +} \ No newline at end of file diff --git a/mobile/assets/adaptive-icon.png b/mobile/assets/adaptive-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..548a5b997fa3c3ace8db0c6bd3aa2035d47f02f5 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6y6B2&#GcYnUGH`Sr RdIJ<^@O1TaS?83{1OTfG4#WTe literal 0 HcmV?d00001 diff --git a/mobile/assets/favicon.png b/mobile/assets/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..548a5b997fa3c3ace8db0c6bd3aa2035d47f02f5 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6y6B2&#GcYnUGH`Sr RdIJ<^@O1TaS?83{1OTfG4#WTe literal 0 HcmV?d00001 diff --git a/mobile/assets/icon.png b/mobile/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..548a5b997fa3c3ace8db0c6bd3aa2035d47f02f5 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6y6B2&#GcYnUGH`Sr RdIJ<^@O1TaS?83{1OTfG4#WTe literal 0 HcmV?d00001 diff --git a/mobile/assets/splash.png b/mobile/assets/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..548a5b997fa3c3ace8db0c6bd3aa2035d47f02f5 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6y6B2&#GcYnUGH`Sr RdIJ<^@O1TaS?83{1OTfG4#WTe literal 0 HcmV?d00001 diff --git a/mobile/babel.config.js b/mobile/babel.config.js new file mode 100644 index 0000000..f07c5da --- /dev/null +++ b/mobile/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + plugins: ["react-native-reanimated/plugin"], + }; +}; \ No newline at end of file diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..231691c --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,35 @@ +{ + "name": "@cloud-reader/mobile", + "version": "1.0.0", + "private": true, + "main": "expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "lint": "eslint ." + }, + "dependencies": { + "expo": "~52.0.0", + "expo-status-bar": "~2.0.0", + "react": "^19.0.0", + "react-native": "0.76.6", + "react-native-safe-area-context": "4.14.1", + "react-native-screens": "~4.4.0", + "@react-navigation/native": "^7.0.0", + "@react-navigation/native-stack": "^7.0.0", + "@react-navigation/bottom-tabs": "^7.0.0", + "axios": "^1.7.9", + "@react-native-async-storage/async-storage": "2.1.0", + "expo-document-picker": "~13.0.0", + "expo-file-system": "~18.0.0", + "react-native-gesture-handler": "~2.20.0", + "react-native-reanimated": "~3.16.0", + "@cloud-reader/shared": "*" + }, + "devDependencies": { + "@types/react": "~19.0.0", + "typescript": "~5.7.0" + } +} \ No newline at end of file diff --git a/mobile/src/api/annotations.ts b/mobile/src/api/annotations.ts new file mode 100644 index 0000000..434fb89 --- /dev/null +++ b/mobile/src/api/annotations.ts @@ -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> { + const params = bookId ? { book: bookId } : {}; + return api + .get>("/api/annotations/bookmarks/", { params }) + .then((res) => res.data); +} + +export function createBookmark( + payload: CreateBookmarkPayload, +): Promise { + return api + .post("/api/annotations/bookmarks/", payload) + .then((res) => res.data); +} + +export function deleteBookmark(id: string): Promise { + return api.delete(`/api/annotations/bookmarks/${id}/`).then(() => {}); +} + +export function fetchNotes(bookId?: string): Promise> { + const params = bookId ? { book: bookId } : {}; + return api + .get>("/api/annotations/notes/", { params }) + .then((res) => res.data); +} + +export function createNote(payload: CreateNotePayload): Promise { + return api + .post("/api/annotations/notes/", payload) + .then((res) => res.data); +} + +export function updateNote( + id: string, + content: string, +): Promise { + return api + .patch(`/api/annotations/notes/${id}/`, { content }) + .then((res) => res.data); +} + +export function deleteNote(id: string): Promise { + return api.delete(`/api/annotations/notes/${id}/`).then(() => {}); +} \ No newline at end of file diff --git a/mobile/src/api/books.ts b/mobile/src/api/books.ts new file mode 100644 index 0000000..9045b61 --- /dev/null +++ b/mobile/src/api/books.ts @@ -0,0 +1,31 @@ +import api from "./client"; +import type { Book, PaginatedResponse } from "@cloud-reader/shared"; + +export function fetchBooks( + page = 1, + pageSize = 20, +): Promise> { + return api + .get>("/api/books/", { + params: { page, page_size: pageSize }, + }) + .then((res) => res.data); +} + +export function fetchBook(id: string): Promise { + return api.get(`/api/books/${id}/`).then((res) => res.data); +} + +export function searchBooks( + query: string, +): Promise> { + return api + .get>("/api/books/search/", { + params: { q: query }, + }) + .then((res) => res.data); +} + +export function deleteBook(id: string): Promise { + return api.delete(`/api/books/${id}/`).then(() => {}); +} \ No newline at end of file diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts new file mode 100644 index 0000000..f1963dc --- /dev/null +++ b/mobile/src/api/client.ts @@ -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 { + return AsyncStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN); +} + +async function getRefreshToken(): Promise { + return AsyncStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN); +} + +async function setTokens(access: string, refresh: string): Promise { + await AsyncStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access); + await AsyncStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh); +} + +async function clearTokens(): Promise { + 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((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; \ No newline at end of file diff --git a/mobile/src/api/ebooks.ts b/mobile/src/api/ebooks.ts new file mode 100644 index 0000000..26b5d50 --- /dev/null +++ b/mobile/src/api/ebooks.ts @@ -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>("/api/ebooks/"); + }, + + /** Get e-book detail */ + get(id: number) { + return apiClient.get(`/api/ebooks/${id}/`); + }, + + /** Get table of contents */ + getToc(id: number) { + return apiClient.get(`/api/ebooks/${id}/toc/`); + }, + + /** Get page content */ + getContent(id: number, page: number) { + return apiClient.get( + `/api/ebooks/${id}/content/?page=${page}`, + ); + }, + + /** Update reading progress */ + updateProgress(id: number, data: Partial) { + return apiClient.patch( + `/api/ebooks/${id}/progress/`, + data, + ); + }, + + /** Get or update reading settings */ + getSettings(id: number) { + return apiClient.get(`/api/ebooks/${id}/settings/`); + }, + + updateSettings(id: number, data: Partial) { + return apiClient.patch( + `/api/ebooks/${id}/settings/`, + data, + ); + }, +}; \ No newline at end of file diff --git a/mobile/src/api/index.ts b/mobile/src/api/index.ts new file mode 100644 index 0000000..3ba8646 --- /dev/null +++ b/mobile/src/api/index.ts @@ -0,0 +1,4 @@ +export { apiClient, saveTokens, loadTokens, clearTokens } from "./client"; +export { booksApi } from "./books"; +export { ebooksApi } from "./ebooks"; +export { annotationsApi } from "./annotations"; \ No newline at end of file diff --git a/mobile/src/context/AuthContext.tsx b/mobile/src/context/AuthContext.tsx new file mode 100644 index 0000000..dc5a0e6 --- /dev/null +++ b/mobile/src/context/AuthContext.tsx @@ -0,0 +1,104 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, + type ReactNode, +} from "react"; +import type { User, TokenResponse } from "@cloud-reader/shared"; +import { apiClient, saveTokens, loadTokens, clearTokens } from "../api/client"; + +interface AuthState { + user: User | null; + isLoading: boolean; + isAuthenticated: boolean; +} + +interface AuthContextValue extends AuthState { + login: (email: string, password: string) => Promise; + register: ( + email: string, + username: string, + password: string, + ) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + user: null, + isLoading: true, + isAuthenticated: false, + }); + + // Restore session on mount + useEffect(() => { + (async () => { + try { + const tokens = await loadTokens(); + if (tokens?.access) { + const response = await apiClient.get("/api/auth/profile/"); + setState({ + user: response.data, + isLoading: false, + isAuthenticated: true, + }); + return; + } + } catch { + await clearTokens(); + } + setState({ user: null, isLoading: false, isAuthenticated: false }); + })(); + }, []); + + const login = useCallback(async (email: string, password: string) => { + const response = await apiClient.post( + "/api/auth/login/", + { email, password }, + ); + await saveTokens(response.data); + const profile = await apiClient.get("/api/auth/profile/"); + setState({ + user: profile.data, + isLoading: false, + isAuthenticated: true, + }); + }, []); + + const register = useCallback( + async (email: string, username: string, password: string) => { + await apiClient.post("/api/auth/register/", { + email, + username, + password, + password2: password, + }); + // Auto-login after registration + await login(email, password); + }, + [login], + ); + + const logout = useCallback(async () => { + await clearTokens(); + setState({ user: null, isLoading: false, isAuthenticated: false }); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +} \ No newline at end of file diff --git a/mobile/src/hooks/index.ts b/mobile/src/hooks/index.ts new file mode 100644 index 0000000..b751c47 --- /dev/null +++ b/mobile/src/hooks/index.ts @@ -0,0 +1,2 @@ +export { useAuth } from "../context/AuthContext"; +export { useAsyncData } from "./useAsyncData"; \ No newline at end of file diff --git a/mobile/src/hooks/useAsyncData.ts b/mobile/src/hooks/useAsyncData.ts new file mode 100644 index 0000000..3f57c37 --- /dev/null +++ b/mobile/src/hooks/useAsyncData.ts @@ -0,0 +1,33 @@ +import { useState, useEffect, useCallback } from "react"; + +/** + * Generic async data fetching hook for mobile screens. + */ +export function useAsyncData( + fetcher: () => Promise, + deps: unknown[] = [], +) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const execute = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await fetcher(); + setData(result); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + useEffect(() => { + execute(); + }, [execute]); + + return { data, loading, error, refetch: execute }; +} \ No newline at end of file diff --git a/mobile/src/navigation/AppNavigator.tsx b/mobile/src/navigation/AppNavigator.tsx new file mode 100644 index 0000000..d64e751 --- /dev/null +++ b/mobile/src/navigation/AppNavigator.tsx @@ -0,0 +1,45 @@ +import { type ReactNode } from "react"; +import { NavigationContainer } from "@react-navigation/native"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { useAuth } from "../context/AuthContext"; +import LoginScreen from "../screens/LoginScreen"; +import RegisterScreen from "../screens/RegisterScreen"; +import MainTabs from "./MainTabs"; + +export type AuthStackParamList = { + Login: undefined; + Register: undefined; +}; + +export type RootStackParamList = { + Auth: undefined; + Main: undefined; +}; + +const RootStack = createNativeStackNavigator(); +const AuthStack = createNativeStackNavigator(); + +function AuthNavigator(): ReactNode { + return ( + + + + + ); +} + +export default function AppNavigator(): ReactNode { + const { state } = useAuth(); + + return ( + + + {state.isAuthenticated ? ( + + ) : ( + + )} + + + ); +} \ No newline at end of file diff --git a/mobile/src/navigation/AuthNavigator.tsx b/mobile/src/navigation/AuthNavigator.tsx new file mode 100644 index 0000000..697e99f --- /dev/null +++ b/mobile/src/navigation/AuthNavigator.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import LoginScreen from "../screens/LoginScreen"; +import RegisterScreen from "../screens/RegisterScreen"; + +export type AuthStackParamList = { + Login: undefined; + Register: undefined; +}; + +const Stack = createNativeStackNavigator(); + +export function AuthNavigator() { + return ( + + + + + ); +} \ No newline at end of file diff --git a/mobile/src/navigation/MainNavigator.tsx b/mobile/src/navigation/MainNavigator.tsx new file mode 100644 index 0000000..886c5f3 --- /dev/null +++ b/mobile/src/navigation/MainNavigator.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import { Text } from "react-native"; +import LibraryScreen from "../screens/LibraryScreen"; +import SearchScreen from "../screens/SearchScreen"; +import SettingsScreen from "../screens/SettingsScreen"; + +export type MainTabParamList = { + Library: undefined; + Search: undefined; + Settings: undefined; +}; + +const Tab = createBottomTabNavigator(); + +function TabIcon({ label, focused }: { label: string; focused: boolean }) { + const icons: Record = { + Library: "📚", + Search: "🔍", + Settings: "⚙️", + }; + return ( + + {icons[label] ?? "●"} + + ); +} + +export function MainNavigator() { + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} \ No newline at end of file diff --git a/mobile/src/navigation/MainTabs.tsx b/mobile/src/navigation/MainTabs.tsx new file mode 100644 index 0000000..9f80e33 --- /dev/null +++ b/mobile/src/navigation/MainTabs.tsx @@ -0,0 +1,55 @@ +import { type ReactNode } from "react"; +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import { Text } from "react-native"; +import LibraryScreen from "../screens/LibraryScreen"; +import SearchScreen from "../screens/SearchScreen"; +import SettingsScreen from "../screens/SettingsScreen"; + +export type MainTabParamList = { + Library: undefined; + Search: undefined; + Settings: undefined; +}; + +const Tab = createBottomTabNavigator(); + +function TabIcon({ label, focused }: { label: string; focused: boolean }) { + return ( + + {label === "Library" ? "📚" : label === "Search" ? "🔍" : "⚙️"} + + ); +} + +export default function MainTabs(): ReactNode { + return ( + ({ + tabBarIcon: ({ focused }: { focused: boolean }) => ( + + ), + tabBarActiveTintColor: "#4f8ef7", + tabBarInactiveTintColor: "#888", + headerStyle: { backgroundColor: "#1a1a2e" }, + headerTintColor: "#fff", + tabBarStyle: { backgroundColor: "#1a1a2e", borderTopColor: "#333" }, + })} + > + + + + + ); +} \ No newline at end of file diff --git a/mobile/src/navigation/RootNavigator.tsx b/mobile/src/navigation/RootNavigator.tsx new file mode 100644 index 0000000..b857e50 --- /dev/null +++ b/mobile/src/navigation/RootNavigator.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { ActivityIndicator, View } from "react-native"; +import { useAuth } from "../context/AuthContext"; +import { AuthNavigator } from "./AuthNavigator"; +import { MainNavigator } from "./MainNavigator"; + +export function RootNavigator() { + const { isLoading, isAuthenticated } = useAuth(); + + if (isLoading) { + return ( + + + + ); + } + + if (!isAuthenticated) { + return ; + } + + return ; +} \ No newline at end of file diff --git a/mobile/src/screens/LibraryScreen.tsx b/mobile/src/screens/LibraryScreen.tsx new file mode 100644 index 0000000..6f10d13 --- /dev/null +++ b/mobile/src/screens/LibraryScreen.tsx @@ -0,0 +1,181 @@ +import { useState, useEffect, useCallback, type ReactNode } from "react"; +import { + View, + Text, + FlatList, + TouchableOpacity, + StyleSheet, + ActivityIndicator, + RefreshControl, +} from "react-native"; +import { fetchBooks } from "../api/books"; +import type { Book, PaginatedResponse } from "@cloud-reader/shared"; + +export default function LibraryScreen({ navigation }: { navigation: any }): ReactNode { + const [books, setBooks] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + + const loadBooks = useCallback(async (pageNum: number, isRefresh = false) => { + try { + const data: PaginatedResponse = await fetchBooks(pageNum); + if (isRefresh) { + setBooks(data.results); + } else { + setBooks((prev) => [...prev, ...data.results]); + } + setHasMore(data.next !== null); + setPage(pageNum); + } catch { + // Silent error for now + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + loadBooks(1, true); + }, [loadBooks]); + + const onRefresh = () => { + setRefreshing(true); + loadBooks(1, true); + }; + + const loadMore = () => { + if (hasMore && !loading) { + loadBooks(page + 1); + } + }; + + const renderBook = ({ item }: { item: Book }) => ( + + navigation.navigate("BookDetail", { bookId: item.id }) + } + > + + + {item.title.charAt(0).toUpperCase()} + + + + + {item.title} + + + {item.author} + + {item.total_pages} pages + + + ); + + if (loading && books.length === 0) { + return ( + + + + ); + } + + return ( + + item.id} + onEndReached={loadMore} + onEndReachedThreshold={0.5} + contentContainerStyle={styles.list} + refreshControl={ + + } + ListEmptyComponent={ + + Your library is empty + + Add books to get started + + + } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#0f0f23", + }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 24, + }, + list: { + padding: 16, + }, + bookCard: { + flexDirection: "row", + backgroundColor: "#1a1a2e", + borderRadius: 12, + padding: 12, + marginBottom: 12, + borderWidth: 1, + borderColor: "#333", + }, + bookCover: { + width: 60, + height: 80, + backgroundColor: "#2a2a4e", + borderRadius: 8, + justifyContent: "center", + alignItems: "center", + }, + coverText: { + fontSize: 24, + fontWeight: "bold", + color: "#4f8ef7", + }, + bookInfo: { + flex: 1, + marginLeft: 12, + justifyContent: "center", + }, + bookTitle: { + fontSize: 16, + fontWeight: "600", + color: "#fff", + marginBottom: 4, + }, + bookAuthor: { + fontSize: 14, + color: "#888", + marginBottom: 4, + }, + bookPages: { + fontSize: 12, + color: "#666", + }, + emptyText: { + fontSize: 18, + fontWeight: "600", + color: "#888", + marginBottom: 8, + }, + emptySubtext: { + fontSize: 14, + color: "#666", + }, +}); \ No newline at end of file diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx new file mode 100644 index 0000000..766fafe --- /dev/null +++ b/mobile/src/screens/LoginScreen.tsx @@ -0,0 +1,173 @@ +import { useState, type ReactNode } from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + Alert, + ActivityIndicator, + KeyboardAvoidingView, + Platform, +} from "react-native"; +import { useAuth } from "../context/AuthContext"; +import { isValidEmail } from "@cloud-reader/shared"; + +export default function LoginScreen({ navigation }: { navigation: any }): ReactNode { + const { state, login, clearError } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const handleLogin = async () => { + clearError(); + + if (!email.trim()) { + Alert.alert("Validation Error", "Please enter your email."); + return; + } + if (!isValidEmail(email.trim())) { + Alert.alert("Validation Error", "Please enter a valid email address."); + return; + } + if (!password) { + Alert.alert("Validation Error", "Please enter your password."); + return; + } + + try { + await login(email.trim(), password); + } catch { + // Error handled in context + } + }; + + return ( + + + Cloud Reader + Sign in to your account + + {state.error && ( + + {state.error} + + )} + + + + + + + {state.isLoading ? ( + + ) : ( + Sign In + )} + + + navigation.navigate("Register")}> + + Don't have an account?{" "} + Sign Up + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#0f0f23", + }, + inner: { + flex: 1, + justifyContent: "center", + paddingHorizontal: 24, + }, + title: { + fontSize: 32, + fontWeight: "bold", + color: "#fff", + textAlign: "center", + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: "#888", + textAlign: "center", + marginBottom: 32, + }, + input: { + backgroundColor: "#1a1a2e", + borderRadius: 8, + padding: 16, + fontSize: 16, + color: "#fff", + marginBottom: 12, + borderWidth: 1, + borderColor: "#333", + }, + button: { + backgroundColor: "#4f8ef7", + borderRadius: 8, + padding: 16, + alignItems: "center", + marginTop: 8, + marginBottom: 24, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + }, + errorBox: { + backgroundColor: "rgba(255, 69, 58, 0.15)", + borderRadius: 8, + padding: 12, + marginBottom: 16, + borderWidth: 1, + borderColor: "rgba(255, 69, 58, 0.3)", + }, + errorText: { + color: "#ff453a", + fontSize: 14, + textAlign: "center", + }, + linkText: { + color: "#888", + textAlign: "center", + fontSize: 14, + }, + linkBold: { + color: "#4f8ef7", + fontWeight: "600", + }, +}); \ No newline at end of file diff --git a/mobile/src/screens/RegisterScreen.tsx b/mobile/src/screens/RegisterScreen.tsx new file mode 100644 index 0000000..e2cce09 --- /dev/null +++ b/mobile/src/screens/RegisterScreen.tsx @@ -0,0 +1,212 @@ +import { useState, type ReactNode } from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + Alert, + ActivityIndicator, + KeyboardAvoidingView, + Platform, + ScrollView, +} from "react-native"; +import { useAuth } from "../context/AuthContext"; +import { + isValidEmail, + validatePasswordStrength, +} from "@cloud-reader/shared"; + +export default function RegisterScreen({ + navigation, +}: { + navigation: any; +}): ReactNode { + const { state, register, clearError } = useAuth(); + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const handleRegister = async () => { + clearError(); + + if (!username.trim()) { + Alert.alert("Validation Error", "Please enter a username."); + return; + } + if (!email.trim()) { + Alert.alert("Validation Error", "Please enter your email."); + return; + } + if (!isValidEmail(email.trim())) { + Alert.alert("Validation Error", "Please enter a valid email address."); + return; + } + const passwordError = validatePasswordStrength(password); + if (passwordError) { + Alert.alert("Validation Error", passwordError); + return; + } + if (password !== confirmPassword) { + Alert.alert("Validation Error", "Passwords do not match."); + return; + } + + try { + await register(email.trim(), password, username.trim()); + } catch { + // Error handled in context + } + }; + + return ( + + + Create Account + Join Cloud Reader + + {state.error && ( + + {state.error} + + )} + + + + + + + + + + + {state.isLoading ? ( + + ) : ( + Create Account + )} + + + navigation.goBack()}> + + Already have an account?{" "} + Sign In + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#0f0f23", + }, + inner: { + flexGrow: 1, + justifyContent: "center", + paddingHorizontal: 24, + paddingVertical: 48, + }, + title: { + fontSize: 28, + fontWeight: "bold", + color: "#fff", + textAlign: "center", + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: "#888", + textAlign: "center", + marginBottom: 32, + }, + input: { + backgroundColor: "#1a1a2e", + borderRadius: 8, + padding: 16, + fontSize: 16, + color: "#fff", + marginBottom: 12, + borderWidth: 1, + borderColor: "#333", + }, + button: { + backgroundColor: "#4f8ef7", + borderRadius: 8, + padding: 16, + alignItems: "center", + marginTop: 8, + marginBottom: 24, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + }, + errorBox: { + backgroundColor: "rgba(255, 69, 58, 0.15)", + borderRadius: 8, + padding: 12, + marginBottom: 16, + borderWidth: 1, + borderColor: "rgba(255, 69, 58, 0.3)", + }, + errorText: { + color: "#ff453a", + fontSize: 14, + textAlign: "center", + }, + linkText: { + color: "#888", + textAlign: "center", + fontSize: 14, + }, + linkBold: { + color: "#4f8ef7", + fontWeight: "600", + }, +}); \ No newline at end of file diff --git a/mobile/src/screens/SearchScreen.tsx b/mobile/src/screens/SearchScreen.tsx new file mode 100644 index 0000000..6a9b383 --- /dev/null +++ b/mobile/src/screens/SearchScreen.tsx @@ -0,0 +1,130 @@ +import { useState, type ReactNode } from "react"; +import { + View, + Text, + TextInput, + FlatList, + TouchableOpacity, + StyleSheet, + ActivityIndicator, +} from "react-native"; +import { searchBooks } from "../api/books"; +import type { Book, PaginatedResponse } from "@cloud-reader/shared"; + +export default function SearchScreen(): ReactNode { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(false); + + const handleSearch = async () => { + if (!query.trim()) return; + setLoading(true); + setSearched(true); + try { + const data: PaginatedResponse = await searchBooks(query.trim()); + setResults(data.results); + } catch { + setResults([]); + } finally { + setLoading(false); + } + }; + + const renderBook = ({ item }: { item: Book }) => ( + + {item.title} + {item.author} + + ); + + return ( + + + + + + {loading && ( + + + + )} + + {!loading && searched && results.length === 0 && ( + + No books found for "{query}" + + )} + + item.id} + contentContainerStyle={styles.list} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#0f0f23", + }, + searchBar: { + padding: 16, + borderBottomWidth: 1, + borderBottomColor: "#333", + }, + input: { + backgroundColor: "#1a1a2e", + borderRadius: 8, + padding: 14, + fontSize: 16, + color: "#fff", + borderWidth: 1, + borderColor: "#333", + }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 24, + }, + list: { + padding: 16, + }, + resultCard: { + backgroundColor: "#1a1a2e", + borderRadius: 12, + padding: 16, + marginBottom: 8, + borderWidth: 1, + borderColor: "#333", + }, + resultTitle: { + fontSize: 16, + fontWeight: "600", + color: "#fff", + marginBottom: 4, + }, + resultAuthor: { + fontSize: 14, + color: "#888", + }, + noResults: { + fontSize: 16, + color: "#888", + textAlign: "center", + }, +}); \ No newline at end of file diff --git a/mobile/src/screens/SettingsScreen.tsx b/mobile/src/screens/SettingsScreen.tsx new file mode 100644 index 0000000..399b66d --- /dev/null +++ b/mobile/src/screens/SettingsScreen.tsx @@ -0,0 +1,91 @@ +import { type ReactNode } from "react"; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Alert, +} from "react-native"; +import { useAuth } from "../context/AuthContext"; + +export default function SettingsScreen(): ReactNode { + const { state, logout } = useAuth(); + + const handleLogout = () => { + Alert.alert("Logout", "Are you sure you want to sign out?", [ + { text: "Cancel", style: "cancel" }, + { text: "Sign Out", style: "destructive", onPress: logout }, + ]); + }; + + return ( + + + Account + + Username + {state.user?.username ?? "—"} + + + Email + {state.user?.email ?? "—"} + + + + + Sign Out + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#0f0f23", + padding: 16, + }, + section: { + backgroundColor: "#1a1a2e", + borderRadius: 12, + padding: 16, + marginBottom: 24, + borderWidth: 1, + borderColor: "#333", + }, + sectionTitle: { + fontSize: 18, + fontWeight: "600", + color: "#fff", + marginBottom: 16, + }, + infoRow: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: "#333", + }, + label: { + fontSize: 14, + color: "#888", + }, + value: { + fontSize: 14, + color: "#fff", + fontWeight: "500", + }, + logoutButton: { + backgroundColor: "rgba(255, 69, 58, 0.15)", + borderRadius: 8, + padding: 16, + alignItems: "center", + borderWidth: 1, + borderColor: "rgba(255, 69, 58, 0.3)", + }, + logoutText: { + color: "#ff453a", + fontSize: 16, + fontWeight: "600", + }, +}); \ No newline at end of file diff --git a/mobile/src/types/index.ts b/mobile/src/types/index.ts new file mode 100644 index 0000000..ee4da34 --- /dev/null +++ b/mobile/src/types/index.ts @@ -0,0 +1,8 @@ +// Mobile-specific type aliases and extensions not covered by shared types + +export type RootStackParamList = { + Auth: undefined; + Main: undefined; + BookReader: { bookId: number }; + BookDetail: { bookId: number }; +}; \ No newline at end of file diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 0000000..52c6bb3 --- /dev/null +++ b/mobile/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/package.json b/package.json index 4dadbf4..ed0d769 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,15 @@ "private": true, "workspaces": [ "frontend", - "backend" - ] + "backend", + "mobile", + "packages/shared" + ], + "scripts": { + "dev:frontend": "yarn workspace @cloud-reader/frontend dev", + "dev:backend": "cd backend && python manage.py runserver", + "start:mobile": "yarn workspace @cloud-reader/mobile start", + "build:shared": "yarn workspace @cloud-reader/shared build", + "install:all": "yarn install" + } } \ No newline at end of file diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..e5ae27e --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,14 @@ +{ + "name": "@cloud-reader/shared", + "version": "1.0.0", + "private": true, + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "typescript": "~5.7.0" + } +} \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..1075af7 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./utils"; \ No newline at end of file diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..3bed57e --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,218 @@ +/** Core domain types for Cloud Reader — shared across web and mobile */ + +// ---- User & Auth ---- + +export interface User { + id: number; + email: string; + username: string; +} + +export interface TokenResponse { + access: string; + refresh: string; +} + +export interface LoginPayload { + email: string; + password: string; +} + +export interface RegisterPayload { + email: string; + username: string; + password: string; + password2: string; +} + +// ---- Books ---- + +export interface Book { + id: string; + title: string; + author: string; + total_pages: number; + cover_image: string; + created_at: string; + updated_at: string; +} + +export interface BookSummary { + id: string; + title: string; + author: string; + total_pages: number; + cover_image: string; +} + +export interface BookListItem { + id: number; + title: string; + author: string; + genre: string; + reading_status: string; + reading_status_display: string; + cover_image: string | null; +} + +export interface BookDetail { + id: number; + title: string; + author: string; + genre: string; + description: string; + reading_status: string; + reading_status_display: string; + cover_image: string | null; + total_pages: number; + created_at: string; + updated_at: string; +} + +export interface BookSearchParams { + q?: string; + genre?: string; + author?: string; + reading_status?: string; + ordering?: string; + page?: number; + page_size?: number; +} + +export const READING_STATUS_OPTIONS = [ + "to_read", + "reading", + "finished", + "dnf", +] as const; + +// ---- E-Books ---- + +export interface EBookListItem { + id: number; + title: string; + author: string; + filename: string; + format: string; + page_count: number; + cover_image: string | null; + created_at: string; + progress: number | null; +} + +export interface EBookDetail { + id: number; + title: string; + author: string; + filename: string; + file_url: string; + format: string; + page_count: number; + file_size: number; + metadata_json: Record; + cover_image: string | null; + created_at: string; + updated_at: string; + progress: ReadingProgress | null; +} + +export interface ReadingProgress { + current_position: number; + last_page: number; +} + +export interface ReadingSettings { + font_size: number; + font_style: "sans-serif" | "serif" | "monospace"; + background_color: string; +} + +export interface BookChapter { + id: number; + title: string; + index: number; + href: string; + children: BookChapter[]; +} + +export interface TocResponse { + chapters: BookChapter[]; + format: string; + page_count: number; +} + +export interface ContentResponse { + page: number; + total_pages: number; + content: string; + chapter_title: string; + format: string; +} + +// ---- Bookmarks & Notes ---- + +export interface Bookmark { + id: string; + book: string; + book_title: string; + page: number; + location_text: string; + created_at: string; + updated_at: string; +} + +export interface Note { + id: string; + book: string; + book_title: string; + page: number; + location_text: string; + content: string; + created_at: string; + updated_at: string; +} + +export interface CreateBookmarkPayload { + book: string; + page: number; + location_text?: string; +} + +export interface CreateNotePayload { + book: string; + page: number; + location_text?: string; + content: string; +} + +export interface UpdateNotePayload { + content: string; +} + +export type AnnotationKind = "bookmark" | "note"; + +export interface AnnotationEntry { + id: string; + kind: AnnotationKind; + book_title: string; + book_id: string; + page: number; + location_text: string; + content?: string; + created_at: string; + updated_at: string; +} + +// ---- Generic API shapes ---- + +export interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +export interface ApiError { + detail?: string; + [key: string]: unknown; +} \ No newline at end of file diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts new file mode 100644 index 0000000..3ff0e28 --- /dev/null +++ b/packages/shared/src/utils.ts @@ -0,0 +1,117 @@ +/** Shared utility functions for Cloud Reader */ + +/** + * Format an ISO date string to a human-readable date. + */ +export function formatDate(iso: string): string { + const date = new Date(iso); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +/** + * Format a date as a relative time string (e.g., "2h ago", "3d ago"). + */ +export function formatRelativeTime(iso: string): string { + const now = Date.now(); + const then = new Date(iso).getTime(); + const diffMs = now - then; + const seconds = Math.floor(diffMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return "just now"; +} + +/** + * Validate an email address format. + */ +export function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +/** + * Password strength check: + * - At least 8 characters + * - At least one uppercase letter + * - At least one lowercase letter + * - At least one digit + */ +export function isStrongPassword(password: string): boolean { + return ( + password.length >= 8 && + /[A-Z]/.test(password) && + /[a-z]/.test(password) && + /\d/.test(password) + ); +} + +/** + * Check if two passwords match. + */ +export function doPasswordsMatch(password: string, confirm: string): boolean { + return password === confirm; +} + +/** + * Get a user-friendly reading status label. + */ +export function readingStatusLabel(status: string): string { + const labels: Record = { + to_read: "To Read", + reading: "Reading", + finished: "Finished", + dnf: "Did Not Finish", + }; + return labels[status] ?? status; +} + +/** + * Format file size in bytes to a human-readable string. + */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// ---- API endpoint constants ---- + +export const API_ENDPOINTS = { + auth: { + login: "/api/auth/login/", + register: "/api/auth/register/", + tokenRefresh: "/api/auth/token/refresh/", + profile: "/api/auth/profile/", + }, + books: { + list: "/api/books/", + detail: (id: number | string) => `/api/books/${id}/`, + genres: "/api/books/genres/", + authors: "/api/books/authors/", + }, + ebooks: { + list: "/api/ebooks/", + detail: (id: number) => `/api/ebooks/${id}/`, + upload: "/api/ebooks/upload/", + toc: (id: number) => `/api/ebooks/${id}/toc/`, + content: (id: number, page: number) => + `/api/ebooks/${id}/content/?page=${page}`, + progress: (id: number) => `/api/ebooks/${id}/progress/`, + settings: (id: number) => `/api/ebooks/${id}/settings/`, + }, + annotations: { + list: "/api/annotations/", + bookmarks: "/api/annotations/bookmarks/", + notes: "/api/annotations/notes/", + bookmarkDetail: (id: string) => `/api/annotations/bookmarks/${id}/`, + noteDetail: (id: string) => `/api/annotations/notes/${id}/`, + }, +} as const; \ No newline at end of file diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..02ba2dd --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"] +} \ No newline at end of file