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
View File
+21
View File
@@ -0,0 +1,21 @@
from django.contrib import admin
from .models import Bookmark, Highlight, ReadingProgress
@admin.register(Bookmark)
class BookmarkAdmin(admin.ModelAdmin):
list_display = ["document", "user", "page", "label", "created_at"]
list_filter = ["created_at"]
@admin.register(Highlight)
class HighlightAdmin(admin.ModelAdmin):
list_display = ["document", "user", "page", "color", "created_at"]
list_filter = ["color", "created_at"]
@admin.register(ReadingProgress)
class ReadingProgressAdmin(admin.ModelAdmin):
list_display = ["document", "user", "percentage", "last_read_at"]
date_hierarchy = "last_read_at"
+84
View File
@@ -0,0 +1,84 @@
from django.conf import settings
from django.db import models
class Bookmark(models.Model):
"""A user bookmark at a specific page in a document."""
document = models.ForeignKey(
"documents.Document",
on_delete=models.CASCADE,
related_name="bookmarks",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="bookmarks",
)
page = models.PositiveIntegerField()
label = models.CharField(max_length=300, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "reading_bookmark"
ordering = ["page"]
unique_together = [["document", "user", "page"]]
def __str__(self) -> str:
return f"{self.document.title} p.{self.page}"
class Highlight(models.Model):
"""A highlighted passage in a document."""
document = models.ForeignKey(
"documents.Document",
on_delete=models.CASCADE,
related_name="highlights",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="highlights",
)
page = models.PositiveIntegerField()
color = models.CharField(max_length=20, default="yellow")
text = models.TextField()
note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "reading_highlight"
ordering = ["-created_at"]
def __str__(self) -> str:
return f"Highlight on {self.document.title} p.{self.page}"
class ReadingProgress(models.Model):
"""Tracks the user's reading progress through a document."""
document = models.ForeignKey(
"documents.Document",
on_delete=models.CASCADE,
related_name="reading_progress",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="reading_progress",
)
current_page = models.PositiveIntegerField(default=1)
total_pages = models.PositiveIntegerField(default=0)
percentage = models.FloatField(default=0.0)
last_read_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "reading_progress"
unique_together = [["document", "user"]]
verbose_name_plural = "Reading progress"
def __str__(self) -> str:
return f"{self.document.title}{self.percentage:.0f}%"
def save(self, *args, **kwargs):
if self.total_pages > 0:
self.percentage = round((self.current_page / self.total_pages) * 100, 1)
super().save(*args, **kwargs)
+35
View File
@@ -0,0 +1,35 @@
from rest_framework import serializers
from .models import Bookmark, Highlight, ReadingProgress
class BookmarkSerializer(serializers.ModelSerializer[Bookmark]):
class Meta:
model = Bookmark
fields = ["id", "document", "page", "label", "created_at"]
read_only_fields = ["id", "created_at"]
class HighlightSerializer(serializers.ModelSerializer[Highlight]):
class Meta:
model = Highlight
fields = ["id", "document", "page", "color", "text", "note", "created_at"]
read_only_fields = ["id", "created_at"]
class ReadingProgressSerializer(serializers.ModelSerializer[ReadingProgress]):
class Meta:
model = ReadingProgress
fields = ["id", "document", "current_page", "total_pages", "percentage", "last_read_at"]
read_only_fields = ["id", "percentage", "last_read_at"]
class ReadingProgressUpdateSerializer(serializers.ModelSerializer[ReadingProgress]):
class Meta:
model = ReadingProgress
fields = ["current_page", "total_pages"]
def validate_current_page(self, value: int) -> int:
if value < 1:
raise serializers.ValidationError("Page must be at least 1.")
return value
+15
View File
@@ -0,0 +1,15 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register("bookmarks", views.BookmarkViewSet, basename="bookmark")
router.register("highlights", views.HighlightViewSet, basename="highlight")
router.register("progress", views.ReadingProgressViewSet, basename="reading-progress")
app_name = "reading"
urlpatterns = [
path("", include(router.urls)),
]
+49
View File
@@ -0,0 +1,49 @@
from rest_framework import permissions, viewsets
from .models import Bookmark, Highlight, ReadingProgress
from .serializers import (
BookmarkSerializer,
HighlightSerializer,
ReadingProgressSerializer,
ReadingProgressUpdateSerializer,
)
class BookmarkViewSet(viewsets.ModelViewSet):
"""User bookmarks for documents."""
serializer_class = BookmarkSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Bookmark.objects.filter(user=self.request.user).select_related("document")
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class HighlightViewSet(viewsets.ModelViewSet):
"""User highlights for documents."""
serializer_class = HighlightSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Highlight.objects.filter(user=self.request.user).select_related("document")
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class ReadingProgressViewSet(viewsets.ModelViewSet):
"""Reading progress tracker."""
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.action in ("create", "update", "partial_update"):
return ReadingProgressUpdateSerializer
return ReadingProgressSerializer
def get_queryset(self):
return ReadingProgress.objects.filter(user=self.request.user).select_related("document")
def perform_create(self, serializer):
serializer.save(user=self.request.user)