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
This commit is contained in:
Marko (Hermes Implementer)
2026-05-26 04:35:13 +00:00
commit 84d8fed3f2
49 changed files with 4205 additions and 0 deletions
+66
View File
@@ -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"])