diff --git a/backend/apps/groups/__init__.py b/backend/apps/groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/groups/admin.py b/backend/apps/groups/admin.py new file mode 100644 index 0000000..856b553 --- /dev/null +++ b/backend/apps/groups/admin.py @@ -0,0 +1,46 @@ +from django.contrib import admin + +from apps.groups.models import ( + GroupBook, + MemberProgress, + ReadingGroup, + ReadingGroupMembership, + ReadingSchedule, + Section, +) + + +@admin.register(ReadingGroup) +class ReadingGroupAdmin(admin.ModelAdmin): + list_display = ["name", "admin", "created_at"] + search_fields = ["name", "admin__email"] + + +@admin.register(ReadingGroupMembership) +class ReadingGroupMembershipAdmin(admin.ModelAdmin): + list_display = ["user", "group", "role", "joined_at"] + list_filter = ["role", "group"] + + +@admin.register(GroupBook) +class GroupBookAdmin(admin.ModelAdmin): + list_display = ["title", "group", "status", "uploaded_by", "created_at"] + list_filter = ["status"] + search_fields = ["title", "group__name"] + + +@admin.register(Section) +class SectionAdmin(admin.ModelAdmin): + list_display = ["title", "group_book", "order", "estimated_reading_minutes"] + ordering = ["group_book", "order"] + + +@admin.register(ReadingSchedule) +class ReadingScheduleAdmin(admin.ModelAdmin): + list_display = ["group_book", "meeting_number", "week_date"] + ordering = ["group_book", "meeting_number"] + + +@admin.register(MemberProgress) +class MemberProgressAdmin(admin.ModelAdmin): + list_display = ["user", "group_book", "current_section", "updated_at"] \ No newline at end of file diff --git a/backend/apps/groups/apps.py b/backend/apps/groups/apps.py new file mode 100644 index 0000000..d97374f --- /dev/null +++ b/backend/apps/groups/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class GroupsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.groups" + verbose_name = "Reading Groups" diff --git a/backend/apps/groups/migrations/0001_initial.py b/backend/apps/groups/migrations/0001_initial.py new file mode 100644 index 0000000..412de54 --- /dev/null +++ b/backend/apps/groups/migrations/0001_initial.py @@ -0,0 +1,154 @@ +# Generated by Django 5.1.7 on 2026-06-20 19:21 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('books', '0003_readingprogress_epub_location'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ReadingGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=256)), + ('description', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('admin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='administered_groups', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Reading Group', + 'verbose_name_plural': 'Reading Groups', + 'db_table': 'groups_reading_group', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='GroupBook', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(db_index=True, max_length=512)), + ('status', models.CharField(choices=[('active', 'Active'), ('replaced', 'Replaced')], db_index=True, default='active', max_length=16)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_books', to='books.ebook')), + ('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uploaded_group_books', to=settings.AUTH_USER_MODEL)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_books', to='groups.readinggroup')), + ], + options={ + 'verbose_name': 'Group Book', + 'verbose_name_plural': 'Group Books', + 'db_table': 'groups_group_book', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ReadingGroupMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)), + ('joined_at', models.DateTimeField(auto_now_add=True)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='groups.readinggroup')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Reading Group Membership', + 'verbose_name_plural': 'Reading Group Memberships', + 'db_table': 'groups_membership', + }, + ), + migrations.AddField( + model_name='readinggroup', + name='members', + field=models.ManyToManyField(related_name='reading_groups', through='groups.ReadingGroupMembership', to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='ReadingSchedule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('meeting_number', models.PositiveIntegerField()), + ('week_date', models.DateField()), + ('section_ids', models.JSONField(default=list, help_text='Ordered list of Section IDs for this meeting')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to='groups.groupbook')), + ], + options={ + 'verbose_name': 'Reading Schedule', + 'verbose_name_plural': 'Reading Schedules', + 'db_table': 'groups_reading_schedule', + 'ordering': ['meeting_number'], + }, + ), + migrations.CreateModel( + name='Section', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=512)), + ('order', models.PositiveIntegerField(db_index=True)), + ('start_chapter_index', models.PositiveIntegerField(default=0)), + ('end_chapter_index', models.PositiveIntegerField(default=0)), + ('estimated_reading_minutes', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='groups.groupbook')), + ], + options={ + 'verbose_name': 'Section', + 'verbose_name_plural': 'Sections', + 'db_table': 'groups_section', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='MemberProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('completed_sections', models.JSONField(default=list, help_text='Ordered list of completed Section IDs')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_progress', to='groups.groupbook')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_progress', to=settings.AUTH_USER_MODEL)), + ('current_section', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_readers', to='groups.section')), + ], + options={ + 'verbose_name': 'Member Progress', + 'verbose_name_plural': 'Member Progress', + 'db_table': 'groups_member_progress', + }, + ), + migrations.AddIndex( + model_name='groupbook', + index=models.Index(fields=['group', 'status'], name='groups_grou_group_i_afdbf7_idx'), + ), + migrations.AlterUniqueTogether( + name='readinggroupmembership', + unique_together={('user', 'group')}, + ), + migrations.AddIndex( + model_name='readingschedule', + index=models.Index(fields=['group_book', 'meeting_number'], name='groups_read_group_b_2fc416_idx'), + ), + migrations.AlterUniqueTogether( + name='readingschedule', + unique_together={('group_book', 'meeting_number')}, + ), + migrations.AddIndex( + model_name='section', + index=models.Index(fields=['group_book', 'order'], name='groups_sect_group_b_e2810a_idx'), + ), + migrations.AlterUniqueTogether( + name='section', + unique_together={('group_book', 'order')}, + ), + migrations.AlterUniqueTogether( + name='memberprogress', + unique_together={('user', 'group_book')}, + ), + ] diff --git a/backend/apps/groups/migrations/__init__.py b/backend/apps/groups/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/groups/models.py b/backend/apps/groups/models.py new file mode 100644 index 0000000..dba4891 --- /dev/null +++ b/backend/apps/groups/models.py @@ -0,0 +1,161 @@ +from django.conf import settings +from django.db import models + +from apps.books.models import EBook + + +class ReadingGroup(models.Model): + """A reading group with an admin and members.""" + + name = models.CharField(max_length=256, db_index=True) + description = models.TextField(blank=True, default="") + admin = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="administered_groups", + ) + members = models.ManyToManyField( + settings.AUTH_USER_MODEL, + through="ReadingGroupMembership", + related_name="reading_groups", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "groups_reading_group" + verbose_name = "Reading Group" + verbose_name_plural = "Reading Groups" + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.name + + +class ReadingGroupMembership(models.Model): + class Role(models.TextChoices): + ADMIN = "admin", "Admin" + MEMBER = "member", "Member" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + group = models.ForeignKey(ReadingGroup, on_delete=models.CASCADE, related_name="memberships") + role = models.CharField(max_length=16, choices=Role.choices, default=Role.MEMBER) + joined_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "groups_membership" + unique_together = [("user", "group")] + verbose_name = "Reading Group Membership" + verbose_name_plural = "Reading Group Memberships" + + def __str__(self) -> str: + return f"{self.user} in {self.group.name} ({self.role})" + + +class GroupBook(models.Model): + """Links an uploaded EBook to a reading group.""" + + class Status(models.TextChoices): + ACTIVE = "active", "Active" + REPLACED = "replaced", "Replaced" + + group = models.ForeignKey( + ReadingGroup, on_delete=models.CASCADE, related_name="group_books" + ) + ebook = models.ForeignKey( + EBook, on_delete=models.CASCADE, related_name="group_books" + ) + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="uploaded_group_books" + ) + title = models.CharField(max_length=512, db_index=True) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.ACTIVE, db_index=True + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "groups_group_book" + verbose_name = "Group Book" + verbose_name_plural = "Group Books" + ordering = ["-created_at"] + indexes = [models.Index(fields=["group", "status"])] + + def __str__(self) -> str: + return f"{self.group.name} — {self.title}" + + +class Section(models.Model): + """A section/chunk of a GroupBook for group reading.""" + + group_book = models.ForeignKey( + GroupBook, on_delete=models.CASCADE, related_name="sections" + ) + title = models.CharField(max_length=512) + order = models.PositiveIntegerField(db_index=True) + start_chapter_index = models.PositiveIntegerField(default=0) + end_chapter_index = models.PositiveIntegerField(default=0) + estimated_reading_minutes = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "groups_section" + verbose_name = "Section" + verbose_name_plural = "Sections" + ordering = ["order"] + unique_together = [("group_book", "order")] + indexes = [models.Index(fields=["group_book", "order"])] + + def __str__(self) -> str: + return f"{self.group_book.title} — Section {self.order}: {self.title}" + + +class ReadingSchedule(models.Model): + """Reading schedule mapping sections to weekly meetings.""" + + group_book = models.ForeignKey( + GroupBook, on_delete=models.CASCADE, related_name="schedules" + ) + meeting_number = models.PositiveIntegerField() + week_date = models.DateField() + section_ids = models.JSONField(default=list, help_text="Ordered list of Section IDs for this meeting") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "groups_reading_schedule" + verbose_name = "Reading Schedule" + verbose_name_plural = "Reading Schedules" + ordering = ["meeting_number"] + unique_together = [("group_book", "meeting_number")] + indexes = [models.Index(fields=["group_book", "meeting_number"])] + + def __str__(self) -> str: + return f"{self.group_book.title} — Meeting {self.meeting_number}" + + +class MemberProgress(models.Model): + """Tracks a member's progress through sections of a group book.""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="group_progress" + ) + group_book = models.ForeignKey( + GroupBook, on_delete=models.CASCADE, related_name="member_progress" + ) + current_section = models.ForeignKey( + Section, on_delete=models.SET_NULL, null=True, blank=True, related_name="current_readers" + ) + completed_sections = models.JSONField( + default=list, help_text="Ordered list of completed Section IDs" + ) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "groups_member_progress" + verbose_name = "Member Progress" + verbose_name_plural = "Member Progress" + unique_together = [("user", "group_book")] + + def __str__(self) -> str: + return f"{self.user} — {self.group_book.title}" diff --git a/backend/apps/groups/serializers.py b/backend/apps/groups/serializers.py new file mode 100644 index 0000000..7f8e9c9 --- /dev/null +++ b/backend/apps/groups/serializers.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from rest_framework import serializers + +from apps.groups.models import ( + GroupBook, + MemberProgress, + ReadingGroup, + ReadingGroupMembership, + ReadingSchedule, + Section, +) +from apps.books.serializers import EBookListSerializer + + +class ReadingGroupMembershipSerializer(serializers.ModelSerializer): + user_email = serializers.CharField(source="user.email", read_only=True) + user_username = serializers.CharField(source="user.username", read_only=True) + + class Meta: + model = ReadingGroupMembership + fields = [ + "id", "user", "user_email", "user_username", + "role", "joined_at", + ] + read_only_fields = ["id", "joined_at"] + + +class ReadingGroupListSerializer(serializers.ModelSerializer): + member_count = serializers.SerializerMethodField() + admin_email = serializers.CharField(source="admin.email", read_only=True) + + class Meta: + model = ReadingGroup + fields = [ + "id", "name", "description", "admin", "admin_email", + "member_count", "created_at", "updated_at", + ] + + def get_member_count(self, obj: ReadingGroup) -> int: + return obj.memberships.count() + + +class ReadingGroupDetailSerializer(serializers.ModelSerializer): + admin_email = serializers.CharField(source="admin.email", read_only=True) + members = ReadingGroupMembershipSerializer(source="memberships", many=True, read_only=True) + + class Meta: + model = ReadingGroup + fields = [ + "id", "name", "description", "admin", "admin_email", + "members", "created_at", "updated_at", + ] + + +class ReadingGroupCreateSerializer(serializers.ModelSerializer): + class Meta: + model = ReadingGroup + fields = ["name", "description"] + + +class AddMemberSerializer(serializers.Serializer): + user_id = serializers.IntegerField() + + def validate_user_id(self, value: int) -> int: + from django.conf import settings + User = settings.AUTH_USER_MODEL + if not User.objects.filter(id=value).exists(): + raise serializers.ValidationError("User not found.") + return value + + +class SectionSerializer(serializers.ModelSerializer): + class Meta: + model = Section + fields = [ + "id", "title", "order", "start_chapter_index", + "end_chapter_index", "estimated_reading_minutes", "created_at", + ] + read_only_fields = ["id", "created_at"] + + +class SectionAdjustSerializer(serializers.Serializer): + """Payload for manual section adjustments (merge/split).""" + operation = serializers.ChoiceField(choices=["merge", "split"]) + section_ids = serializers.ListField( + child=serializers.IntegerField(), min_length=1, + help_text="For merge: list of section IDs to merge. For split: [section_id] to split." + ) + split_at = serializers.IntegerField( + required=False, default=2, min_value=2, + help_text="Number of new sections when splitting" + ) + + +class ReadingScheduleSerializer(serializers.ModelSerializer): + section_details = serializers.SerializerMethodField() + + class Meta: + model = ReadingSchedule + fields = [ + "id", "meeting_number", "week_date", "section_ids", + "section_details", "created_at", + ] + read_only_fields = ["id", "created_at"] + + def get_section_details(self, obj: ReadingSchedule) -> list[dict]: + sections = Section.objects.filter( + id__in=obj.section_ids, group_book=obj.group_book + ).order_by("order") + return SectionSerializer(sections, many=True).data + + +class MemberProgressSerializer(serializers.ModelSerializer): + user_email = serializers.CharField(source="user.email", read_only=True) + user_username = serializers.CharField(source="user.username", read_only=True) + current_section_title = serializers.SerializerMethodField() + + class Meta: + model = MemberProgress + fields = [ + "id", "user", "user_email", "user_username", + "current_section", "current_section_title", + "completed_sections", "updated_at", + ] + read_only_fields = ["id", "updated_at"] + + def get_current_section_title(self, obj: MemberProgress) -> str | None: + if obj.current_section: + return obj.current_section.title + return None + + +class GroupBookListSerializer(serializers.ModelSerializer): + ebook = EBookListSerializer(read_only=True) + uploaded_by_email = serializers.CharField(source="uploaded_by.email", read_only=True) + section_count = serializers.SerializerMethodField() + + class Meta: + model = GroupBook + fields = [ + "id", "title", "status", "ebook", "uploaded_by", + "uploaded_by_email", "section_count", "created_at", "updated_at", + ] + + def get_section_count(self, obj: GroupBook) -> int: + return obj.sections.count() + + +class GroupBookDetailSerializer(serializers.ModelSerializer): + ebook = EBookListSerializer(read_only=True) + uploaded_by_email = serializers.CharField(source="uploaded_by.email", read_only=True) + sections = SectionSerializer(many=True, read_only=True) + schedules = ReadingScheduleSerializer(source="schedules", many=True, read_only=True) + + class Meta: + model = GroupBook + fields = [ + "id", "group", "title", "status", "ebook", + "uploaded_by", "uploaded_by_email", + "sections", "schedules", "created_at", "updated_at", + ] + + +class GroupBookCreateSerializer(serializers.Serializer): + ebook_id = serializers.IntegerField() + title = serializers.CharField(max_length=512, required=False) + + def validate_ebook_id(self, value: int) -> int: + from apps.books.models import EBook + if not EBook.objects.filter(id=value).exists(): + raise serializers.ValidationError("EBook not found.") + # Check format is EPUB + ebook = EBook.objects.get(id=value) + if ebook.format != "epub": + raise serializers.ValidationError("Only EPUB format is supported for group books.") + return value + + def validate(self, data: dict) -> dict: + request = self.context.get("request") + if request and not data.get("title"): + from apps.books.models import EBook + ebook = EBook.objects.get(id=data["ebook_id"]) + data["title"] = ebook.title + return data diff --git a/backend/apps/groups/services/__init__.py b/backend/apps/groups/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/groups/services/section_splitting.py b/backend/apps/groups/services/section_splitting.py new file mode 100644 index 0000000..671af89 --- /dev/null +++ b/backend/apps/groups/services/section_splitting.py @@ -0,0 +1,241 @@ +"""Automatic section detection and meeting recommendation for group books.""" + +from __future__ import annotations + +import logging +from datetime import date, timedelta +from typing import Any + +from django.db.models import QuerySet + +from apps.books.models import BookChapter, EBook +from apps.groups.models import GroupBook, ReadingSchedule, Section + +logger = logging.getLogger(__name__) + +WORDS_PER_MINUTE = 250 # Average reading speed + + +def _fetch_chapter_text(ebook: EBook, chapter: BookChapter) -> str: + """Extract plain text from a chapter for word counting.""" + try: + from ebooklib import epub + from bs4 import BeautifulSoup + except ImportError: + return "" + + try: + book = epub.read_epub(ebook.file.path) + href = chapter.href or "" + for item in book.get_items(): + item_name = item.get_name() or "" + if href and (item_name.endswith(href) or href.endswith(item_name)): + content = item.get_content() + soup = BeautifulSoup(content, "html.parser") + body = soup.find("body") + if body: + return body.get_text(separator=" ", strip=True) + return soup.get_text(separator=" ", strip=True) + return "" + except Exception: + logger.exception("Failed to extract text for chapter %s", chapter.id) + return "" + + +def _estimate_reading_minutes(text: str) -> int: + """Estimate reading time based on word count at WORDS_PER_MINUTE.""" + word_count = len(text.split()) + if word_count == 0: + return 1 + return max(1, round(word_count / WORDS_PER_MINUTE)) + + +def detect_sections(group_book: GroupBook) -> list[dict[str, Any]]: + """Auto-detect sections from a GroupBook's chapters. + + Groups consecutive chapters into logical sections based on TOC structure. + Top-level TOC entries become sections; if there are very few (< 3), + groups of ~5 chapters become sections instead. + """ + ebook = group_book.ebook + chapters = list( + BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook") + ) + + if not chapters: + return [] + + top_level = [ch for ch in chapters if not ch.children or len(ch.children) == 0] + has_children = [ch for ch in chapters if ch.children and len(ch.children) > 0] + + sections: list[dict[str, Any]] = [] + + if len(has_children) >= 3: + # Use TOC structure: each top-level chapter (with children) is a section + for idx, ch in enumerate(has_children): + # Find all child chapters belonging to this parent + child_indices = _collect_child_indices(chapters, ch, idx) + start_idx = ch.index + end_idx = child_indices[-1] if child_indices else start_idx + + section_chapters = [c for c in chapters if start_idx <= c.index <= end_idx] + total_text = "" + for sc in section_chapters: + total_text += " " + _fetch_chapter_text(ebook, sc) + + sections.append({ + "title": ch.title, + "order": idx + 1, + "start_chapter_index": start_idx, + "end_chapter_index": end_idx + 1, + "estimated_reading_minutes": _estimate_reading_minutes(total_text), + }) + else: + # Group chapters into chunks of ~5 + chunk_size = max(1, len(top_level) // 6 if len(top_level) > 6 else 5) + chunk_size = max(3, min(chunk_size, 10)) + + group_start = 0 + section_order = 1 + total = len(top_level) or len(chapters) + source = top_level or chapters + + while group_start < total: + group_end = min(group_start + chunk_size, total) + chunk = source[group_start:group_end] + + total_text = "" + for ch in chunk: + total_text += " " + _fetch_chapter_text(ebook, ch) + + first_title = chunk[0].title if chunk else "Section" + last_title = chunk[-1].title if len(chunk) > 1 else "" + title = f"{first_title}" if not last_title or first_title == last_title else f"{first_title} — {last_title}" + + sections.append({ + "title": title, + "order": section_order, + "start_chapter_index": chunk[0].index, + "end_chapter_index": chunk[-1].index + 1, + "estimated_reading_minutes": _estimate_reading_minutes(total_text), + }) + group_start = group_end + section_order += 1 + + return sections + + +def _collect_child_indices(chapters: list[BookChapter], parent: BookChapter, parent_idx: int) -> list[int]: + """Collect indices of all chapters that are children of the given parent TOC entry.""" + indices: list[int] = [parent.index] + child_hrefs: set[str] = set() + for child in parent.children: + if isinstance(child, dict): + child_hrefs.add(child.get("href", "")) + elif hasattr(child, "href"): + child_hrefs.add(getattr(child, "href", "")) + + for ch in chapters: + if ch.index == parent.index: + continue + if ch.href in child_hrefs or any( + ch.href.endswith(h) or h.endswith(ch.href) for h in child_hrefs + ): + indices.append(ch.index) + + # Also include chapters between this parent and the next parent + if parent_idx + 1 < len(chapters): + next_parent = chapters[parent_idx + 1] + for ch in chapters: + if parent.index < ch.index < next_parent.index: + indices.append(ch.index) + + return sorted(set(indices)) + + +def apply_sections(group_book: GroupBook) -> list[Section]: + """Detect sections and persist them to the database, replacing existing ones.""" + Section.objects.filter(group_book=group_book).delete() + sections_data = detect_sections(group_book) + created: list[Section] = [] + for data in sections_data: + section = Section.objects.create( + group_book=group_book, + title=data["title"], + order=data["order"], + start_chapter_index=data["start_chapter_index"], + end_chapter_index=data["end_chapter_index"], + estimated_reading_minutes=data["estimated_reading_minutes"], + ) + created.append(section) + return created + + +def recommend_meetings(group_book: GroupBook, num_meetings: int = 4) -> list[dict[str, Any]]: + """Recommend which sections to assign to each weekly meeting. + + Distributes sections across meetings, trying to balance total reading time. + Returns a list of meeting assignments ready for schedule creation. + """ + sections = list( + Section.objects.filter(group_book=group_book).order_by("order") + ) + + if not sections: + return [] + + # Calculate total minutes to distribute + total_minutes = sum(s.estimated_reading_minutes for s in sections) + target_per_meeting = total_minutes / num_meetings + + meetings: list[dict[str, Any]] = [] + current_meeting: list[int] = [] + current_minutes = 0 + + for section in sections: + if current_meeting and current_minutes + section.estimated_reading_minutes > target_per_meeting * 1.4: + # Start new meeting if adding this section would overshoot too much + if len(meetings) < num_meetings - 1: + meetings.append({ + "meeting_number": len(meetings) + 1, + "section_ids": current_meeting, + "total_minutes": current_minutes, + }) + current_meeting = [] + current_minutes = 0 + + current_meeting.append(section.id) + current_minutes += section.estimated_reading_minutes + + # Add the last meeting + if current_meeting: + meetings.append({ + "meeting_number": len(meetings) + 1, + "section_ids": current_meeting, + "total_minutes": current_minutes, + }) + + # If we have fewer than num_meetings, we could split the largest one + # For now, just return what we have + return meetings + + +def apply_schedule(group_book: GroupBook, num_meetings: int = 4) -> list[ReadingSchedule]: + """Generate and persist a reading schedule.""" + ReadingSchedule.objects.filter(group_book=group_book).delete() + + recommendations = recommend_meetings(group_book, num_meetings) + today = date.today() + + created: list[ReadingSchedule] = [] + for rec in recommendations: + week_date = today + timedelta(weeks=rec["meeting_number"] - 1) + schedule = ReadingSchedule.objects.create( + group_book=group_book, + meeting_number=rec["meeting_number"], + week_date=week_date, + section_ids=rec["section_ids"], + ) + created.append(schedule) + + return created diff --git a/backend/apps/groups/urls.py b/backend/apps/groups/urls.py new file mode 100644 index 0000000..dc96619 --- /dev/null +++ b/backend/apps/groups/urls.py @@ -0,0 +1,43 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.groups.views import GroupBookViewSet, ReadingGroupViewSet + +# ReadingGroup routes (standard ViewSet) +router = DefaultRouter() +router.register(r"", ReadingGroupViewSet, basename="group") + +urlpatterns = [ + path("", include(router.urls)), + # Nested GroupBook routes under a group + path( + "/books/", + GroupBookViewSet.as_view({"get": "list", "post": "create"}), + name="group-book-list", + ), + path( + "/books//", + GroupBookViewSet.as_view({"get": "retrieve", "delete": "destroy"}), + name="group-book-detail", + ), + path( + "/books//detect-sections/", + GroupBookViewSet.as_view({"post": "detect_sections_action"}), + name="group-book-detect-sections", + ), + path( + "/books//adjust-sections/", + GroupBookViewSet.as_view({"post": "adjust_sections"}), + name="group-book-adjust-sections", + ), + path( + "/books//schedule/", + GroupBookViewSet.as_view({"get": "schedule", "post": "schedule", "delete": "schedule"}), + name="group-book-schedule", + ), + path( + "/books//progress/", + GroupBookViewSet.as_view({"get": "progress", "patch": "progress"}), + name="group-book-progress", + ), +] diff --git a/backend/apps/groups/views.py b/backend/apps/groups/views.py new file mode 100644 index 0000000..d683ab1 --- /dev/null +++ b/backend/apps/groups/views.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import logging +from typing import Any + +from django.db.models import QuerySet +from django.shortcuts import get_object_or_404 +from rest_framework import permissions, status, viewsets +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response + +from apps.books.models import EBook +from apps.groups.models import ( + GroupBook, + MemberProgress, + ReadingGroup, + ReadingGroupMembership, + ReadingSchedule, + Section, +) +from apps.groups.serializers import ( + AddMemberSerializer, + GroupBookCreateSerializer, + GroupBookDetailSerializer, + GroupBookListSerializer, + MemberProgressSerializer, + ReadingGroupCreateSerializer, + ReadingGroupDetailSerializer, + ReadingGroupListSerializer, + ReadingScheduleSerializer, + SectionAdjustSerializer, + SectionSerializer, +) +from apps.groups.services.section_splitting import ( + apply_schedule, + apply_sections, + detect_sections, +) + +logger = logging.getLogger(__name__) + + +class IsGroupAdmin(permissions.BasePermission): + """Only the group admin can modify group resources.""" + + def has_permission(self, request: Request, view: object) -> bool: + if not request.user.is_authenticated: + return False + if view.action in ("list", "retrieve"): + return True + group_id = view.kwargs.get("pk") or view.kwargs.get("group_pk") + if group_id: + return ReadingGroup.objects.filter(id=group_id, admin=request.user).exists() + return True + + def has_object_permission(self, request: Request, view: object, obj: ReadingGroup) -> bool: + if request.method in permissions.SAFE_METHODS: + return True + return obj.admin == request.user + + +class IsGroupMember(permissions.BasePermission): + """Only group members (including admin) can view group content.""" + + def has_permission(self, request: Request, view: object) -> bool: + if not request.user.is_authenticated: + return False + group_id = view.kwargs.get("pk") or view.kwargs.get("group_pk") + if group_id: + return ReadingGroupMembership.objects.filter( + group_id=group_id, user=request.user + ).exists() + return True + + +class ReadingGroupViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated, IsGroupAdmin] + queryset = ReadingGroup.objects.prefetch_related("memberships__user") + + def get_serializer_class(self): + if self.action == "create": + return ReadingGroupCreateSerializer + if self.action == "retrieve": + return ReadingGroupDetailSerializer + return ReadingGroupListSerializer + + def get_queryset(self) -> QuerySet[ReadingGroup]: + user = self.request.user + return ReadingGroup.objects.filter( + memberships__user=user + ).prefetch_related("memberships__user").distinct() + + def perform_create(self, serializer: ReadingGroupCreateSerializer) -> ReadingGroup: + group = serializer.save(admin=self.request.user) + ReadingGroupMembership.objects.create( + user=self.request.user, group=group, role=ReadingGroupMembership.Role.ADMIN + ) + return group + + @action(detail=True, methods=["post"], permission_classes=[IsGroupAdmin]) + def add_member(self, request: Request, pk: int | None = None) -> Response: + group = self.get_object() + member_serializer = AddMemberSerializer(data=request.data) + member_serializer.is_valid(raise_exception=True) + + user_id = member_serializer.validated_data["user_id"] + from django.conf import settings + User = settings.AUTH_USER_MODEL + new_member = get_object_or_404(User, id=user_id) + + if ReadingGroupMembership.objects.filter(group=group, user=new_member).exists(): + return Response( + {"detail": "User is already a member of this group."}, + status=status.HTTP_409_CONFLICT, + ) + + membership = ReadingGroupMembership.objects.create( + user=new_member, group=group, role=ReadingGroupMembership.Role.MEMBER + ) + from apps.groups.serializers import ReadingGroupMembershipSerializer + return Response( + ReadingGroupMembershipSerializer(membership).data, + status=status.HTTP_201_CREATED, + ) + + @action(detail=True, methods=["post"], permission_classes=[IsGroupAdmin]) + def remove_member(self, request: Request, pk: int | None = None) -> Response: + group = self.get_object() + member_serializer = AddMemberSerializer(data=request.data) + member_serializer.is_valid(raise_exception=True) + + user_id = member_serializer.validated_data["user_id"] + if user_id == group.admin_id: + return Response( + {"detail": "Cannot remove the group admin."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + deleted, _ = ReadingGroupMembership.objects.filter( + group=group, user_id=user_id + ).delete() + if not deleted: + return Response( + {"detail": "User is not a member of this group."}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class GroupBookViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated, IsGroupAdmin] + + def get_serializer_class(self): + if self.action == "create": + return GroupBookCreateSerializer + if self.action == "retrieve": + return GroupBookDetailSerializer + return GroupBookListSerializer + + def get_queryset(self) -> QuerySet[GroupBook]: + group_id = self.kwargs.get("group_pk") + return GroupBook.objects.filter( + group_id=group_id + ).select_related("ebook", "ebook__user", "uploaded_by").prefetch_related( + "sections", "schedules" + ) + + def perform_create(self, serializer: GroupBookCreateSerializer) -> GroupBook: + group = get_object_or_404(ReadingGroup, id=self.kwargs["group_pk"]) + ebook = get_object_or_404(EBook, id=serializer.validated_data["ebook_id"]) + title = serializer.validated_data.get("title") or ebook.title + + # Mark existing active books as replaced + GroupBook.objects.filter(group=group, status=GroupBook.Status.ACTIVE).update( + status=GroupBook.Status.REPLACED + ) + + group_book = GroupBook.objects.create( + group=group, + ebook=ebook, + uploaded_by=self.request.user, + title=title, + status=GroupBook.Status.ACTIVE, + ) + + # Auto-detect sections + try: + apply_sections(group_book) + except Exception: + logger.exception("Auto-section detection failed for group_book %s", group_book.id) + + # Auto-generate schedule + try: + apply_schedule(group_book) + except Exception: + logger.exception("Schedule generation failed for group_book %s", group_book.id) + + return group_book + + @action(detail=True, methods=["post"], url_path="detect-sections") + def detect_sections_action(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response: + group_book = self.get_object() + try: + sections = apply_sections(group_book) + except Exception as exc: + logger.exception("Section detection failed for group_book %s", group_book.id) + return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response(SectionSerializer(sections, many=True).data) + + @action(detail=True, methods=["post"], url_path="adjust-sections") + def adjust_sections(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response: + group_book = self.get_object() + adjust_serializer = SectionAdjustSerializer(data=request.data) + adjust_serializer.is_valid(raise_exception=True) + + operation = adjust_serializer.validated_data["operation"] + section_ids: list[int] = adjust_serializer.validated_data["section_ids"] + + if operation == "merge": + if len(section_ids) < 2: + return Response( + {"error": "At least 2 section IDs required for merge."}, + status=status.HTTP_400_BAD_REQUEST, + ) + sections = list( + Section.objects.filter(id__in=section_ids, group_book=group_book).order_by("order") + ) + if len(sections) < 2: + return Response( + {"error": "Not enough valid sections found."}, + status=status.HTTP_400_BAD_REQUEST, + ) + merged_title = " — ".join(s.title for s in sections) + merged_minutes = sum(s.estimated_reading_minutes for s in sections) + merged_start = sections[0].start_chapter_index + merged_end = sections[-1].end_chapter_index + merged_order = sections[0].order + + # Delete old sections + Section.objects.filter(id__in=section_ids, group_book=group_book).delete() + + # Create merged section + merged = Section.objects.create( + group_book=group_book, + title=merged_title, + order=merged_order, + start_chapter_index=merged_start, + end_chapter_index=merged_end, + estimated_reading_minutes=merged_minutes, + ) + # Reorder remaining sections + _renumber_sections(group_book) + return Response(SectionSerializer(merged).data) + + if operation == "split": + if len(section_ids) != 1: + return Response( + {"error": "Exactly 1 section ID required for split."}, + status=status.HTTP_400_BAD_REQUEST, + ) + split_at = adjust_serializer.validated_data.get("split_at", 2) + section = get_object_or_404(Section, id=section_ids[0], group_book=group_book) + chapters = section.end_chapter_index - section.start_chapter_index + if chapters < split_at: + return Response( + {"error": f"Section only has {chapters} chapters, cannot split into {split_at}."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Delete original + orig_order = section.order + orig_title = section.title + section.delete() + + # Create split sections + chunk_size = max(1, chapters // split_at) + new_sections: list[Section] = [] + for i in range(split_at): + start_idx = section.start_chapter_index + i * chunk_size + end_idx = start_idx + chunk_size if i < split_at - 1 else section.end_chapter_index + minutes_each = max(1, section.estimated_reading_minutes // split_at) + new_sec = Section.objects.create( + group_book=group_book, + title=f"{orig_title} (Part {i + 1})", + order=orig_order + i, + start_chapter_index=start_idx, + end_chapter_index=end_idx, + estimated_reading_minutes=minutes_each, + ) + new_sections.append(new_sec) + + _renumber_sections(group_book) + return Response(SectionSerializer(new_sections, many=True).data) + + return Response({"error": "Invalid operation."}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["get", "post", "delete"], url_path="schedule") + def schedule(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response: + group_book = self.get_object() + + if request.method == "GET": + schedules = ReadingSchedule.objects.filter(group_book=group_book).order_by("meeting_number") + return Response(ReadingScheduleSerializer(schedules, many=True).data) + + if request.method == "POST": + ReadingSchedule.objects.filter(group_book=group_book).delete() + num_meetings = int(request.data.get("num_meetings", 4)) + try: + schedules = apply_schedule(group_book, num_meetings=num_meetings) + except Exception as exc: + logger.exception("Schedule generation failed for group_book %s", group_book.id) + return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response( + ReadingScheduleSerializer(schedules, many=True).data, + status=status.HTTP_201_CREATED, + ) + + if request.method == "DELETE": + ReadingSchedule.objects.filter(group_book=group_book).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["get", "patch"], url_path="progress") + def progress(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response: + group_book = self.get_object() + + if request.method == "GET": + progress_records = MemberProgress.objects.filter( + group_book=group_book + ).select_related("user", "current_section") + return Response(MemberProgressSerializer(progress_records, many=True).data) + + # PATCH: update own progress + progress_obj, _created = MemberProgress.objects.get_or_create( + user=request.user, group_book=group_book, + ) + serializer = MemberProgressSerializer(progress_obj, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + +def _renumber_sections(group_book: GroupBook) -> None: + """Re-number sections sequentially by their current order.""" + sections = list(Section.objects.filter(group_book=group_book).order_by("order")) + for idx, section in enumerate(sections): + if section.order != idx + 1: + section.order = idx + 1 + section.save(update_fields=["order"]) diff --git a/backend/config/django.py b/backend/config/django.py index 4019b7c..372f744 100644 --- a/backend/config/django.py +++ b/backend/config/django.py @@ -39,6 +39,7 @@ INSTALLED_APPS = [ # Local apps "apps.users", "apps.books", + "apps.groups", "apps.annotations", "apps.reader", ] diff --git a/backend/config/urls.py b/backend/config/urls.py index 07d350e..f551733 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ path("admin/", admin.site.urls), path("api/auth/", include("apps.users.urls")), path("api/books/", include("apps.books.urls")), + path("api/groups/", include("apps.groups.urls")), path("api/annotations/", include("apps.annotations.urls")), path("api/reader/", include("apps.reader.urls")), ] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 45ba735..5f50d90 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,9 @@ const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage }))); const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage }))); const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default }))); +const GroupsPage = lazy(() => import("./pages/GroupsPage").then((m) => ({ default: m.GroupsPage }))); +const GroupDetailPage = lazy(() => import("./pages/GroupDetailPage").then((m) => ({ default: m.GroupDetailPage }))); +const GroupBookPage = lazy(() => import("./pages/GroupBookPage").then((m) => ({ default: m.GroupBookPage }))); const AuthPage = lazy(() => import("./pages/AuthPage")); @@ -44,6 +47,9 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api/groups.ts b/frontend/src/api/groups.ts new file mode 100644 index 0000000..8730dcb --- /dev/null +++ b/frontend/src/api/groups.ts @@ -0,0 +1,151 @@ +import api from "./client"; +import type { + AddMemberPayload, + AdjustSectionsPayload, + CreateGroupBookPayload, + CreateGroupPayload, + GroupBook, + GroupBookDetail, + MemberProgress, + ReadingGroup, + ReadingGroupDetail, + ReadingSchedule, + Section, +} from "../types/group"; + +export const groupsApi = { + // ---- Reading Groups ---- + + async listGroups(): Promise { + const { data } = await api.get<{ count: number; results: ReadingGroup[] } | ReadingGroup[]>( + "/groups/" + ); + if (Array.isArray(data)) return data; + return data.results ?? []; + }, + + async getGroup(id: number): Promise { + const { data } = await api.get(`/groups/${id}/`); + return data; + }, + + async createGroup(payload: CreateGroupPayload): Promise { + const { data } = await api.post("/groups/", payload); + return data; + }, + + async deleteGroup(id: number): Promise { + await api.delete(`/groups/${id}/`); + }, + + async addMember(groupId: number, payload: AddMemberPayload): Promise { + await api.post(`/groups/${groupId}/add_member/`, payload); + }, + + async removeMember(groupId: number, payload: AddMemberPayload): Promise { + await api.post(`/groups/${groupId}/remove_member/`, payload); + }, + + // ---- Group Books ---- + + async listGroupBooks(groupId: number): Promise { + const { data } = await api.get(`/groups/${groupId}/books/`); + return Array.isArray(data) ? data : (data as { results: GroupBook[] }).results ?? []; + }, + + async getGroupBook(groupId: number, bookId: number): Promise { + const { data } = await api.get(`/groups/${groupId}/books/${bookId}/`); + return data; + }, + + async createGroupBook( + groupId: number, + payload: CreateGroupBookPayload + ): Promise { + const { data } = await api.post( + `/groups/${groupId}/books/`, + payload + ); + return data; + }, + + async deleteGroupBook(groupId: number, bookId: number): Promise { + await api.delete(`/groups/${groupId}/books/${bookId}/`); + }, + + // ---- Sections ---- + + async detectSections( + groupId: number, + bookId: number + ): Promise { + const { data } = await api.post( + `/groups/${groupId}/books/${bookId}/detect-sections/` + ); + return data; + }, + + async adjustSections( + groupId: number, + bookId: number, + payload: AdjustSectionsPayload + ): Promise
{ + const { data } = await api.post
( + `/groups/${groupId}/books/${bookId}/adjust-sections/`, + payload + ); + return data; + }, + + // ---- Schedule ---- + + async getSchedule( + groupId: number, + bookId: number + ): Promise { + const { data } = await api.get( + `/groups/${groupId}/books/${bookId}/schedule/` + ); + return data; + }, + + async generateSchedule( + groupId: number, + bookId: number, + numMeetings: number = 4 + ): Promise { + const { data } = await api.post( + `/groups/${groupId}/books/${bookId}/schedule/`, + { num_meetings: numMeetings } + ); + return data; + }, + + async deleteSchedule(groupId: number, bookId: number): Promise { + await api.delete(`/groups/${groupId}/books/${bookId}/schedule/`); + }, + + // ---- Progress ---- + + async getProgress( + groupId: number, + bookId: number + ): Promise { + const { data } = await api.get( + `/groups/${groupId}/books/${bookId}/progress/` + ); + return data; + }, + + async updateProgress( + groupId: number, + bookId: number, + payload: { current_section?: number; completed_sections?: number[] } + ): Promise { + const { data } = await api.patch( + `/groups/${groupId}/books/${bookId}/progress/`, + payload + ); + return data; + }, +}; \ No newline at end of file diff --git a/frontend/src/locales/en-US.ts b/frontend/src/locales/en-US.ts index aaf3571..87e6909 100644 --- a/frontend/src/locales/en-US.ts +++ b/frontend/src/locales/en-US.ts @@ -245,6 +245,54 @@ const enUS = { home: "Home", bookmarksNotes: "Bookmarks & Notes", }, + groups: { + title: "Reading Groups", + createGroup: "Create Group", + create: "Create", + cancel: "Cancel", + groupName: "Group Name", + groupNamePlaceholder: "Enter group name", + description: "Description", + descriptionPlaceholder: "What is this group about?", + optional: "Optional", + noGroups: "No groups yet", + noGroupsHint: "Create a reading group to start reading together!", + members: "Members", + admin: "Admin", + addMember: "Add Member", + memberIdPlaceholder: "Enter user ID", + add: "Add", + remove: "Remove", + books: "Group Books", + uploadEpub: "Upload EPUB", + selectEbook: "Select an EPUB to share with the group", + noEpubBooks: "No EPUB files uploaded yet", + uploadFirst: "Upload an EPUB first", + confirmUpload: "Confirm Upload", + noBooks: "No books in this group yet", + sections: "Sections", + schedule: "Schedule", + progress: "Progress", + detectSections: "Auto-Detect Sections", + mergeSections: "Merge Sections", + selectSectionsHint: "Click sections to select for merging", + merge: "Merge", + clear: "Clear", + split: "Split", + splitInto: "Split into", + parts: "parts", + splitBtn: "Split", + noSections: "No sections detected yet", + noSectionsHint: 'Click "Auto-Detect Sections" to parse the EPUB chapters into reading sections.', + readingSchedule: "Reading Schedule", + generateSchedule: "Generate Schedule", + noSchedule: "No schedule generated yet", + noScheduleHint: "Detect sections first, then generate a reading schedule for your group.", + meeting: "Meeting", + memberProgress: "Member Progress", + noProgress: "No progress data yet", + noProgressHint: "Members will show progress once they start reading.", + }, } as const; export default enUS; diff --git a/frontend/src/locales/es-ES.ts b/frontend/src/locales/es-ES.ts index d3a6996..eefc801 100644 --- a/frontend/src/locales/es-ES.ts +++ b/frontend/src/locales/es-ES.ts @@ -247,6 +247,54 @@ const esES: Locale = { home: "Inicio", bookmarksNotes: "Marcadores y notas", }, + groups: { + title: "Grupos de Lectura", + createGroup: "Crear Grupo", + create: "Crear", + cancel: "Cancelar", + groupName: "Nombre del Grupo", + groupNamePlaceholder: "Ingresa el nombre del grupo", + description: "Descripción", + descriptionPlaceholder: "¿De qué trata este grupo?", + optional: "Opcional", + noGroups: "Aún no hay grupos", + noGroupsHint: "¡Crea un grupo de lectura para leer juntos!", + members: "Miembros", + admin: "Admin", + addMember: "Agregar Miembro", + memberIdPlaceholder: "Ingresa ID de usuario", + add: "Agregar", + remove: "Eliminar", + books: "Libros del Grupo", + uploadEpub: "Subir EPUB", + selectEbook: "Selecciona un EPUB para compartir con el grupo", + noEpubBooks: "No hay archivos EPUB subidos aún", + uploadFirst: "Sube un EPUB primero", + confirmUpload: "Confirmar Subida", + noBooks: "No hay libros en este grupo aún", + sections: "Secciones", + schedule: "Calendario", + progress: "Progreso", + detectSections: "Auto-Detectar Secciones", + mergeSections: "Fusionar Secciones", + selectSectionsHint: "Haz clic en las secciones para seleccionar y fusionar", + merge: "Fusionar", + clear: "Limpiar", + split: "Dividir", + splitInto: "Dividir en", + parts: "partes", + splitBtn: "Dividir", + noSections: "No se detectaron secciones", + noSectionsHint: 'Haz clic en "Auto-Detectar Secciones" para analizar los capítulos del EPUB.', + readingSchedule: "Calendario de Lectura", + generateSchedule: "Generar Calendario", + noSchedule: "No hay calendario generado", + noScheduleHint: "Detecta secciones primero, luego genera un calendario de lectura para tu grupo.", + meeting: "Reunión", + memberProgress: "Progreso de Miembros", + noProgress: "Sin datos de progreso", + noProgressHint: "Los miembros mostrarán progreso cuando comiencen a leer.", + }, }; export default esES; diff --git a/frontend/src/pages/GroupBookPage.tsx b/frontend/src/pages/GroupBookPage.tsx new file mode 100644 index 0000000..f816630 --- /dev/null +++ b/frontend/src/pages/GroupBookPage.tsx @@ -0,0 +1,426 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18n-lite"; +import { groupsApi } from "../api/groups"; +import { getApiErrorMessage } from "../api/errors"; +import type { + GroupBookDetail, + ReadingSchedule, + Section, + MemberProgress, +} from "../types/group"; + +export function GroupBookPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { groupId, bookId } = useParams<{ groupId: string; bookId: string }>(); + + const [book, setBook] = useState(null); + const [sections, setSections] = useState([]); + const [schedules, setSchedules] = useState([]); + const [progress, setProgress] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tab, setTab] = useState<"sections" | "schedule" | "progress">("sections"); + + // Merge state + const [selectedForMerge, setSelectedForMerge] = useState([]); + const [splitSectionId, setSplitSectionId] = useState(null); + const [splitAt, setSplitAt] = useState(2); + + const gId = Number(groupId); + const bId = Number(bookId); + + const loadData = useCallback(async () => { + if (!gId || !bId) return; + setLoading(true); + try { + const [bookData, schedData, progData] = await Promise.all([ + groupsApi.getGroupBook(gId, bId), + groupsApi.getSchedule(gId, bId).catch(() => [] as ReadingSchedule[]), + groupsApi.getProgress(gId, bId).catch(() => [] as MemberProgress[]), + ]); + setBook(bookData); + setSections(bookData.sections ?? []); + setSchedules(schedData); + setProgress(progData); + } catch (err) { + setError(getApiErrorMessage(err, "Failed to load book")); + } finally { + setLoading(false); + } + }, [gId, bId]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleDetectSections = async () => { + if (!gId || !bId) return; + try { + const newSections = await groupsApi.detectSections(gId, bId); + setSections(newSections); + } catch (err) { + setError(getApiErrorMessage(err, "Failed to detect sections")); + } + }; + + const handleMerge = async () => { + if (selectedForMerge.length < 2 || !gId || !bId) return; + try { + await groupsApi.adjustSections(gId, bId, { + operation: "merge", + section_ids: selectedForMerge, + }); + setSelectedForMerge([]); + await loadData(); + } catch (err) { + setError(getApiErrorMessage(err, "Failed to merge sections")); + } + }; + + const handleSplit = async () => { + if (!splitSectionId || !gId || !bId) return; + try { + await groupsApi.adjustSections(gId, bId, { + operation: "split", + section_ids: [splitSectionId], + split_at: splitAt, + }); + setSplitSectionId(null); + await loadData(); + } catch (err) { + setError(getApiErrorMessage(err, "Failed to split section")); + } + }; + + const handleGenerateSchedule = async () => { + if (!gId || !bId) return; + try { + const newSched = await groupsApi.generateSchedule(gId, bId, 4); + setSchedules(newSched); + } catch (err) { + setError(getApiErrorMessage(err, "Failed to generate schedule")); + } + }; + + const toggleMergeSelect = (id: number) => { + setSelectedForMerge((prev) => + prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id] + ); + }; + + const formatMinutes = (mins: number): string => { + if (mins < 60) return `${mins}m`; + const h = Math.floor(mins / 60); + const m = mins % 60; + return m > 0 ? `${h}h ${m}m` : `${h}h`; + }; + + if (loading) { + return ( +
+

{t("common.loading")}

+
+ ); + } + + if (!book) { + return ( +
+

Book not found

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

{book.title}

+

+ {book.ebook.author && `by ${book.ebook.author} · `} + {book.ebook.page_count} chapters · {book.ebook.format.toUpperCase()} +

+
+ +
+ + {error && ( +
+ {error} + +
+ )} + + {/* Tabs */} +
+ {(["sections", "schedule", "progress"] as const).map((t) => ( + + ))} +
+ + {/* Sections Tab */} + {tab === "sections" && ( +
+
+

+ {sections.length} {t("groups.sections")} +

+
+ +
+
+ + {/* Merge controls */} +
+
+ {t("groups.mergeSections")}: + + {selectedForMerge.length === 0 + ? t("groups.selectSectionsHint") + : `${selectedForMerge.length} selected`} + + {selectedForMerge.length >= 2 && ( + + )} + {selectedForMerge.length > 0 && ( + + )} +
+
+ + {sections.length === 0 ? ( +
+

{t("groups.noSections")}

+

{t("groups.noSectionsHint")}

+
+ ) : ( +
+ {sections.map((section) => ( +
toggleMergeSelect(section.id)} + > +
+
+
+ + {section.order} + +

{section.title}

+
+

+ Ch. {section.start_chapter_index}–{section.end_chapter_index - 1} · ~{formatMinutes(section.estimated_reading_minutes)} reading time +

+
+ +
+ {splitSectionId === section.id && ( +
e.stopPropagation()} + > + {t("groups.splitInto")} + setSplitAt(Number(e.target.value))} + style={{ width: 50, padding: "4px 8px", borderRadius: 4, border: "1px solid #ddd", fontSize: 13, textAlign: "center" }} + /> + {t("groups.parts")} + +
+ )} +
+ ))} +
+ )} +
+ )} + + {/* Schedule Tab */} + {tab === "schedule" && ( +
+
+

+ {t("groups.readingSchedule")} +

+ +
+ + {schedules.length === 0 ? ( +
+

{t("groups.noSchedule")}

+

{t("groups.noScheduleHint")}

+
+ ) : ( +
+ {schedules.map((s) => ( +
+
+
+

+ {t("groups.meeting")} {s.meeting_number} +

+

+ Week of {new Date(s.week_date).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} +

+
+ + {s.section_details.reduce((sum, sec) => sum + sec.estimated_reading_minutes, 0) > 0 + ? `~${formatMinutes(s.section_details.reduce((sum, sec) => sum + sec.estimated_reading_minutes, 0))}` + : ""} + +
+
+ {s.section_details.map((sec) => ( +
+ + {sec.order} + + {sec.title} + {formatMinutes(sec.estimated_reading_minutes)} +
+ ))} +
+
+ ))} +
+ )} +
+ )} + + {/* Progress Tab */} + {tab === "progress" && ( +
+

+ {t("groups.memberProgress")} +

+ {progress.length === 0 ? ( +
+

{t("groups.noProgress")}

+

{t("groups.noProgressHint")}

+
+ ) : ( +
+ {progress.map((p) => { + const totalSections = sections.length; + const completed = p.completed_sections.length; + const pct = totalSections > 0 ? Math.round((completed / totalSections) * 100) : 0; + return ( +
+
+ {p.user_email} + {completed}/{totalSections} · {pct}% +
+
+
+
+ {p.current_section_title && ( +

+ Currently: {p.current_section_title} +

+ )} +
+ ); + })} +
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/GroupDetailPage.tsx b/frontend/src/pages/GroupDetailPage.tsx new file mode 100644 index 0000000..43e4ecc --- /dev/null +++ b/frontend/src/pages/GroupDetailPage.tsx @@ -0,0 +1,300 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18n-lite"; +import { groupsApi } from "../api/groups"; +import { booksApi } from "../api/books"; +import { getApiErrorMessage } from "../api/errors"; +import type { ReadingGroupDetail, GroupBook, GroupBookDetail } from "../types/group"; +import type { EBookListItem } from "../types/book"; + +export function GroupDetailPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { groupId } = useParams<{ groupId: string }>(); + const [group, setGroup] = useState(null); + const [books, setBooks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Upload state + const [showUpload, setShowUpload] = useState(false); + const [ebooks, setEbooks] = useState([]); + const [selectedEbookId, setSelectedEbookId] = useState(null); + const [uploading, setUploading] = useState(false); + + // Add member state + const [showAddMember, setShowAddMember] = useState(false); + const [memberEmail, setMemberEmail] = useState(""); + const [adding, setAdding] = useState(false); + + const id = Number(groupId); + + const loadData = useCallback(async () => { + if (!id) return; + setLoading(true); + try { + const [groupData, bookData] = await Promise.all([ + groupsApi.getGroup(id), + groupsApi.listGroupBooks(id), + ]); + setGroup(groupData); + setBooks(bookData); + } catch (err) { + setError(getApiErrorMessage(err, "Failed to load group")); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleUploadClick = async () => { + setShowUpload(true); + try { + const ebookList = await booksApi.getEBooks(); + setEbooks(ebookList.filter((e) => e.format === "epub")); + } catch { + // Ebook list fetch failed silently + } + }; + + const handleUpload = async () => { + if (!selectedEbookId || !id) return; + setUploading(true); + try { + const created = await groupsApi.createGroupBook(id, { ebook_id: selectedEbookId }); + setShowUpload(false); + setSelectedEbookId(null); + await loadData(); + } catch (err) { + setError(getApiErrorMessage(err, "Failed to upload book to group")); + } finally { + setUploading(false); + } + }; + + const handleAddMember = async (e: React.FormEvent) => { + e.preventDefault(); + if (!memberEmail.trim() || !id) return; + setAdding(true); + try { + // Note: In a real implementation, we'd look up user by email. + // For now this requires user_id. Email lookup would be an enhancement. + await groupsApi.addMember(id, { user_id: Number(memberEmail) }); + setShowAddMember(false); + setMemberEmail(""); + await loadData(); + } catch (err) { + setError(getApiErrorMessage(err, "Failed to add member. Please use a valid user ID.")); + } finally { + setAdding(false); + } + }; + + const handleRemoveMember = async (userId: number) => { + if (!id) return; + try { + await groupsApi.removeMember(id, { user_id: userId }); + await loadData(); + } catch (err) { + setError(getApiErrorMessage(err, "Failed to remove member")); + } + }; + + if (loading) { + return ( +
+

{t("common.loading")}

+
+ ); + } + + if (!group) { + return ( +
+

Group not found

+ +
+ ); + } + + return ( +
+
+
+

{group.name}

+ {group.description &&

{group.description}

} +
+ +
+ + {error && ( +
+ {error} + +
+ )} + + {/* Members Section */} +
+
+

{t("groups.members")} ({group.members.length})

+ +
+ {showAddMember && ( +
+ setMemberEmail(e.target.value)} + placeholder={t("groups.memberIdPlaceholder")} + style={{ flex: 1, padding: "8px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 14, outline: "none" }} + /> + +
+ )} +
+ {group.members.map((m) => ( +
+
+ {m.user_email} + + {m.role} + +
+ {m.role !== "admin" && ( + + )} +
+ ))} +
+
+ + {/* Books Section */} +
+
+

{t("groups.books")} ({books.length})

+ +
+ + {showUpload && ( +
+

{t("groups.selectEbook")}

+ {ebooks.length === 0 ? ( +
+

{t("groups.noEpubBooks")}

+ +
+ ) : ( +
+
+ {ebooks.map((eb) => ( + + ))} +
+
+ + +
+
+ )} +
+ )} + + {books.length === 0 ? ( +

{t("groups.noBooks")}

+ ) : ( +
+ {books.map((book) => ( +
navigate(`/groups/${groupId}/books/${book.id}`)} + style={{ + display: "flex", justifyContent: "space-between", alignItems: "center", + padding: 16, borderRadius: 10, cursor: "pointer", transition: "background 0.2s", + background: book.status === "active" ? "#f0faf0" : "#fafafa", + border: book.status === "active" ? "2px solid #27ae60" : "1px solid #eee", + }} + onMouseEnter={(e) => (e.currentTarget.style.background = book.status === "active" ? "#e8f5e9" : "#f5f5f5")} + onMouseLeave={(e) => (e.currentTarget.style.background = book.status === "active" ? "#f0faf0" : "#fafafa")} + > +
+

+ {book.title} + {book.status === "active" && ( + Active + )} +

+

+ {book.section_count} sections · {book.ebook.page_count} chapters · uploaded by {book.uploaded_by_email} +

+
+ +
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/GroupsPage.tsx b/frontend/src/pages/GroupsPage.tsx new file mode 100644 index 0000000..6f0f62d --- /dev/null +++ b/frontend/src/pages/GroupsPage.tsx @@ -0,0 +1,162 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18n-lite"; +import { groupsApi } from "../api/groups"; +import { getApiErrorMessage } from "../api/errors"; +import type { ReadingGroup } from "../types/group"; + +export function GroupsPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(""); + const [newDesc, setNewDesc] = useState(""); + const [creating, setCreating] = useState(false); + + const loadGroups = useCallback(async () => { + setLoading(true); + try { + const data = await groupsApi.listGroups(); + setGroups(data); + } catch (err) { + setError(getApiErrorMessage(err, "Failed to load groups")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadGroups(); + }, [loadGroups]); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newName.trim()) return; + setCreating(true); + try { + await groupsApi.createGroup({ name: newName.trim(), description: newDesc.trim() }); + setShowCreate(false); + setNewName(""); + setNewDesc(""); + await loadGroups(); + } catch (err) { + setError(getApiErrorMessage(err, "Failed to create group")); + } finally { + setCreating(false); + } + }; + + return ( +
+
+

{t("groups.title")}

+ +
+ + {error && ( +
+ {error} + +
+ )} + +
+ +
+ + {showCreate && ( +
+
+ + setNewName(e.target.value)} + placeholder={t("groups.groupNamePlaceholder")} + style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} + required + /> +
+
+ +