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 0000000..548a5b9
Binary files /dev/null and b/mobile/assets/adaptive-icon.png differ
diff --git a/mobile/assets/favicon.png b/mobile/assets/favicon.png
new file mode 100644
index 0000000..548a5b9
Binary files /dev/null and b/mobile/assets/favicon.png differ
diff --git a/mobile/assets/icon.png b/mobile/assets/icon.png
new file mode 100644
index 0000000..548a5b9
Binary files /dev/null and b/mobile/assets/icon.png differ
diff --git a/mobile/assets/splash.png b/mobile/assets/splash.png
new file mode 100644
index 0000000..548a5b9
Binary files /dev/null and b/mobile/assets/splash.png differ
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