Archived
feat: Integrate Expo mobile application into monorepo (#16)
- Add mobile/ directory with Expo React Native project - Create API client with JWT auth and token refresh using AsyncStorage - Implement AuthContext for login/register/logout flow - Add screens: Login, Register, Library, Search, Settings - Set up React Navigation with AuthStack and MainTabs - Create packages/shared/ with shared types and utilities - Add shared validation utilities (email, password strength) - Update root package.json workspaces to include mobile + shared - Add spec document docs/backend/009-expo-integration.md
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import api from "./client";
|
||||
import type {
|
||||
Bookmark,
|
||||
Note,
|
||||
CreateBookmarkPayload,
|
||||
CreateNotePayload,
|
||||
PaginatedResponse,
|
||||
} from "@cloud-reader/shared";
|
||||
|
||||
export function fetchBookmarks(
|
||||
bookId?: string,
|
||||
): Promise<PaginatedResponse<Bookmark>> {
|
||||
const params = bookId ? { book: bookId } : {};
|
||||
return api
|
||||
.get<PaginatedResponse<Bookmark>>("/api/annotations/bookmarks/", { params })
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function createBookmark(
|
||||
payload: CreateBookmarkPayload,
|
||||
): Promise<Bookmark> {
|
||||
return api
|
||||
.post<Bookmark>("/api/annotations/bookmarks/", payload)
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function deleteBookmark(id: string): Promise<void> {
|
||||
return api.delete(`/api/annotations/bookmarks/${id}/`).then(() => {});
|
||||
}
|
||||
|
||||
export function fetchNotes(bookId?: string): Promise<PaginatedResponse<Note>> {
|
||||
const params = bookId ? { book: bookId } : {};
|
||||
return api
|
||||
.get<PaginatedResponse<Note>>("/api/annotations/notes/", { params })
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function createNote(payload: CreateNotePayload): Promise<Note> {
|
||||
return api
|
||||
.post<Note>("/api/annotations/notes/", payload)
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function updateNote(
|
||||
id: string,
|
||||
content: string,
|
||||
): Promise<Note> {
|
||||
return api
|
||||
.patch<Note>(`/api/annotations/notes/${id}/`, { content })
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function deleteNote(id: string): Promise<void> {
|
||||
return api.delete(`/api/annotations/notes/${id}/`).then(() => {});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import api from "./client";
|
||||
import type { Book, PaginatedResponse } from "@cloud-reader/shared";
|
||||
|
||||
export function fetchBooks(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PaginatedResponse<Book>> {
|
||||
return api
|
||||
.get<PaginatedResponse<Book>>("/api/books/", {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function fetchBook(id: string): Promise<Book> {
|
||||
return api.get<Book>(`/api/books/${id}/`).then((res) => res.data);
|
||||
}
|
||||
|
||||
export function searchBooks(
|
||||
query: string,
|
||||
): Promise<PaginatedResponse<Book>> {
|
||||
return api
|
||||
.get<PaginatedResponse<Book>>("/api/books/search/", {
|
||||
params: { q: query },
|
||||
})
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function deleteBook(id: string): Promise<void> {
|
||||
return api.delete(`/api/books/${id}/`).then(() => {});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
ACCESS_TOKEN: "access_token",
|
||||
REFRESH_TOKEN: "refresh_token",
|
||||
} as const;
|
||||
|
||||
interface RetryConfig extends InternalAxiosRequestConfig {
|
||||
_retry?: boolean;
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:8000",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// ── Token helpers ────────────────────────────────────────────────────
|
||||
|
||||
async function getAccessToken(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
async function getRefreshToken(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
async function setTokens(access: string, refresh: string): Promise<void> {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access);
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh);
|
||||
}
|
||||
|
||||
async function clearTokens(): Promise<void> {
|
||||
await AsyncStorage.multiRemove([
|
||||
STORAGE_KEYS.ACCESS_TOKEN,
|
||||
STORAGE_KEYS.REFRESH_TOKEN,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Request interceptor ─────────────────────────────────────────────
|
||||
|
||||
api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||
const token = await getAccessToken();
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// ── Response interceptor: auto-refresh on 401 ───────────────────────
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
function processQueue(error: unknown, token: string | null = null): void {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else if (token) {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as RetryConfig | undefined;
|
||||
|
||||
if (!originalRequest) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (
|
||||
error.response?.status !== 401 ||
|
||||
originalRequest._retry ||
|
||||
originalRequest.url?.includes("/api/auth/token/refresh/") ||
|
||||
originalRequest.url?.includes("/api/auth/login/") ||
|
||||
originalRequest.url?.includes("/api/auth/register/") ||
|
||||
originalRequest.url?.includes("/api/auth/logout/")
|
||||
) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return api(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
const refreshToken = await getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
isRefreshing = false;
|
||||
await clearTokens();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${api.defaults.baseURL}/api/auth/token/refresh/`,
|
||||
{ refresh: refreshToken },
|
||||
);
|
||||
|
||||
const newAccess = response.data.access as string;
|
||||
const newRefresh = response.data.refresh as string;
|
||||
await setTokens(newAccess, newRefresh);
|
||||
|
||||
processQueue(null, newAccess);
|
||||
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
|
||||
}
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
await clearTokens();
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export { getAccessToken, getRefreshToken, setTokens, clearTokens };
|
||||
export default api;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { apiClient } from "./client";
|
||||
import type {
|
||||
EBookListItem,
|
||||
EBookDetail,
|
||||
ReadingProgress,
|
||||
ReadingSettings,
|
||||
TocResponse,
|
||||
ContentResponse,
|
||||
PaginatedResponse,
|
||||
} from "@cloud-reader/shared";
|
||||
|
||||
export const ebooksApi = {
|
||||
/** List uploaded e-books */
|
||||
list() {
|
||||
return apiClient.get<PaginatedResponse<EBookListItem>>("/api/ebooks/");
|
||||
},
|
||||
|
||||
/** Get e-book detail */
|
||||
get(id: number) {
|
||||
return apiClient.get<EBookDetail>(`/api/ebooks/${id}/`);
|
||||
},
|
||||
|
||||
/** Get table of contents */
|
||||
getToc(id: number) {
|
||||
return apiClient.get<TocResponse>(`/api/ebooks/${id}/toc/`);
|
||||
},
|
||||
|
||||
/** Get page content */
|
||||
getContent(id: number, page: number) {
|
||||
return apiClient.get<ContentResponse>(
|
||||
`/api/ebooks/${id}/content/?page=${page}`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Update reading progress */
|
||||
updateProgress(id: number, data: Partial<ReadingProgress>) {
|
||||
return apiClient.patch<ReadingProgress>(
|
||||
`/api/ebooks/${id}/progress/`,
|
||||
data,
|
||||
);
|
||||
},
|
||||
|
||||
/** Get or update reading settings */
|
||||
getSettings(id: number) {
|
||||
return apiClient.get<ReadingSettings>(`/api/ebooks/${id}/settings/`);
|
||||
},
|
||||
|
||||
updateSettings(id: number, data: Partial<ReadingSettings>) {
|
||||
return apiClient.patch<ReadingSettings>(
|
||||
`/api/ebooks/${id}/settings/`,
|
||||
data,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { apiClient, saveTokens, loadTokens, clearTokens } from "./client";
|
||||
export { booksApi } from "./books";
|
||||
export { ebooksApi } from "./ebooks";
|
||||
export { annotationsApi } from "./annotations";
|
||||
@@ -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<void>;
|
||||
register: (
|
||||
email: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
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<User>("/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<TokenResponse>(
|
||||
"/api/auth/login/",
|
||||
{ email, password },
|
||||
);
|
||||
await saveTokens(response.data);
|
||||
const profile = await apiClient.get<User>("/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 (
|
||||
<AuthContext.Provider value={{ ...state, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useAuth } from "../context/AuthContext";
|
||||
export { useAsyncData } from "./useAsyncData";
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Generic async data fetching hook for mobile screens.
|
||||
*/
|
||||
export function useAsyncData<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
deps: unknown[] = [],
|
||||
) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(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 };
|
||||
}
|
||||
@@ -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<RootStackParamList>();
|
||||
const AuthStack = createNativeStackNavigator<AuthStackParamList>();
|
||||
|
||||
function AuthNavigator(): ReactNode {
|
||||
return (
|
||||
<AuthStack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<AuthStack.Screen name="Login" component={LoginScreen} />
|
||||
<AuthStack.Screen name="Register" component={RegisterScreen} />
|
||||
</AuthStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppNavigator(): ReactNode {
|
||||
const { state } = useAuth();
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<RootStack.Navigator screenOptions={{ headerShown: false }}>
|
||||
{state.isAuthenticated ? (
|
||||
<RootStack.Screen name="Main" component={MainTabs} />
|
||||
) : (
|
||||
<RootStack.Screen name="Auth" component={AuthNavigator} />
|
||||
)}
|
||||
</RootStack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
@@ -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<AuthStackParamList>();
|
||||
|
||||
export function AuthNavigator() {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
@@ -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<MainTabParamList>();
|
||||
|
||||
function TabIcon({ label, focused }: { label: string; focused: boolean }) {
|
||||
const icons: Record<string, string> = {
|
||||
Library: "📚",
|
||||
Search: "🔍",
|
||||
Settings: "⚙️",
|
||||
};
|
||||
return (
|
||||
<Text style={{ fontSize: 22, opacity: focused ? 1 : 0.5 }}>
|
||||
{icons[label] ?? "●"}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function MainNavigator() {
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: "#fff" },
|
||||
headerTitleStyle: { fontWeight: "600", color: "#1a1a2e" },
|
||||
tabBarActiveTintColor: "#4a6cf7",
|
||||
tabBarInactiveTintColor: "#999",
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Library"
|
||||
component={LibraryScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIcon label="Library" focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Search"
|
||||
component={SearchScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIcon label="Search" focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIcon label="Settings" focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
@@ -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<MainTabParamList>();
|
||||
|
||||
function TabIcon({ label, focused }: { label: string; focused: boolean }) {
|
||||
return (
|
||||
<Text style={{ fontSize: 22, opacity: focused ? 1 : 0.5 }}>
|
||||
{label === "Library" ? "📚" : label === "Search" ? "🔍" : "⚙️"}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MainTabs(): ReactNode {
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
tabBarIcon: ({ focused }: { focused: boolean }) => (
|
||||
<TabIcon label={route.name} focused={focused} />
|
||||
),
|
||||
tabBarActiveTintColor: "#4f8ef7",
|
||||
tabBarInactiveTintColor: "#888",
|
||||
headerStyle: { backgroundColor: "#1a1a2e" },
|
||||
headerTintColor: "#fff",
|
||||
tabBarStyle: { backgroundColor: "#1a1a2e", borderTopColor: "#333" },
|
||||
})}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Library"
|
||||
component={LibraryScreen}
|
||||
options={{ title: "My Library" }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Search"
|
||||
component={SearchScreen}
|
||||
options={{ title: "Search" }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{ title: "Settings" }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<ActivityIndicator size="large" color="#4a6cf7" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AuthNavigator />;
|
||||
}
|
||||
|
||||
return <MainNavigator />;
|
||||
}
|
||||
@@ -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<Book[]>([]);
|
||||
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<Book> = 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 }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.bookCard}
|
||||
onPress={() =>
|
||||
navigation.navigate("BookDetail", { bookId: item.id })
|
||||
}
|
||||
>
|
||||
<View style={styles.bookCover}>
|
||||
<Text style={styles.coverText}>
|
||||
{item.title.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.bookInfo}>
|
||||
<Text style={styles.bookTitle} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={styles.bookAuthor} numberOfLines={1}>
|
||||
{item.author}
|
||||
</Text>
|
||||
<Text style={styles.bookPages}>{item.total_pages} pages</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (loading && books.length === 0) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color="#4f8ef7" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={books}
|
||||
renderItem={renderBook}
|
||||
keyExtractor={(item) => item.id}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor="#4f8ef7"
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.emptyText}>Your library is empty</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Add books to get started
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<View style={styles.inner}>
|
||||
<Text style={styles.title}>Cloud Reader</Text>
|
||||
<Text style={styles.subtitle}>Sign in to your account</Text>
|
||||
|
||||
{state.error && (
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorText}>{state.error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
placeholderTextColor="#666"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
placeholderTextColor="#666"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, state.isLoading && styles.buttonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={state.isLoading}
|
||||
>
|
||||
{state.isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={() => navigation.navigate("Register")}>
|
||||
<Text style={styles.linkText}>
|
||||
Don't have an account?{" "}
|
||||
<Text style={styles.linkBold}>Sign Up</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.inner}>
|
||||
<Text style={styles.title}>Create Account</Text>
|
||||
<Text style={styles.subtitle}>Join Cloud Reader</Text>
|
||||
|
||||
{state.error && (
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorText}>{state.error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Username"
|
||||
placeholderTextColor="#666"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
placeholderTextColor="#666"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
placeholderTextColor="#666"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Confirm Password"
|
||||
placeholderTextColor="#666"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, state.isLoading && styles.buttonDisabled]}
|
||||
onPress={handleRegister}
|
||||
disabled={state.isLoading}
|
||||
>
|
||||
{state.isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Create Account</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={() => navigation.goBack()}>
|
||||
<Text style={styles.linkText}>
|
||||
Already have an account?{" "}
|
||||
<Text style={styles.linkBold}>Sign In</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -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<Book[]>([]);
|
||||
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<Book> = await searchBooks(query.trim());
|
||||
setResults(data.results);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderBook = ({ item }: { item: Book }) => (
|
||||
<View style={styles.resultCard}>
|
||||
<Text style={styles.resultTitle}>{item.title}</Text>
|
||||
<Text style={styles.resultAuthor}>{item.author}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.searchBar}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Search books by title, author..."
|
||||
placeholderTextColor="#666"
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
onSubmitEditing={handleSearch}
|
||||
returnKeyType="search"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{loading && (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color="#4f8ef7" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!loading && searched && results.length === 0 && (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.noResults}>No books found for "{query}"</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={results}
|
||||
renderItem={renderBook}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Account</Text>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.label}>Username</Text>
|
||||
<Text style={styles.value}>{state.user?.username ?? "—"}</Text>
|
||||
</View>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<Text style={styles.value}>{state.user?.email ?? "—"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||
<Text style={styles.logoutText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user