feat: Integrate Expo mobile application into monorepo (#16)

- Add mobile/ directory with Expo React Native project
- Create API client with JWT auth and token refresh using AsyncStorage
- Implement AuthContext for login/register/logout flow
- Add screens: Login, Register, Library, Search, Settings
- Set up React Navigation with AuthStack and MainTabs
- Create packages/shared/ with shared types and utilities
- Add shared validation utilities (email, password strength)
- Update root package.json workspaces to include mobile + shared
- Add spec document docs/backend/009-expo-integration.md
This commit is contained in:
Marko (Hermes Implementer)
2026-05-29 02:50:09 +00:00
parent 332b539880
commit fa82fab44a
36 changed files with 2051 additions and 4 deletions
+14
View File
@@ -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"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./types";
export * from "./utils";
+218
View File
@@ -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<string, unknown>;
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<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
export interface ApiError {
detail?: string;
[key: string]: unknown;
}
+117
View File
@@ -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<string, string> = {
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;
+19
View File
@@ -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/**/*"]
}