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,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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user