feat: monorepo structure with Django backend, React frontend, and Expo mobile app

- Backend: Django 5 + DRF with accounts, documents, collections, and reading apps
  - Custom User model with email-based auth, JWT via SimpleJWT
  - Full CRUD viewsets with ModelSerializer + DRF routers
  - pytest, Ruff, drf-spectacular (OpenAPI), whitenoise
  - Dockerfile for production deployment

- Frontend: React 18 + TypeScript + Vite
  - Lazy-loaded routes with ProtectedRoute/PublicRoute guards
  - Auth context with useReducer, token refresh interceptor
  - Pages: Login, Register, Library, Document Detail, Reader, Collections, Settings
  - Dark theme, responsive grid layout, Vite proxy to Django backend

- Mobile: Expo SDK 51 + React Native + Expo Router
  - File-based routing with login, register, and library screens
  - AsyncStorage for token persistence, token refresh interceptor
  - Shared API types via @cloud-reader/shared workspace package

- Shared: TypeScript types (API responses, auth, documents, etc.)
- CI/CD: 3 independent GitHub Actions pipelines (backend, frontend, mobile)
This commit is contained in:
Marko (Hermes Implementer)
2026-05-26 00:51:54 +00:00
commit b8bd1dca14
79 changed files with 3717 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
import React, { useEffect, useState } from "react";
import { View, Text, FlatList, TouchableOpacity, StyleSheet, ActivityIndicator } from "react-native";
import { useRouter } from "expo-router";
import api from "../src/services/api";
import type { Document } from "@cloud-reader/shared";
export default function LibraryScreen(): React.ReactElement {
const router = useRouter();
const [documents, setDocuments] = useState<Document[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetch = async (): Promise<void> => {
try {
const { data } = await api.get("/documents/");
setDocuments(data.results || []);
} catch {
// Not authenticated
} finally {
setLoading(false);
}
};
fetch();
}, []);
const renderDoc = ({ item }: { item: Document }): React.ReactElement => (
<TouchableOpacity style={styles.card} onPress={() => router.push(`/documents/${item.id}`)}>
<View style={styles.cardContent}>
<Text style={styles.cardTitle}>{item.title}</Text>
{item.author && <Text style={styles.cardAuthor}>{item.author}</Text>}
<View style={styles.cardMeta}>
<Text style={styles.badge}>{item.file_type.toUpperCase()}</Text>
<Text style={styles.metaText}>{(item.file_size / (1024 * 1024)).toFixed(1)} MB</Text>
</View>
</View>
</TouchableOpacity>
);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#4f8cff" />
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>My Library ({documents.length})</Text>
</View>
{documents.length === 0 ? (
<View style={styles.center}>
<Text style={styles.emptyText}>No documents yet.</Text>
</View>
) : (
<FlatList
data={documents}
keyExtractor={(item) => String(item.id)}
renderItem={renderDoc}
contentContainerStyle={styles.list}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#0f1419" },
center: { flex: 1, justifyContent: "center", alignItems: "center" },
header: { padding: 16, backgroundColor: "#1a1f2e", borderBottomWidth: 1, borderBottomColor: "#2a3042" },
headerTitle: { fontSize: 20, fontWeight: "700", color: "#e1e4ed" },
list: { padding: 16 },
card: { backgroundColor: "#1a1f2e", borderRadius: 12, padding: 16, marginBottom: 12, borderWidth: 1, borderColor: "#2a3042" },
cardContent: {},
cardTitle: { fontSize: 16, fontWeight: "600", color: "#e1e4ed", marginBottom: 4 },
cardAuthor: { fontSize: 14, color: "#8892a4", marginBottom: 8 },
cardMeta: { flexDirection: "row", gap: 10 },
badge: { fontSize: 12, fontWeight: "600", color: "#4f8cff" },
metaText: { fontSize: 12, color: "#8892a4" },
emptyText: { color: "#8892a4", fontSize: 16 },
});