From 84d8fed3f2efefb54b91e5bad0a84834b9a1ad94 Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Tue, 26 May 2026 04:35:13 +0000 Subject: [PATCH] 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 --- .gitignore | 34 + backend/books/__init__.py | 0 backend/books/admin.py | 16 + backend/books/apps.py | 5 + backend/books/migrations/0001_initial.py | 41 + backend/books/migrations/__init__.py | 0 backend/books/models.py | 66 + backend/books/serializers.py | 95 ++ backend/books/tests.py | 325 +++++ backend/books/urls.py | 13 + backend/books/views.py | 91 ++ backend/config/__init__.py | 0 backend/config/asgi.py | 16 + backend/config/settings.py | 99 ++ backend/config/settings_app.py | 33 + backend/config/urls.py | 8 + backend/config/wsgi.py | 16 + backend/manage.py | 22 + backend/pytest.ini | 3 + backend/requirements.txt | 6 + docs/backend/api-spec.md | 84 ++ docs/frontend/component-spec.md | 82 ++ frontend/.gitignore | 24 + frontend/README.md | 73 ++ frontend/eslint.config.js | 22 + frontend/index.html | 13 + frontend/package.json | 32 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.css | 577 +++++++++ frontend/src/App.tsx | 32 + frontend/src/api/books.ts | 90 ++ frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/components/BookCard.tsx | 54 + frontend/src/components/BookDetail.tsx | 151 +++ frontend/src/components/BookForm.tsx | 209 ++++ frontend/src/components/Library.tsx | 227 ++++ frontend/src/index.css | 16 + frontend/src/main.tsx | 10 + frontend/src/types.ts | 58 + frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.app.json | 25 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 19 + package.json | 14 + yarn.lock | 1445 ++++++++++++++++++++++ 49 files changed, 4205 insertions(+) create mode 100644 .gitignore create mode 100644 backend/books/__init__.py create mode 100644 backend/books/admin.py create mode 100644 backend/books/apps.py create mode 100644 backend/books/migrations/0001_initial.py create mode 100644 backend/books/migrations/__init__.py create mode 100644 backend/books/models.py create mode 100644 backend/books/serializers.py create mode 100644 backend/books/tests.py create mode 100644 backend/books/urls.py create mode 100644 backend/books/views.py create mode 100644 backend/config/__init__.py create mode 100644 backend/config/asgi.py create mode 100644 backend/config/settings.py create mode 100644 backend/config/settings_app.py create mode 100644 backend/config/urls.py create mode 100644 backend/config/wsgi.py create mode 100755 backend/manage.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt create mode 100644 docs/backend/api-spec.md create mode 100644 docs/frontend/component-spec.md create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/books.ts create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/BookCard.tsx create mode 100644 frontend/src/components/BookDetail.tsx create mode 100644 frontend/src/components/BookForm.tsx create mode 100644 frontend/src/components/Library.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 package.json create mode 100644 yarn.lock 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 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 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}
} + +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +