Compare commits

..
Author SHA1 Message Date
Marko d5def297b9 feat: implement group creation and management (US #28)
Backend:
- Add groups Django app with models: Group, GroupMember, GroupInvite, JoinRequest
- Create serializers with business rule validation
- Implement GroupViewSet with full CRUD + custom actions (members, invites, roles, leave, join requests)
- Add JoinGroupViewSet for invite-based joining flow
- Register app in Django config and URL routing

Frontend:
- Add shared types for groups to @cloud-reader/shared
- Create groups API client (groupsApi)
- Build GroupsListPage, GroupDetailPage (member mgmt, invites, role transfer)
- Build CreateGroupPage and JoinGroupPage
- Add lazy-loaded routes to App.tsx with ProtectedRoute
- Add navigation links to Library header

Ref: #28
2026-06-20 19:25:46 +00:00
26 changed files with 1490 additions and 2169 deletions
+21 -37
View File
@@ -1,46 +1,30 @@
from django.contrib import admin from django.contrib import admin
from apps.groups.models import ( from apps.groups.models import Group, GroupInvite, GroupMember, JoinRequest
GroupBook,
MemberProgress,
ReadingGroup,
ReadingGroupMembership,
ReadingSchedule,
Section,
)
@admin.register(ReadingGroup) @admin.register(Group)
class ReadingGroupAdmin(admin.ModelAdmin): class GroupAdmin(admin.ModelAdmin):
list_display = ["name", "admin", "created_at"] list_display = ["id", "name", "created_by", "created_at"]
search_fields = ["name", "admin__email"] search_fields = ["name", "created_by__email"]
@admin.register(ReadingGroupMembership) @admin.register(GroupMember)
class ReadingGroupMembershipAdmin(admin.ModelAdmin): class GroupMemberAdmin(admin.ModelAdmin):
list_display = ["user", "group", "role", "joined_at"] list_display = ["id", "group", "user", "role", "joined_at"]
list_filter = ["role", "group"] list_filter = ["role"]
search_fields = ["group__name", "user__email"]
@admin.register(GroupBook) @admin.register(GroupInvite)
class GroupBookAdmin(admin.ModelAdmin): class GroupInviteAdmin(admin.ModelAdmin):
list_display = ["title", "group", "status", "uploaded_by", "created_at"] list_display = ["id", "group", "code", "created_by", "use_count", "is_active", "created_at"]
list_filter = ["is_active"]
search_fields = ["code", "group__name"]
@admin.register(JoinRequest)
class JoinRequestAdmin(admin.ModelAdmin):
list_display = ["id", "group", "user", "status", "created_at"]
list_filter = ["status"] list_filter = ["status"]
search_fields = ["title", "group__name"] search_fields = ["group__name", "user__email"]
@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"]
+1 -1
View File
@@ -4,4 +4,4 @@ from django.apps import AppConfig
class GroupsConfig(AppConfig): class GroupsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "apps.groups" name = "apps.groups"
verbose_name = "Reading Groups" verbose_name = "Groups"
+50 -116
View File
@@ -1,154 +1,88 @@
# Generated by Django 5.1.7 on 2026-06-20 19:21 # Generated by Django 5.x for groups app
import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('books', '0003_readingprogress_epub_location'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='ReadingGroup', name="Group",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
('name', models.CharField(db_index=True, max_length=256)), ("name", models.CharField(db_index=True, max_length=256)),
('description', models.TextField(blank=True, default='')), ("description", models.TextField(blank=True, default="")),
('created_at', models.DateTimeField(auto_now_add=True)), ("created_at", models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=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)), ("created_by", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="created_groups", to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'verbose_name': 'Reading Group', "db_table": "groups_group",
'verbose_name_plural': 'Reading Groups', "verbose_name": "Group",
'db_table': 'groups_reading_group', "verbose_name_plural": "Groups",
'ordering': ['-created_at'], "ordering": ["-created_at"],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='GroupBook', name="GroupMember",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
('title', models.CharField(db_index=True, max_length=512)), ("role", models.CharField(choices=[("admin", "Admin"), ("member", "Member")], default="member", max_length=16)),
('status', models.CharField(choices=[('active', 'Active'), ('replaced', 'Replaced')], db_index=True, default='active', max_length=16)), ("joined_at", models.DateTimeField(auto_now_add=True)),
('created_at', models.DateTimeField(auto_now_add=True)), ("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="memberships", to="groups.group")),
('updated_at', models.DateTimeField(auto_now=True)), ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="group_memberships", to=settings.AUTH_USER_MODEL)),
('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={ options={
'verbose_name': 'Group Book', "db_table": "groups_member",
'verbose_name_plural': 'Group Books', "verbose_name": "Group Member",
'db_table': 'groups_group_book', "verbose_name_plural": "Group Members",
'ordering': ['-created_at'], "ordering": ["joined_at"],
"unique_together": {("group", "user")},
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='ReadingGroupMembership', name="GroupInvite",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("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)), ("code", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
('joined_at', models.DateTimeField(auto_now_add=True)), ("max_uses", models.PositiveIntegerField(default=0, help_text="0 = unlimited")),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='groups.readinggroup')), ("use_count", models.PositiveIntegerField(default=0)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ("is_active", models.BooleanField(db_index=True, default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("created_by", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="created_invites", to=settings.AUTH_USER_MODEL)),
("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="invites", to="groups.group")),
], ],
options={ options={
'verbose_name': 'Reading Group Membership', "db_table": "groups_invite",
'verbose_name_plural': 'Reading Group Memberships', "verbose_name": "Group Invite",
'db_table': 'groups_membership', "verbose_name_plural": "Group Invites",
}, "ordering": ["-created_at"],
),
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( migrations.CreateModel(
name='Section', name="JoinRequest",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
('title', models.CharField(max_length=512)), ("status", models.CharField(choices=[("pending", "Pending"), ("approved", "Approved"), ("rejected", "Rejected")], db_index=True, default="pending", max_length=16)),
('order', models.PositiveIntegerField(db_index=True)), ("created_at", models.DateTimeField(auto_now_add=True)),
('start_chapter_index', models.PositiveIntegerField(default=0)), ("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="join_requests", to="groups.group")),
('end_chapter_index', models.PositiveIntegerField(default=0)), ("invite", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="join_requests", to="groups.groupinvite")),
('estimated_reading_minutes', models.PositiveIntegerField(default=0)), ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="join_requests", to=settings.AUTH_USER_MODEL)),
('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={ options={
'verbose_name': 'Section', "db_table": "groups_join_request",
'verbose_name_plural': 'Sections', "verbose_name": "Join Request",
'db_table': 'groups_section', "verbose_name_plural": "Join Requests",
'ordering': ['order'], "ordering": ["-created_at"],
"unique_together": {("group", "user")},
}, },
), ),
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')},
),
] ]
+81 -113
View File
@@ -1,161 +1,129 @@
import uuid
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from apps.books.models import EBook
class GroupRole(models.TextChoices):
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
class ReadingGroup(models.Model): class JoinRequestStatus(models.TextChoices):
"""A reading group with an admin and members.""" PENDING = "pending", "Pending"
APPROVED = "approved", "Approved"
REJECTED = "rejected", "Rejected"
class Group(models.Model):
name = models.CharField(max_length=256, db_index=True) name = models.CharField(max_length=256, db_index=True)
description = models.TextField(blank=True, default="") description = models.TextField(blank=True, default="")
admin = models.ForeignKey( created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="administered_groups", related_name="created_groups",
)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
through="ReadingGroupMembership",
related_name="reading_groups",
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
db_table = "groups_reading_group" db_table = "groups_group"
verbose_name = "Reading Group" verbose_name = "Group"
verbose_name_plural = "Reading Groups" verbose_name_plural = "Groups"
ordering = ["-created_at"] ordering = ["-created_at"]
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
class ReadingGroupMembership(models.Model): class GroupMember(models.Model):
class Role(models.TextChoices): group = models.ForeignKey(
ADMIN = "admin", "Admin" Group,
MEMBER = "member", "Member" on_delete=models.CASCADE,
related_name="memberships",
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) )
group = models.ForeignKey(ReadingGroup, on_delete=models.CASCADE, related_name="memberships") user = models.ForeignKey(
role = models.CharField(max_length=16, choices=Role.choices, default=Role.MEMBER) settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="group_memberships",
)
role = models.CharField(
max_length=16,
choices=GroupRole.choices,
default=GroupRole.MEMBER,
)
joined_at = models.DateTimeField(auto_now_add=True) joined_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
db_table = "groups_membership" db_table = "groups_member"
unique_together = [("user", "group")] verbose_name = "Group Member"
verbose_name = "Reading Group Membership" verbose_name_plural = "Group Members"
verbose_name_plural = "Reading Group Memberships" ordering = ["joined_at"]
unique_together = [("group", "user")]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.user} in {self.group.name} ({self.role})" return f"{self.user} in {self.group} ({self.role})"
class GroupBook(models.Model): class GroupInvite(models.Model):
"""Links an uploaded EBook to a reading group."""
class Status(models.TextChoices):
ACTIVE = "active", "Active"
REPLACED = "replaced", "Replaced"
group = models.ForeignKey( group = models.ForeignKey(
ReadingGroup, on_delete=models.CASCADE, related_name="group_books" Group,
on_delete=models.CASCADE,
related_name="invites",
) )
ebook = models.ForeignKey( created_by = models.ForeignKey(
EBook, on_delete=models.CASCADE, related_name="group_books" settings.AUTH_USER_MODEL,
) on_delete=models.CASCADE,
uploaded_by = models.ForeignKey( related_name="created_invites",
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
) )
code = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
max_uses = models.PositiveIntegerField(default=0, help_text="0 = unlimited")
use_count = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
db_table = "groups_group_book" db_table = "groups_invite"
verbose_name = "Group Book" verbose_name = "Group Invite"
verbose_name_plural = "Group Books" verbose_name_plural = "Group Invites"
ordering = ["-created_at"] ordering = ["-created_at"]
indexes = [models.Index(fields=["group", "status"])]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.group.name} {self.title}" return f"Invite for {self.group.name} ({self.code})"
class Section(models.Model): class JoinRequest(models.Model):
"""A section/chunk of a GroupBook for group reading.""" group = models.ForeignKey(
Group,
group_book = models.ForeignKey( on_delete=models.CASCADE,
GroupBook, on_delete=models.CASCADE, related_name="sections" related_name="join_requests",
) )
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( user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="group_progress" settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="join_requests",
) )
group_book = models.ForeignKey( invite = models.ForeignKey(
GroupBook, on_delete=models.CASCADE, related_name="member_progress" GroupInvite,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="join_requests",
) )
current_section = models.ForeignKey( status = models.CharField(
Section, on_delete=models.SET_NULL, null=True, blank=True, related_name="current_readers" max_length=16,
choices=JoinRequestStatus.choices,
default=JoinRequestStatus.PENDING,
db_index=True,
) )
completed_sections = models.JSONField( created_at = models.DateTimeField(auto_now_add=True)
default=list, help_text="Ordered list of completed Section IDs"
)
updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
db_table = "groups_member_progress" db_table = "groups_join_request"
verbose_name = "Member Progress" verbose_name = "Join Request"
verbose_name_plural = "Member Progress" verbose_name_plural = "Join Requests"
unique_together = [("user", "group_book")] ordering = ["-created_at"]
unique_together = [("group", "user")]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.user} {self.group_book.title}" return f"{self.user} {self.group.name} ({self.status})"
+25
View File
@@ -0,0 +1,25 @@
from rest_framework import permissions
from rest_framework.request import Request
from apps.groups.models import Group, GroupMember, GroupRole
class IsGroupAdmin(permissions.BasePermission):
"""Only group admins can perform the action."""
def has_object_permission(self, request: Request, view: object, obj: Group) -> bool:
return GroupMember.objects.filter(
group=obj,
user=request.user,
role=GroupRole.ADMIN,
).exists()
class IsGroupMember(permissions.BasePermission):
"""Only group members (any role) can perform the action."""
def has_object_permission(self, request: Request, view: object, obj: Group) -> bool:
return GroupMember.objects.filter(
group=obj,
user=request.user,
).exists()
+144 -131
View File
@@ -2,184 +2,197 @@ from __future__ import annotations
from rest_framework import serializers from rest_framework import serializers
from apps.groups.models import ( from apps.groups.models import Group, GroupInvite, GroupMember, GroupRole, JoinRequest, JoinRequestStatus
GroupBook,
MemberProgress,
ReadingGroup,
ReadingGroupMembership,
ReadingSchedule,
Section,
)
from apps.books.serializers import EBookListSerializer
class ReadingGroupMembershipSerializer(serializers.ModelSerializer): class GroupMemberSerializer(serializers.ModelSerializer):
user_id = serializers.IntegerField(source="user.id", read_only=True)
user_email = serializers.CharField(source="user.email", read_only=True) user_email = serializers.CharField(source="user.email", read_only=True)
user_username = serializers.CharField(source="user.username", read_only=True) user_username = serializers.CharField(source="user.username", read_only=True)
class Meta: class Meta:
model = ReadingGroupMembership model = GroupMember
fields = [ fields = [
"id", "user", "user_email", "user_username", "id", "user_id", "user_email", "user_username",
"role", "joined_at", "role", "joined_at",
] ]
read_only_fields = ["id", "joined_at"]
class ReadingGroupListSerializer(serializers.ModelSerializer): class GroupListSerializer(serializers.ModelSerializer):
member_count = serializers.SerializerMethodField() member_count = serializers.SerializerMethodField()
admin_email = serializers.CharField(source="admin.email", read_only=True) user_role = serializers.SerializerMethodField()
class Meta: class Meta:
model = ReadingGroup model = Group
fields = [ fields = [
"id", "name", "description", "admin", "admin_email", "id", "name", "description", "created_by",
"member_count", "created_at", "updated_at", "member_count", "user_role", "created_at", "updated_at",
] ]
def get_member_count(self, obj: ReadingGroup) -> int: def get_member_count(self, obj: Group) -> int:
return obj.memberships.count() return getattr(obj, "_member_count", obj.memberships.count())
def get_user_role(self, obj: Group) -> str | None:
request = self.context.get("request")
if not request or not request.user.is_authenticated:
return None
membership = getattr(obj, "_user_membership", None)
if membership is None:
try:
membership = obj.memberships.get(user=request.user)
except GroupMember.DoesNotExist:
return None
return membership.role
class ReadingGroupDetailSerializer(serializers.ModelSerializer): class GroupDetailSerializer(serializers.ModelSerializer):
admin_email = serializers.CharField(source="admin.email", read_only=True) members = GroupMemberSerializer(source="memberships", many=True, read_only=True)
members = ReadingGroupMembershipSerializer(source="memberships", many=True, read_only=True) member_count = serializers.SerializerMethodField()
user_role = serializers.SerializerMethodField()
created_by_email = serializers.CharField(source="created_by.email", read_only=True)
created_by_username = serializers.CharField(source="created_by.username", read_only=True)
class Meta: class Meta:
model = ReadingGroup model = Group
fields = [ fields = [
"id", "name", "description", "admin", "admin_email", "id", "name", "description", "created_by", "created_by_email",
"members", "created_at", "updated_at", "created_by_username", "members", "member_count", "user_role",
"created_at", "updated_at",
] ]
def get_member_count(self, obj: Group) -> int:
return getattr(obj, "_member_count", obj.memberships.count())
class ReadingGroupCreateSerializer(serializers.ModelSerializer): def get_user_role(self, obj: Group) -> str | None:
request = self.context.get("request")
if not request or not request.user.is_authenticated:
return None
membership = getattr(obj, "_user_membership", None)
if membership is None:
try:
membership = obj.memberships.get(user=request.user)
except GroupMember.DoesNotExist:
return None
return membership.role
class GroupCreateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ReadingGroup model = Group
fields = ["name", "description"] fields = ["name", "description"]
def validate_name(self, value: str) -> str:
if not value.strip():
raise serializers.ValidationError("Group name cannot be empty.")
if len(value.strip()) < 2:
raise serializers.ValidationError("Group name must be at least 2 characters.")
return value.strip()
class AddMemberSerializer(serializers.Serializer): def create(self, validated_data: dict) -> Group:
user_id = serializers.IntegerField() user = self.context["request"].user
group = Group.objects.create(created_by=user, **validated_data)
GroupMember.objects.create(group=group, user=user, role=GroupRole.ADMIN)
return group
def validate_user_id(self, value: int) -> int:
from django.conf import settings class GroupUpdateSerializer(serializers.ModelSerializer):
User = settings.AUTH_USER_MODEL class Meta:
if not User.objects.filter(id=value).exists(): model = Group
raise serializers.ValidationError("User not found.") fields = ["name", "description"]
def validate_name(self, value: str) -> str:
if not value.strip():
raise serializers.ValidationError("Group name cannot be empty.")
if len(value.strip()) < 2:
raise serializers.ValidationError("Group name must be at least 2 characters.")
return value.strip()
class GroupInviteSerializer(serializers.ModelSerializer):
created_by_email = serializers.CharField(source="created_by.email", read_only=True)
group_name = serializers.CharField(source="group.name", read_only=True)
join_url = serializers.SerializerMethodField()
class Meta:
model = GroupInvite
fields = [
"id", "group", "group_name", "code", "created_by", "created_by_email",
"max_uses", "use_count", "is_active", "join_url", "created_at",
]
read_only_fields = ["id", "group", "code", "created_by", "use_count", "created_at"]
def get_join_url(self, obj: GroupInvite) -> str:
request = self.context.get("request")
if request:
return f"{request.build_absolute_uri('/')[:-1]}/groups/join/{obj.code}"
return f"/groups/join/{obj.code}"
class GroupInviteCreateSerializer(serializers.ModelSerializer):
class Meta:
model = GroupInvite
fields = ["max_uses"]
def validate_max_uses(self, value: int) -> int:
if value < 0:
raise serializers.ValidationError("Max uses cannot be negative.")
return value return value
def create(self, validated_data: dict) -> GroupInvite:
class SectionSerializer(serializers.ModelSerializer): group = self.context["group"]
class Meta: user = self.context["request"].user
model = Section return GroupInvite.objects.create(
fields = [ group=group,
"id", "title", "order", "start_chapter_index", created_by=user,
"end_chapter_index", "estimated_reading_minutes", "created_at", **validated_data,
]
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): class JoinRequestSerializer(serializers.ModelSerializer):
section_details = serializers.SerializerMethodField() user_id = serializers.IntegerField(source="user.id", read_only=True)
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_email = serializers.CharField(source="user.email", read_only=True)
user_username = serializers.CharField(source="user.username", read_only=True) user_username = serializers.CharField(source="user.username", read_only=True)
current_section_title = serializers.SerializerMethodField() group_name = serializers.CharField(source="group.name", read_only=True)
invite_code = serializers.UUIDField(source="invite.code", read_only=True, default=None)
class Meta: class Meta:
model = MemberProgress model = JoinRequest
fields = [ fields = [
"id", "user", "user_email", "user_username", "id", "group", "group_name", "user", "user_id", "user_email",
"current_section", "current_section_title", "user_username", "invite", "invite_code", "status", "created_at",
"completed_sections", "updated_at",
] ]
read_only_fields = ["id", "updated_at"] read_only_fields = ["id", "group", "user", "invite", "created_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): class JoinViaInviteSerializer(serializers.Serializer):
ebook = EBookListSerializer(read_only=True) """Validates and processes joining a group via an invite code."""
uploaded_by_email = serializers.CharField(source="uploaded_by.email", read_only=True)
section_count = serializers.SerializerMethodField()
class Meta: code = serializers.UUIDField()
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: def validate_code(self, value: str) -> str:
return obj.sections.count() try:
invite = GroupInvite.objects.select_related("group").get(code=value)
except GroupInvite.DoesNotExist:
raise serializers.ValidationError("Invalid invite code.")
if not invite.is_active:
raise serializers.ValidationError("This invite is no longer active.")
class GroupBookDetailSerializer(serializers.ModelSerializer): if invite.max_uses > 0 and invite.use_count >= invite.max_uses:
ebook = EBookListSerializer(read_only=True) raise serializers.ValidationError("This invite has reached its maximum uses.")
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 return value
def validate(self, data: dict) -> dict:
request = self.context.get("request") class RoleUpdateSerializer(serializers.Serializer):
if request and not data.get("title"): role = serializers.ChoiceField(choices=GroupRole.choices)
from apps.books.models import EBook
ebook = EBook.objects.get(id=data["ebook_id"]) def validate_role(self, value: str) -> str:
data["title"] = ebook.title if value == GroupRole.MEMBER:
return data group = self.context["group"]
admin_count = group.memberships.filter(role=GroupRole.ADMIN).count()
if admin_count <= 1:
raise serializers.ValidationError(
"Cannot remove the last admin. Transfer admin role first or dissolve the group."
)
return value
@@ -1,241 +0,0 @@
"""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
+3 -34
View File
@@ -1,43 +1,12 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.groups.views import GroupBookViewSet, ReadingGroupViewSet from apps.groups.views import GroupViewSet, JoinGroupViewSet
# ReadingGroup routes (standard ViewSet)
router = DefaultRouter() router = DefaultRouter()
router.register(r"", ReadingGroupViewSet, basename="group") router.register(r"groups", GroupViewSet, basename="group")
router.register(r"join", JoinGroupViewSet, basename="join-group")
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),
# Nested GroupBook routes under a group
path(
"<int:group_pk>/books/",
GroupBookViewSet.as_view({"get": "list", "post": "create"}),
name="group-book-list",
),
path(
"<int:group_pk>/books/<int:pk>/",
GroupBookViewSet.as_view({"get": "retrieve", "delete": "destroy"}),
name="group-book-detail",
),
path(
"<int:group_pk>/books/<int:pk>/detect-sections/",
GroupBookViewSet.as_view({"post": "detect_sections_action"}),
name="group-book-detect-sections",
),
path(
"<int:group_pk>/books/<int:pk>/adjust-sections/",
GroupBookViewSet.as_view({"post": "adjust_sections"}),
name="group-book-adjust-sections",
),
path(
"<int:group_pk>/books/<int:pk>/schedule/",
GroupBookViewSet.as_view({"get": "schedule", "post": "schedule", "delete": "schedule"}),
name="group-book-schedule",
),
path(
"<int:group_pk>/books/<int:pk>/progress/",
GroupBookViewSet.as_view({"get": "progress", "patch": "progress"}),
name="group-book-progress",
),
] ]
+309 -327
View File
@@ -1,349 +1,331 @@
from __future__ import annotations from __future__ import annotations
import logging from django.db.models import Count, Prefetch, QuerySet
from typing import Any from rest_framework import status, viewsets
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.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from apps.books.models import EBook from apps.groups.models import Group, GroupInvite, GroupMember, GroupRole, JoinRequest, JoinRequestStatus
from apps.groups.models import ( from apps.groups.permissions import IsGroupAdmin, IsGroupMember
GroupBook,
MemberProgress,
ReadingGroup,
ReadingGroupMembership,
ReadingSchedule,
Section,
)
from apps.groups.serializers import ( from apps.groups.serializers import (
AddMemberSerializer, GroupCreateSerializer,
GroupBookCreateSerializer, GroupDetailSerializer,
GroupBookDetailSerializer, GroupInviteCreateSerializer,
GroupBookListSerializer, GroupInviteSerializer,
MemberProgressSerializer, GroupListSerializer,
ReadingGroupCreateSerializer, GroupMemberSerializer,
ReadingGroupDetailSerializer, GroupUpdateSerializer,
ReadingGroupListSerializer, JoinRequestSerializer,
ReadingScheduleSerializer, JoinViaInviteSerializer,
SectionAdjustSerializer, RoleUpdateSerializer,
SectionSerializer,
)
from apps.groups.services.section_splitting import (
apply_schedule,
apply_sections,
detect_sections,
) )
logger = logging.getLogger(__name__)
class GroupViewSet(viewsets.ModelViewSet):
class IsGroupAdmin(permissions.BasePermission): permission_classes = [IsAuthenticated]
"""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): def get_serializer_class(self):
if self.action == "create": if self.action == "create":
return ReadingGroupCreateSerializer return GroupCreateSerializer
if self.action in ("update", "partial_update"):
return GroupUpdateSerializer
if self.action == "retrieve": if self.action == "retrieve":
return ReadingGroupDetailSerializer return GroupDetailSerializer
return ReadingGroupListSerializer return GroupListSerializer
def get_queryset(self) -> QuerySet[ReadingGroup]: def get_queryset(self) -> QuerySet[Group]:
user = self.request.user user = self.request.user
return ReadingGroup.objects.filter( qs = Group.objects.filter(memberships__user=user).distinct()
memberships__user=user qs = qs.annotate(_member_count=Count("memberships"))
).prefetch_related("memberships__user").distinct() if self.action in ("list", "retrieve"):
qs = qs.prefetch_related(
def perform_create(self, serializer: ReadingGroupCreateSerializer) -> ReadingGroup: Prefetch(
group = serializer.save(admin=self.request.user) "memberships",
ReadingGroupMembership.objects.create( queryset=GroupMember.objects.select_related("user").order_by("joined_at"),
user=self.request.user, group=group, role=ReadingGroupMembership.Role.ADMIN
) )
return group )
return qs
@action(detail=True, methods=["post"], permission_classes=[IsGroupAdmin]) def get_object(self) -> Group:
def add_member(self, request: Request, pk: int | None = None) -> Response: obj = super().get_object()
# Cache the requesting user's membership for serializers
try:
obj._user_membership = obj.memberships.get(user=self.request.user)
except GroupMember.DoesNotExist:
obj._user_membership = None
return obj
def perform_create(self, serializer: GroupCreateSerializer) -> Group:
return serializer.save()
def perform_destroy(self, instance: Group) -> None:
# Only admin can delete/dissolve the group
if not GroupMember.objects.filter(
group=instance, user=self.request.user, role=GroupRole.ADMIN
).exists():
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("Only group admins can delete the group.")
instance.delete()
# ---- Members ----
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated, IsGroupMember])
def members(self, request: Request, pk: str | None = None) -> Response:
"""List all members of the group."""
group = self.get_object() group = self.get_object()
member_serializer = AddMemberSerializer(data=request.data) memberships = group.memberships.select_related("user").order_by("joined_at")
member_serializer.is_valid(raise_exception=True) serializer = GroupMemberSerializer(memberships, many=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) return Response(serializer.data)
@action(
detail=True,
methods=["delete"],
url_path="members/(?P<user_id>[^/.]+)",
permission_classes=[IsAuthenticated, IsGroupAdmin],
)
def remove_member(self, request: Request, pk: str | None = None, user_id: str | None = None) -> Response:
"""Admin removes a member from the group."""
group = self.get_object()
try:
membership = GroupMember.objects.get(group=group, user_id=user_id)
except GroupMember.DoesNotExist:
return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND)
def _renumber_sections(group_book: GroupBook) -> None: if membership.user == request.user:
"""Re-number sections sequentially by their current order.""" return Response(
sections = list(Section.objects.filter(group_book=group_book).order_by("order")) {"error": "Admins cannot remove themselves. Use leave instead, or transfer admin first."},
for idx, section in enumerate(sections): status=status.HTTP_400_BAD_REQUEST,
if section.order != idx + 1: )
section.order = idx + 1
section.save(update_fields=["order"]) membership.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(
detail=True,
methods=["patch"],
url_path="members/(?P<user_id>[^/.]+)/role",
permission_classes=[IsAuthenticated, IsGroupAdmin],
)
def update_member_role(self, request: Request, pk: str | None = None, user_id: str | None = None) -> Response:
"""Admin transfers admin role or changes member role."""
group = self.get_object()
serializer = RoleUpdateSerializer(data=request.data, context={"group": group})
serializer.is_valid(raise_exception=True)
try:
membership = GroupMember.objects.get(group=group, user_id=user_id)
except GroupMember.DoesNotExist:
return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND)
membership.role = serializer.validated_data["role"]
membership.save(update_fields=["role"])
if serializer.validated_data["role"] == GroupRole.ADMIN and membership.user != request.user:
# Downgrade the current admin to member
GroupMember.objects.filter(group=group, user=request.user).update(role=GroupRole.MEMBER)
return Response(GroupMemberSerializer(membership).data)
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated, IsGroupMember])
def leave(self, request: Request, pk: str | None = None) -> Response:
"""Member leaves the group. If admin is last admin, dissolve the group."""
group = self.get_object()
membership = GroupMember.objects.filter(group=group, user=request.user).first()
if not membership:
return Response({"error": "You are not a member of this group."}, status=status.HTTP_400_BAD_REQUEST)
if membership.role == GroupRole.ADMIN:
admin_count = GroupMember.objects.filter(group=group, role=GroupRole.ADMIN).count()
if admin_count <= 1:
# Last admin leaving — dissolve the group
group.delete()
return Response({"detail": "You were the last admin. The group has been dissolved."})
membership.delete()
return Response({"detail": "You have left the group."})
# ---- Invites ----
@action(detail=True, methods=["get", "post"], permission_classes=[IsAuthenticated, IsGroupAdmin])
def invites(self, request: Request, pk: str | None = None) -> Response:
"""List or create invites for the group."""
group = self.get_object()
if request.method == "GET":
invites_qs = group.invites.select_related("created_by").order_by("-created_at")
serializer = GroupInviteSerializer(invites_qs, many=True, context={"request": request})
return Response(serializer.data)
serializer = GroupInviteCreateSerializer(
data=request.data,
context={"group": group, "request": request},
)
serializer.is_valid(raise_exception=True)
invite = serializer.save()
return Response(
GroupInviteSerializer(invite, context={"request": request}).data,
status=status.HTTP_201_CREATED,
)
@action(
detail=True,
methods=["delete"],
url_path="invites/(?P<invite_id>[^/.]+)",
permission_classes=[IsAuthenticated, IsGroupAdmin],
)
def revoke_invite(self, request: Request, pk: str | None = None, invite_id: str | None = None) -> Response:
"""Revoke an invite by deactivating it."""
group = self.get_object()
try:
invite = GroupInvite.objects.get(id=invite_id, group=group)
except GroupInvite.DoesNotExist:
return Response({"error": "Invite not found."}, status=status.HTTP_404_NOT_FOUND)
invite.is_active = False
invite.save(update_fields=["is_active"])
return Response(status=status.HTTP_204_NO_CONTENT)
# ---- Join Requests ----
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated, IsGroupAdmin])
def requests(self, request: Request, pk: str | None = None) -> Response:
"""List pending join requests for the group (admin only)."""
group = self.get_object()
join_requests = group.join_requests.select_related("user", "invite").order_by("-created_at")
serializer = JoinRequestSerializer(join_requests, many=True)
return Response(serializer.data)
@action(
detail=True,
methods=["post"],
url_path="requests/(?P<request_id>[^/.]+)/approve",
permission_classes=[IsAuthenticated, IsGroupAdmin],
)
def approve_request(self, request: Request, pk: str | None = None, request_id: str | None = None) -> Response:
"""Approve a pending join request."""
group = self.get_object()
try:
join_request = JoinRequest.objects.get(id=request_id, group=group, status=JoinRequestStatus.PENDING)
except JoinRequest.DoesNotExist:
return Response({"error": "Pending join request not found."}, status=status.HTTP_404_NOT_FOUND)
join_request.status = JoinRequestStatus.APPROVED
join_request.save(update_fields=["status"])
GroupMember.objects.get_or_create(
group=group,
user=join_request.user,
defaults={"role": GroupRole.MEMBER},
)
# Increment invite use count
if join_request.invite:
join_request.invite.use_count += 1
join_request.invite.save(update_fields=["use_count"])
return Response(JoinRequestSerializer(join_request).data)
@action(
detail=True,
methods=["post"],
url_path="requests/(?P<request_id>[^/.]+)/reject",
permission_classes=[IsAuthenticated, IsGroupAdmin],
)
def reject_request(self, request: Request, pk: str | None = None, request_id: str | None = None) -> Response:
"""Reject a pending join request."""
group = self.get_object()
try:
join_request = JoinRequest.objects.get(id=request_id, group=group, status=JoinRequestStatus.PENDING)
except JoinRequest.DoesNotExist:
return Response({"error": "Pending join request not found."}, status=status.HTTP_404_NOT_FOUND)
join_request.status = JoinRequestStatus.REJECTED
join_request.save(update_fields=["status"])
return Response(JoinRequestSerializer(join_request).data)
class JoinGroupViewSet(viewsets.GenericViewSet):
"""Public(ish) endpoint for joining a group via an invite code."""
permission_classes = [IsAuthenticated]
@action(detail=False, methods=["get"], url_path="(?P<code>[^/.]+)")
def validate_invite(self, request: Request, code: str | None = None) -> Response:
"""Check if an invite code is valid and show group info."""
try:
invite = GroupInvite.objects.select_related("group", "group__created_by").get(code=code)
except GroupInvite.DoesNotExist:
return Response({"error": "Invalid invite code."}, status=status.HTTP_404_NOT_FOUND)
if not invite.is_active:
return Response({"error": "This invite is no longer active."}, status=status.HTTP_410_GONE)
if invite.max_uses > 0 and invite.use_count >= invite.max_uses:
return Response({"error": "This invite has reached its maximum uses."}, status=status.HTTP_410_GONE)
return Response({
"group": {
"id": invite.group.id,
"name": invite.group.name,
"description": invite.group.description,
"created_by_email": invite.group.created_by.email,
"member_count": invite.group.memberships.count(),
},
"invite": {
"code": str(invite.code),
"created_by_email": invite.created_by.email,
},
})
@action(detail=False, methods=["post"], url_path="(?P<code>[^/.]+)")
def join(self, request: Request, code: str | None = None) -> Response:
"""Join a group via invite code."""
try:
invite = GroupInvite.objects.select_related("group").get(code=code)
except GroupInvite.DoesNotExist:
return Response({"error": "Invalid invite code."}, status=status.HTTP_404_NOT_FOUND)
if not invite.is_active:
return Response({"error": "This invite is no longer active."}, status=status.HTTP_410_GONE)
if invite.max_uses > 0 and invite.use_count >= invite.max_uses:
return Response({"error": "This invite has reached its maximum uses."}, status=status.HTTP_410_GONE)
group = invite.group
# Check if already a member
if GroupMember.objects.filter(group=group, user=request.user).exists():
return Response(
{"detail": "You are already a member of this group.", "group_id": group.id},
status=status.HTTP_200_OK,
)
# Check for existing pending request
existing_request = JoinRequest.objects.filter(
group=group, user=request.user, status=JoinRequestStatus.PENDING
).first()
if existing_request:
return Response(
JoinRequestSerializer(existing_request).data,
status=status.HTTP_200_OK,
)
# Create join request or add directly (direct join for now — simple invite flow)
member = GroupMember.objects.create(group=group, user=request.user, role=GroupRole.MEMBER)
invite.use_count += 1
invite.save(update_fields=["use_count"])
# Also create a join request record for tracking
JoinRequest.objects.create(
group=group,
user=request.user,
invite=invite,
status=JoinRequestStatus.APPROVED,
)
serializer = GroupDetailSerializer(group, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
+1 -1
View File
@@ -39,9 +39,9 @@ INSTALLED_APPS = [
# Local apps # Local apps
"apps.users", "apps.users",
"apps.books", "apps.books",
"apps.groups",
"apps.annotations", "apps.annotations",
"apps.reader", "apps.reader",
"apps.groups",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
+1 -1
View File
@@ -6,9 +6,9 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/auth/", include("apps.users.urls")), path("api/auth/", include("apps.users.urls")),
path("api/books/", include("apps.books.urls")), path("api/books/", include("apps.books.urls")),
path("api/groups/", include("apps.groups.urls")),
path("api/annotations/", include("apps.annotations.urls")), path("api/annotations/", include("apps.annotations.urls")),
path("api/reader/", include("apps.reader.urls")), path("api/reader/", include("apps.reader.urls")),
path("api/", include("apps.groups.urls")),
] ]
if settings.DEBUG: if settings.DEBUG:
+7 -5
View File
@@ -12,9 +12,10 @@ const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default:
const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage }))); const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage })));
const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage }))); const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage })));
const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default }))); const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default })));
const GroupsPage = lazy(() => import("./pages/GroupsPage").then((m) => ({ default: m.GroupsPage }))); const GroupsListPage = lazy(() => import("./pages/GroupsListPage").then((m) => ({ default: m.GroupsListPage })));
const GroupDetailPage = lazy(() => import("./pages/GroupDetailPage").then((m) => ({ default: m.GroupDetailPage }))); const GroupDetailPage = lazy(() => import("./pages/GroupDetailPage").then((m) => ({ default: m.GroupDetailPage })));
const GroupBookPage = lazy(() => import("./pages/GroupBookPage").then((m) => ({ default: m.GroupBookPage }))); const CreateGroupPage = lazy(() => import("./pages/CreateGroupPage").then((m) => ({ default: m.CreateGroupPage })));
const JoinGroupPage = lazy(() => import("./pages/JoinGroupPage").then((m) => ({ default: m.JoinGroupPage })));
const AuthPage = lazy(() => import("./pages/AuthPage")); const AuthPage = lazy(() => import("./pages/AuthPage"));
@@ -47,10 +48,11 @@ function AppRoutes() {
<Route path="/reader/:id" element={<ProtectedRoute><ReaderRedirect /></ProtectedRoute>} /> <Route path="/reader/:id" element={<ProtectedRoute><ReaderRedirect /></ProtectedRoute>} />
<Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} /> <Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} /> <Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/groups" element={<ProtectedRoute><GroupsPage /></ProtectedRoute>} />
<Route path="/groups/:groupId" element={<ProtectedRoute><GroupDetailPage /></ProtectedRoute>} />
<Route path="/groups/:groupId/books/:bookId" element={<ProtectedRoute><GroupBookPage /></ProtectedRoute>} />
<Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} /> <Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
<Route path="/groups" element={<ProtectedRoute><GroupsListPage /></ProtectedRoute>} />
<Route path="/groups/create" element={<ProtectedRoute><CreateGroupPage /></ProtectedRoute>} />
<Route path="/groups/join/:code" element={<ProtectedRoute><JoinGroupPage /></ProtectedRoute>} />
<Route path="/groups/:id" element={<ProtectedRoute><GroupDetailPage /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
+51 -102
View File
@@ -1,36 +1,36 @@
import api from "./client"; import api from "./client";
import type { import type {
AddMemberPayload,
AdjustSectionsPayload,
CreateGroupBookPayload,
CreateGroupPayload, CreateGroupPayload,
GroupBook, CreateInvitePayload,
GroupBookDetail, GroupDetail,
MemberProgress, GroupInvite,
ReadingGroup, GroupListItem,
ReadingGroupDetail, InviteValidation,
ReadingSchedule, JoinRequest,
Section, UpdateGroupPayload,
} from "../types/group"; } from "../../packages/shared/src/types";
export const groupsApi = { export const groupsApi = {
// ---- Reading Groups ---- // ---- Group CRUD ----
async listGroups(): Promise<ReadingGroup[]> { async listGroups(): Promise<GroupListItem[]> {
const { data } = await api.get<{ count: number; results: ReadingGroup[] } | ReadingGroup[]>( const { data } = await api.get<{ count: number; results: GroupListItem[] } | GroupListItem[]>("/groups/");
"/groups/"
);
if (Array.isArray(data)) return data; if (Array.isArray(data)) return data;
return data.results ?? []; return data.results ?? [];
}, },
async getGroup(id: number): Promise<ReadingGroupDetail> { async getGroup(id: number): Promise<GroupDetail> {
const { data } = await api.get<ReadingGroupDetail>(`/groups/${id}/`); const { data } = await api.get<GroupDetail>(`/groups/${id}/`);
return data; return data;
}, },
async createGroup(payload: CreateGroupPayload): Promise<ReadingGroup> { async createGroup(payload: CreateGroupPayload): Promise<GroupDetail> {
const { data } = await api.post<ReadingGroup>("/groups/", payload); const { data } = await api.post<GroupDetail>("/groups/", payload);
return data;
},
async updateGroup(id: number, payload: UpdateGroupPayload): Promise<GroupDetail> {
const { data } = await api.patch<GroupDetail>(`/groups/${id}/`, payload);
return data; return data;
}, },
@@ -38,114 +38,63 @@ export const groupsApi = {
await api.delete(`/groups/${id}/`); await api.delete(`/groups/${id}/`);
}, },
async addMember(groupId: number, payload: AddMemberPayload): Promise<void> { // ---- Members ----
await api.post(`/groups/${groupId}/add_member/`, payload);
async removeMember(groupId: number, userId: number): Promise<void> {
await api.delete(`/groups/${groupId}/members/${userId}/`);
}, },
async removeMember(groupId: number, payload: AddMemberPayload): Promise<void> { async updateMemberRole(groupId: number, userId: number, role: "admin" | "member"): Promise<void> {
await api.post(`/groups/${groupId}/remove_member/`, payload); await api.patch(`/groups/${groupId}/members/${userId}/role/`, { role });
}, },
// ---- Group Books ---- async leaveGroup(groupId: number): Promise<{ detail: string }> {
const { data } = await api.post<{ detail: string }>(`/groups/${groupId}/leave/`);
async listGroupBooks(groupId: number): Promise<GroupBook[]> {
const { data } = await api.get<GroupBook[]>(`/groups/${groupId}/books/`);
return Array.isArray(data) ? data : (data as { results: GroupBook[] }).results ?? [];
},
async getGroupBook(groupId: number, bookId: number): Promise<GroupBookDetail> {
const { data } = await api.get<GroupBookDetail>(`/groups/${groupId}/books/${bookId}/`);
return data; return data;
}, },
async createGroupBook( // ---- Invites ----
groupId: number,
payload: CreateGroupBookPayload async listInvites(groupId: number): Promise<GroupInvite[]> {
): Promise<GroupBookDetail> { const { data } = await api.get<GroupInvite[]>(`/groups/${groupId}/invites/`);
const { data } = await api.post<GroupBookDetail>(
`/groups/${groupId}/books/`,
payload
);
return data; return data;
}, },
async deleteGroupBook(groupId: number, bookId: number): Promise<void> { async createInvite(groupId: number, payload: CreateInvitePayload = {}): Promise<GroupInvite> {
await api.delete(`/groups/${groupId}/books/${bookId}/`); const { data } = await api.post<GroupInvite>(`/groups/${groupId}/invites/`, payload);
},
// ---- Sections ----
async detectSections(
groupId: number,
bookId: number
): Promise<Section[]> {
const { data } = await api.post<Section[]>(
`/groups/${groupId}/books/${bookId}/detect-sections/`
);
return data; return data;
}, },
async adjustSections( async revokeInvite(groupId: number, inviteId: number): Promise<void> {
groupId: number, await api.delete(`/groups/${groupId}/invites/${inviteId}/`);
bookId: number, },
payload: AdjustSectionsPayload
): Promise<Section | Section[]> { // ---- Join Requests ----
const { data } = await api.post<Section | Section[]>(
`/groups/${groupId}/books/${bookId}/adjust-sections/`, async listJoinRequests(groupId: number): Promise<JoinRequest[]> {
payload const { data } = await api.get<JoinRequest[]>(`/groups/${groupId}/requests/`);
);
return data; return data;
}, },
// ---- Schedule ---- async approveRequest(groupId: number, requestId: number): Promise<JoinRequest> {
const { data } = await api.post<JoinRequest>(`/groups/${groupId}/requests/${requestId}/approve/`);
async getSchedule(
groupId: number,
bookId: number
): Promise<ReadingSchedule[]> {
const { data } = await api.get<ReadingSchedule[]>(
`/groups/${groupId}/books/${bookId}/schedule/`
);
return data; return data;
}, },
async generateSchedule( async rejectRequest(groupId: number, requestId: number): Promise<JoinRequest> {
groupId: number, const { data } = await api.post<JoinRequest>(`/groups/${groupId}/requests/${requestId}/reject/`);
bookId: number,
numMeetings: number = 4
): Promise<ReadingSchedule[]> {
const { data } = await api.post<ReadingSchedule[]>(
`/groups/${groupId}/books/${bookId}/schedule/`,
{ num_meetings: numMeetings }
);
return data; return data;
}, },
async deleteSchedule(groupId: number, bookId: number): Promise<void> { // ---- Join via Invite ----
await api.delete(`/groups/${groupId}/books/${bookId}/schedule/`);
},
// ---- Progress ---- async validateInvite(code: string): Promise<InviteValidation> {
const { data } = await api.get<InviteValidation>(`/join/${code}/`);
async getProgress(
groupId: number,
bookId: number
): Promise<MemberProgress[]> {
const { data } = await api.get<MemberProgress[]>(
`/groups/${groupId}/books/${bookId}/progress/`
);
return data; return data;
}, },
async updateProgress( async joinViaInvite(code: string): Promise<GroupDetail> {
groupId: number, const { data } = await api.post<GroupDetail>(`/join/${code}/`);
bookId: number,
payload: { current_section?: number; completed_sections?: number[] }
): Promise<MemberProgress> {
const { data } = await api.patch<MemberProgress>(
`/groups/${groupId}/books/${bookId}/progress/`,
payload
);
return data; return data;
}, },
}; };
-48
View File
@@ -245,54 +245,6 @@ const enUS = {
home: "Home", home: "Home",
bookmarksNotes: "Bookmarks & Notes", 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; } as const;
export default enUS; export default enUS;
-48
View File
@@ -247,54 +247,6 @@ const esES: Locale = {
home: "Inicio", home: "Inicio",
bookmarksNotes: "Marcadores y notas", 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; export default esES;
+104
View File
@@ -0,0 +1,104 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { groupsApi } from "../api/groups";
import { useToast } from "../hooks/useToast";
const S = {
container: { maxWidth: 500, margin: "0 auto", padding: "24px 16px" } satisfies React.CSSProperties,
backBtn: {
background: "none", border: "none", color: "#3b82f6", fontSize: 14, cursor: "pointer",
padding: 0, marginBottom: 16, minHeight: 44, minWidth: 44,
} satisfies React.CSSProperties,
title: { fontSize: 24, fontWeight: 700, marginBottom: 24 } satisfies React.CSSProperties,
label: { display: "block", fontSize: 14, fontWeight: 500, marginBottom: 6 } satisfies React.CSSProperties,
input: {
width: "100%", padding: "10px 12px", border: "1px solid #d1d5db", borderRadius: 8,
fontSize: 14, marginBottom: 16, boxSizing: "border-box" as const,
} satisfies React.CSSProperties,
textarea: {
width: "100%", padding: "10px 12px", border: "1px solid #d1d5db", borderRadius: 8,
fontSize: 14, marginBottom: 16, minHeight: 80, resize: "vertical" as const,
boxSizing: "border-box" as const, fontFamily: "inherit",
} satisfies React.CSSProperties,
submitBtn: (disabled: boolean): React.CSSProperties => ({
width: "100%", padding: "12px", backgroundColor: disabled ? "#93c5fd" : "#3b82f6",
color: "#fff", border: "none", borderRadius: 8, fontSize: 15, fontWeight: 600,
cursor: disabled ? "not-allowed" : "pointer", minHeight: 44,
}),
errorText: { color: "#ef4444", fontSize: 13, marginBottom: 12 },
};
export function CreateGroupPage() {
const navigate = useNavigate();
const { showToast } = useToast();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError("Group name is required.");
return;
}
if (name.trim().length < 2) {
setError("Group name must be at least 2 characters.");
return;
}
setSubmitting(true);
setError(null);
try {
const group = await groupsApi.createGroup({
name: name.trim(),
description: description.trim() || undefined,
});
showToast({ message: "Group created!", variant: "success" });
navigate(`/groups/${group.id}`);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to create group";
setError(msg);
showToast({ message: msg, variant: "error" });
} finally {
setSubmitting(false);
}
};
return (
<div style={S.container}>
<button style={S.backBtn} onClick={() => navigate("/groups")}>
Back to Groups
</button>
<h1 style={S.title}>Create a Group</h1>
<form onSubmit={handleSubmit}>
{error && <div style={S.errorText}>{error}</div>}
<label style={S.label} htmlFor="group-name">Group Name *</label>
<input
id="group-name"
style={S.input}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Sci-Fi Book Club"
maxLength={256}
autoFocus
/>
<label style={S.label} htmlFor="group-desc">Description</label>
<textarea
id="group-desc"
style={S.textarea}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's this group about? (optional)"
/>
<button type="submit" style={S.submitBtn(submitting || !name.trim())} disabled={submitting || !name.trim()}>
{submitting ? "Creating..." : "Create Group"}
</button>
</form>
</div>
);
}
-426
View File
@@ -1,426 +0,0 @@
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<GroupBookDetail | null>(null);
const [sections, setSections] = useState<Section[]>([]);
const [schedules, setSchedules] = useState<ReadingSchedule[]>([]);
const [progress, setProgress] = useState<MemberProgress[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"sections" | "schedule" | "progress">("sections");
// Merge state
const [selectedForMerge, setSelectedForMerge] = useState<number[]>([]);
const [splitSectionId, setSplitSectionId] = useState<number | null>(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 (
<div style={{ maxWidth: 800, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<p style={{ textAlign: "center", color: "#888", padding: 40 }}>{t("common.loading")}</p>
</div>
);
}
if (!book) {
return (
<div style={{ maxWidth: 800, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<p style={{ textAlign: "center", color: "#e74c3c", padding: 40 }}>Book not found</p>
<button onClick={() => navigate(`/groups/${groupId}`)} style={{ display: "block", margin: "0 auto" }}> Back</button>
</div>
);
}
return (
<div style={{ maxWidth: 800, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
{/* Header */}
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "16px 0", borderBottom: "1px solid #eee", marginBottom: 20 }}>
<div>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>{book.title}</h1>
<p style={{ margin: 4, fontSize: 13, color: "#888" }}>
{book.ebook.author && `by ${book.ebook.author} · `}
{book.ebook.page_count} chapters · {book.ebook.format.toUpperCase()}
</p>
</div>
<button onClick={() => navigate(`/groups/${groupId}`)} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}>
{t("common.back")}
</button>
</header>
{error && (
<div style={{ background: "#fde8e8", padding: 12, borderRadius: 6, color: "#e74c3c", fontSize: 14, marginBottom: 16 }}>
{error}
<button onClick={() => setError(null)} style={{ marginLeft: 12, background: "none", border: "none", cursor: "pointer", fontWeight: 600 }}></button>
</div>
)}
{/* Tabs */}
<div style={{ display: "flex", gap: 4, marginBottom: 20, background: "#eee", borderRadius: 8, padding: 4 }}>
{(["sections", "schedule", "progress"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
flex: 1, padding: "10px 16px", borderRadius: 6, border: "none",
fontSize: 14, fontWeight: 600, cursor: "pointer",
background: tab === t ? "#fff" : "transparent",
color: tab === t ? "#1a1a2e" : "#888",
boxShadow: tab === t ? "0 1px 4px rgba(0,0,0,0.1)" : "none",
}}
>
{t === "sections" ? t("groups.sections") : t === "schedule" ? t("groups.schedule") : t("groups.progress")}
</button>
))}
</div>
{/* Sections Tab */}
{tab === "sections" && (
<section>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ fontSize: 18, fontWeight: 600, color: "#1a1a2e", margin: 0 }}>
{sections.length} {t("groups.sections")}
</h2>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={handleDetectSections}
style={{ padding: "8px 16px", borderRadius: 6, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 13, fontWeight: 600, cursor: "pointer" }}
>
{t("groups.detectSections")}
</button>
</div>
</div>
{/* Merge controls */}
<div style={{ background: "#fff", padding: 16, borderRadius: 10, marginBottom: 12, boxShadow: "0 1px 4px rgba(0,0,0,0.06)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<span style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("groups.mergeSections")}:</span>
<span style={{ fontSize: 13, color: "#888" }}>
{selectedForMerge.length === 0
? t("groups.selectSectionsHint")
: `${selectedForMerge.length} selected`}
</span>
{selectedForMerge.length >= 2 && (
<button
onClick={handleMerge}
style={{ padding: "6px 14px", borderRadius: 6, border: "none", background: "#27ae60", color: "#fff", fontSize: 13, fontWeight: 600, cursor: "pointer" }}
>
{t("groups.merge")}
</button>
)}
{selectedForMerge.length > 0 && (
<button
onClick={() => setSelectedForMerge([])}
style={{ padding: "6px 12px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", fontSize: 13, cursor: "pointer" }}
>
{t("groups.clear")}
</button>
)}
</div>
</div>
{sections.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#888" }}>
<p style={{ fontSize: 16 }}>{t("groups.noSections")}</p>
<p style={{ fontSize: 14 }}>{t("groups.noSectionsHint")}</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{sections.map((section) => (
<div
key={section.id}
style={{
background: selectedForMerge.includes(section.id) ? "#e8f0fe" : "#fff",
border: selectedForMerge.includes(section.id) ? "2px solid #1a1a2e" : "1px solid #eee",
padding: 16, borderRadius: 10, cursor: "pointer",
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
transition: "all 0.15s",
}}
onClick={() => toggleMergeSelect(section.id)}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{
background: "#1a1a2e", color: "#fff", borderRadius: 20,
width: 26, height: 26, display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 13, fontWeight: 700, flexShrink: 0,
}}>
{section.order}
</span>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: "#1a1a2e" }}>{section.title}</h3>
</div>
<p style={{ margin: "6px 0 0 34px", fontSize: 13, color: "#888" }}>
Ch. {section.start_chapter_index}{section.end_chapter_index - 1} · ~{formatMinutes(section.estimated_reading_minutes)} reading time
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setSplitSectionId(splitSectionId === section.id ? null : section.id);
}}
style={{
padding: "4px 10px", borderRadius: 4, border: "1px solid #ddd",
background: splitSectionId === section.id ? "#1a1a2e" : "#fff",
color: splitSectionId === section.id ? "#fff" : "#666",
fontSize: 12, cursor: "pointer", flexShrink: 0,
}}
>
{t("groups.split")}
</button>
</div>
{splitSectionId === section.id && (
<div
style={{ marginTop: 10, padding: 10, background: "#f8f9fa", borderRadius: 6, display: "flex", alignItems: "center", gap: 8 }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ fontSize: 13 }}>{t("groups.splitInto")}</span>
<input
type="number"
value={splitAt}
min={2}
max={10}
onChange={(e) => setSplitAt(Number(e.target.value))}
style={{ width: 50, padding: "4px 8px", borderRadius: 4, border: "1px solid #ddd", fontSize: 13, textAlign: "center" }}
/>
<span style={{ fontSize: 13 }}>{t("groups.parts")}</span>
<button
onClick={handleSplit}
style={{ padding: "4px 12px", borderRadius: 4, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 12, cursor: "pointer" }}
>
{t("groups.splitBtn")}
</button>
</div>
)}
</div>
))}
</div>
)}
</section>
)}
{/* Schedule Tab */}
{tab === "schedule" && (
<section>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ fontSize: 18, fontWeight: 600, color: "#1a1a2e", margin: 0 }}>
{t("groups.readingSchedule")}
</h2>
<button
onClick={handleGenerateSchedule}
style={{ padding: "8px 16px", borderRadius: 6, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 13, fontWeight: 600, cursor: "pointer" }}
>
{t("groups.generateSchedule")}
</button>
</div>
{schedules.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#888" }}>
<p style={{ fontSize: 16 }}>{t("groups.noSchedule")}</p>
<p style={{ fontSize: 14 }}>{t("groups.noScheduleHint")}</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{schedules.map((s) => (
<div
key={s.id}
style={{ background: "#fff", padding: 20, borderRadius: 12, boxShadow: "0 2px 8px rgba(0,0,0,0.06)" }}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<div>
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: "#1a1a2e" }}>
{t("groups.meeting")} {s.meeting_number}
</h3>
<p style={{ margin: "4px 0 0", fontSize: 13, color: "#888" }}>
Week of {new Date(s.week_date).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
</p>
</div>
<span style={{
background: "#f0f0f0", padding: "6px 12px", borderRadius: 20,
fontSize: 13, fontWeight: 600, color: "#555",
}}>
{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))}`
: ""}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{s.section_details.map((sec) => (
<div
key={sec.id}
style={{
display: "flex", alignItems: "center", gap: 10, padding: "8px 12px",
background: "#f8f9fa", borderRadius: 6, fontSize: 14,
}}
>
<span style={{
background: "#1a1a2e", color: "#fff", borderRadius: 12,
width: 22, height: 22, display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 700, flexShrink: 0,
}}>
{sec.order}
</span>
<span style={{ flex: 1 }}>{sec.title}</span>
<span style={{ fontSize: 12, color: "#999" }}>{formatMinutes(sec.estimated_reading_minutes)}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</section>
)}
{/* Progress Tab */}
{tab === "progress" && (
<section>
<h2 style={{ fontSize: 18, fontWeight: 600, color: "#1a1a2e", margin: "0 0 16px" }}>
{t("groups.memberProgress")}
</h2>
{progress.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#888" }}>
<p style={{ fontSize: 16 }}>{t("groups.noProgress")}</p>
<p style={{ fontSize: 14 }}>{t("groups.noProgressHint")}</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{progress.map((p) => {
const totalSections = sections.length;
const completed = p.completed_sections.length;
const pct = totalSections > 0 ? Math.round((completed / totalSections) * 100) : 0;
return (
<div
key={p.id}
style={{ background: "#fff", padding: 16, borderRadius: 10, boxShadow: "0 1px 4px rgba(0,0,0,0.06)" }}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<span style={{ fontSize: 15, fontWeight: 600 }}>{p.user_email}</span>
<span style={{ fontSize: 13, color: "#888" }}>{completed}/{totalSections} · {pct}%</span>
</div>
<div style={{ height: 6, background: "#eee", borderRadius: 3, overflow: "hidden" }}>
<div
style={{
height: "100%", borderRadius: 3,
background: "linear-gradient(90deg, #27ae60, #2ecc71)",
width: `${pct}%`, transition: "width 0.3s",
}}
/>
</div>
{p.current_section_title && (
<p style={{ margin: "8px 0 0", fontSize: 13, color: "#888" }}>
Currently: {p.current_section_title}
</p>
)}
</div>
);
})}
</div>
)}
</section>
)}
</div>
);
}
+272 -238
View File
@@ -1,300 +1,334 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { groupsApi } from "../api/groups"; import { groupsApi } from "../api/groups";
import { booksApi } from "../api/books"; import { useAuth } from "../context/AuthContext";
import { getApiErrorMessage } from "../api/errors"; import { useToast } from "../hooks/useToast";
import type { ReadingGroupDetail, GroupBook, GroupBookDetail } from "../types/group"; import type { GroupDetail, GroupInvite, GroupMember } from "../../packages/shared/src/types";
import type { EBookListItem } from "../types/book";
const S = {
container: { maxWidth: 800, margin: "0 auto", padding: "24px 16px" } satisfies React.CSSProperties,
backBtn: {
background: "none", border: "none", color: "#3b82f6", fontSize: 14, cursor: "pointer",
padding: 0, marginBottom: 16, minHeight: 44, minWidth: 44,
} satisfies React.CSSProperties,
title: { fontSize: 28, fontWeight: 700, margin: "0 0 4px 0" } satisfies React.CSSProperties,
desc: { fontSize: 14, color: "#6b7280", margin: "0 0 20px 0" } satisfies React.CSSProperties,
section: { marginTop: 28 } satisfies React.CSSProperties,
sectionTitle: { fontSize: 18, fontWeight: 600, marginBottom: 12 } satisfies React.CSSProperties,
memberItem: {
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "10px 0", borderBottom: "1px solid #f3f4f6",
} satisfies React.CSSProperties,
memberInfo: { display: "flex", flexDirection: "column" as const },
memberName: { fontSize: 14, fontWeight: 500 } satisfies React.CSSProperties,
memberEmail: { fontSize: 12, color: "#9ca3af" } satisfies React.CSSProperties,
badge: (role: string): React.CSSProperties => ({
display: "inline-block", padding: "2px 8px", borderRadius: 6, fontSize: 11, fontWeight: 600,
backgroundColor: role === "admin" ? "#dbeafe" : "#f3f4f6",
color: role === "admin" ? "#1d4ed8" : "#6b7280",
}),
removeBtn: {
padding: "4px 12px", fontSize: 12, color: "#ef4444", background: "#fef2f2",
border: "1px solid #fecaca", borderRadius: 6, cursor: "pointer", minHeight: 32,
} satisfies React.CSSProperties,
transferBtn: {
padding: "4px 12px", fontSize: 12, color: "#3b82f6", background: "#eff6ff",
border: "1px solid #bfdbfe", borderRadius: 6, cursor: "pointer", minHeight: 32,
marginRight: 8,
} satisfies React.CSSProperties,
inviteCard: {
padding: "12px 16px", border: "1px solid #e5e7eb", borderRadius: 8, marginBottom: 8,
} satisfies React.CSSProperties,
inviteCode: { fontSize: 13, fontFamily: "monospace", marginBottom: 4 } satisfies React.CSSProperties,
inviteMeta: { fontSize: 12, color: "#9ca3af" } satisfies React.CSSProperties,
actionBtn: (color: string): React.CSSProperties => ({
padding: "8px 16px", backgroundColor: color, color: "#fff", border: "none",
borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: "pointer",
minHeight: 44, minWidth: 44,
}),
dangerBtn: {
padding: "8px 16px", backgroundColor: "#ef4444", color: "#fff", border: "none",
borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: "pointer",
minHeight: 44, minWidth: 44,
} satisfies React.CSSProperties,
leaveBtn: {
padding: "8px 16px", backgroundColor: "#fff", color: "#ef4444",
border: "1px solid #ef4444", borderRadius: 8, fontSize: 13, fontWeight: 600,
cursor: "pointer", minHeight: 44, minWidth: 44,
} satisfies React.CSSProperties,
inlineBtn: {
padding: "4px 10px", fontSize: 12, color: "#ef4444", background: "#fef2f2",
border: "1px solid #fecaca", borderRadius: 6, cursor: "pointer", minHeight: 28,
} satisfies React.CSSProperties,
copyRow: { display: "flex", gap: 8, alignItems: "center", marginBottom: 12 } satisfies React.CSSProperties,
input: {
flex: 1, padding: "10px 12px", border: "1px solid #d1d5db", borderRadius: 8, fontSize: 14,
} satisfies React.CSSProperties,
loading: { textAlign: "center" as const, padding: 60, color: "#9ca3af" },
error: { textAlign: "center" as const, padding: 40, color: "#ef4444" },
};
export function GroupDetailPage() { export function GroupDetailPage() {
const { t } = useTranslation(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { groupId } = useParams<{ groupId: string }>(); const { user } = useAuth();
const [group, setGroup] = useState<ReadingGroupDetail | null>(null); const { showToast } = useToast();
const [books, setBooks] = useState<GroupBook[]>([]);
const [group, setGroup] = useState<GroupDetail | null>(null);
const [invites, setInvites] = useState<GroupInvite[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editingName, setEditingName] = useState(false);
const [editName, setEditName] = useState("");
const [showInviteSection, setShowInviteSection] = useState(false);
// Upload state const groupId = Number(id);
const [showUpload, setShowUpload] = useState(false); const isAdmin = group?.user_role === "admin";
const [ebooks, setEbooks] = useState<EBookListItem[]>([]);
const [selectedEbookId, setSelectedEbookId] = useState<number | null>(null);
const [uploading, setUploading] = useState(false);
// Add member state const loadGroup = useCallback(async () => {
const [showAddMember, setShowAddMember] = useState(false); if (!groupId) return;
const [memberEmail, setMemberEmail] = useState("");
const [adding, setAdding] = useState(false);
const id = Number(groupId);
const loadData = useCallback(async () => {
if (!id) return;
setLoading(true); setLoading(true);
setError(null);
try { try {
const [groupData, bookData] = await Promise.all([ const data = await groupsApi.getGroup(groupId);
groupsApi.getGroup(id), setGroup(data);
groupsApi.listGroupBooks(id), } catch (err: unknown) {
]); setError(err instanceof Error ? err.message : "Failed to load group");
setGroup(groupData);
setBooks(bookData);
} catch (err) {
setError(getApiErrorMessage(err, "Failed to load group"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [id]); }, [groupId]);
const loadInvites = useCallback(async () => {
if (!groupId || !isAdmin) return;
try {
const data = await groupsApi.listInvites(groupId);
setInvites(data);
} catch {
// Silently fail — invites are supplementary
}
}, [groupId, isAdmin]);
useEffect(() => { useEffect(() => {
loadData(); loadGroup();
}, [loadData]); }, [loadGroup]);
const handleUploadClick = async () => { useEffect(() => {
setShowUpload(true); if (group && isAdmin) loadInvites();
}, [group, isAdmin, loadInvites]);
const handleRemoveMember = async (userId: number, memberEmail: string) => {
if (!confirm(`Remove ${memberEmail} from the group?`)) return;
try { try {
const ebookList = await booksApi.getEBooks(); await groupsApi.removeMember(groupId, userId);
setEbooks(ebookList.filter((e) => e.format === "epub")); showToast({ message: "Member removed", variant: "success" });
} catch { loadGroup();
// Ebook list fetch failed silently } catch (err: unknown) {
showToast({ message: "Failed to remove member", variant: "error" });
} }
}; };
const handleUpload = async () => { const handleTransferAdmin = async (userId: number, memberEmail: string) => {
if (!selectedEbookId || !id) return; if (!confirm(`Transfer admin role to ${memberEmail}? You will become a regular member.`)) return;
setUploading(true);
try { try {
const created = await groupsApi.createGroupBook(id, { ebook_id: selectedEbookId }); await groupsApi.updateMemberRole(groupId, userId, "admin");
setShowUpload(false); showToast({ message: "Admin role transferred", variant: "success" });
setSelectedEbookId(null); loadGroup();
await loadData(); } catch (err: unknown) {
} catch (err) { showToast({ message: "Failed to transfer admin", variant: "error" });
setError(getApiErrorMessage(err, "Failed to upload book to group"));
} finally {
setUploading(false);
} }
}; };
const handleAddMember = async (e: React.FormEvent) => { const handleLeave = async () => {
e.preventDefault(); if (!confirm("Are you sure you want to leave this group?")) return;
if (!memberEmail.trim() || !id) return;
setAdding(true);
try { try {
// Note: In a real implementation, we'd look up user by email. const result = await groupsApi.leaveGroup(groupId);
// For now this requires user_id. Email lookup would be an enhancement. showToast({ message: result.detail || "Left group", variant: "success" });
await groupsApi.addMember(id, { user_id: Number(memberEmail) }); navigate("/groups");
setShowAddMember(false); } catch (err: unknown) {
setMemberEmail(""); showToast({ message: "Failed to leave group", variant: "error" });
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) => { const handleCreateInvite = async () => {
if (!id) return;
try { try {
await groupsApi.removeMember(id, { user_id: userId }); const invite = await groupsApi.createInvite(groupId);
await loadData(); setInvites((prev) => [invite, ...prev]);
} catch (err) { showToast({ message: "Invite link created!", variant: "success" });
setError(getApiErrorMessage(err, "Failed to remove member")); } catch (err: unknown) {
showToast({ message: "Failed to create invite", variant: "error" });
} }
}; };
if (loading) { const handleRevokeInvite = async (inviteId: number) => {
return ( try {
<div style={{ maxWidth: 700, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}> await groupsApi.revokeInvite(groupId, inviteId);
<p style={{ textAlign: "center", color: "#888", padding: 40 }}>{t("common.loading")}</p> setInvites((prev) => prev.map((i) => (i.id === inviteId ? { ...i, is_active: false } : i)));
</div> showToast({ message: "Invite revoked", variant: "success" });
} catch (err: unknown) {
showToast({ message: "Failed to revoke invite", variant: "error" });
}
};
const handleSaveName = async () => {
if (!editName.trim()) return;
try {
const updated = await groupsApi.updateGroup(groupId, { name: editName.trim() });
setGroup(updated);
setEditingName(false);
showToast({ message: "Group name updated", variant: "success" });
} catch (err: unknown) {
showToast({ message: "Failed to update group", variant: "error" });
}
};
const handleDeleteGroup = async () => {
if (!confirm("Delete this group? This cannot be undone.")) return;
try {
await groupsApi.deleteGroup(groupId);
showToast({ message: "Group deleted", variant: "success" });
navigate("/groups");
} catch (err: unknown) {
showToast({ message: "Failed to delete group", variant: "error" });
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(
() => showToast({ message: "Link copied!" }),
() => showToast({ message: "Failed to copy", variant: "error" }),
); );
} };
if (!group) { if (loading) return <div style={S.loading}>Loading group...</div>;
return ( if (error) return <div style={S.error}>{error} <br /><button onClick={loadGroup} style={{ marginTop: 12, padding: "8px 16px", cursor: "pointer", border: "1px solid #d1d5db", borderRadius: 6, background: "#fff" }}>Retry</button></div>;
<div style={{ maxWidth: 700, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}> if (!group) return <div style={S.error}>Group not found</div>;
<p style={{ textAlign: "center", color: "#e74c3c", padding: 40 }}>Group not found</p>
<button onClick={() => navigate("/groups")} style={{ display: "block", margin: "0 auto" }}> Back to Groups</button>
</div>
);
}
return ( return (
<div style={{ maxWidth: 700, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}> <div style={S.container}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "16px 0", borderBottom: "1px solid #eee", marginBottom: 20 }}> <button style={S.backBtn} onClick={() => navigate("/groups")}>
<div> Back to Groups
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>{group.name}</h1>
{group.description && <p style={{ margin: "4px 0 0", fontSize: 14, color: "#666" }}>{group.description}</p>}
</div>
<button onClick={() => navigate("/groups")} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}>
{t("common.back")}
</button> </button>
</header>
{error && ( {/* Group Header */}
<div style={{ background: "#fde8e8", padding: 12, borderRadius: 6, color: "#e74c3c", fontSize: 14, marginBottom: 16 }}> {editingName ? (
{error} <div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<button onClick={() => setError(null)} style={{ marginLeft: 12, background: "none", border: "none", cursor: "pointer", fontWeight: 600 }}></button> <input
style={S.input}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSaveName(); if (e.key === "Escape") setEditingName(false); }}
autoFocus
/>
<button style={S.actionBtn("#3b82f6")} onClick={handleSaveName}>Save</button>
<button style={{ ...S.actionBtn("#6b7280"), padding: "8px 16px" }} onClick={() => setEditingName(false)}>Cancel</button>
</div>
) : (
<h1
style={S.title}
onClick={() => {
if (isAdmin) { setEditName(group.name); setEditingName(true); }
}}
title={isAdmin ? "Click to edit name" : undefined}
>
{group.name}
</h1>
)}
<p style={S.desc}>{group.description || "No description"}</p>
<p style={{ fontSize: 12, color: "#9ca3af", marginBottom: 20 }}>
Created by {group.created_by_email} · {group.member_count} member{group.member_count !== 1 ? "s" : ""}
</p>
{/* Admin Actions */}
{isAdmin && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 20 }}>
<button
style={S.actionBtn("#3b82f6")}
onClick={() => setShowInviteSection((v) => !v)}
>
{showInviteSection ? "Hide Invites" : "Manage Invites"}
</button>
<button style={S.dangerBtn} onClick={handleDeleteGroup}>
Delete Group
</button>
</div>
)}
{/* Invites Section */}
{isAdmin && showInviteSection && (
<div style={S.section}>
<h2 style={S.sectionTitle}>Invite Links</h2>
<button style={S.actionBtn("#22c55e")} onClick={handleCreateInvite}>
+ Generate Invite Link
</button>
{invites.length === 0 ? (
<p style={{ color: "#9ca3af", fontSize: 14, marginTop: 12 }}>No invites yet.</p>
) : (
invites.map((inv) => (
<div key={inv.id} style={S.inviteCard}>
<div style={S.inviteCode}>{inv.code}</div>
<div style={S.inviteMeta}>
{inv.is_active ? "Active" : "Revoked"} ·{" "}
{inv.max_uses > 0 ? `${inv.use_count}/${inv.max_uses} uses` : `${inv.use_count} uses (unlimited)`}
</div>
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
<button style={S.actionBtn("#3b82f6")} onClick={() => copyToClipboard(inv.join_url)}>
Copy Link
</button>
{inv.is_active && (
<button style={S.inlineBtn} onClick={() => handleRevokeInvite(inv.id)}>
Revoke
</button>
)}
</div>
</div>
))
)}
</div> </div>
)} )}
{/* Members Section */} {/* Members Section */}
<section style={{ background: "#fff", padding: 20, borderRadius: 12, marginBottom: 20, boxShadow: "0 2px 8px rgba(0,0,0,0.06)" }}> <div style={S.section}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}> <h2 style={S.sectionTitle}>Members ({group.member_count})</h2>
<h2 style={{ fontSize: 18, fontWeight: 600, color: "#1a1a2e", margin: 0 }}>{t("groups.members")} ({group.members.length})</h2> {group.members.map((member) => (
<button <div key={member.id} style={S.memberItem}>
onClick={() => setShowAddMember(!showAddMember)} <div style={S.memberInfo}>
style={{ padding: "6px 14px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 13 }} <span style={S.memberName}>
> {member.user_username || member.user_email}{" "}
{showAddMember ? t("groups.cancel") : `+ ${t("groups.addMember")}`} <span style={S.badge(member.role)}>{member.role}</span>
</button>
</div>
{showAddMember && (
<form onSubmit={handleAddMember} style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<input
type="text"
value={memberEmail}
onChange={(e) => setMemberEmail(e.target.value)}
placeholder={t("groups.memberIdPlaceholder")}
style={{ flex: 1, padding: "8px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 14, outline: "none" }}
/>
<button
type="submit"
disabled={adding}
style={{ padding: "8px 16px", borderRadius: 6, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 14, cursor: "pointer", opacity: adding ? 0.6 : 1 }}
>
{t("groups.add")}
</button>
</form>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{group.members.map((m) => (
<div key={m.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid #f0f0f0" }}>
<div>
<span style={{ fontSize: 14, fontWeight: 500 }}>{m.user_email}</span>
<span style={{ marginLeft: 8, fontSize: 12, color: m.role === "admin" ? "#e67e22" : "#999", background: m.role === "admin" ? "#fef3e2" : "#f0f0f0", padding: "2px 8px", borderRadius: 4 }}>
{m.role}
</span> </span>
<span style={S.memberEmail}>{member.user_email}</span>
</div> </div>
{m.role !== "admin" && ( {isAdmin && member.user_id !== Number(user?.email ? undefined : undefined) && (
<button
onClick={() => handleRemoveMember(m.user)}
style={{ background: "none", border: "none", color: "#e74c3c", cursor: "pointer", fontSize: 13 }}
>
{t("groups.remove")}
</button>
)}
</div>
))}
</div>
</section>
{/* Books Section */}
<section style={{ background: "#fff", padding: 20, borderRadius: 12, boxShadow: "0 2px 8px rgba(0,0,0,0.06)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<h2 style={{ fontSize: 18, fontWeight: 600, color: "#1a1a2e", margin: 0 }}>{t("groups.books")} ({books.length})</h2>
<button
onClick={handleUploadClick}
style={{ padding: "8px 18px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 14, fontWeight: 600, cursor: "pointer" }}
>
{t("groups.uploadEpub")}
</button>
</div>
{showUpload && (
<div style={{ background: "#f8f9fa", padding: 16, borderRadius: 8, marginBottom: 16 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, margin: "0 0 12px" }}>{t("groups.selectEbook")}</h3>
{ebooks.length === 0 ? (
<div style={{ textAlign: "center", padding: 20, color: "#888" }}>
<p>{t("groups.noEpubBooks")}</p>
<button onClick={() => navigate("/add")} style={{ marginTop: 8, padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 13 }}>
{t("groups.uploadFirst")}
</button>
</div>
) : (
<div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 12, maxHeight: 200, overflowY: "auto" }}>
{ebooks.map((eb) => (
<label
key={eb.id}
style={{
display: "flex", alignItems: "center", gap: 10, padding: "10px 12px",
borderRadius: 8, cursor: "pointer", fontSize: 14,
background: selectedEbookId === eb.id ? "#e8f0fe" : "#fff",
border: selectedEbookId === eb.id ? "2px solid #1a1a2e" : "1px solid #eee",
}}
>
<input
type="radio"
name="ebook"
value={eb.id}
checked={selectedEbookId === eb.id}
onChange={() => setSelectedEbookId(eb.id)}
style={{ accentColor: "#1a1a2e" }}
/>
<div>
<strong>{eb.title}</strong>
{eb.author && <span style={{ color: "#888", marginLeft: 8 }}>by {eb.author}</span>}
<span style={{ marginLeft: 8, fontSize: 12, color: "#aaa" }}>({eb.filename})</span>
</div>
</label>
))}
</div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
{member.role === "member" && (
<button <button
onClick={handleUpload} style={S.transferBtn}
disabled={!selectedEbookId || uploading} onClick={() => handleTransferAdmin(member.user_id, member.user_email)}
style={{ padding: "10px 20px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 14, fontWeight: 600, cursor: uploading ? "default" : "pointer", opacity: uploading ? 0.6 : 1 }}
> >
{uploading ? t("common.loading") : t("groups.confirmUpload")} Make Admin
</button> </button>
)}
<button <button
onClick={() => setShowUpload(false)} style={S.removeBtn}
style={{ padding: "10px 20px", borderRadius: 8, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }} onClick={() => handleRemoveMember(member.user_id, member.user_email)}
> >
{t("groups.cancel")} Remove
</button> </button>
</div> </div>
</div>
)} )}
</div> </div>
)}
{books.length === 0 ? (
<p style={{ textAlign: "center", color: "#888", padding: 30 }}>{t("groups.noBooks")}</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{books.map((book) => (
<div
key={book.id}
onClick={() => 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")}
>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: "#1a1a2e" }}>
{book.title}
{book.status === "active" && (
<span style={{ marginLeft: 8, fontSize: 11, color: "#27ae60", background: "#e8f5e9", padding: "2px 8px", borderRadius: 4 }}>Active</span>
)}
</h3>
<p style={{ margin: "4px 0 0", fontSize: 13, color: "#888" }}>
{book.section_count} sections · {book.ebook.page_count} chapters · uploaded by {book.uploaded_by_email}
</p>
</div>
<span style={{ fontSize: 20, color: "#ccc" }}></span>
</div>
))} ))}
</div> </div>
{/* Leave Group (non-admins only) */}
{!isAdmin && (
<div style={{ ...S.section, marginTop: 40 }}>
<button style={S.leaveBtn} onClick={handleLeave}>
Leave Group
</button>
</div>
)} )}
</section>
</div> </div>
); );
} }
+195
View File
@@ -0,0 +1,195 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { groupsApi } from "../api/groups";
import type { GroupListItem } from "../../packages/shared/src/types";
import { useAuth } from "../context/AuthContext";
const styles = {
container: {
maxWidth: 800,
margin: "0 auto",
padding: "24px 16px",
} satisfies React.CSSProperties,
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
} satisfies React.CSSProperties,
title: {
fontSize: 28,
fontWeight: 700,
margin: 0,
} satisfies React.CSSProperties,
createBtn: {
padding: "10px 20px",
backgroundColor: "#3b82f6",
color: "#fff",
border: "none",
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
minHeight: 44,
minWidth: 44,
} satisfies React.CSSProperties,
groupCard: {
border: "1px solid #e5e7eb",
borderRadius: 12,
padding: 20,
marginBottom: 12,
cursor: "pointer",
transition: "box-shadow 0.15s",
} satisfies React.CSSProperties,
groupName: {
fontSize: 18,
fontWeight: 600,
margin: "0 0 4px 0",
} satisfies React.CSSProperties,
groupDesc: {
fontSize: 14,
color: "#6b7280",
margin: "0 0 8px 0",
} satisfies React.CSSProperties,
groupMeta: {
display: "flex",
gap: 12,
fontSize: 13,
color: "#9ca3af",
} satisfies React.CSSProperties,
badge: {
display: "inline-block",
padding: "2px 8px",
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
} satisfies React.CSSProperties,
adminBadge: {
backgroundColor: "#dbeafe",
color: "#1d4ed8",
} satisfies React.CSSProperties,
memberBadge: {
backgroundColor: "#f3f4f6",
color: "#6b7280",
} satisfies React.CSSProperties,
empty: {
textAlign: "center" as const,
padding: 60,
color: "#9ca3af",
},
loadingText: {
textAlign: "center" as const,
padding: 60,
color: "#9ca3af",
fontSize: 16,
},
errorText: {
textAlign: "center" as const,
padding: 40,
color: "#ef4444",
fontSize: 14,
},
};
export function GroupsListPage() {
const navigate = useNavigate();
const { user } = useAuth();
const [groups, setGroups] = useState<GroupListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadGroups = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await groupsApi.listGroups();
setGroups(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load groups");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadGroups();
}, [loadGroups]);
if (loading) {
return <div style={styles.loadingText}>Loading groups...</div>;
}
if (error) {
return (
<div style={styles.container}>
<div style={styles.errorText}>
{error}
<br />
<button
onClick={loadGroups}
style={{
marginTop: 12,
padding: "8px 16px",
cursor: "pointer",
border: "1px solid #d1d5db",
borderRadius: 6,
background: "#fff",
}}
>
Retry
</button>
</div>
</div>
);
}
return (
<div style={styles.container}>
<div style={styles.header}>
<h1 style={styles.title}>Groups</h1>
<button
style={styles.createBtn}
onClick={() => navigate("/groups/create")}
>
+ Create Group
</button>
</div>
{groups.length === 0 ? (
<div style={styles.empty}>
<p style={{ fontSize: 16, marginBottom: 8 }}>You're not in any groups yet.</p>
<p style={{ fontSize: 14 }}>Create a group to start reading together with friends!</p>
</div>
) : (
groups.map((group) => (
<div
key={group.id}
style={styles.groupCard}
onClick={() => navigate(`/groups/${group.id}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/groups/${group.id}`);
}
}}
role="button"
tabIndex={0}
>
<h2 style={styles.groupName}>{group.name}</h2>
{group.description && (
<p style={styles.groupDesc}>{group.description}</p>
)}
<div style={styles.groupMeta}>
<span>{group.member_count} member{group.member_count !== 1 ? "s" : ""}</span>
{group.user_role === "admin" ? (
<span style={{ ...styles.badge, ...styles.adminBadge }}>Admin</span>
) : group.user_role === "member" ? (
<span style={{ ...styles.badge, ...styles.memberBadge }}>Member</span>
) : null}
</div>
</div>
))
)}
</div>
);
}
-162
View File
@@ -1,162 +0,0 @@
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<ReadingGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div style={{ maxWidth: 700, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "16px 0", borderBottom: "1px solid #eee", marginBottom: 20 }}>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>{t("groups.title")}</h1>
<button
type="button"
onClick={() => navigate("/")}
style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}
>
{t("common.back")}
</button>
</header>
{error && (
<div style={{ background: "#fde8e8", padding: 12, borderRadius: 6, color: "#e74c3c", fontSize: 14, marginBottom: 16 }}>
{error}
<button onClick={() => setError(null)} style={{ marginLeft: 12, background: "none", border: "none", cursor: "pointer", fontWeight: 600 }}></button>
</div>
)}
<div style={{ marginBottom: 20 }}>
<button
type="button"
onClick={() => setShowCreate(!showCreate)}
style={{
padding: "12px 24px", borderRadius: 8, border: "none",
background: "#1a1a2e", color: "#fff", fontSize: 15, fontWeight: 600, cursor: "pointer",
}}
>
{showCreate ? t("groups.cancel") : t("groups.createGroup")}
</button>
</div>
{showCreate && (
<form onSubmit={handleCreate} style={{
background: "#fff", padding: 20, borderRadius: 12, marginBottom: 24,
boxShadow: "0 2px 8px rgba(0,0,0,0.06)", display: "flex", flexDirection: "column", gap: 14,
}}>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("groups.groupName")}</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("groups.groupNamePlaceholder")}
style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }}
required
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("groups.description")} ({t("common.optional")})</label>
<textarea
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder={t("groups.descriptionPlaceholder")}
rows={3}
style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 15, outline: "none", resize: "vertical" }}
/>
</div>
<button
type="submit"
disabled={creating || !newName.trim()}
style={{
padding: "10px 20px", borderRadius: 8, border: "none",
background: "#1a1a2e", color: "#fff", fontSize: 15, fontWeight: 600,
cursor: creating ? "default" : "pointer", opacity: creating ? 0.6 : 1, alignSelf: "flex-start",
}}
>
{creating ? t("common.loading") : t("groups.create")}
</button>
</form>
)}
{loading ? (
<p style={{ textAlign: "center", color: "#888", padding: 40 }}>{t("common.loading")}</p>
) : groups.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#888" }}>
<p style={{ fontSize: 16, marginBottom: 8 }}>{t("groups.noGroups")}</p>
<p style={{ fontSize: 14 }}>{t("groups.noGroupsHint")}</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{groups.map((group) => (
<div
key={group.id}
onClick={() => navigate(`/groups/${group.id}`)}
style={{
background: "#fff", padding: 20, borderRadius: 12, cursor: "pointer",
boxShadow: "0 2px 8px rgba(0,0,0,0.06)", transition: "box-shadow 0.2s",
display: "flex", justifyContent: "space-between", alignItems: "center",
}}
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "0 4px 16px rgba(0,0,0,0.1)")}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "0 2px 8px rgba(0,0,0,0.06)")}
>
<div>
<h3 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: "#1a1a2e" }}>{group.name}</h3>
{group.description && (
<p style={{ margin: "6px 0 0", fontSize: 14, color: "#666" }}>{group.description}</p>
)}
<p style={{ margin: "8px 0 0", fontSize: 13, color: "#999" }}>
{group.member_count} {t("groups.members")} · {t("groups.admin")}: {group.admin_email}
</p>
</div>
<span style={{ fontSize: 20, color: "#ccc" }}></span>
</div>
))}
</div>
)}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { groupsApi } from "../api/groups";
import { useToast } from "../hooks/useToast";
import type { InviteValidation } from "../../packages/shared/src/types";
const S = {
container: { maxWidth: 500, margin: "0 auto", padding: "24px 16px" } satisfies React.CSSProperties,
backBtn: {
background: "none", border: "none", color: "#3b82f6", fontSize: 14, cursor: "pointer",
padding: 0, marginBottom: 16, minHeight: 44, minWidth: 44,
} satisfies React.CSSProperties,
card: {
padding: 24, border: "1px solid #e5e7eb", borderRadius: 12, textAlign: "center" as const,
} satisfies React.CSSProperties,
groupIcon: {
width: 64, height: 64, borderRadius: "50%", backgroundColor: "#dbeafe",
display: "flex", alignItems: "center", justifyContent: "center",
margin: "0 auto 16px auto", fontSize: 28, color: "#3b82f6", fontWeight: 700,
} satisfies React.CSSProperties,
groupName: { fontSize: 22, fontWeight: 700, marginBottom: 4 } satisfies React.CSSProperties,
groupDesc: { fontSize: 14, color: "#6b7280", marginBottom: 8 } satisfies React.CSSProperties,
groupMeta: { fontSize: 13, color: "#9ca3af", marginBottom: 20 } satisfies React.CSSProperties,
joinBtn: {
padding: "12px 32px", backgroundColor: "#3b82f6", color: "#fff",
border: "none", borderRadius: 8, fontSize: 16, fontWeight: 600,
cursor: "pointer", minHeight: 44,
} satisfies React.CSSProperties,
joinBtnDisabled: {
padding: "12px 32px", backgroundColor: "#93c5fd", color: "#fff",
border: "none", borderRadius: 8, fontSize: 16, fontWeight: 600,
cursor: "not-allowed", minHeight: 44,
} satisfies React.CSSProperties,
loading: { textAlign: "center" as const, padding: 60, color: "#9ca3af" },
error: { textAlign: "center" as const, padding: 40, color: "#ef4444" },
errorCard: {
padding: 24, border: "1px solid #fecaca", borderRadius: 12, textAlign: "center" as const,
backgroundColor: "#fef2f2",
} satisfies React.CSSProperties,
errorTitle: { fontSize: 18, fontWeight: 600, color: "#dc2626", marginBottom: 8 } satisfies React.CSSProperties,
errorMsg: { fontSize: 14, color: "#ef4444" } satisfies React.CSSProperties,
};
export function JoinGroupPage() {
const { code } = useParams<{ code: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const [inviteInfo, setInviteInfo] = useState<InviteValidation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [joining, setJoining] = useState(false);
const validateInvite = useCallback(async () => {
if (!code) return;
setLoading(true);
setError(null);
try {
const data = await groupsApi.validateInvite(code);
setInviteInfo(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Invalid invite");
} finally {
setLoading(false);
}
}, [code]);
useEffect(() => {
validateInvite();
}, [validateInvite]);
const handleJoin = async () => {
if (!code) return;
setJoining(true);
try {
const group = await groupsApi.joinViaInvite(code);
showToast({ message: `You've joined ${group.name}!`, variant: "success" });
navigate(`/groups/${group.id}`);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to join group";
showToast({ message: msg, variant: "error" });
setError(msg);
} finally {
setJoining(false);
}
};
if (loading) return <div style={S.loading}>Validating invite...</div>;
if (error && !inviteInfo) {
return (
<div style={S.container}>
<div style={S.errorCard}>
<div style={S.errorTitle}>Invalid Invite</div>
<div style={S.errorMsg}>{error}</div>
<button
style={{ ...S.backBtn, marginTop: 16, display: "inline-block" }}
onClick={() => navigate("/groups")}
>
Go to Groups
</button>
</div>
</div>
);
}
if (!inviteInfo) return null;
return (
<div style={S.container}>
<button style={S.backBtn} onClick={() => navigate("/groups")}>
Back to Groups
</button>
<div style={S.card}>
<div style={S.groupIcon}>
{inviteInfo.group.name.charAt(0).toUpperCase()}
</div>
<h1 style={S.groupName}>{inviteInfo.group.name}</h1>
{inviteInfo.group.description && (
<p style={S.groupDesc}>{inviteInfo.group.description}</p>
)}
<p style={S.groupMeta}>
{inviteInfo.group.member_count} member{inviteInfo.group.member_count !== 1 ? "s" : ""} ·{" "}
Created by {inviteInfo.invite.created_by_email}
</p>
<button
style={joining ? S.joinBtnDisabled : S.joinBtn}
onClick={handleJoin}
disabled={joining}
>
{joining ? "Joining..." : "Join Group"}
</button>
</div>
</div>
);
}
+2
View File
@@ -241,6 +241,7 @@ export function LibraryPage() {
{isMobile ? ( {isMobile ? (
<> <>
<button onClick={() => navigate("/add")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.addBook")}></button> <button onClick={() => navigate("/add")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.addBook")}></button>
<button onClick={() => navigate("/groups")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Groups">👥</button>
<button onClick={() => navigate("/bookmarks-notes")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.bookmarks")}>🔖</button> <button onClick={() => navigate("/bookmarks-notes")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.bookmarks")}>🔖</button>
<button onClick={() => navigate("/settings")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.settings")}></button> <button onClick={() => navigate("/settings")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.settings")}></button>
<button onClick={logout} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.logout")}>🚪</button> <button onClick={logout} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.logout")}>🚪</button>
@@ -248,6 +249,7 @@ export function LibraryPage() {
) : ( ) : (
<> <>
<button onClick={() => navigate("/add")} className="btn">+ {t("library.addBook")}</button> <button onClick={() => navigate("/add")} className="btn">+ {t("library.addBook")}</button>
<button onClick={() => navigate("/groups")} className="btn btn-secondary">Groups</button>
<button onClick={() => navigate("/bookmarks-notes")} className="btn btn-secondary">{t("library.bookmarks")}</button> <button onClick={() => navigate("/bookmarks-notes")} className="btn btn-secondary">{t("library.bookmarks")}</button>
<button onClick={() => navigate("/settings")} className="btn btn-secondary">{t("library.settings")}</button> <button onClick={() => navigate("/settings")} className="btn btn-secondary">{t("library.settings")}</button>
<button onClick={logout} className="btn btn-danger">{t("library.logout")}</button> <button onClick={logout} className="btn btn-danger">{t("library.logout")}</button>
-109
View File
@@ -1,109 +0,0 @@
/** Types for reading groups, group books, sections, and schedules */
export interface ReadingGroup {
id: number;
name: string;
description: string;
admin: number;
admin_email: string;
member_count: number;
created_at: string;
updated_at: string;
}
export interface ReadingGroupDetail {
id: number;
name: string;
description: string;
admin: number;
admin_email: string;
members: GroupMembership[];
created_at: string;
updated_at: string;
}
export interface GroupMembership {
id: number;
user: number;
user_email: string;
user_username: string;
role: "admin" | "member";
joined_at: string;
}
export interface GroupBook {
id: number;
title: string;
status: "active" | "replaced";
ebook: {
id: number;
title: string;
author: string;
filename: string;
format: string;
page_count: number;
file_size: number;
cover_image: string | null;
created_at: string;
};
uploaded_by: number;
uploaded_by_email: string;
section_count: number;
created_at: string;
updated_at: string;
}
export interface Section {
id: number;
title: string;
order: number;
start_chapter_index: number;
end_chapter_index: number;
estimated_reading_minutes: number;
created_at: string;
}
export interface GroupBookDetail extends GroupBook {
sections: Section[];
schedules: ReadingSchedule[];
}
export interface ReadingSchedule {
id: number;
meeting_number: number;
week_date: string;
section_ids: number[];
section_details: Section[];
created_at: string;
}
export interface MemberProgress {
id: number;
user: number;
user_email: string;
user_username: string;
current_section: number | null;
current_section_title: string | null;
completed_sections: number[];
updated_at: string;
}
export interface CreateGroupPayload {
name: string;
description?: string;
}
export interface AddMemberPayload {
user_id: number;
}
export interface CreateGroupBookPayload {
ebook_id: number;
title?: string;
}
export interface AdjustSectionsPayload {
operation: "merge" | "split";
section_ids: number[];
split_at?: number;
}
+72 -20
View File
@@ -217,46 +217,98 @@ export interface ApiError {
[key: string]: unknown; [key: string]: unknown;
} }
// ---- Reading Groups ---- // ---- Groups ----
export interface ReadingGroup { export type GroupRole = "admin" | "member";
export type JoinRequestStatus = "pending" | "approved" | "rejected";
export interface GroupListItem {
id: number; id: number;
name: string; name: string;
description: string; description: string;
admin: number; created_by: number;
admin_email: string;
member_count: number; member_count: number;
user_role: GroupRole | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export interface GroupMembership { export interface GroupMember {
id: number; id: number;
user: number; user_id: number;
user_email: string; user_email: string;
user_username: string; user_username: string;
role: "admin" | "member"; role: GroupRole;
joined_at: string; joined_at: string;
} }
export interface GroupBookSummary { export interface GroupDetail {
id: number; id: number;
title: string; name: string;
status: "active" | "replaced"; description: string;
section_count: number; created_by: number;
created_by_email: string;
created_by_username: string;
members: GroupMember[];
member_count: number;
user_role: GroupRole | null;
created_at: string;
updated_at: string;
}
export interface CreateGroupPayload {
name: string;
description?: string;
}
export interface UpdateGroupPayload {
name?: string;
description?: string;
}
export interface GroupInvite {
id: number;
group: number;
group_name: string;
code: string;
created_by: number;
created_by_email: string;
max_uses: number;
use_count: number;
is_active: boolean;
join_url: string;
created_at: string; created_at: string;
} }
export interface SectionSummary { export interface CreateInvitePayload {
id: number; max_uses?: number;
title: string;
order: number;
estimated_reading_minutes: number;
} }
export interface ReadingScheduleEntry { export interface JoinRequest {
id: number; id: number;
meeting_number: number; group: number;
week_date: string; group_name: string;
section_ids: number[]; user: number;
user_id: number;
user_email: string;
user_username: string;
invite: number | null;
invite_code: string | null;
status: JoinRequestStatus;
created_at: string;
}
export interface InviteValidation {
group: {
id: number;
name: string;
description: string;
created_by_email: string;
member_count: number;
};
invite: {
code: string;
created_by_email: string;
};
} }
+10 -6
View File
@@ -117,11 +117,15 @@ export const API_ENDPOINTS = {
groups: { groups: {
list: "/api/groups/", list: "/api/groups/",
detail: (id: number) => `/api/groups/${id}/`, detail: (id: number) => `/api/groups/${id}/`,
books: (groupId: number) => `/api/groups/${groupId}/books/`, members: (id: number) => `/api/groups/${id}/members/`,
bookDetail: (groupId: number, bookId: number) => `/api/groups/${groupId}/books/${bookId}/`, removeMember: (id: number, userId: number) => `/api/groups/${id}/members/${userId}/`,
detectSections: (groupId: number, bookId: number) => `/api/groups/${groupId}/books/${bookId}/detect-sections/`, updateRole: (id: number, userId: number) => `/api/groups/${id}/members/${userId}/role/`,
adjustSections: (groupId: number, bookId: number) => `/api/groups/${groupId}/books/${bookId}/adjust-sections/`, leave: (id: number) => `/api/groups/${id}/leave/`,
schedule: (groupId: number, bookId: number) => `/api/groups/${groupId}/books/${bookId}/schedule/`, invites: (id: number) => `/api/groups/${id}/invites/`,
progress: (groupId: number, bookId: number) => `/api/groups/${groupId}/books/${bookId}/progress/`, revokeInvite: (id: number, inviteId: number) => `/api/groups/${id}/invites/${inviteId}/`,
requests: (id: number) => `/api/groups/${id}/requests/`,
approveRequest: (id: number, requestId: number) => `/api/groups/${id}/requests/${requestId}/approve/`,
rejectRequest: (id: number, requestId: number) => `/api/groups/${id}/requests/${requestId}/reject/`,
join: (code: string) => `/api/join/${code}/`,
}, },
} as const; } as const;