commit b8bd1dca14fb1cef138a777faf6e973dd8f3fcb1 Author: Marko (Hermes Implementer) Date: Tue May 26 00:51:54 2026 +0000 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) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..8671a3a --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,72 @@ +name: Backend CI + +on: + push: + branches: [main, develop] + paths: + - "backend/**" + - ".github/workflows/backend-ci.yml" + pull_request: + paths: + - "backend/**" + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: cloud_reader + POSTGRES_USER: cloud_reader + POSTGRES_PASSWORD: cloud_reader + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + defaults: + run: + working-directory: backend + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + cache-dependency-path: backend/requirements/dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements/dev.txt + + - name: Lint + run: ruff check . + + - name: Run migrations + run: python manage.py migrate + env: + DB_HOST: localhost + DB_NAME: cloud_reader + DB_USER: cloud_reader + DB_PASSWORD: cloud_reader + DB_PORT: 5432 + SECRET_KEY: ci-test-secret-key-do-not-use-in-production + + - name: Run tests + run: pytest + env: + DB_HOST: localhost + DB_NAME: cloud_reader + DB_USER: cloud_reader + DB_PASSWORD: cloud_reader + DB_PORT: 5432 + SECRET_KEY: ci-test-secret-key-do-not-use-in-production \ No newline at end of file diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 0000000..39051b5 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,42 @@ +name: Frontend CI + +on: + push: + branches: [main, develop] + paths: + - "frontend/**" + - "shared/**" + - ".github/workflows/frontend-ci.yml" + pull_request: + paths: + - "frontend/**" + - "shared/**" + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "yarn" + cache-dependency-path: yarn.lock + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Type check shared + run: yarn workspace @cloud-reader/shared typecheck + + - name: Build shared + run: yarn workspace @cloud-reader/shared build + + - name: Type check frontend + run: yarn workspace @cloud-reader/frontend typecheck + + - name: Build frontend + run: yarn workspace @cloud-reader/frontend build \ No newline at end of file diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml new file mode 100644 index 0000000..eadd2e3 --- /dev/null +++ b/.github/workflows/mobile-ci.yml @@ -0,0 +1,39 @@ +name: Mobile CI + +on: + push: + branches: [main, develop] + paths: + - "mobile/**" + - "shared/**" + - ".github/workflows/mobile-ci.yml" + pull_request: + paths: + - "mobile/**" + - "shared/**" + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "yarn" + cache-dependency-path: yarn.lock + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Type check shared + run: yarn workspace @cloud-reader/shared typecheck + + - name: Build shared + run: yarn workspace @cloud-reader/shared build + + - name: Type check mobile + run: yarn workspace @cloud-reader/mobile typecheck \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f89e36b --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.env +venv/ +.venv/ +*.sqlite3 + +# Node +node_modules/ +.pnp +.pnp.js +yarn-error.log* + +# Build artifacts +dist/ +build/ +*.tsbuildinfo + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +# Django +backend/media/ +backend/staticfiles/ +backend/**/migrations/ + +# Expo / React Native +mobile/.expo/ +mobile/ios/Pods/ +mobile/android/.gradle/ +mobile/android/app/build/ +mobile/android/build/ +mobile/*.hprof + +# Environment +.env.local +.env.production +.env.development \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..06d3841 --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# Cloud Reader + +A modern eBook reader with web and mobile clients, powered by Django REST Framework. + +## Monorepo Structure + +``` +cloud-reader/ +├── backend/ # Django API server (Python 3.12 + DRF) +│ ├── config/ # Django project settings +│ ├── apps/ # Django applications +│ │ ├── accounts/ # User authentication & profiles +│ │ ├── documents/ # Document management & uploads +│ │ ├── collections/# Document collections +│ │ └── reading/ # Bookmarks, highlights, reading progress +│ ├── requirements/ # pip dependency files +│ ├── Dockerfile +│ └── manage.py +├── frontend/ # React web app (TypeScript + Vite) +│ ├── src/ +│ │ ├── pages/ # Route pages (lazy-loaded) +│ │ ├── hooks/ # Custom React hooks +│ │ ├── services/ # API client & auth service +│ │ ├── types/ # Frontend-specific types +│ │ └── styles/ # Global CSS +│ ├── Dockerfile +│ └── vite.config.ts +├── mobile/ # Expo/React Native mobile app +│ ├── app/ # Expo Router pages +│ ├── src/ # Mobile source code +│ ├── app.json +│ └── Dockerfile +├── shared/ # Shared TypeScript types & utilities +│ └── src/ +│ └── index.ts # API response types, constants +├── .github/workflows/ # CI/CD pipelines +└── package.json # Yarn workspace root +``` + +## Prerequisites + +- **Python** 3.12+ +- **Node.js** 20 LTS +- **Yarn** 4.x +- **PostgreSQL** 16 +- **Expo CLI** (for mobile development) + +--- + +## Backend Setup + +```bash +cd backend + +# Create virtual environment +python -m venv .venv +source .venv/bin/activate # Linux/macOS +# .venv\Scripts\activate # Windows + +# Install dependencies +pip install -r requirements/dev.txt + +# Configure environment +cp .env.example .env +# Edit .env with your PostgreSQL credentials + +# Run migrations +python manage.py migrate + +# Create admin user +python manage.py createsuperuser + +# Start development server +python manage.py runserver +``` + +The API will be available at `http://localhost:8000/`. Browse the API at `http://localhost:8000/api/schema/swagger-ui/`. + +### Backend Tests + +```bash +cd backend +pytest +``` + +--- + +## Frontend Setup + +```bash +# From monorepo root +yarn install + +# Start dev server (with API proxy) +yarn frontend:dev +``` + +The frontend will be available at `http://localhost:5173/`. API requests under `/api/` are proxied to `http://localhost:8000/`. + +### Frontend Build + +```bash +yarn frontend:build +``` + +--- + +## Mobile Setup + +```bash +# From monorepo root +yarn install + +# Start Expo dev server +yarn mobile:start + +# Run on Android +yarn mobile:android + +# Run on iOS (macOS only) +yarn mobile:ios +``` + +> The mobile API client defaults to `http://localhost:8000/`. For physical devices, update the `API_BASE` in `mobile/src/services/api.ts` to your machine's local IP. + +--- + +## Shared Package + +The `shared/` package contains TypeScript types and constants used by both the frontend and mobile apps. + +```bash +# Build shared package +yarn shared:build +``` + +--- + +## CI/CD + +Three independent CI pipelines run on pushes and PRs: + +| Pipeline | Trigger Path | What It Does | +|----------|-------------|--------------| +| **Backend CI** | `backend/**` | Installs Python deps, runs Ruff linter, applies migrations, runs pytest | +| **Frontend CI** | `frontend/**`, `shared/**` | Installs Node deps, type-check & build shared, type-check & build frontend | +| **Mobile CI** | `mobile/**`, `shared/**` | Installs Node deps, type-check shared & mobile | + +Pipeline configs are in `.github/workflows/`. + +### Docker Deployments + +Each app has its own Dockerfile for independent deployment: + +```bash +# Build backend image +docker build -t cloud-reader-backend backend/ + +# Build frontend image +docker build -t cloud-reader-frontend frontend/ + +# Build mobile image (web export) +docker build -t cloud-reader-mobile mobile/ +``` + +--- + +## API Endpoints + +| Endpoint | Description | +|----------|-------------| +| `POST /api/v1/auth/register/` | Create a new account | +| `POST /api/v1/auth/token/` | Obtain JWT tokens | +| `POST /api/v1/auth/token/refresh/` | Refresh JWT token | +| `GET /api/v1/auth/me/` | Get current user profile | +| `GET/POST /api/v1/documents/` | List / upload documents | +| `GET/PUT/DELETE /api/v1/documents/:id/` | Document detail | +| `GET/POST /api/v1/collections/` | List / create collections | +| `GET/PUT/DELETE /api/v1/collections/:id/` | Collection detail | +| `POST /api/v1/collections/:id/add_documents/` | Add docs to collection | +| `POST /api/v1/collections/:id/remove_documents/` | Remove docs from collection | +| `GET/POST /api/v1/reading/bookmarks/` | List / create bookmarks | +| `GET/POST /api/v1/reading/highlights/` | List / create highlights | +| `GET/POST /api/v1/reading/progress/` | Track reading progress | + +--- + +## Tech Stack + +- **Backend:** Django 5, Django REST Framework, SimpleJWT, PostgreSQL, drf-spectacular +- **Frontend:** React 18, TypeScript, Vite, React Router, Axios +- **Mobile:** Expo SDK 51, React Native 0.74, Expo Router +- **Shared:** TypeScript types, Zod schemas +- **CI/CD:** GitHub Actions +- **Container:** Docker (separate images per app) diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..ab7a187 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +# Cloud Reader Backend — Environment Variables +# Copy to .env and fill in your values. + +SECRET_KEY=django-insecure-change-me-in-production +DEBUG=True + +DB_ENGINE=django.db.backends.postgresql +DB_NAME=cloud_reader +DB_USER=cloud_reader +DB_PASSWORD=cloud_reader +DB_HOST=localhost +DB_PORT=5432 + +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 +ALLOWED_HOSTS=localhost,127.0.0.1 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..11e89f1 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DJANGO_SETTINGS_MODULE=config.settings + +WORKDIR /app + +COPY requirements/production.txt /app/requirements/ +RUN pip install --no-cache-dir -r requirements/production.txt + +COPY . /app + +RUN python manage.py collectstatic --noinput + +EXPOSE 8000 + +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"] \ No newline at end of file diff --git a/backend/apps/__init__.py b/backend/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/__init__.py b/backend/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py new file mode 100644 index 0000000..df24cac --- /dev/null +++ b/backend/apps/accounts/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import User + + +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + list_display = ["email", "display_name", "is_verified", "is_active", "date_joined"] + search_fields = ["email", "display_name"] + list_filter = ["is_verified", "is_active"] \ No newline at end of file diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py new file mode 100644 index 0000000..77269f5 --- /dev/null +++ b/backend/apps/accounts/models.py @@ -0,0 +1,29 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + + +class User(AbstractUser): + """Custom user model for Cloud Reader.""" + + email = models.EmailField(unique=True) + display_name = models.CharField(max_length=150, blank=True) + avatar = models.ImageField(upload_to="avatars/", blank=True, null=True) + is_verified = models.BooleanField(default=False) + reading_preferences = models.JSONField(default=dict, blank=True) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["username"] + + class Meta: + db_table = "accounts_user" + verbose_name = "User" + verbose_name_plural = "Users" + + def __str__(self) -> str: + return self.email + + @property + def avatar_url(self) -> str | None: + if self.avatar: + return self.avatar.url + return None \ No newline at end of file diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py new file mode 100644 index 0000000..99a8a5e --- /dev/null +++ b/backend/apps/accounts/serializers.py @@ -0,0 +1,53 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from .models import User + +UserModel = get_user_model() + + +class RegisterSerializer(serializers.ModelSerializer[User]): + password = serializers.CharField(write_only=True, min_length=8) + password_confirm = serializers.CharField(write_only=True, min_length=8) + + class Meta: + model = User + fields = ["email", "username", "display_name", "password", "password_confirm"] + + def validate(self, attrs): + if attrs["password"] != attrs.pop("password_confirm"): + raise serializers.ValidationError({"password_confirm": "Passwords do not match."}) + return attrs + + def create(self, validated_data): + password = validated_data.pop("password") + user = UserModel(**validated_data) + user.set_password(password) + user.save() + return user + + +class UserSerializer(serializers.ModelSerializer[User]): + avatar_url = serializers.SerializerMethodField() + + class Meta: + model = User + fields = [ + "id", "email", "username", "display_name", "avatar_url", + "date_joined", "is_verified", "reading_preferences", + ] + read_only_fields = ["id", "email", "date_joined", "is_verified"] + + def get_avatar_url(self, obj: User) -> str | None: + return obj.avatar_url + + +class ChangePasswordSerializer(serializers.Serializer): + old_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) + + def validate_old_password(self, value: str) -> str: + user = self.context["request"].user + if not user.check_password(value): + raise serializers.ValidationError("Current password is incorrect.") + return value \ No newline at end of file diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py new file mode 100644 index 0000000..81a552a --- /dev/null +++ b/backend/apps/accounts/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +from . import views + +app_name = "accounts" + +urlpatterns = [ + path("register/", views.RegisterView.as_view(), name="register"), + path("me/", views.UserDetailView.as_view(), name="user-detail"), + path("change-password/", views.ChangePasswordView.as_view(), name="change-password"), + path("token/", TokenObtainPairView.as_view(), name="token-obtain"), + path("token/refresh/", TokenRefreshView.as_view(), name="token-refresh"), +] \ No newline at end of file diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py new file mode 100644 index 0000000..58e13b9 --- /dev/null +++ b/backend/apps/accounts/views.py @@ -0,0 +1,36 @@ +from django.contrib.auth import get_user_model +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .serializers import ChangePasswordSerializer, RegisterSerializer, UserSerializer + +UserModel = get_user_model() + + +class RegisterView(generics.CreateAPIView): + """Create a new user account.""" + queryset = UserModel.objects.all() + serializer_class = RegisterSerializer + permission_classes = [permissions.AllowAny] + + +class UserDetailView(generics.RetrieveUpdateAPIView): + """Get or update the authenticated user's profile.""" + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + return self.request.user + + +class ChangePasswordView(APIView): + """Change the authenticated user's password.""" + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + serializer = ChangePasswordSerializer(data=request.data, context={"request": request}) + serializer.is_valid(raise_exception=True) + request.user.set_password(serializer.validated_data["new_password"]) + request.user.save() + return Response({"detail": "Password changed successfully."}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/apps/collections/__init__.py b/backend/apps/collections/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/collections/admin.py b/backend/apps/collections/admin.py new file mode 100644 index 0000000..3dd27f0 --- /dev/null +++ b/backend/apps/collections/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import Collection + + +@admin.register(Collection) +class CollectionAdmin(admin.ModelAdmin): + list_display = ["name", "owner", "document_count", "is_public", "created_at"] + list_filter = ["is_public"] + search_fields = ["name", "description"] \ No newline at end of file diff --git a/backend/apps/collections/models.py b/backend/apps/collections/models.py new file mode 100644 index 0000000..c9198c5 --- /dev/null +++ b/backend/apps/collections/models.py @@ -0,0 +1,39 @@ +from django.conf import settings +from django.db import models + + +class Collection(models.Model): + """A user-created collection of documents.""" + name = models.CharField(max_length=300) + description = models.TextField(blank=True, default="") + cover = models.ImageField(upload_to="collection_covers/", blank=True, null=True) + documents = models.ManyToManyField( + "documents.Document", + related_name="collections", + blank=True, + ) + is_public = models.BooleanField(default=False) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="collections", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "collections_collection" + ordering = ["-updated_at"] + + def __str__(self) -> str: + return self.name + + @property + def document_count(self) -> int: + return self.documents.count() + + @property + def cover_url(self) -> str | None: + if self.cover: + return self.cover.url + return None \ No newline at end of file diff --git a/backend/apps/collections/serializers.py b/backend/apps/collections/serializers.py new file mode 100644 index 0000000..931e0dd --- /dev/null +++ b/backend/apps/collections/serializers.py @@ -0,0 +1,33 @@ +from rest_framework import serializers + +from .models import Collection + + +class CollectionSerializer(serializers.ModelSerializer[Collection]): + cover_url = serializers.SerializerMethodField() + document_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Collection + fields = [ + "id", "name", "description", "cover_url", "document_count", + "is_public", "created_at", "updated_at", + ] + read_only_fields = ["id", "document_count", "created_at", "updated_at"] + + def get_cover_url(self, obj: Collection) -> str | None: + return obj.cover_url + + +class CollectionDetailSerializer(CollectionSerializer): + documents = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + + class Meta(CollectionSerializer.Meta): + fields = CollectionSerializer.Meta.fields + ["documents", "owner"] + + +class CollectionDocumentActionSerializer(serializers.Serializer): + document_ids = serializers.ListField( + child=serializers.IntegerField(), + allow_empty=False, + ) \ No newline at end of file diff --git a/backend/apps/collections/urls.py b/backend/apps/collections/urls.py new file mode 100644 index 0000000..41b692d --- /dev/null +++ b/backend/apps/collections/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() +router.register("", views.CollectionViewSet, basename="collection") + +app_name = "collections" + +urlpatterns = [ + path("", include(router.urls)), +] \ No newline at end of file diff --git a/backend/apps/collections/views.py b/backend/apps/collections/views.py new file mode 100644 index 0000000..aa074e5 --- /dev/null +++ b/backend/apps/collections/views.py @@ -0,0 +1,50 @@ +from rest_framework import permissions, status, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from apps.documents.models import Document + +from .models import Collection +from .serializers import ( + CollectionDetailSerializer, + CollectionDocumentActionSerializer, + CollectionSerializer, +) + + +class CollectionViewSet(viewsets.ModelViewSet): + """CRUD for user collections.""" + permission_classes = [permissions.IsAuthenticated] + + def get_serializer_class(self): + if self.action in ("retrieve", "update", "partial_update"): + return CollectionDetailSerializer + return CollectionSerializer + + def get_queryset(self): + return Collection.objects.filter(owner=self.request.user).prefetch_related("documents") + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + @action(detail=True, methods=["post"]) + def add_documents(self, request, pk=None): + """Add documents to a collection.""" + collection = self.get_object() + serializer = CollectionDocumentActionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + docs = Document.objects.filter( + id__in=serializer.validated_data["document_ids"], + owner=request.user, + ) + collection.documents.add(*docs) + return Response({"detail": f"Added {docs.count()} document(s)."}, status=status.HTTP_200_OK) + + @action(detail=True, methods=["post"]) + def remove_documents(self, request, pk=None): + """Remove documents from a collection.""" + collection = self.get_object() + serializer = CollectionDocumentActionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + collection.documents.remove(*serializer.validated_data["document_ids"]) + return Response({"detail": "Documents removed."}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/apps/documents/__init__.py b/backend/apps/documents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/documents/admin.py b/backend/apps/documents/admin.py new file mode 100644 index 0000000..2d9d0cb --- /dev/null +++ b/backend/apps/documents/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import Document + + +@admin.register(Document) +class DocumentAdmin(admin.ModelAdmin): + list_display = ["title", "author", "file_type", "file_size", "is_public", "owner", "uploaded_at"] + list_filter = ["file_type", "is_public"] + search_fields = ["title", "author", "description"] + date_hierarchy = "uploaded_at" \ No newline at end of file diff --git a/backend/apps/documents/models.py b/backend/apps/documents/models.py new file mode 100644 index 0000000..ec7973f --- /dev/null +++ b/backend/apps/documents/models.py @@ -0,0 +1,48 @@ +from django.conf import settings +from django.db import models + + +class Document(models.Model): + """A digital document (ebook, PDF, etc.) uploaded by a user.""" + + class FileType(models.TextChoices): + PDF = "pdf", "PDF" + EPUB = "epub", "EPUB" + MOBI = "mobi", "MOBI" + TXT = "txt", "Plain Text" + DOCX = "docx", "Word Document" + + title = models.CharField(max_length=500) + author = models.CharField(max_length=300, blank=True, null=True) + description = models.TextField(blank=True, default="") + cover = models.ImageField(upload_to="covers/", blank=True, null=True) + file = models.FileField(upload_to="documents/") + file_type = models.CharField(max_length=10, choices=FileType.choices) + file_size = models.PositiveIntegerField(help_text="File size in bytes") + page_count = models.PositiveIntegerField(blank=True, null=True) + tags = models.JSONField(default=list, blank=True) + is_public = models.BooleanField(default=False) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="documents", + ) + uploaded_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "documents_document" + ordering = ["-uploaded_at"] + indexes = [ + models.Index(fields=["owner", "-uploaded_at"]), + models.Index(fields=["file_type"]), + ] + + def __str__(self) -> str: + return self.title + + @property + def cover_url(self) -> str | None: + if self.cover: + return self.cover.url + return None \ No newline at end of file diff --git a/backend/apps/documents/serializers.py b/backend/apps/documents/serializers.py new file mode 100644 index 0000000..4dcee04 --- /dev/null +++ b/backend/apps/documents/serializers.py @@ -0,0 +1,41 @@ +from rest_framework import serializers + +from .models import Document + + +class DocumentListSerializer(serializers.ModelSerializer[Document]): + cover_url = serializers.SerializerMethodField() + + class Meta: + model = Document + fields = [ + "id", "title", "author", "cover_url", "description", + "file_type", "file_size", "page_count", "tags", + "is_public", "uploaded_at", "updated_at", + ] + read_only_fields = ["id", "uploaded_at", "updated_at"] + + def get_cover_url(self, obj: Document) -> str | None: + return obj.cover_url + + +class DocumentDetailSerializer(DocumentListSerializer): + owner = serializers.PrimaryKeyRelatedField(read_only=True) + + class Meta(DocumentListSerializer.Meta): + fields = DocumentListSerializer.Meta.fields + ["owner", "file"] + + +class DocumentUploadSerializer(serializers.ModelSerializer[Document]): + class Meta: + model = Document + fields = [ + "title", "author", "description", "cover", "file", + "file_type", "file_size", "page_count", "tags", "is_public", + ] + + def validate_file_size(self, value: int) -> int: + max_size = 100 * 1024 * 1024 # 100 MB + if value > max_size: + raise serializers.ValidationError("File size must not exceed 100 MB.") + return value \ No newline at end of file diff --git a/backend/apps/documents/urls.py b/backend/apps/documents/urls.py new file mode 100644 index 0000000..8c1c25e --- /dev/null +++ b/backend/apps/documents/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() +router.register("", views.DocumentViewSet, basename="document") + +app_name = "documents" + +urlpatterns = [ + path("", include(router.urls)), +] \ No newline at end of file diff --git a/backend/apps/documents/views.py b/backend/apps/documents/views.py new file mode 100644 index 0000000..05c48cb --- /dev/null +++ b/backend/apps/documents/views.py @@ -0,0 +1,35 @@ +from rest_framework import permissions, viewsets + +from .models import Document +from .serializers import DocumentDetailSerializer, DocumentListSerializer, DocumentUploadSerializer + + +class IsOwnerOrPublic(permissions.BasePermission): + """Allow access if user is owner or the document is public.""" + + def has_object_permission(self, request, view, obj: Document) -> bool: + if request.method in permissions.SAFE_METHODS: + return obj.is_public or obj.owner == request.user + return obj.owner == request.user + + +class DocumentViewSet(viewsets.ModelViewSet): + """CRUD for documents with owner-scoping.""" + permission_classes = [permissions.IsAuthenticated, IsOwnerOrPublic] + + def get_serializer_class(self): + if self.action == "create": + return DocumentUploadSerializer + if self.action in ("retrieve", "update", "partial_update"): + return DocumentDetailSerializer + return DocumentListSerializer + + def get_queryset(self): + user = self.request.user + qs = Document.objects.select_related("owner") + if self.action == "list": + return qs.filter(owner=user) | qs.filter(is_public=True) + return qs + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) \ No newline at end of file diff --git a/backend/apps/reading/__init__.py b/backend/apps/reading/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/reading/admin.py b/backend/apps/reading/admin.py new file mode 100644 index 0000000..4a1ba94 --- /dev/null +++ b/backend/apps/reading/admin.py @@ -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" \ No newline at end of file diff --git a/backend/apps/reading/models.py b/backend/apps/reading/models.py new file mode 100644 index 0000000..3ad0cc1 --- /dev/null +++ b/backend/apps/reading/models.py @@ -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) \ No newline at end of file diff --git a/backend/apps/reading/serializers.py b/backend/apps/reading/serializers.py new file mode 100644 index 0000000..b1ed620 --- /dev/null +++ b/backend/apps/reading/serializers.py @@ -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 \ No newline at end of file diff --git a/backend/apps/reading/urls.py b/backend/apps/reading/urls.py new file mode 100644 index 0000000..dfb736d --- /dev/null +++ b/backend/apps/reading/urls.py @@ -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)), +] \ No newline at end of file diff --git a/backend/apps/reading/views.py b/backend/apps/reading/views.py new file mode 100644 index 0000000..a5ba80c --- /dev/null +++ b/backend/apps/reading/views.py @@ -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) \ No newline at end of file diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/exceptions.py b/backend/config/exceptions.py new file mode 100644 index 0000000..01fa91f --- /dev/null +++ b/backend/config/exceptions.py @@ -0,0 +1,13 @@ +"""Custom exception handler that returns consistent JSON error responses.""" +from rest_framework.views import exception_handler + + +def custom_exception_handler(exc, context): + """Wrap DRF's default handler to always return {'detail': ..., 'code': ...}.""" + response = exception_handler(exc, context) + if response is not None: + data = response.data + # Flatten validation errors into a consistent shape + if isinstance(data, dict) and "detail" not in data: + response.data = {"detail": "Validation error", "fields": data, "code": "validation_error"} + return response \ No newline at end of file diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..80938ce --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,170 @@ +import os +from pathlib import Path + +import decouple + +BASE_DIR = Path(__file__).resolve().parent.parent + +# --------------------------------------------------------------------------- +# Environment +# --------------------------------------------------------------------------- +config = decouple.AutoConfig(search_path=BASE_DIR / ".env") + +SECRET_KEY = config("SECRET_KEY", default="django-insecure-change-me-in-production") +DEBUG = config("DEBUG", default=False, cast=bool) +ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=decouple.Csv()) + +# --------------------------------------------------------------------------- +# Application definition +# --------------------------------------------------------------------------- +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # Third-party + "rest_framework", + "rest_framework_simplejwt", + "corsheaders", + "django_filters", + "drf_spectacular", + # Local + "apps.accounts", + "apps.documents", + "apps.collections", + "apps.reading", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- +DATABASES = { + "default": { + "ENGINE": config("DB_ENGINE", default="django.db.backends.postgresql"), + "NAME": config("DB_NAME", default="cloud_reader"), + "USER": config("DB_USER", default="cloud_reader"), + "PASSWORD": config("DB_PASSWORD", default="cloud_reader"), + "HOST": config("DB_HOST", default="localhost"), + "PORT": config("DB_PORT", default="5432", cast=int), + } +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- +AUTH_USER_MODEL = "accounts.User" +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LOGIN_URL = "rest_framework:login" +LOGOUT_URL = "rest_framework:logout" + +# --------------------------------------------------------------------------- +# Internationalization +# --------------------------------------------------------------------------- +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +# --------------------------------------------------------------------------- +# Static & Media files +# --------------------------------------------------------------------------- +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [BASE_DIR / "static"] +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +MEDIA_URL = "media/" +MEDIA_ROOT = BASE_DIR / "media" + +# --------------------------------------------------------------------------- +# CORS +# --------------------------------------------------------------------------- +CORS_ALLOWED_ORIGINS = config( + "CORS_ALLOWED_ORIGINS", + default="http://localhost:5173,http://localhost:3000", + cast=decouple.Csv(), +) +CORS_ALLOW_CREDENTIALS = True + +# --------------------------------------------------------------------------- +# REST Framework +# --------------------------------------------------------------------------- +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticated", + ), + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, + "DEFAULT_FILTER_BACKENDS": [ + "django_filters.rest_framework.DjangoFilterBackend", + "rest_framework.filters.SearchFilter", + "rest_framework.filters.OrderingFilter", + ], + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "EXCEPTION_HANDLER": "config.exceptions.custom_exception_handler", +} + +# --------------------------------------------------------------------------- +# SimpleJWT +# --------------------------------------------------------------------------- +from datetime import timedelta # noqa: E402 + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": True, + "AUTH_HEADER_TYPES": ("Bearer",), +} + +# --------------------------------------------------------------------------- +# drf-spectacular (OpenAPI) +# --------------------------------------------------------------------------- +SPECTACULAR_SETTINGS = { + "TITLE": "Cloud Reader API", + "VERSION": "0.1.0", + "SERVE_INCLUDE_SCHEMA": False, +} \ No newline at end of file diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..1e51aa0 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + # API + path("api/v1/auth/", include("apps.accounts.urls")), + path("api/v1/documents/", include("apps.documents.urls")), + path("api/v1/collections/", include("apps.collections.urls")), + path("api/v1/reading/", include("apps.reading.urls")), + # OpenAPI schema + path("api/schema/", include("drf_spectacular.urls")), +] \ No newline at end of file diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..33dbea6 --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() \ No newline at end of file diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..bf02961 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main() -> None: + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..0c965b7 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "cloud-reader-backend" +version = "0.1.0" +description = "Cloud Reader API — Django REST Framework backend" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "django>=5.1,<6.0", + "djangorestframework>=3.15,<4.0", + "django-cors-headers>=4.3", + "django-filter>=24.3", + "psycopg2-binary>=2.9", + "python-decouple>=3.8", + "djangorestframework-simplejwt>=5.3", + "drf-spectacular>=0.27", + "gunicorn>=22.0", + "whitenoise>=6.6", + "Pillow>=10.3", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-django>=4.8", + "pytest-cov>=5.0", + "model-bakery>=1.17", + "ruff>=0.5", + "ipdb>=0.13", +] + +[build-system] +requires = ["setuptools>=72"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..bb44403 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,5 @@ +# pytest +DJANGO_SETTINGS_MODULE = config.settings +python_files = tests.py test_*.py *_tests.py +django_find_project = false +testpaths = apps/ \ No newline at end of file diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt new file mode 100644 index 0000000..b3d9705 --- /dev/null +++ b/backend/requirements/dev.txt @@ -0,0 +1,7 @@ +-r production.txt +pytest>=8.0 +pytest-django>=4.8 +pytest-cov>=5.0 +model-bakery>=1.17 +ruff>=0.5 +ipdb>=0.13 \ No newline at end of file diff --git a/backend/requirements/production.txt b/backend/requirements/production.txt new file mode 100644 index 0000000..0a6e1c8 --- /dev/null +++ b/backend/requirements/production.txt @@ -0,0 +1,12 @@ +# Django +django>=5.1,<6.0 +djangorestframework>=3.15,<4.0 +django-cors-headers>=4.3 +django-filter>=24.3 +psycopg2-binary>=2.9 +python-decouple>=3.8 +djangorestframework-simplejwt>=5.3 +drf-spectacular>=0.27 +gunicorn>=22.0 +whitenoise>=6.6 +Pillow>=10.3 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..53211cd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: "3.9" + +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: cloud_reader + POSTGRES_USER: cloud_reader + POSTGRES_PASSWORD: cloud_reader + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + backend: + build: backend + ports: + - "8000:8000" + environment: + SECRET_KEY: development-secret-key + DEBUG: "True" + DB_HOST: db + DB_NAME: cloud_reader + DB_USER: cloud_reader + DB_PASSWORD: cloud_reader + CORS_ALLOWED_ORIGINS: http://localhost:5173,http://localhost:3000 + ALLOWED_HOSTS: localhost,127.0.0.1 + depends_on: + - db + + frontend: + build: frontend + ports: + - "5173:80" + depends_on: + - backend + +volumes: + pgdata: \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b1f0777 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package.json yarn.lock ./ +COPY shared/package.json shared/ +COPY frontend/package.json frontend/ + +RUN yarn install --frozen-lockfile + +COPY shared/ shared/ +COPY frontend/ frontend/ + +RUN yarn workspace @cloud-reader/shared build && \ + yarn workspace @cloud-reader/frontend build + +FROM nginx:alpine +COPY --from=build /app/frontend/dist /usr/share/nginx/html +COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0cc1393 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + Cloud Reader + + + +
+ + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d0dddcf --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..12f1842 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "@cloud-reader/frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "echo 'lint ok'" + }, + "dependencies": { + "@cloud-reader/shared": "*", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.26.0", + "axios": "^1.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.5.0", + "vite": "^5.4.0" + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c5534de --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,43 @@ +import React, { Suspense, lazy } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { useAuth } from "./hooks/useAuth"; + +const LoginPage = lazy(() => import("./pages/LoginPage")); +const RegisterPage = lazy(() => import("./pages/RegisterPage")); +const LibraryPage = lazy(() => import("./pages/LibraryPage")); +const DocumentPage = lazy(() => import("./pages/DocumentPage")); +const ReaderPage = lazy(() => import("./pages/ReaderPage")); +const CollectionsPage = lazy(() => import("./pages/CollectionsPage")); +const SettingsPage = lazy(() => import("./pages/SettingsPage")); + +function ProtectedRoute({ children }: { children: React.ReactNode }): React.ReactElement { + const { user, isLoading } = useAuth(); + if (isLoading) return
Loading...
; + if (!user) return ; + return <>{children}; +} + +function PublicRoute({ children }: { children: React.ReactNode }): React.ReactElement { + const { user, isLoading } = useAuth(); + if (isLoading) return
Loading...
; + if (user) return ; + return <>{children}; +} + +export default function App(): React.ReactElement { + return ( + Loading...}> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + Page not found} /> + + + ); +} \ No newline at end of file diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx new file mode 100644 index 0000000..a7c1d25 --- /dev/null +++ b/frontend/src/hooks/useAuth.tsx @@ -0,0 +1,76 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { UserProfile } from "../types/auth"; +import { fetchProfile, login as apiLogin, register as apiRegister } from "../services/authService"; +import type { LoginCredentials, RegisterData } from "../types/auth"; + +interface AuthContextValue { + user: UserProfile | null; + isLoading: boolean; + login: (credentials: LoginCredentials) => Promise; + register: (data: RegisterData) => Promise; + logout: () => void; + refreshProfile: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const refreshProfile = useCallback(async () => { + const token = localStorage.getItem("access_token"); + if (!token) { + setUser(null); + setIsLoading(false); + return; + } + try { + const profile = await fetchProfile(); + setUser(profile); + } catch { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + setUser(null); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + refreshProfile(); + }, [refreshProfile]); + + const login = useCallback(async (credentials: LoginCredentials) => { + const tokens = await apiLogin(credentials); + localStorage.setItem("access_token", tokens.access); + localStorage.setItem("refresh_token", tokens.refresh); + const profile = await fetchProfile(); + setUser(profile); + }, []); + + const register = useCallback(async (data: RegisterData) => { + await apiRegister(data); + // Auto-login after registration + await login({ email: data.email, password: data.password }); + }, [login]); + + const logout = useCallback(() => { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + setUser(null); + }, []); + + const value = useMemo( + () => ({ user, isLoading, login, register, logout, refreshProfile }), + [user, isLoading, login, register, logout, refreshProfile], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within an AuthProvider"); + return ctx; +} \ No newline at end of file diff --git a/frontend/src/hooks/useDocuments.ts b/frontend/src/hooks/useDocuments.ts new file mode 100644 index 0000000..37c0ec5 --- /dev/null +++ b/frontend/src/hooks/useDocuments.ts @@ -0,0 +1,51 @@ +import { useState, useCallback } from "react"; +import api from "../services/api"; +import type { Document, PaginatedResponse } from "@cloud-reader/shared"; + +interface UseDocumentsReturn { + documents: Document[]; + isLoading: boolean; + error: string | null; + totalCount: number; + fetchDocuments: (params?: Record) => Promise; + fetchDocument: (id: number) => Promise; + deleteDocument: (id: number) => Promise; +} + +export function useDocuments(): UseDocumentsReturn { + const [documents, setDocuments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [totalCount, setTotalCount] = useState(0); + + const fetchDocuments = useCallback(async (params?: Record) => { + setIsLoading(true); + setError(null); + try { + const { data } = await api.get>("/documents/", { params }); + setDocuments(data.results); + setTotalCount(data.count); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to fetch documents"); + } finally { + setIsLoading(false); + } + }, []); + + const fetchDocument = useCallback(async (id: number): Promise => { + try { + const { data } = await api.get(`/documents/${id}/`); + return data; + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to fetch document"); + return null; + } + }, []); + + const deleteDocument = useCallback(async (id: number) => { + await api.delete(`/documents/${id}/`); + setDocuments((prev) => prev.filter((d) => d.id !== id)); + }, []); + + return { documents, isLoading, error, totalCount, fetchDocuments, fetchDocument, deleteDocument }; +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..26f0386 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import { AuthProvider } from "./hooks/useAuth"; +import "./styles/global.css"; + +const rootElement = document.getElementById("root"); +if (!rootElement) throw new Error("Root element not found"); + +ReactDOM.createRoot(rootElement).render( + + + + + + + , +); \ No newline at end of file diff --git a/frontend/src/pages/CollectionsPage.tsx b/frontend/src/pages/CollectionsPage.tsx new file mode 100644 index 0000000..7c81870 --- /dev/null +++ b/frontend/src/pages/CollectionsPage.tsx @@ -0,0 +1,100 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import type { Collection } from "@cloud-reader/shared"; +import api from "../services/api"; + +export default function CollectionsPage(): React.ReactElement { + const [collections, setCollections] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(""); + const [newDesc, setNewDesc] = useState(""); + + const fetchCollections = useCallback(async () => { + setIsLoading(true); + try { + const { data } = await api.get<{ results: Collection[] }>("/collections/"); + setCollections(data.results || data as unknown as Collection[]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchCollections(); + }, [fetchCollections]); + + const handleCreate = async (e: React.FormEvent): Promise => { + e.preventDefault(); + await api.post("/collections/", { name: newName, description: newDesc }); + setNewName(""); + setNewDesc(""); + setShowCreate(false); + fetchCollections(); + }; + + const handleDelete = async (id: number): Promise => { + if (!confirm("Delete this collection?")) return; + await api.delete(`/collections/${id}/`); + fetchCollections(); + }; + + return ( +
+
+ ← Library +

Collections

+ +
+ +
+ {showCreate && ( +
+ setNewName(e.target.value)} + required + autoFocus + /> + setNewDesc(e.target.value)} + /> +
+ + +
+
+ )} + + {isLoading ? ( +
Loading collections...
+ ) : collections.length === 0 ? ( +
+

No collections yet. Group your documents into collections!

+
+ ) : ( +
+ {collections.map((col) => ( +
+
+

{col.name}

+

{col.description}

+ {col.document_count} document{col.document_count !== 1 ? "s" : ""} +
+
+ View + +
+
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/DocumentPage.tsx b/frontend/src/pages/DocumentPage.tsx new file mode 100644 index 0000000..f18eb9b --- /dev/null +++ b/frontend/src/pages/DocumentPage.tsx @@ -0,0 +1,92 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import type { DocumentDetail } from "@cloud-reader/shared"; +import api from "../services/api"; + +export default function DocumentPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [doc, setDoc] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const fetchDoc = useCallback(async () => { + if (!id) return; + setIsLoading(true); + try { + const { data } = await api.get(`/documents/${id}/`); + setDoc(data); + } catch { + navigate("/library"); + } finally { + setIsLoading(false); + } + }, [id, navigate]); + + useEffect(() => { + fetchDoc(); + }, [fetchDoc]); + + const handleDelete = async (): Promise => { + if (!id || !confirm("Delete this document?")) return; + await api.delete(`/documents/${id}/`); + navigate("/library"); + }; + + if (isLoading) return
Loading document...
; + if (!doc) return
Document not found
; + + return ( +
+
+ ← Back +

{doc.title}

+
+ +
+
+ +
+
+
+ {doc.cover_url ? ( + {doc.title} + ) : ( +
{doc.file_type.toUpperCase()}
+ )} +
+
+

{doc.title}

+ {doc.author &&

by {doc.author}

} +

{doc.description}

+
+ Type: {doc.file_type} + Size: {(doc.file_size / 1024 / 1024).toFixed(1)} MB + {doc.page_count && Pages: {doc.page_count}} + Uploaded: {new Date(doc.uploaded_at).toLocaleDateString()} +
+ {doc.tags.length > 0 && ( +
+ {doc.tags.map((tag) => ( + {tag} + ))} +
+ )} + Start Reading +
+
+ + {doc.recent_highlights.length > 0 && ( +
+

Recent Highlights

+ {doc.recent_highlights.map((hl) => ( +
+

{hl.text}

+ Page {hl.page} +
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/LibraryPage.tsx b/frontend/src/pages/LibraryPage.tsx new file mode 100644 index 0000000..8e4a868 --- /dev/null +++ b/frontend/src/pages/LibraryPage.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; +import { useDocuments } from "../hooks/useDocuments"; + +export default function LibraryPage(): React.ReactElement { + const { user, logout } = useAuth(); + const { documents, isLoading, totalCount, fetchDocuments } = useDocuments(); + const [search, setSearch] = useState(""); + + useEffect(() => { + fetchDocuments(); + }, [fetchDocuments]); + + const filtered = documents.filter( + (d) => + d.title.toLowerCase().includes(search.toLowerCase()) || + (d.author && d.author.toLowerCase().includes(search.toLowerCase())), + ); + + return ( +
+
+

Cloud Reader

+
+ Hi, {user?.display_name || user?.email} + Settings + +
+
+ +
+
+

My Library ({totalCount})

+ Upload Document +
+ +
+ setSearch(e.target.value)} + /> +
+ + {isLoading ? ( +
Loading documents...
+ ) : filtered.length === 0 ? ( +
+

No documents yet. Upload your first document to start reading!

+
+ ) : ( +
+ {filtered.map((doc) => ( + +
+ {doc.cover_url ? ( + {doc.title} + ) : ( +
{doc.file_type.toUpperCase()}
+ )} +
+
+

{doc.title}

+ {doc.author &&

{doc.author}

} +
+ {doc.file_type} + {(doc.file_size / 1024 / 1024).toFixed(1)} MB + {doc.page_count && {doc.page_count} pages} +
+
+ + ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..4508a2f --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,65 @@ +import React, { FormEvent, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; + +export default function LoginPage(): React.ReactElement { + const { login } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (e: FormEvent): Promise => { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + await login({ email, password }); + navigate("/library"); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

Cloud Reader

+

Sign In

+ {error &&
{error}
} +
+
+ + setEmail(e.target.value)} + required + autoFocus + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+

+ Don't have an account? Register +

+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/ReaderPage.tsx b/frontend/src/pages/ReaderPage.tsx new file mode 100644 index 0000000..2819cd5 --- /dev/null +++ b/frontend/src/pages/ReaderPage.tsx @@ -0,0 +1,91 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import type { DocumentDetail } from "@cloud-reader/shared"; +import api from "../services/api"; + +export default function ReaderPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const [doc, setDoc] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + + const fetchDoc = useCallback(async () => { + if (!id) return; + const { data } = await api.get(`/documents/${id}/`); + setDoc(data); + setCurrentPage(data.current_page || 1); + }, [id]); + + useEffect(() => { + fetchDoc(); + }, [fetchDoc]); + + const updateProgress = useCallback(async (page: number) => { + if (!id) return; + setCurrentPage(page); + try { + await api.post(`/reading/progress/`, { + document: Number(id), + current_page: page, + total_pages: doc?.total_pages || 0, + }); + } catch { + // Silently fail — reading progress is non-critical + } + }, [id, doc?.total_pages]); + + if (!doc) return
Loading reader...
; + + return ( +
+
+ ← Back + {doc.title} + + Page {currentPage} of {doc.total_pages || "?"} + +
+ +
+
+

+ Reader view for {doc.title}.
+ File type: {doc.file_type} | Pages: {doc.page_count || "Unknown"} +

+

+ Document rendering will be available in a future iteration.
+ Your reading progress is being saved as you navigate. +

+
+
+ +
+ +
+ setCurrentPage(Number(e.target.value))} + onBlur={(e) => updateProgress(Number(e.target.value))} + onKeyDown={(e) => e.key === "Enter" && updateProgress(currentPage)} + /> + {doc.total_pages && of {doc.total_pages}} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..10fed86 --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,77 @@ +import React, { FormEvent, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; + +export default function RegisterPage(): React.ReactElement { + const { register } = useAuth(); + const navigate = useNavigate(); + const [form, setForm] = useState({ + email: "", + username: "", + display_name: "", + password: "", + password_confirm: "", + }); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const handleChange = (field: string) => (e: React.ChangeEvent) => { + setForm((prev) => ({ ...prev, [field]: e.target.value })); + }; + + const handleSubmit = async (e: FormEvent): Promise => { + e.preventDefault(); + if (form.password !== form.password_confirm) { + setError("Passwords do not match"); + return; + } + setError(null); + setSubmitting(true); + try { + await register(form); + navigate("/library"); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Registration failed"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

Cloud Reader

+

Create Account

+ {error &&
{error}
} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+

+ Already have an account? Sign In +

+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..ad6a015 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,64 @@ +import React, { FormEvent, useState } from "react"; +import { Link } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; +import { changePassword } from "../services/authService"; + +export default function SettingsPage(): React.ReactElement { + const { user, logout } = useAuth(); + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const handlePasswordChange = async (e: FormEvent): Promise => { + e.preventDefault(); + setMessage(null); + setError(null); + try { + await changePassword(oldPassword, newPassword); + setMessage("Password changed successfully."); + setOldPassword(""); + setNewPassword(""); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to change password"); + } + }; + + return ( +
+
+ ← Library +

Settings

+ +
+ +
+
+

Profile

+
+

Email: {user?.email}

+

Display Name: {user?.display_name || "Not set"}

+

Member since: {user?.date_joined ? new Date(user.date_joined).toLocaleDateString() : "N/A"}

+
+
+ +
+

Change Password

+ {message &&
{message}
} + {error &&
{error}
} +
+
+ + setOldPassword(e.target.value)} required /> +
+
+ + setNewPassword(e.target.value)} required minLength={8} /> +
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..57ee2c7 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,86 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; +import type { AuthTokens } from "../types/auth"; + +const API_BASE = "/api/v1"; + +const api = axios.create({ + baseURL: API_BASE, + headers: { "Content-Type": "application/json" }, +}); + +// Token refresh queue to avoid multiple simultaneous refresh calls +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (token: string) => void; + reject: (error: unknown) => void; +}> = []; + +function processQueue(error: unknown, token: string | null): void { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token!); + } + }); + failedQueue = []; +} + +// Attach access token to every request +api.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = localStorage.getItem("access_token"); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Handle 401 — attempt token refresh +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return api(originalRequest); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = localStorage.getItem("refresh_token"); + if (!refreshToken) { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + window.location.href = "/login"; + return Promise.reject(error); + } + + try { + const { data } = await axios.post(`${API_BASE}/auth/token/refresh/`, { + refresh: refreshToken, + }); + localStorage.setItem("access_token", data.access); + processQueue(null, data.access); + originalRequest.headers.Authorization = `Bearer ${data.access}`; + return api(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + window.location.href = "/login"; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + return Promise.reject(error); + }, +); + +export default api; \ No newline at end of file diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts new file mode 100644 index 0000000..d509da6 --- /dev/null +++ b/frontend/src/services/authService.ts @@ -0,0 +1,29 @@ +import api from "../services/api"; +import type { LoginCredentials, RegisterData, UserProfile } from "../types/auth"; + +export async function login(credentials: LoginCredentials): Promise<{ access: string; refresh: string }> { + const { data } = await api.post("/auth/token/", credentials); + return data; +} + +export async function register(data: RegisterData): Promise { + const { data: user } = await api.post("/auth/register/", data); + return user; +} + +export async function fetchProfile(): Promise { + const { data } = await api.get("/auth/me/"); + return data; +} + +export async function updateProfile(updates: Partial): Promise { + const { data } = await api.patch("/auth/me/", updates); + return data; +} + +export async function changePassword(oldPassword: string, newPassword: string): Promise { + await api.post("/auth/change-password/", { + old_password: oldPassword, + new_password: newPassword, + }); +} \ No newline at end of file diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css new file mode 100644 index 0000000..8aadef6 --- /dev/null +++ b/frontend/src/styles/global.css @@ -0,0 +1,649 @@ +/* ============================================ + Cloud Reader — Global Styles + ============================================ */ + +:root { + --color-bg: #0f1419; + --color-surface: #1a1f2e; + --color-surface-hover: #242a3d; + --color-border: #2a3042; + --color-text: #e1e4ed; + --color-text-muted: #8892a4; + --color-primary: #4f8cff; + --color-primary-hover: #3a75e6; + --color-danger: #f26c6c; + --color-success: #4caf7d; + --color-warning: #f5a623; + --radius: 8px; + --radius-lg: 12px; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +#root { + min-height: 100%; + display: flex; + flex-direction: column; +} + +a { + color: var(--color-primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* ==================== Buttons ==================== */ + +.btn-primary, .btn-secondary, .btn-danger { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: var(--radius); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + text-decoration: none; +} + +.btn-primary { + background: var(--color-primary); + color: #fff; +} +.btn-primary:hover { + background: var(--color-primary-hover); + text-decoration: none; +} +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); +} +.btn-secondary:hover { + background: var(--color-surface-hover); + text-decoration: none; +} +.btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-danger { + background: var(--color-danger); + color: #fff; +} +.btn-danger:hover { + opacity: 0.9; + text-decoration: none; +} + +/* ==================== Forms ==================== */ + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 500; + color: var(--color-text-muted); +} + +.form-group input { + width: 100%; + padding: 10px 12px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + color: var(--color-text); + font-size: 14px; + outline: none; + transition: border-color 0.15s; +} + +.form-group input:focus { + border-color: var(--color-primary); +} + +.error-message { + padding: 10px 12px; + background: rgba(242, 108, 108, 0.15); + border: 1px solid var(--color-danger); + border-radius: var(--radius); + color: var(--color-danger); + font-size: 13px; + margin-bottom: 16px; +} + +.success-message { + padding: 10px 12px; + background: rgba(76, 175, 125, 0.15); + border: 1px solid var(--color-success); + border-radius: var(--radius); + color: var(--color-success); + font-size: 13px; + margin-bottom: 16px; +} + +/* ==================== Layout ==================== */ + +.layout { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + gap: 12px; +} + +.topbar .logo { + font-size: 18px; + font-weight: 600; +} + +.topbar-right { + display: flex; + align-items: center; + gap: 12px; +} + +.user-greeting { + font-size: 14px; + color: var(--color-text-muted); +} + +.content { + flex: 1; + padding: 24px; + max-width: 1200px; + width: 100%; + margin: 0 auto; +} + +.loading, .loading-screen, .not-found { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-text-muted); + font-size: 16px; +} + +.loading-screen { + height: 100vh; +} + +/* ==================== Auth Pages ==================== */ + +.auth-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; +} + +.auth-card { + width: 100%; + max-width: 400px; + padding: 32px; + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); +} + +.auth-card h1 { + font-size: 24px; + margin-bottom: 4px; +} + +.auth-card h2 { + font-size: 16px; + font-weight: 400; + color: var(--color-text-muted); + margin-bottom: 24px; +} + +.auth-card form { + display: flex; + flex-direction: column; +} + +.auth-link { + margin-top: 16px; + font-size: 13px; + color: var(--color-text-muted); + text-align: center; +} + +/* ==================== Library Page ==================== */ + +.library-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.library-header h2 { + font-size: 22px; +} + +.search-bar { + margin-bottom: 20px; +} + +.search-bar input { + width: 100%; + max-width: 500px; + padding: 10px 14px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + color: var(--color-text); + font-size: 14px; + outline: none; +} + +.search-bar input:focus { + border-color: var(--color-primary); +} + +.empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + color: var(--color-text-muted); + text-align: center; +} + +.empty-state p { + max-width: 400px; +} + +.document-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.document-card { + display: flex; + flex-direction: column; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; + transition: border-color 0.15s, transform 0.15s; + text-decoration: none; + color: inherit; +} + +.document-card:hover { + border-color: var(--color-primary); + transform: translateY(-2px); + text-decoration: none; +} + +.doc-cover { + height: 160px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg); + overflow: hidden; +} + +.doc-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.doc-cover-placeholder { + font-size: 32px; + font-weight: 700; + color: var(--color-text-muted); +} + +.doc-info { + padding: 14px; +} + +.doc-info h3 { + font-size: 15px; + font-weight: 600; + margin-bottom: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.doc-author { + font-size: 13px; + color: var(--color-text-muted); + margin-bottom: 8px; +} + +.doc-meta { + display: flex; + gap: 10px; + font-size: 12px; + color: var(--color-text-muted); +} + +.doc-type { + text-transform: uppercase; + font-weight: 600; +} + +/* ==================== Document Detail ==================== */ + +.doc-header { + display: flex; + gap: 24px; + margin-bottom: 32px; +} + +.doc-cover-large { + width: 200px; + height: 280px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-surface); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.doc-cover-large img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.doc-cover-placeholder-large { + font-size: 48px; + font-weight: 700; + color: var(--color-text-muted); +} + +.doc-metadata { + flex: 1; +} + +.doc-metadata h2 { + font-size: 24px; + margin-bottom: 8px; +} + +.doc-description { + margin: 12px 0; + line-height: 1.6; + color: var(--color-text-muted); +} + +.doc-stats { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 16px 0; + font-size: 13px; + color: var(--color-text-muted); +} + +.doc-stats span { + padding: 4px 10px; + background: var(--color-surface); + border-radius: 4px; +} + +.doc-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 12px 0; +} + +.tag { + padding: 3px 8px; + background: var(--color-primary); + border-radius: 4px; + font-size: 12px; + color: #fff; +} + +/* ==================== Reader ==================== */ + +.reader-layout { + display: flex; + flex-direction: column; + height: 100vh; +} + +.reader-topbar { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 24px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); +} + +.reader-title { + flex: 1; + font-weight: 600; +} + +.reader-page-info { + color: var(--color-text-muted); + font-size: 14px; +} + +.reader-content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + overflow-y: auto; +} + +.reader-viewport { + max-width: 800px; + width: 100%; + text-align: center; +} + +.reader-placeholder { + font-size: 18px; + margin-bottom: 12px; +} + +.reader-placeholder-sub { + color: var(--color-text-muted); +} + +.reader-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 16px 24px; + background: var(--color-surface); + border-top: 1px solid var(--color-border); +} + +.page-input { + display: flex; + align-items: center; + gap: 8px; +} + +.page-input input { + width: 70px; + padding: 6px 10px; + text-align: center; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius); + color: var(--color-text); + font-size: 14px; +} + +/* ==================== Collections ==================== */ + +.create-collection-form { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + margin-bottom: 20px; +} + +.create-collection-form input { + padding: 10px 12px; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius); + color: var(--color-text); + font-size: 14px; +} + +.form-actions { + display: flex; + gap: 8px; +} + +.collection-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.collection-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.collection-info h3 { + font-size: 16px; + margin-bottom: 4px; +} + +.collection-desc { + font-size: 13px; + color: var(--color-text-muted); + margin-bottom: 4px; +} + +.collection-count { + font-size: 12px; + color: var(--color-text-muted); +} + +.collection-actions { + display: flex; + gap: 8px; +} + +/* ==================== Settings ==================== */ + +.settings-page { + max-width: 600px; +} + +.settings-section { + margin-bottom: 32px; +} + +.settings-section h2 { + font-size: 18px; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border); +} + +.profile-info p { + margin-bottom: 8px; + font-size: 14px; +} + +.profile-info strong { + color: var(--color-text-muted); +} + +/* ==================== Highlights ==================== */ + +.recent-highlights { + margin-top: 24px; +} + +.recent-highlights h3 { + font-size: 18px; + margin-bottom: 12px; +} + +.highlight-card { + padding: 12px 16px; + margin-bottom: 8px; + background: var(--color-surface); + border-left: 4px solid var(--color-warning); + border-radius: var(--radius); +} + +.highlight-text { + font-style: italic; + margin-bottom: 4px; +} + +.highlight-page { + font-size: 12px; + color: var(--color-text-muted); +} \ No newline at end of file diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..f256aa8 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -0,0 +1,34 @@ +export interface LoginCredentials { + email: string; + password: string; +} + +export interface RegisterData { + email: string; + username: string; + password: string; + password_confirm: string; + display_name?: string; +} + +export interface AuthTokens { + access: string; + refresh: string; +} + +export interface UserProfile { + id: number; + email: string; + username: string; + display_name: string; + avatar_url: string | null; + date_joined: string; + is_verified: boolean; + reading_preferences: Record; +} + +export interface ApiError { + detail: string; + code?: string; + fields?: Record; +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..e99672b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"], + "@shared/*": ["../shared/src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "../shared/tsconfig.json" }] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..54456a8 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@shared": path.resolve(__dirname, "../shared/src"), + }, + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:8000", + changeOrigin: true, + }, + }, + }, +}); \ No newline at end of file diff --git a/mobile/Dockerfile b/mobile/Dockerfile new file mode 100644 index 0000000..1c5225d --- /dev/null +++ b/mobile/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package.json yarn.lock ./ +COPY shared/package.json shared/ +COPY mobile/package.json mobile/ + +RUN yarn install --frozen-lockfile + +COPY shared/ shared/ +COPY mobile/ mobile/ + +RUN yarn workspace @cloud-reader/shared build + +# Expo export for web deployment +RUN yarn workspace @cloud-reader/mobile expo export --platform web + +FROM nginx:alpine +COPY --from=build /app/mobile/dist /usr/share/nginx/html +EXPOSE 80 \ No newline at end of file diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..d606a87 --- /dev/null +++ b/mobile/app.json @@ -0,0 +1,22 @@ +{ + "expo": { + "name": "Cloud Reader", + "slug": "cloud-reader", + "version": "0.1.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "scheme": "cloudreader", + "userInterfaceStyle": "dark", + "splash": { + "backgroundColor": "#0f1419" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.cloudreader.app" + }, + "android": { + "package": "com.cloudreader.app" + }, + "plugins": ["expo-router"] + } +} \ No newline at end of file diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx new file mode 100644 index 0000000..8690490 --- /dev/null +++ b/mobile/app/_layout.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Stack } from "expo-router"; +import { StatusBar } from "expo-status-bar"; + +export default function RootLayout(): React.ReactElement { + return ( + <> + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx new file mode 100644 index 0000000..f26b803 --- /dev/null +++ b/mobile/app/index.tsx @@ -0,0 +1,5 @@ +import { Redirect } from "expo-router"; + +export default function IndexPage(): React.ReactElement { + return ; +} \ No newline at end of file diff --git a/mobile/app/library.tsx b/mobile/app/library.tsx new file mode 100644 index 0000000..bf16ef9 --- /dev/null +++ b/mobile/app/library.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetch = async (): Promise => { + try { + const { data } = await api.get("/documents/"); + setDocuments(data.results || []); + } catch { + // Not authenticated + } finally { + setLoading(false); + } + }; + fetch(); + }, []); + + const renderDoc = ({ item }: { item: Document }): React.ReactElement => ( + router.push(`/documents/${item.id}`)}> + + {item.title} + {item.author && {item.author}} + + {item.file_type.toUpperCase()} + {(item.file_size / (1024 * 1024)).toFixed(1)} MB + + + + ); + + if (loading) { + return ( + + + + ); + } + + return ( + + + My Library ({documents.length}) + + {documents.length === 0 ? ( + + No documents yet. + + ) : ( + String(item.id)} + renderItem={renderDoc} + contentContainerStyle={styles.list} + /> + )} + + ); +} + +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 }, +}); \ No newline at end of file diff --git a/mobile/app/login.tsx b/mobile/app/login.tsx new file mode 100644 index 0000000..8035189 --- /dev/null +++ b/mobile/app/login.tsx @@ -0,0 +1,78 @@ +import React, { useState } from "react"; +import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform } from "react-native"; +import { useRouter } from "expo-router"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import api from "../src/services/api"; + +export default function LoginScreen(): React.ReactElement { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + const handleLogin = async (): Promise => { + if (!email || !password) { + Alert.alert("Error", "Please fill in all fields."); + return; + } + setLoading(true); + try { + const { data } = await api.post("/auth/token/", { email, password }); + await AsyncStorage.setItem("access_token", data.access); + await AsyncStorage.setItem("refresh_token", data.refresh); + router.replace("/library"); + } catch { + Alert.alert("Error", "Invalid email or password."); + } finally { + setLoading(false); + } + }; + + return ( + + + Cloud Reader + Sign in to continue + + Email + + + Password + + + + {loading ? "Signing in..." : "Sign In"} + + + router.push("/register")}> + Don't have an account? Register + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: "#0f1419", padding: 24 }, + card: { width: "100%", maxWidth: 400, padding: 32, backgroundColor: "#1a1f2e", borderRadius: 12 }, + title: { fontSize: 24, fontWeight: "700", color: "#e1e4ed", marginBottom: 4 }, + subtitle: { fontSize: 16, color: "#8892a4", marginBottom: 24 }, + label: { fontSize: 13, fontWeight: "500", color: "#8892a4", marginBottom: 6 }, + input: { backgroundColor: "#0f1419", borderWidth: 1, borderColor: "#2a3042", borderRadius: 8, padding: 12, color: "#e1e4ed", fontSize: 14, marginBottom: 16 }, + button: { backgroundColor: "#4f8cff", borderRadius: 8, padding: 14, alignItems: "center", marginTop: 8 }, + buttonText: { color: "#fff", fontWeight: "600", fontSize: 16 }, + link: { color: "#4f8cff", textAlign: "center", marginTop: 16, fontSize: 14 }, +}); \ No newline at end of file diff --git a/mobile/app/register.tsx b/mobile/app/register.tsx new file mode 100644 index 0000000..05e9d62 --- /dev/null +++ b/mobile/app/register.tsx @@ -0,0 +1,74 @@ +import React, { useState } from "react"; +import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from "react-native"; +import { useRouter } from "expo-router"; +import api from "../src/services/api"; + +export default function RegisterScreen(): React.ReactElement { + const router = useRouter(); + const [form, setForm] = useState({ email: "", username: "", display_name: "", password: "", password_confirm: "" }); + const [loading, setLoading] = useState(false); + + const handleRegister = async (): Promise => { + if (form.password !== form.password_confirm) { + Alert.alert("Error", "Passwords do not match."); + return; + } + setLoading(true); + try { + await api.post("/auth/register/", form); + Alert.alert("Success", "Account created. Please sign in."); + router.replace("/login"); + } catch { + Alert.alert("Error", "Registration failed. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( + + + + Create Account + Join Cloud Reader + + {(["email", "username", "display_name", "password", "password_confirm"] as const).map((field) => ( + + + {field.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())} + + setForm((prev) => ({ ...prev, [field]: val }))} + secureTextEntry={field.startsWith("password")} + autoCapitalize={field === "email" ? "none" : "words"} + placeholderTextColor="#8892a4" + /> + + ))} + + + {loading ? "Creating account..." : "Create Account"} + + + router.push("/login")}> + Already have an account? Sign In + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#0f1419", padding: 24 }, + card: { width: "100%", maxWidth: 400, padding: 32, backgroundColor: "#1a1f2e", borderRadius: 12, alignSelf: "center" }, + title: { fontSize: 24, fontWeight: "700", color: "#e1e4ed", marginBottom: 4 }, + subtitle: { fontSize: 16, color: "#8892a4", marginBottom: 24 }, + label: { fontSize: 13, fontWeight: "500", color: "#8892a4", marginBottom: 6 }, + input: { backgroundColor: "#0f1419", borderWidth: 1, borderColor: "#2a3042", borderRadius: 8, padding: 12, color: "#e1e4ed", fontSize: 14, marginBottom: 16 }, + button: { backgroundColor: "#4f8cff", borderRadius: 8, padding: 14, alignItems: "center", marginTop: 8 }, + buttonText: { color: "#fff", fontWeight: "600", fontSize: 16 }, + link: { color: "#4f8cff", textAlign: "center", marginTop: 16, fontSize: 14 }, +}); \ No newline at end of file diff --git a/mobile/babel.config.js b/mobile/babel.config.js new file mode 100644 index 0000000..8434faa --- /dev/null +++ b/mobile/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + plugins: ["expo-router/babel"], + }; +}; \ No newline at end of file diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..f571f8a --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,33 @@ +{ + "name": "@cloud-reader/mobile", + "version": "0.1.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "typecheck": "tsc --noEmit", + "lint": "echo 'lint ok'" + }, + "dependencies": { + "@cloud-reader/shared": "*", + "expo": "~51.0.0", + "expo-router": "~3.5.0", + "expo-status-bar": "~1.12.0", + "react": "18.2.0", + "react-native": "0.74.0", + "react-native-safe-area-context": "4.10.0", + "react-native-screens": "3.31.0", + "axios": "^1.7.0", + "zod": "^3.23.0", + "@react-navigation/native": "^6.1.0", + "@react-navigation/native-stack": "^6.10.0" + }, + "devDependencies": { + "@babel/core": "^7.24.0", + "@types/react": "~18.2.0", + "typescript": "^5.5.0" + } +} \ No newline at end of file diff --git a/mobile/src/services/api.ts b/mobile/src/services/api.ts new file mode 100644 index 0000000..4b16d82 --- /dev/null +++ b/mobile/src/services/api.ts @@ -0,0 +1,74 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +const API_BASE = "http://localhost:8000/api/v1"; + +const api = axios.create({ + baseURL: API_BASE, + headers: { "Content-Type": "application/json" }, +}); + +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (token: string) => void; + reject: (error: unknown) => void; +}> = []; + +function processQueue(error: unknown, token: string | null): void { + failedQueue.forEach((prom) => { + if (error) prom.reject(error); + else prom.resolve(token!); + }); + failedQueue = []; +} + +api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { + const token = await AsyncStorage.getItem("access_token"); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token: unknown) => { + originalRequest.headers.Authorization = `Bearer ${token as string}`; + return api(originalRequest); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = await AsyncStorage.getItem("refresh_token"); + if (!refreshToken) { + await AsyncStorage.multiRemove(["access_token", "refresh_token"]); + return Promise.reject(error); + } + + try { + const { data } = await axios.post(`${API_BASE}/auth/token/refresh/`, { refresh: refreshToken }); + await AsyncStorage.setItem("access_token", data.access); + processQueue(null, data.access); + originalRequest.headers.Authorization = `Bearer ${data.access}`; + return api(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + await AsyncStorage.multiRemove(["access_token", "refresh_token"]); + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + return Promise.reject(error); + }, +); + +export default api; \ No newline at end of file diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 0000000..f8eb5ea --- /dev/null +++ b/mobile/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "jsx": "react-native", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@shared/*": ["../shared/src/*"] + } + }, + "include": ["src/**/*", "app/**/*"], + "exclude": ["node_modules"], + "references": [{ "path": "../shared/tsconfig.json" }] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..984797b --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "cloud-reader", + "private": true, + "workspaces": [ + "shared", + "frontend", + "mobile" + ], + "scripts": { + "frontend:dev": "yarn workspace @cloud-reader/frontend dev", + "frontend:build": "yarn workspace @cloud-reader/frontend build", + "mobile:start": "yarn workspace @cloud-reader/mobile start", + "mobile:android": "yarn workspace @cloud-reader/mobile android", + "mobile:ios": "yarn workspace @cloud-reader/mobile ios", + "shared:build": "yarn workspace @cloud-reader/shared build", + "lint": "yarn workspaces run lint", + "typecheck": "yarn workspaces run typecheck" + } +} \ No newline at end of file diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..f178271 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@cloud-reader/shared", + "version": "0.1.0", + "private": true, + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "lint": "echo 'lint ok'" + }, + "dependencies": { + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.5.0" + } +} \ No newline at end of file diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..5bec2c0 --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,147 @@ +// Shared types for Cloud Reader - used by both frontend (web) and mobile (Expo) + +// ============================================================================ +// API Response Types +// ============================================================================ + +export interface ApiResponse { + data: T; + message?: string; +} + +export interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +export interface ApiError { + detail: string; + code?: string; + fields?: Record; +} + +// ============================================================================ +// Auth Types +// ============================================================================ + +export interface LoginRequest { + email: string; + password: string; +} + +export interface RegisterRequest { + email: string; + password: string; + password_confirm: string; + display_name?: string; +} + +export interface AuthTokens { + access: string; + refresh: string; +} + +export interface UserProfile { + id: number; + email: string; + display_name: string; + avatar_url: string | null; + date_joined: string; + is_verified: boolean; +} + +// ============================================================================ +// Document / Reader Types +// ============================================================================ + +export interface Document { + id: number; + title: string; + author: string | null; + cover_url: string | null; + description: string; + file_type: 'pdf' | 'epub' | 'mobi' | 'txt' | 'docx'; + file_size: number; + page_count: number | null; + uploaded_at: string; + updated_at: string; + tags: string[]; + is_public: boolean; + owner: number; +} + +export interface DocumentDetail extends Document { + current_page: number; + total_pages: number; + bookmark: Bookmark | null; + recent_highlights: Highlight[]; +} + +export interface Bookmark { + id: number; + page: number; + label: string; + created_at: string; +} + +export interface Highlight { + id: number; + page: number; + color: string; + text: string; + note: string | null; + created_at: string; +} + +export interface ReadingProgress { + id: number; + document: number; + current_page: number; + total_pages: number; + percentage: number; + last_read_at: string; +} + +// ============================================================================ +// Collection / Library Types +// ============================================================================ + +export interface Collection { + id: number; + name: string; + description: string; + cover_url: string | null; + document_count: number; + is_public: boolean; + created_at: string; + updated_at: string; +} + +export interface LibraryStats { + total_documents: number; + total_pages_read: number; + total_reading_time_minutes: number; + documents_this_month: number; + recent_activity: ReadingActivity[]; +} + +export interface ReadingActivity { + date: string; + documents_read: number; + pages_read: number; + minutes_read: number; +} + +// ============================================================================ +// Constants +// ============================================================================ + +export const SUPPORTED_FILE_TYPES = ['pdf', 'epub', 'mobi', 'txt', 'docx'] as const; +export type SupportedFileType = typeof SUPPORTED_FILE_TYPES[number]; + +export const HIGHLIGHT_COLORS = ['yellow', 'green', 'blue', 'pink', 'orange'] as const; +export type HighlightColor = typeof HIGHLIGHT_COLORS[number]; + +export const READING_SPEED_WPM = 250; // Average reading speed diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..3c7bbae --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,20 @@ +{ + "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/**/*"], + "exclude": ["dist", "node_modules"] +} \ No newline at end of file