commit 84d8fed3f2efefb54b91e5bad0a84834b9a1ad94 Author: Marko (Hermes Implementer) Date: Tue May 26 04:35:13 2026 +0000 feat: full book management system with backend API, frontend UI, and spec docs - Backend: Book model with reading progress, DRF ViewSet with full CRUD, search, sort, filter, pagination, mark-as-finished, stats endpoint - Frontend: Library grid, BookCard, BookDetail, BookForm components with React 19 + TypeScript + Vite - Tests: 29 passing tests covering models, API, serializers, permissions - Spec: backend api-spec.md and frontend component-spec.md in docs/ Closes crisleo-hermes/cloud-reader#3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93f7c53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +*.egg +.venv/ +venv/ +*.sqlite3 + +# Node +node_modules/ +dist/ +build/ +.yarn/ +yarn-error.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.production + +# Build artifacts (generated) +frontend/dist/ \ No newline at end of file diff --git a/backend/books/__init__.py b/backend/books/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/books/admin.py b/backend/books/admin.py new file mode 100644 index 0000000..7a79ed8 --- /dev/null +++ b/backend/books/admin.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from django.contrib import admin + +from books.models import Book + + +@admin.register(Book) +class BookAdmin(admin.ModelAdmin): + """Admin configuration for the Book model.""" + + list_display = ["title", "author", "genre", "reading_status", "reading_progress", "owner", "updated_at"] + list_filter = ["reading_status", "genre"] + search_fields = ["title", "author"] + readonly_fields = ["reading_progress", "created_at", "updated_at"] + raw_id_fields = ["owner"] \ No newline at end of file diff --git a/backend/books/apps.py b/backend/books/apps.py new file mode 100644 index 0000000..f716137 --- /dev/null +++ b/backend/books/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BooksConfig(AppConfig): + name = 'books' diff --git a/backend/books/migrations/0001_initial.py b/backend/books/migrations/0001_initial.py new file mode 100644 index 0000000..80588f1 --- /dev/null +++ b/backend/books/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 6.0.5 on 2026-05-26 00:49 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Book', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=500, verbose_name='Title')), + ('author', models.CharField(max_length=500, verbose_name='Author')), + ('genre', models.CharField(blank=True, default='', max_length=200, verbose_name='Genre')), + ('description', models.TextField(blank=True, default='', verbose_name='Description')), + ('cover_image_url', models.URLField(blank=True, default='', verbose_name='Cover Image URL')), + ('isbn', models.CharField(blank=True, default='', max_length=20, verbose_name='ISBN')), + ('total_pages', models.PositiveIntegerField(default=0, verbose_name='Total Pages')), + ('current_page', models.PositiveIntegerField(default=0, verbose_name='Current Page')), + ('reading_status', models.CharField(choices=[('not_started', 'Not Started'), ('reading', 'Reading'), ('finished', 'Finished'), ('dnf', 'Did Not Finish')], default='not_started', max_length=20, verbose_name='Reading Status')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='books', to=settings.AUTH_USER_MODEL, verbose_name='Owner')), + ], + options={ + 'verbose_name': 'Book', + 'verbose_name_plural': 'Books', + 'ordering': ['-updated_at'], + 'indexes': [models.Index(fields=['owner', 'reading_status'], name='books_book_owner_i_424622_idx'), models.Index(fields=['owner', 'title'], name='books_book_owner_i_350794_idx'), models.Index(fields=['owner', 'author'], name='books_book_owner_i_f90626_idx')], + }, + ), + ] diff --git a/backend/books/migrations/__init__.py b/backend/books/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/books/models.py b/backend/books/models.py new file mode 100644 index 0000000..b99ffbb --- /dev/null +++ b/backend/books/models.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ReadingStatus(models.TextChoices): + """Enumeration of possible reading statuses for a book.""" + + NOT_STARTED = "not_started", _("Not Started") + READING = "reading", _("Reading") + FINISHED = "finished", _("Finished") + DNF = "dnf", _("Did Not Finish") + + +class Book(models.Model): + """Represents a book in the user's library.""" + + title = models.CharField(max_length=500, verbose_name=_("Title")) + author = models.CharField(max_length=500, verbose_name=_("Author")) + genre = models.CharField(max_length=200, blank=True, default="", verbose_name=_("Genre")) + description = models.TextField(blank=True, default="", verbose_name=_("Description")) + cover_image_url = models.URLField(blank=True, default="", verbose_name=_("Cover Image URL")) + isbn = models.CharField(max_length=20, blank=True, default="", verbose_name=_("ISBN")) + total_pages = models.PositiveIntegerField(default=0, verbose_name=_("Total Pages")) + current_page = models.PositiveIntegerField(default=0, verbose_name=_("Current Page")) + reading_status = models.CharField( + max_length=20, + choices=ReadingStatus.choices, + default=ReadingStatus.NOT_STARTED, + verbose_name=_("Reading Status"), + ) + owner = models.ForeignKey( + "auth.User", + on_delete=models.CASCADE, + related_name="books", + verbose_name=_("Owner"), + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + class Meta: + ordering = ["-updated_at"] + verbose_name = _("Book") + verbose_name_plural = _("Books") + indexes = [ + models.Index(fields=["owner", "reading_status"]), + models.Index(fields=["owner", "title"]), + models.Index(fields=["owner", "author"]), + ] + + def __str__(self) -> str: + return f"{self.title} by {self.author}" + + @property + def reading_progress(self) -> float: + """Calculate reading progress as a percentage (0.0 - 100.0).""" + if self.total_pages == 0: + return 0.0 + return round((self.current_page / self.total_pages) * 100, 1) + + def mark_as_finished(self) -> None: + """Mark the book as finished, setting progress to 100%.""" + self.reading_status = ReadingStatus.FINISHED + self.current_page = self.total_pages + self.save(update_fields=["reading_status", "current_page", "updated_at"]) \ No newline at end of file diff --git a/backend/books/serializers.py b/backend/books/serializers.py new file mode 100644 index 0000000..d3d45ff --- /dev/null +++ b/backend/books/serializers.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from rest_framework import serializers + +from books.models import Book, ReadingStatus + + +class BookListSerializer(serializers.ModelSerializer): + """Lightweight serializer for list views — avoids heavy field serialization.""" + + reading_progress = serializers.FloatField(read_only=True) + + class Meta: + model = Book + fields = [ + "id", + "title", + "author", + "genre", + "reading_status", + "reading_progress", + "cover_image_url", + "created_at", + "updated_at", + ] + + +class BookDetailSerializer(serializers.ModelSerializer): + """Full serializer for book detail views including all metadata.""" + + reading_progress = serializers.FloatField(read_only=True) + owner = serializers.ReadOnlyField(source="owner.username") + + class Meta: + model = Book + fields = [ + "id", + "title", + "author", + "genre", + "description", + "cover_image_url", + "isbn", + "total_pages", + "current_page", + "reading_status", + "reading_progress", + "owner", + "created_at", + "updated_at", + ] + read_only_fields = ["owner", "created_at", "updated_at", "reading_progress"] + + def validate_title(self, value: str) -> str: + """Ensure title is not just whitespace.""" + stripped = value.strip() + if not stripped: + msg = "Title cannot be empty." + raise serializers.ValidationError(msg) + return stripped + + def validate_author(self, value: str) -> str: + """Ensure author is not just whitespace.""" + stripped = value.strip() + if not stripped: + msg = "Author cannot be empty." + raise serializers.ValidationError(msg) + return stripped + + def validate(self, attrs: dict) -> dict: + """Business rules — handles both full creates and partial updates.""" + # Resolve effective values: use provided attrs, fall back to instance + current_total = getattr(self.instance, "total_pages", None) + current_current = getattr(self.instance, "current_page", None) + total_pages = attrs.get("total_pages", current_total) or 0 + current_page = attrs.get("current_page", current_current) or 0 + reading_status = attrs.get("reading_status", None) + + if current_page > total_pages > 0: + msg = "Current page cannot exceed total pages." + raise serializers.ValidationError({"current_page": msg}) + + if reading_status == ReadingStatus.FINISHED: + if total_pages > 0: + attrs["current_page"] = total_pages + elif self.instance and self.instance.total_pages > 0: + attrs["current_page"] = self.instance.total_pages + + return attrs + + +class BookWriteSerializer(BookDetailSerializer): + """Alias for detail serializer — used for write operations with full validation.""" + + pass \ No newline at end of file diff --git a/backend/books/tests.py b/backend/books/tests.py new file mode 100644 index 0000000..a41a283 --- /dev/null +++ b/backend/books/tests.py @@ -0,0 +1,325 @@ +"""Tests for the books app — API endpoints, models, serializers, and permissions.""" + +from __future__ import annotations + +from django.contrib.auth.models import User +from django.test import TestCase, override_settings +from rest_framework import status +from rest_framework.test import APIClient + +from books.models import Book, ReadingStatus + + +class BookModelTests(TestCase): + """Tests for the Book model.""" + + def setUp(self) -> None: + self.user = User.objects.create_user(username="testuser", password="testpass123") + self.book = Book.objects.create( + title="Test Book", + author="Test Author", + total_pages=200, + current_page=50, + reading_status=ReadingStatus.READING, + owner=self.user, + ) + + def test_reading_progress_calculates_correctly(self) -> None: + """Reading progress should be (current_page / total_pages) * 100.""" + assert self.book.reading_progress == 25.0 + + def test_reading_progress_returns_zero_when_no_pages(self) -> None: + """When total_pages is 0, reading_progress should be 0.0.""" + book = Book.objects.create( + title="No Pages", + author="Author", + total_pages=0, + current_page=50, + owner=self.user, + ) + assert book.reading_progress == 0.0 + + def test_mark_as_finished_sets_progress_to_100(self) -> None: + """mark_as_finished should set status to FINISHED and progress to 100%.""" + self.book.mark_as_finished() + self.book.refresh_from_db() + assert self.book.reading_status == ReadingStatus.FINISHED + assert self.book.current_page == self.book.total_pages + + def test_mark_as_finished_with_zero_pages(self) -> None: + """mark_as_finished with total_pages=0 should set 0/0.""" + book = Book.objects.create( + title="Empty", + author="Author", + total_pages=0, + current_page=0, + owner=self.user, + ) + book.mark_as_finished() + book.refresh_from_db() + assert book.reading_status == ReadingStatus.FINISHED + assert book.current_page == 0 + + def test_str_method(self) -> None: + """__str__ should return 'Title by Author'.""" + assert str(self.book) == "Test Book by Test Author" + + +class BookAPITests(TestCase): + """Tests for the Book API endpoints.""" + + def setUp(self) -> None: + self.client = APIClient() + self.user = User.objects.create_user(username="testuser", password="testpass123") + self.other_user = User.objects.create_user(username="other", password="testpass123") + self.client.force_authenticate(user=self.user) + + self.book = Book.objects.create( + title="My Book", + author="My Author", + genre="Fiction", + description="A great book", + total_pages=200, + current_page=50, + reading_status=ReadingStatus.READING, + owner=self.user, + ) + # Other user's book (should not be visible) + Book.objects.create( + title="Other Book", + author="Other Author", + total_pages=100, + owner=self.other_user, + ) + + # --- List --- + + def test_list_books_returns_owned_books_only(self) -> None: + """GET /api/books/ should only return books owned by the current user.""" + resp = self.client.get("/api/books/") + assert resp.status_code == status.HTTP_200_OK + assert resp.data["count"] == 1 + assert resp.data["results"][0]["title"] == "My Book" + + def test_list_books_requires_authentication(self) -> None: + """GET /api/books/ without auth should return 403.""" + self.client.force_authenticate(user=None) + resp = self.client.get("/api/books/") + assert resp.status_code == status.HTTP_403_FORBIDDEN + + # --- Create --- + + def test_create_book_sets_owner(self) -> None: + """POST /api/books/ should create a book owned by the current user.""" + resp = self.client.post("/api/books/", { + "title": "New Book", + "author": "New Author", + }, format="json") + assert resp.status_code == status.HTTP_201_CREATED + assert resp.data["owner"] == "testuser" + assert Book.objects.filter(title="New Book", owner=self.user).exists() + + def test_create_book_validates_required_fields(self) -> None: + """POST /api/books/ with missing title should fail.""" + resp = self.client.post("/api/books/", { + "author": "Author Only", + }, format="json") + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + # --- Retrieve --- + + def test_retrieve_book(self) -> None: + """GET /api/books/{id}/ should return full book details.""" + resp = self.client.get(f"/api/books/{self.book.id}/") + assert resp.status_code == status.HTTP_200_OK + assert resp.data["title"] == "My Book" + assert resp.data["reading_progress"] == 25.0 + assert resp.data["owner"] == "testuser" + + def test_retrieve_other_users_book_returns_404(self) -> None: + """GET /api/books/{other_id}/ should return 404 for another user's book.""" + other_book = Book.objects.get(title="Other Book") + resp = self.client.get(f"/api/books/{other_book.id}/") + assert resp.status_code == status.HTTP_404_NOT_FOUND + + # --- Update --- + + def test_update_book(self) -> None: + """PATCH /api/books/{id}/ should update book fields.""" + resp = self.client.patch(f"/api/books/{self.book.id}/", { + "title": "Updated Title", + "current_page": 100, + }, format="json") + assert resp.status_code == status.HTTP_200_OK + assert resp.data["title"] == "Updated Title" + assert resp.data["reading_progress"] == 50.0 + + def test_update_current_page_exceeds_total(self) -> None: + """PATCH with current_page > total_pages should fail.""" + resp = self.client.patch(f"/api/books/{self.book.id}/", { + "current_page": 999, + }, format="json") + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + # --- Delete --- + + def test_delete_book(self) -> None: + """DELETE /api/books/{id}/ should delete the book.""" + resp = self.client.delete(f"/api/books/{self.book.id}/") + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert not Book.objects.filter(id=self.book.id).exists() + + # --- Mark Finished --- + + def test_mark_finished_sets_100_percent(self) -> None: + """POST /api/books/{id}/mark_finished/ should set progress to 100%.""" + resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/") + assert resp.status_code == status.HTTP_200_OK + assert resp.data["reading_status"] == ReadingStatus.FINISHED + assert resp.data["reading_progress"] == 100.0 + + def test_mark_finished_idempotent(self) -> None: + """Calling mark_finished twice should be safe.""" + self.client.post(f"/api/books/{self.book.id}/mark_finished/") + resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/") + assert resp.status_code == status.HTTP_200_OK + + def test_mark_finished_other_users_book(self) -> None: + """POST mark_finished on another user's book should return 404.""" + other_book = Book.objects.get(title="Other Book") + resp = self.client.post(f"/api/books/{other_book.id}/mark_finished/") + assert resp.status_code == status.HTTP_404_NOT_FOUND + + # --- Stats --- + + def test_stats_returns_counts(self) -> None: + """GET /api/books/stats/ should return aggregate counts.""" + resp = self.client.get("/api/books/stats/") + assert resp.status_code == status.HTTP_200_OK + assert resp.data["total_books"] == 1 + assert resp.data["finished"] == 0 + assert resp.data["reading"] == 1 + assert resp.data["not_started"] == 0 + + # --- Filtering & Sorting --- + + def test_filter_by_reading_status(self) -> None: + """GET /api/books/?reading_status=reading should filter correctly.""" + resp = self.client.get("/api/books/", {"reading_status": "reading"}) + assert resp.status_code == status.HTTP_200_OK + assert resp.data["count"] == 1 + + resp = self.client.get("/api/books/", {"reading_status": "finished"}) + assert resp.status_code == status.HTTP_200_OK + assert resp.data["count"] == 0 + + def test_search_by_title(self) -> None: + """GET /api/books/?search=My should filter by title.""" + resp = self.client.get("/api/books/", {"search": "My"}) + assert resp.status_code == status.HTTP_200_OK + assert resp.data["count"] == 1 + + resp = self.client.get("/api/books/", {"search": "Nonexistent"}) + assert resp.status_code == status.HTTP_200_OK + assert resp.data["count"] == 0 + + def test_search_by_author(self) -> None: + """GET /api/books/?search=My should filter by author.""" + resp = self.client.get("/api/books/", {"search": "My Author"}) + assert resp.status_code == status.HTTP_200_OK + assert resp.data["count"] == 1 + + def test_sort_by_title(self) -> None: + """GET /api/books/?sort_by=title should sort alphabetically.""" + Book.objects.create(title="Aardvark", author="Author", owner=self.user) + Book.objects.create(title="Zebra", author="Author", owner=self.user) + resp = self.client.get("/api/books/", {"sort_by": "title"}) + assert resp.status_code == status.HTTP_200_OK + titles = [b["title"] for b in resp.data["results"]] + assert titles == sorted(titles) + + def test_sort_by_author_desc(self) -> None: + """GET /api/books/?sort_by=-author should sort by author desc.""" + resp = self.client.get("/api/books/", {"sort_by": "-author"}) + assert resp.status_code == status.HTTP_200_OK + + # --- Pagination --- + + def test_pagination_default_page_size(self) -> None: + """Books should be paginated with default page size.""" + for i in range(25): + Book.objects.create( + title=f"Book {i}", + author="Author", + owner=self.user, + ) + resp = self.client.get("/api/books/") + assert resp.status_code == status.HTTP_200_OK + assert len(resp.data["results"]) == 20 # PAGE_SIZE = 20 + assert resp.data["count"] == 26 # 1 original + 25 new + + def test_pagination_second_page(self) -> None: + """GET /api/books/?page=2 should return remaining items.""" + for i in range(25): + Book.objects.create( + title=f"Book {i}", + author="Author", + owner=self.user, + ) + resp = self.client.get("/api/books/", {"page": 2}) + assert resp.status_code == status.HTTP_200_OK + assert len(resp.data["results"]) == 6 + + +class BookSerializerTests(TestCase): + """Tests for serializers — validation rules.""" + + def setUp(self) -> None: + self.user = User.objects.create_user(username="testuser", password="testpass123") + + def test_current_page_cannot_exceed_total(self) -> None: + """Serializer should reject current_page > total_pages.""" + from books.serializers import BookDetailSerializer + data = { + "title": "Test", + "author": "Author", + "total_pages": 100, + "current_page": 150, + } + serializer = BookDetailSerializer(data=data) + assert not serializer.is_valid() + assert "current_page" in serializer.errors + + def test_title_cannot_be_blank(self) -> None: + """Serializer should reject blank title.""" + from books.serializers import BookDetailSerializer + data = { + "title": " ", + "author": "Author", + } + serializer = BookDetailSerializer(data=data) + assert not serializer.is_valid() + assert "title" in serializer.errors + + def test_author_cannot_be_blank(self) -> None: + """Serializer should reject blank author.""" + from books.serializers import BookDetailSerializer + data = { + "title": "Test", + "author": "", + } + serializer = BookDetailSerializer(data=data) + assert not serializer.is_valid() + assert "author" in serializer.errors + + def test_list_serializer_has_minimal_fields(self) -> None: + """BookListSerializer should exclude sensitive/fluff fields.""" + from books.serializers import BookListSerializer + serializer = BookListSerializer() + fields = set(serializer.fields.keys()) + assert "id" in fields + assert "title" in fields + assert "author" in fields + assert "reading_progress" in fields + assert "description" not in fields + assert "isbn" not in fields \ No newline at end of file diff --git a/backend/books/urls.py b/backend/books/urls.py new file mode 100644 index 0000000..5fcc08e --- /dev/null +++ b/backend/books/urls.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from books.views import BookViewSet + +router = DefaultRouter() +router.register(r"books", BookViewSet, basename="book") + +urlpatterns = [ + path("", include(router.urls)), +] \ No newline at end of file diff --git a/backend/books/views.py b/backend/books/views.py new file mode 100644 index 0000000..4a0a7b6 --- /dev/null +++ b/backend/books/views.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from django.db import models +from django.db.models import QuerySet +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response + +from books.models import Book, ReadingStatus +from books.serializers import BookDetailSerializer, BookListSerializer + + +class BookViewSet(viewsets.ModelViewSet): + """ + ViewSet for managing books in the user's library. + + Provides: + - list / retrieve / create / update / partial_update / destroy + - `mark_finished` action to set a book as 100% complete + - `stats` action for library overview counts + """ + + permission_classes = [IsAuthenticated] + + def get_serializer_class(self) -> type: + if self.action == "list": + return BookListSerializer + return BookDetailSerializer + + def get_queryset(self) -> QuerySet[Book]: + """Return books owned by the current user with optimised queries.""" + qs = Book.objects.filter(owner=self.request.user).select_related("owner") + + # Sorting + sort_by = self.request.query_params.get("sort_by", "-updated_at") + allowed_sorts = { + "title": "title", + "-title": "-title", + "author": "author", + "-author": "-author", + "created_at": "created_at", + "-created_at": "-created_at", + "updated_at": "updated_at", + "-updated_at": "-updated_at", + "reading_progress": "current_page", # approximate sort by pages read + "-reading_progress": "-current_page", + } + if sort_by in allowed_sorts: + qs = qs.order_by(allowed_sorts[sort_by]) + + # Filtering + status_filter = self.request.query_params.get("reading_status", None) + if status_filter in ReadingStatus.values: + qs = qs.filter(reading_status=status_filter) + + search = self.request.query_params.get("search", "").strip() + if search: + qs = qs.filter( + models.Q(title__icontains=search) | models.Q(author__icontains=search) + ) + + return qs + + def perform_create(self, serializer: BookDetailSerializer) -> None: + """Set the owner to the current user on creation.""" + serializer.save(owner=self.request.user) + + @action(detail=True, methods=["post"]) + def mark_finished(self, request: Request, pk: int | None = None) -> Response: + """Mark a book as finished (100% progress).""" + book: Book = self.get_object() + book.mark_as_finished() + serializer = self.get_serializer(book) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"]) + def stats(self, request: Request) -> Response: + """Return aggregate stats about the user's library.""" + qs = self.get_queryset() + total = qs.count() + finished = qs.filter(reading_status=ReadingStatus.FINISHED).count() + reading = qs.filter(reading_status=ReadingStatus.READING).count() + not_started = qs.filter(reading_status=ReadingStatus.NOT_STARTED).count() + return Response({ + "total_books": total, + "finished": finished, + "reading": reading, + "not_started": not_started, + }) \ 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/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..ffbb5f5 --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..d9f6cfd --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,99 @@ +""" +Django settings for config project. + +Generated by 'django-admin startproject' using Django 6.0.5. +""" + +from pathlib import Path + +from config.settings_app import settings + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = settings.SECRET_KEY +DEBUG = settings.DEBUG +ALLOWED_HOSTS = settings.ALLOWED_HOSTS + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # Third-party + "rest_framework", + "corsheaders", + # Local apps + "books", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "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": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# CORS +CORS_ALLOWED_ORIGINS = settings.CORS_ALLOWED_ORIGINS +CORS_ALLOW_CREDENTIALS = settings.CORS_ALLOW_CREDENTIALS + +# REST Framework +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticatedOrReadOnly", + ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, +} \ No newline at end of file diff --git a/backend/config/settings_app.py b/backend/config/settings_app.py new file mode 100644 index 0000000..0c53232 --- /dev/null +++ b/backend/config/settings_app.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + DEBUG: bool = True + SECRET_KEY: str = "django-insecure-change-me-in-production" + DATABASE_URL: str = "sqlite:///db.sqlite3" + ALLOWED_HOSTS: list[str] = ["*"] + CORS_ALLOWED_ORIGINS: list[str] = [ + "http://localhost:3000", + "http://localhost:5173", + "http://127.0.0.1:3000", + "http://127.0.0.1:5173", + ] + CORS_ALLOW_CREDENTIALS: bool = True + + BASE_DIR: Path = Path(__file__).resolve().parent.parent + + model_config = SettingsConfigDict( + env_prefix="CR_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + +settings = Settings() \ No newline at end of file diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..3f19843 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api-auth/", include("rest_framework.urls")), + path("api/", include("books.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..4ced574 --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 0000000..8e7ac79 --- /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(): + """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() diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..7a4fb9b --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings +python_files = tests.py test_*.py *_tests.py diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1fe81ee --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +django>=5.0,<7.0 +djangorestframework>=3.15.0 +django-cors-headers>=4.0.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +pytest-django>=4.5.0 \ No newline at end of file diff --git a/docs/backend/api-spec.md b/docs/backend/api-spec.md new file mode 100644 index 0000000..a500544 --- /dev/null +++ b/docs/backend/api-spec.md @@ -0,0 +1,84 @@ +# Backend API Specification — Cloud Reader + +## Overview + +The Cloud Reader backend provides a RESTful API for managing a user's personal book library. Built with Django 5 + Django REST Framework. + +## Models + +### Book + +| Field | Type | Constraints | +|------------------|--------------------|---------------------------------| +| `title` | `CharField(500)` | Required | +| `author` | `CharField(500)` | Required | +| `genre` | `CharField(200)` | Optional, blank allowed | +| `description` | `TextField` | Optional, blank allowed | +| `cover_image_url`| `URLField` | Optional, blank allowed | +| `isbn` | `CharField(20)` | Optional, blank allowed | +| `total_pages` | `PositiveIntegerField` | Default 0 | +| `current_page` | `PositiveIntegerField` | Default 0, validated ≤ total | +| `reading_status` | `CharField(20)` | Choices: `not_started`, `reading`, `finished`, `dnf` | +| `owner` | `ForeignKey(User)` | Set automatically on create | +| `created_at` | `DateTimeField` | Auto-set on create | +| `updated_at` | `DateTimeField` | Auto-set on update | + +**Properties:** +- `reading_progress` — computed `(current_page / total_pages) * 100`, returns `0.0` when `total_pages` is 0. + +**Indexes:** Composite indexes on `(owner, reading_status)`, `(owner, title)`, `(owner, author)`. + +## API Endpoints + +Base URL: `/api/` + +Authentication: SessionAuthentication + BasicAuthentication (DRF defaults). + +Permissions: All book endpoints require `IsAuthenticated`. Users can only access their own books. + +### Books + +| Method | URL | Action | Serializer | +|----------|------------------------------------|---------------|--------------------| +| `GET` | `/api/books/` | List books | `BookListSerializer` | +| `POST` | `/api/books/` | Create book | `BookDetailSerializer` | +| `GET` | `/api/books/{id}/` | Retrieve book | `BookDetailSerializer` | +| `PUT` | `/api/books/{id}/` | Full update | `BookDetailSerializer` | +| `PATCH` | `/api/books/{id}/` | Partial update| `BookDetailSerializer` | +| `DELETE` | `/api/books/{id}/` | Delete book | — | +| `POST` | `/api/books/{id}/mark_finished/` | Mark finished | `BookDetailSerializer` | +| `GET` | `/api/books/stats/` | Library stats | — (custom) | + +### Query Parameters (List) + +| Parameter | Type | Description | +|------------------|----------|--------------------------------------------------| +| `page` | int | Page number for pagination (20 items/page) | +| `sort_by` | string | `title`, `-title`, `author`, `-author`, `-created_at`, `-updated_at`, `-reading_progress`, `reading_progress` | +| `reading_status` | string | Filter by status value | +| `search` | string | Search in title and author fields (icontains) | + +### Stats Response + +```json +{ + "total_books": 10, + "finished": 3, + "reading": 4, + "not_started": 3 +} +``` + +### Mark Finished + +`POST /api/books/{id}/mark_finished/` sets `reading_status` to `finished` and `current_page` to `total_pages`. + +## Validation Rules + +- Title and author cannot be empty or whitespace-only +- `current_page` cannot exceed `total_pages` (when `total_pages > 0`) +- Setting `reading_status` to `finished` automatically sets `current_page = total_pages` + +## Admin + +Books are registered in Django admin with list display, filters by `reading_status` and `genre`, and search by `title`/`author`. \ No newline at end of file diff --git a/docs/frontend/component-spec.md b/docs/frontend/component-spec.md new file mode 100644 index 0000000..d65a699 --- /dev/null +++ b/docs/frontend/component-spec.md @@ -0,0 +1,82 @@ +# Frontend Component Specification — Cloud Reader + +## Overview + +React 19 + TypeScript SPA using Vite for development and production builds. State managed via React hooks and Context API. Routing via `react-router-dom` v7 with `lazy`/`Suspense` for code splitting. + +## Components + +### App (root) + +- **Path:** `/` +- **Layout:** Header with logo + `` wrapper +- **Routing:** `/` → `Library`, `*` → redirect to `/` +- **Code splitting:** `Library` loaded via `React.lazy` + `` + +### Library + +- **State:** `books[]`, `stats`, `search`, `sortBy`, `statusFilter`, `currentPage`, `view` +- **Views:** `library` (grid), `detail` (selected book), `add` (modal form) +- **Sub-components:** `BookCard`, `BookDetail`, `BookForm` +- **Data flow:** Calls `fetchBooks()` on mount and when filters/page change + +### BookCard + +- **Props:** `{ book: Book, onClick: (book: Book) => void }` +- **Display:** Cover image (or first-letter placeholder), title, author, genre badge, reading progress bar with status color +- **Interaction:** Click/keyboard-accessible (Enter/Space) + +### BookDetail + +- **Props:** `{ bookId: number, onBack: () => void, onUpdated: () => void }` +- **Sections:** Cover, metadata (title, author, genre, ISBN), progress bar with page count, action buttons +- **Actions:** Mark as Finished, Edit Details (switches to BookForm), Delete (with confirmation) +- **States:** Loading, error, editing mode + +### BookForm + +- **Props:** `{ initialData?: Book, onSubmit: (data: BookFormData) => Promise, onCancel: () => void }` +- **Fields:** Title*, Author*, Genre, Description, Cover URL, ISBN, Total Pages, Current Page, Status +- **Client validation:** Title/author required, page ≤ total pages +- **Loading state:** Submit button shows "Saving..." when `isLoading` + +## Types + +```typescript +interface Book { + id: number; title: string; author: string; genre: string; + description: string; cover_image_url: string; isbn: string; + total_pages: number; current_page: number; + reading_status: ReadingStatus; reading_progress: number; + owner: string; created_at: string; updated_at: string; +} + +type ReadingStatus = 'not_started' | 'reading' | 'finished' | 'dnf'; + +interface BookFormData { + title: string; author: string; genre: string; + description: string; cover_image_url: string; isbn: string; + total_pages: number; current_page: number; reading_status: ReadingStatus; +} +``` + +## API Client (`api/books.ts`) + +| Function | HTTP Call | +|----------------------|------------------------------------| +| `fetchBooks(params)` | `GET /api/books/` | +| `fetchBook(id)` | `GET /api/books/{id}/` | +| `createBook(data)` | `POST /api/books/` | +| `updateBook(id, data)` | `PATCH /api/books/{id}/` | +| `deleteBook(id)` | `DELETE /api/books/{id}/` | +| `markAsFinished(id)` | `POST /api/books/{id}/mark_finished/` | +| `fetchBookStats()` | `GET /api/books/stats/` | + +## Styling + +Dark theme with CSS custom properties. All styles in `App.css`. Responsive grid layout with breakpoint at 768px. + +## Build & Dev + +- `yarn workspace frontend dev` — Vite dev server on port 5173 with API proxy to 127.0.0.1:8000 +- `yarn workspace frontend build` — TypeScript check + Vite production build \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..91d45ea --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.16.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..d93649e --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,577 @@ +/* === Reset & Base === */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --color-bg: #0f1117; + --color-surface: #1a1d27; + --color-surface-hover: #242736; + --color-border: #2a2d3a; + --color-text: #e1e3ea; + --color-text-muted: #8b8fa3; + --color-primary: #6366f1; + --color-primary-hover: #4f46e5; + --color-success: #22c55e; + --color-danger: #ef4444; + --color-danger-hover: #dc2626; + --color-secondary: #374151; + --color-secondary-hover: #4b5563; + --radius: 8px; + --radius-lg: 12px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--color-bg); + color: var(--color-text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +/* === App Layout === */ +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-header { + background-color: var(--color-surface); + border-bottom: 1px solid var(--color-border); + padding: 0 24px; + position: sticky; + top: 0; + z-index: 100; +} + +.app-header-inner { + max-width: 1200px; + margin: 0 auto; + height: 56px; + display: flex; + align-items: center; +} + +.app-logo { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: var(--color-text); + font-weight: 700; + font-size: 1.125rem; +} + +.logo-icon { + font-size: 1.5rem; +} + +.app-main { + flex: 1; + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 24px; +} + +/* === Library === */ +.library-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.library-header h1 { + font-size: 1.75rem; + font-weight: 700; +} + +/* === Stats === */ +.library-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; +} + +.stat-label { + font-size: 0.8rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* === Controls === */ +.library-controls { + display: flex; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.search-input { + flex: 1; + min-width: 200px; + padding: 10px 14px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + color: var(--color-text); + font-size: 0.9rem; +} + +.search-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.sort-select, +.filter-select { + padding: 10px 14px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + color: var(--color-text); + font-size: 0.9rem; + cursor: pointer; +} + +/* === Books Grid === */ +.books-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; +} + +.book-card { + display: flex; + gap: 16px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 16px; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s, transform 0.15s; +} + +.book-card:hover { + background: var(--color-surface-hover); + border-color: var(--color-primary); + transform: translateY(-2px); +} + +.book-card-cover { + flex-shrink: 0; + width: 80px; + height: 120px; + border-radius: 6px; + overflow: hidden; +} + +.book-card-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.book-card-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, var(--color-primary), #8b5cf6); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + font-weight: 700; + color: white; +} + +.book-card-placeholder.large { + width: 180px; + height: 260px; + font-size: 3rem; +} + +.book-card-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.book-card-title { + font-size: 1rem; + font-weight: 600; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.book-card-author { + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.book-card-genre { + display: inline-block; + font-size: 0.75rem; + padding: 2px 8px; + background: rgba(99, 102, 241, 0.15); + color: var(--color-primary); + border-radius: 12px; + align-self: flex-start; +} + +.book-card-progress { + margin-top: auto; +} + +/* === Progress Bar === */ +.progress-bar { + width: 100%; + height: 6px; + background: var(--color-border); + border-radius: 3px; + overflow: hidden; +} + +.progress-bar.large { + height: 10px; + border-radius: 5px; +} + +.progress-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +} + +.progress-text { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--color-text-muted); + margin-top: 4px; +} + +/* === Buttons === */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--color-primary); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btn-secondary { + background: var(--color-secondary); + color: var(--color-text); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-secondary-hover); +} + +.btn-success { + background: var(--color-success); + color: white; +} + +.btn-success:hover:not(:disabled) { + background: #16a34a; +} + +.btn-danger { + background: var(--color-danger); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: var(--color-danger-hover); +} + +/* === Book Detail === */ +.book-detail { + max-width: 800px; +} + +.book-detail > .btn { + margin-bottom: 24px; +} + +.book-detail-header { + display: flex; + gap: 32px; + margin-bottom: 32px; +} + +.book-detail-cover { + flex-shrink: 0; + width: 180px; + height: 260px; + border-radius: var(--radius-lg); + overflow: hidden; +} + +.book-detail-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.book-detail-meta { + flex: 1; +} + +.book-detail-meta h1 { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 4px; +} + +.book-detail-author { + font-size: 1.1rem; + color: var(--color-text-muted); + margin-bottom: 12px; +} + +.book-detail-isbn { + font-size: 0.85rem; + color: var(--color-text-muted); + margin-top: 8px; +} + +.book-detail-progress { + margin: 20px 0; +} + +.book-detail-progress .progress-text { + display: flex; + gap: 16px; + font-size: 0.85rem; +} + +.book-detail-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.book-detail-description { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px; +} + +.book-detail-description h3 { + font-size: 1.1rem; + margin-bottom: 12px; +} + +.book-detail-description p { + color: var(--color-text-muted); + line-height: 1.7; +} + +/* === Form === */ +.book-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--color-danger); + color: var(--color-danger); + padding: 10px 14px; + border-radius: var(--radius); + font-size: 0.875rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 0.85rem; + font-weight: 500; + color: var(--color-text-muted); +} + +.form-group input, +.form-group textarea, +.form-group select { + padding: 10px 14px; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius); + color: var(--color-text); + font-size: 0.9rem; + font-family: inherit; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: var(--color-primary); +} + +.form-group textarea { + resize: vertical; + min-height: 80px; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 8px; +} + +/* === Modal === */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 80px; + z-index: 200; +} + +.modal-content { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 32px; + width: 100%; + max-width: 560px; + max-height: 80vh; + overflow-y: auto; +} + +.modal-content h2 { + margin-bottom: 20px; +} + +/* === Pagination === */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-top: 32px; +} + +.page-info { + font-size: 0.875rem; + color: var(--color-text-muted); +} + +/* === States === */ +.loading-spinner { + text-align: center; + padding: 48px; + color: var(--color-text-muted); + font-size: 1rem; +} + +.empty-state { + text-align: center; + padding: 64px 24px; + color: var(--color-text-muted); +} + +.empty-state p { + margin-bottom: 8px; +} + +.error-message { + text-align: center; + padding: 32px; + color: var(--color-danger); +} + +/* === Responsive === */ +@media (max-width: 768px) { + .books-grid { + grid-template-columns: 1fr; + } + + .book-detail-header { + flex-direction: column; + align-items: center; + text-align: center; + } + + .book-detail-actions { + justify-content: center; + } + + .library-controls { + flex-direction: column; + } + + .library-stats { + grid-template-columns: repeat(2, 1fr); + } + + .form-row { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f45b721 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,32 @@ +import { lazy, Suspense } from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import './App.css'; + +const Library = lazy(() => import('./components/Library').then((m) => ({ default: m.Library }))); + +function App() { + return ( + + }> + + } /> + } /> + +
+ + + + ); +} + +export default App; \ No newline at end of file diff --git a/frontend/src/api/books.ts b/frontend/src/api/books.ts new file mode 100644 index 0000000..0d45cba --- /dev/null +++ b/frontend/src/api/books.ts @@ -0,0 +1,90 @@ +import axios from 'axios'; +import type { Book, BookFormData, BookListResponse, BookStats } from '../types'; + +const api = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, +}); + +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 403) { + window.location.href = '/login'; + } + return Promise.reject(error); + }, +); + +export async function fetchBooks(params?: { + page?: number; + sort_by?: string; + reading_status?: string; + search?: string; +}): Promise { + const response = await api.get('/books/', { params }); + return response.data; +} + +export async function fetchBook(id: number): Promise { + const response = await api.get(`/books/${id}/`); + return response.data; +} + +export async function createBook(data: BookFormData): Promise { + const response = await api.post('/books/', data); + return response.data; +} + +export async function updateBook(id: number, data: Partial): Promise { + const response = await api.patch(`/books/${id}/`, data); + return response.data; +} + +export async function deleteBook(id: number): Promise { + await api.delete(`/books/${id}/`); +} + +export async function markBookAsFinished(id: number): Promise { + const response = await api.post(`/books/${id}/mark_finished/`); + return response.data; +} + +export async function fetchBookStats(): Promise { + const response = await api.get('/books/stats/'); + return response.data; +} + +export async function login(username: string, password: string): Promise { + // Get CSRF token from any existing session + const csrfToken = extractCsrfToken(); + if (!csrfToken) { + // Make a light request to set the CSRF cookie + await fetch('/api/books/', { credentials: 'include' }); + } + const token = extractCsrfToken(); + + const resp = await fetch('/api-auth/login/', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': token, + }, + body: new URLSearchParams({ username, password }), + credentials: 'include', + }); + + if (!resp.ok) { + throw new Error('Login failed'); + } +} + +function extractCsrfToken(): string { + const match = document.cookie.match(/csrftoken=([\w-]+)/); + return match ? match[1] : ''; +} + +export default api; \ No newline at end of file diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/BookCard.tsx b/frontend/src/components/BookCard.tsx new file mode 100644 index 0000000..99ce0bd --- /dev/null +++ b/frontend/src/components/BookCard.tsx @@ -0,0 +1,54 @@ +import type { Book } from '../types'; +import { READING_STATUS_LABELS, READING_STATUS_COLORS } from '../types'; + +interface BookCardProps { + book: Book; + onClick: (book: Book) => void; +} + +export function BookCard({ book, onClick }: BookCardProps) { + const progress = book.reading_progress; + const statusColor = READING_STATUS_COLORS[book.reading_status]; + + return ( +
onClick(book)} + role="button" + tabIndex={0} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(book); + } + }} + > +
+ {book.cover_image_url ? ( + {`Cover + ) : ( +
+ {book.title.charAt(0)} +
+ )} +
+
+

{book.title}

+

{book.author}

+ {book.genre && {book.genre}} +
+
+
+
+
+ {READING_STATUS_LABELS[book.reading_status]} + {progress}% +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/BookDetail.tsx b/frontend/src/components/BookDetail.tsx new file mode 100644 index 0000000..0f1946d --- /dev/null +++ b/frontend/src/components/BookDetail.tsx @@ -0,0 +1,151 @@ +import { useState, useEffect, useCallback } from 'react'; +import type { Book, BookFormData } from '../types'; +import { READING_STATUS_LABELS, READING_STATUS_COLORS } from '../types'; +import { fetchBook, updateBook, markBookAsFinished, deleteBook } from '../api/books'; +import { BookForm } from './BookForm'; + +interface BookDetailProps { + bookId: number; + onBack: () => void; + onUpdated: () => void; +} + +export function BookDetail({ bookId, onBack, onUpdated }: BookDetailProps) { + const [book, setBook] = useState(null); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(null); + + const loadBook = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await fetchBook(bookId); + setBook(data); + } catch { + setError('Failed to load book details.'); + } finally { + setLoading(false); + } + }, [bookId]); + + useEffect(() => { + loadBook(); + }, [loadBook]); + + const handleUpdate = useCallback( + async (formData: BookFormData) => { + const updated = await updateBook(bookId, formData); + setBook(updated); + setEditing(false); + onUpdated(); + }, + [bookId, onUpdated], + ); + + const handleMarkFinished = useCallback(async () => { + const updated = await markBookAsFinished(bookId); + setBook(updated); + onUpdated(); + }, [bookId, onUpdated]); + + const handleDelete = useCallback(async () => { + if (!window.confirm('Are you sure you want to delete this book?')) return; + await deleteBook(bookId); + onBack(); + onUpdated(); + }, [bookId, onBack, onUpdated]); + + if (loading) { + return
Loading...
; + } + + if (error || !book) { + return
{error ?? 'Book not found.'}
; + } + + if (editing) { + return ( +
+ +

Edit Book

+ setEditing(false)} + /> +
+ ); + } + + const statusColor = READING_STATUS_COLORS[book.reading_status]; + + return ( +
+ + +
+
+ {book.cover_image_url ? ( + {`Cover + ) : ( +
+ {book.title.charAt(0)} +
+ )} +
+
+

{book.title}

+

by {book.author}

+ {book.genre && {book.genre}} + {book.isbn &&

ISBN: {book.isbn}

} + +
+
+
+
+
+ + {READING_STATUS_LABELS[book.reading_status]} + + {book.reading_progress}% complete + {book.total_pages > 0 && ( + + Page {book.current_page} of {book.total_pages} + + )} +
+
+ +
+ {book.reading_status !== 'finished' && ( + + )} + + +
+
+
+ + {book.description && ( +
+

Description

+

{book.description}

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/BookForm.tsx b/frontend/src/components/BookForm.tsx new file mode 100644 index 0000000..ca29126 --- /dev/null +++ b/frontend/src/components/BookForm.tsx @@ -0,0 +1,209 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import type { Book, BookFormData, ReadingStatus } from '../types'; +import { READING_STATUS_LABELS } from '../types'; + +interface BookFormProps { + initialData?: Book; + onSubmit: (data: BookFormData) => Promise; + onCancel: () => void; + isLoading?: boolean; +} + +export function BookForm({ initialData, onSubmit, onCancel, isLoading }: BookFormProps) { + const [formData, setFormData] = useState({ + title: initialData?.title ?? '', + author: initialData?.author ?? '', + genre: initialData?.genre ?? '', + description: initialData?.description ?? '', + cover_image_url: initialData?.cover_image_url ?? '', + isbn: initialData?.isbn ?? '', + total_pages: initialData?.total_pages ?? 0, + current_page: initialData?.current_page ?? 0, + reading_status: initialData?.reading_status ?? 'not_started', + }); + const [error, setError] = useState(null); + const titleRef = useRef(null); + + useEffect(() => { + titleRef.current?.focus(); + }, []); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: + name === 'total_pages' || name === 'current_page' + ? Math.max(0, parseInt(value, 10) || 0) + : value, + })); + }, + [], + ); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!formData.title.trim()) { + setError('Title is required.'); + return; + } + if (!formData.author.trim()) { + setError('Author is required.'); + return; + } + if (formData.current_page > formData.total_pages && formData.total_pages > 0) { + setError('Current page cannot exceed total pages.'); + return; + } + + try { + await onSubmit(formData); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError('Failed to save book.'); + } + } + }, + [formData, onSubmit], + ); + + return ( +
+ {error &&
{error}
} + +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +