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
21 changed files with 1567 additions and 1325 deletions
+21 -14
View File
@@ -1,23 +1,30 @@
from django.contrib import admin from django.contrib import admin
from apps.groups.models import GroupMembership, MemberProgress, ReadingGroup from apps.groups.models import Group, GroupInvite, GroupMember, JoinRequest
@admin.register(ReadingGroup) @admin.register(Group)
class ReadingGroupAdmin(admin.ModelAdmin): class GroupAdmin(admin.ModelAdmin):
list_display = ["name", "ebook", "created_by", "created_at"] list_display = ["id", "name", "created_by", "created_at"]
search_fields = ["name", "ebook__title", "created_by__email"] search_fields = ["name", "created_by__email"]
@admin.register(GroupMembership) @admin.register(GroupMember)
class GroupMembershipAdmin(admin.ModelAdmin): class GroupMemberAdmin(admin.ModelAdmin):
list_display = ["group", "user", "role", "joined_at"] list_display = ["id", "group", "user", "role", "joined_at"]
list_filter = ["role"] list_filter = ["role"]
search_fields = ["user__email", "group__name"] search_fields = ["group__name", "user__email"]
@admin.register(MemberProgress) @admin.register(GroupInvite)
class MemberProgressAdmin(admin.ModelAdmin): class GroupInviteAdmin(admin.ModelAdmin):
list_display = ["user", "group", "current_section", "percentage", "time_spent_seconds", "is_public", "updated_at"] list_display = ["id", "group", "code", "created_by", "use_count", "is_active", "created_at"]
list_filter = ["is_public"] list_filter = ["is_active"]
search_fields = ["user__email", "group__name"] search_fields = ["code", "group__name"]
@admin.register(JoinRequest)
class JoinRequestAdmin(admin.ModelAdmin):
list_display = ["id", "group", "user", "status", "created_at"]
list_filter = ["status"]
search_fields = ["group__name", "user__email"]
+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"
+56 -43
View File
@@ -1,75 +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)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_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)),
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reading_groups', to='books.ebook')),
], ],
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='GroupMembership', 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")),
('role', models.CharField(choices=[('member', 'Member'), ('admin', 'Admin')], default='member', max_length=16)), ("role", models.CharField(choices=[("admin", "Admin"), ("member", "Member")], default="member", max_length=16)),
('joined_at', models.DateTimeField(auto_now_add=True)), ("joined_at", models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)), ("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="memberships", to="groups.group")),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='groups.readinggroup')), ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="group_memberships", to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'verbose_name': 'Group Membership', "db_table": "groups_member",
'verbose_name_plural': 'Group Memberships', "verbose_name": "Group Member",
'db_table': 'groups_membership', "verbose_name_plural": "Group Members",
'ordering': ['joined_at'], "ordering": ["joined_at"],
'unique_together': {('group', 'user')}, "unique_together": {("group", "user")},
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='MemberProgress', 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")),
('current_section', models.PositiveIntegerField(default=0)), ("code", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
('percentage', models.FloatField(default=0.0)), ("max_uses", models.PositiveIntegerField(default=0, help_text="0 = unlimited")),
('time_spent_seconds', models.PositiveIntegerField(default=0)), ("use_count", models.PositiveIntegerField(default=0)),
('last_position', models.JSONField(blank=True, default=dict)), ("is_active", models.BooleanField(db_index=True, default=True)),
('is_public', models.BooleanField(default=True)), ("created_at", models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ("created_by", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="created_invites", to=settings.AUTH_USER_MODEL)),
('membership', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='groups.groupmembership')), ("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="invites", to="groups.group")),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_progress', to=settings.AUTH_USER_MODEL)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_progress', to='groups.readinggroup')),
], ],
options={ options={
'verbose_name': 'Member Progress', "db_table": "groups_invite",
'verbose_name_plural': 'Member Progress', "verbose_name": "Group Invite",
'db_table': 'groups_member_progress', "verbose_name_plural": "Group Invites",
'indexes': [models.Index(fields=['group', '-percentage'], name='groups_memb_group_i_42e333_idx'), models.Index(fields=['group', 'user'], name='groups_memb_group_i_f50793_idx')], "ordering": ["-created_at"],
'unique_together': {('group', 'user')}, },
),
migrations.CreateModel(
name="JoinRequest",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("status", models.CharField(choices=[("pending", "Pending"), ("approved", "Approved"), ("rejected", "Rejected")], db_index=True, default="pending", max_length=16)),
("created_at", models.DateTimeField(auto_now_add=True)),
("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="join_requests", to="groups.group")),
("invite", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="join_requests", to="groups.groupinvite")),
("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="join_requests", to=settings.AUTH_USER_MODEL)),
],
options={
"db_table": "groups_join_request",
"verbose_name": "Join Request",
"verbose_name_plural": "Join Requests",
"ordering": ["-created_at"],
"unique_together": {("group", "user")},
}, },
), ),
] ]
+76 -85
View File
@@ -1,46 +1,44 @@
import uuid
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
class ReadingGroup(models.Model): class GroupRole(models.TextChoices):
"""A group of users reading the same book together.""" ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
class JoinRequestStatus(models.TextChoices):
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)
ebook = models.ForeignKey( description = models.TextField(blank=True, default="")
"books.EBook",
on_delete=models.CASCADE,
related_name="reading_groups",
)
created_by = models.ForeignKey( created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="created_groups", related_name="created_groups",
) )
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)
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
@property
def total_sections(self) -> int:
return self.ebook.chapters.count()
class GroupMembership(models.Model):
class Role(models.TextChoices):
MEMBER = "member", "Member"
ADMIN = "admin", "Admin"
class GroupMember(models.Model):
group = models.ForeignKey( group = models.ForeignKey(
ReadingGroup, Group,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="memberships", related_name="memberships",
) )
@@ -51,88 +49,81 @@ class GroupMembership(models.Model):
) )
role = models.CharField( role = models.CharField(
max_length=16, max_length=16,
choices=Role.choices, choices=GroupRole.choices,
default=Role.MEMBER, 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"
verbose_name = "Group Membership" verbose_name = "Group Member"
verbose_name_plural = "Group Memberships" verbose_name_plural = "Group Members"
unique_together = [("group", "user")]
ordering = ["joined_at"] 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 MemberProgress(models.Model): class GroupInvite(models.Model):
"""Per-member reading progress within a reading group."""
membership = models.OneToOneField(
GroupMembership,
on_delete=models.CASCADE,
related_name="progress",
)
group = models.ForeignKey( group = models.ForeignKey(
ReadingGroup, Group,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="member_progress", related_name="invites",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="created_invites",
)
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)
class Meta:
db_table = "groups_invite"
verbose_name = "Group Invite"
verbose_name_plural = "Group Invites"
ordering = ["-created_at"]
def __str__(self) -> str:
return f"Invite for {self.group.name} ({self.code})"
class JoinRequest(models.Model):
group = models.ForeignKey(
Group,
on_delete=models.CASCADE,
related_name="join_requests",
) )
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="member_progress", related_name="join_requests",
) )
current_section = models.PositiveIntegerField(default=0) invite = models.ForeignKey(
percentage = models.FloatField(default=0.0) GroupInvite,
time_spent_seconds = models.PositiveIntegerField(default=0) on_delete=models.SET_NULL,
last_position = models.JSONField(blank=True, default=dict) null=True,
is_public = models.BooleanField(default=True) blank=True,
updated_at = models.DateTimeField(auto_now=True) related_name="join_requests",
)
status = models.CharField(
max_length=16,
choices=JoinRequestStatus.choices,
default=JoinRequestStatus.PENDING,
db_index=True,
)
created_at = models.DateTimeField(auto_now_add=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"
ordering = ["-created_at"]
unique_together = [("group", "user")] unique_together = [("group", "user")]
indexes = [
models.Index(fields=["group", "-percentage"]),
models.Index(fields=["group", "user"]),
]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.user} {self.group.name} ({self.percentage:.0f}%)" return f"{self.user} {self.group.name} ({self.status})"
def total_sections(self) -> int:
return self.group.total_sections
def update_progress(
self,
current_section: int,
percentage: float | None = None,
time_spent_delta: int = 0,
last_position: dict | None = None,
) -> None:
"""Update progress with computed percentage if not provided."""
self.current_section = current_section
if percentage is not None:
self.percentage = min(100.0, max(0.0, percentage))
elif (ts := self.total_sections()) > 0:
self.percentage = min(100.0, (current_section / ts) * 100.0)
else:
self.percentage = 0.0
if time_spent_delta > 0:
self.time_spent_seconds += time_spent_delta
if last_position is not None:
self.last_position = last_position
self.save(
update_fields=[
"current_section",
"percentage",
"time_spent_seconds",
"last_position",
"updated_at",
]
)
+17 -20
View File
@@ -1,28 +1,25 @@
from rest_framework import permissions from rest_framework import permissions
from rest_framework.request import Request from rest_framework.request import Request
from apps.groups.models import GroupMembership from apps.groups.models import Group, GroupMember, GroupRole
class IsGroupMember(permissions.BasePermission):
"""Allow access only to members of the group."""
def has_permission(self, request: Request, view: object) -> bool:
group_id = view.kwargs.get("group_pk") or view.kwargs.get("pk")
if not group_id:
return False
return GroupMembership.objects.filter(
group_id=group_id, user=request.user
).exists()
class IsGroupAdmin(permissions.BasePermission): class IsGroupAdmin(permissions.BasePermission):
"""Allow access only to group admins.""" """Only group admins can perform the action."""
def has_permission(self, request: Request, view: object) -> bool: def has_object_permission(self, request: Request, view: object, obj: Group) -> bool:
group_id = view.kwargs.get("group_pk") or view.kwargs.get("pk") return GroupMember.objects.filter(
if not group_id: group=obj,
return False user=request.user,
return GroupMembership.objects.filter( role=GroupRole.ADMIN,
group_id=group_id, user=request.user, role=GroupMembership.Role.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() ).exists()
+171 -156
View File
@@ -1,183 +1,198 @@
from __future__ import annotations
from rest_framework import serializers from rest_framework import serializers
from apps.groups.models import GroupMembership, MemberProgress, ReadingGroup from apps.groups.models import Group, GroupInvite, GroupMember, GroupRole, JoinRequest, JoinRequestStatus
class GroupMembershipSerializer(serializers.ModelSerializer): class GroupMemberSerializer(serializers.ModelSerializer):
user_email = serializers.CharField(source="user.email", read_only=True)
user_id = serializers.IntegerField(source="user.id", read_only=True) user_id = serializers.IntegerField(source="user.id", read_only=True)
role = serializers.CharField(read_only=True) user_email = serializers.CharField(source="user.email", read_only=True)
user_username = serializers.CharField(source="user.username", read_only=True)
class Meta: class Meta:
model = GroupMembership model = GroupMember
fields = ["id", "user_id", "user_email", "role", "joined_at"]
class MemberProgressSerializer(serializers.ModelSerializer):
user_email = serializers.CharField(source="user.email", read_only=True)
user_id = serializers.IntegerField(source="user.id", read_only=True)
total_sections = serializers.SerializerMethodField()
section_label = serializers.SerializerMethodField()
class Meta:
model = MemberProgress
fields = [ fields = [
"id", "id", "user_id", "user_email", "user_username",
"user_id", "role", "joined_at",
"user_email",
"current_section",
"total_sections",
"section_label",
"percentage",
"time_spent_seconds",
"last_position",
"is_public",
"updated_at",
]
read_only_fields = ["id", "user_id", "user_email", "updated_at"]
extra_kwargs = {
"percentage": {"required": False, "min_value": 0.0, "max_value": 100.0},
}
def get_total_sections(self, obj: MemberProgress) -> int:
return obj.total_sections()
def get_section_label(self, obj: MemberProgress) -> str:
ts = obj.total_sections()
if ts:
return f"Section {obj.current_section} of {ts}"
return "No sections"
def validate_percentage(self, value: float) -> float:
if value < 0.0 or value > 100.0:
raise serializers.ValidationError("Percentage must be between 0.0 and 100.0.")
return value
class MemberProgressPublicSerializer(serializers.ModelSerializer):
"""Limited view for other group members — section label only, no exact position."""
user_email = serializers.CharField(source="user.email", read_only=True)
user_id = serializers.IntegerField(source="user.id", read_only=True)
total_sections = serializers.SerializerMethodField()
section_label = serializers.SerializerMethodField()
class Meta:
model = MemberProgress
fields = [
"user_id",
"user_email",
"current_section",
"total_sections",
"section_label",
"percentage",
"time_spent_seconds",
"updated_at",
] ]
def get_total_sections(self, obj: MemberProgress) -> int:
return obj.total_sections()
def get_section_label(self, obj: MemberProgress) -> str: class GroupListSerializer(serializers.ModelSerializer):
ts = obj.total_sections()
if ts:
return f"Section {obj.current_section} of {ts}"
return "No sections"
class MemberProgressUpdateSerializer(serializers.Serializer):
current_section = serializers.IntegerField(min_value=0)
percentage = serializers.FloatField(required=False, min_value=0.0, max_value=100.0)
time_spent_delta = serializers.IntegerField(default=0, min_value=0)
last_position = serializers.JSONField(required=False, default=dict)
is_public = serializers.BooleanField(required=False)
class ReadingGroupListSerializer(serializers.ModelSerializer):
created_by_email = serializers.CharField(source="created_by.email", read_only=True)
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
ebook_author = serializers.CharField(source="ebook.author", read_only=True)
member_count = serializers.SerializerMethodField() member_count = serializers.SerializerMethodField()
my_progress = serializers.SerializerMethodField() user_role = serializers.SerializerMethodField()
class Meta: class Meta:
model = ReadingGroup model = Group
fields = [ fields = [
"id", "id", "name", "description", "created_by",
"name", "member_count", "user_role", "created_at", "updated_at",
"ebook",
"ebook_title",
"ebook_author",
"created_by_email",
"description",
"member_count",
"my_progress",
"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_my_progress(self, obj: ReadingGroup) -> dict | None: def get_user_role(self, obj: Group) -> str | None:
request = self.context.get("request") request = self.context.get("request")
if not request or not request.user.is_authenticated: if not request or not request.user.is_authenticated:
return None return None
try: membership = getattr(obj, "_user_membership", None)
mp = MemberProgress.objects.get(group=obj, user=request.user) if membership is None:
except MemberProgress.DoesNotExist:
return None
return {
"current_section": mp.current_section,
"percentage": mp.percentage,
"time_spent_seconds": mp.time_spent_seconds,
"section_label": MemberProgressSerializer().get_section_label(mp),
}
class ReadingGroupDetailSerializer(ReadingGroupListSerializer):
members = serializers.SerializerMethodField()
class Meta(ReadingGroupListSerializer.Meta):
fields = ReadingGroupListSerializer.Meta.fields + ["members"]
def get_members(self, obj: ReadingGroup) -> list[dict]:
memberships = obj.memberships.select_related("user").prefetch_related("progress")
result: list[dict] = []
for m in memberships:
entry: dict = {
"id": m.id,
"user_id": m.user.id,
"user_email": m.user.email,
"role": m.role,
"joined_at": m.joined_at,
}
try: try:
mp = m.progress membership = obj.memberships.get(user=request.user)
except MemberProgress.DoesNotExist: except GroupMember.DoesNotExist:
entry["progress"] = None return None
else: return membership.role
if mp.is_public:
entry["progress"] = MemberProgressPublicSerializer(mp).data
else:
entry["progress"] = {"is_public": False, "note": "Private"}
result.append(entry)
return result
class ReadingGroupCreateSerializer(serializers.ModelSerializer): class GroupDetailSerializer(serializers.ModelSerializer):
members = GroupMemberSerializer(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 = ["name", "ebook", "description"] fields = [
"id", "name", "description", "created_by", "created_by_email",
"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())
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 AdminProgressSummarySerializer(serializers.Serializer): class GroupCreateSerializer(serializers.ModelSerializer):
group_id = serializers.IntegerField() class Meta:
group_name = serializers.CharField() model = Group
ebook_title = serializers.CharField() fields = ["name", "description"]
total_members = serializers.IntegerField()
members_started = serializers.IntegerField() def validate_name(self, value: str) -> str:
members_finished = serializers.IntegerField() if not value.strip():
average_percentage = serializers.FloatField() raise serializers.ValidationError("Group name cannot be empty.")
average_time_spent_hours = serializers.FloatField() if len(value.strip()) < 2:
member_details = serializers.ListField(child=serializers.JSONField()) raise serializers.ValidationError("Group name must be at least 2 characters.")
return value.strip()
def create(self, validated_data: dict) -> Group:
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
class GroupUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = Group
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
def create(self, validated_data: dict) -> GroupInvite:
group = self.context["group"]
user = self.context["request"].user
return GroupInvite.objects.create(
group=group,
created_by=user,
**validated_data,
)
class JoinRequestSerializer(serializers.ModelSerializer):
user_id = serializers.IntegerField(source="user.id", read_only=True)
user_email = serializers.CharField(source="user.email", read_only=True)
user_username = serializers.CharField(source="user.username", read_only=True)
group_name = serializers.CharField(source="group.name", read_only=True)
invite_code = serializers.UUIDField(source="invite.code", read_only=True, default=None)
class Meta:
model = JoinRequest
fields = [
"id", "group", "group_name", "user", "user_id", "user_email",
"user_username", "invite", "invite_code", "status", "created_at",
]
read_only_fields = ["id", "group", "user", "invite", "created_at"]
class JoinViaInviteSerializer(serializers.Serializer):
"""Validates and processes joining a group via an invite code."""
code = serializers.UUIDField()
def validate_code(self, value: str) -> str:
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.")
if invite.max_uses > 0 and invite.use_count >= invite.max_uses:
raise serializers.ValidationError("This invite has reached its maximum uses.")
return value
class RoleUpdateSerializer(serializers.Serializer):
role = serializers.ChoiceField(choices=GroupRole.choices)
def validate_role(self, value: str) -> str:
if value == GroupRole.MEMBER:
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
+3 -2
View File
@@ -1,10 +1,11 @@
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 ReadingGroupViewSet from apps.groups.views import GroupViewSet, JoinGroupViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r"", ReadingGroupViewSet, basename="reading-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)),
+291 -163
View File
@@ -1,203 +1,331 @@
from __future__ import annotations from __future__ import annotations
from django.db import models from django.db.models import Count, Prefetch, QuerySet
from rest_framework import permissions, status, viewsets from rest_framework import 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.groups.models import GroupMembership, MemberProgress, ReadingGroup from apps.groups.models import Group, GroupInvite, GroupMember, GroupRole, JoinRequest, JoinRequestStatus
from apps.groups.permissions import IsGroupAdmin, IsGroupMember from apps.groups.permissions import IsGroupAdmin, IsGroupMember
from apps.groups.serializers import ( from apps.groups.serializers import (
AdminProgressSummarySerializer, GroupCreateSerializer,
GroupMembershipSerializer, GroupDetailSerializer,
MemberProgressPublicSerializer, GroupInviteCreateSerializer,
MemberProgressSerializer, GroupInviteSerializer,
MemberProgressUpdateSerializer, GroupListSerializer,
ReadingGroupCreateSerializer, GroupMemberSerializer,
ReadingGroupDetailSerializer, GroupUpdateSerializer,
ReadingGroupListSerializer, JoinRequestSerializer,
JoinViaInviteSerializer,
RoleUpdateSerializer,
) )
import logging
logger = logging.getLogger(__name__) class GroupViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
class ReadingGroupViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return (
ReadingGroup.objects.filter(memberships__user=self.request.user)
.select_related("ebook", "created_by")
.prefetch_related("memberships", "memberships__progress")
.distinct()
)
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 perform_create(self, serializer): def get_queryset(self) -> QuerySet[Group]:
group = serializer.save(created_by=self.request.user) user = self.request.user
# Creator automatically becomes admin member qs = Group.objects.filter(memberships__user=user).distinct()
GroupMembership.objects.create( qs = qs.annotate(_member_count=Count("memberships"))
group=group, if self.action in ("list", "retrieve"):
user=self.request.user, qs = qs.prefetch_related(
role=GroupMembership.Role.ADMIN, Prefetch(
) "memberships",
queryset=GroupMember.objects.select_related("user").order_by("joined_at"),
)
)
return qs
# ── Membership ─────────────────────────────────────────────────────── def get_object(self) -> Group:
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
@action(detail=True, methods=["post"], permission_classes=[IsGroupMember]) def perform_create(self, serializer: GroupCreateSerializer) -> Group:
def join(self, request: Request, pk: int | None = None) -> Response: return serializer.save()
"""Join a reading group (public join link)."""
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()
membership, created = GroupMembership.objects.get_or_create( memberships = group.memberships.select_related("user").order_by("joined_at")
group=group, user=request.user serializer = GroupMemberSerializer(memberships, many=True)
) return Response(serializer.data)
if not created:
return Response({"detail": "Already a member."}, status=status.HTTP_200_OK)
MemberProgress.objects.get_or_create(
membership=membership,
group=group,
user=request.user,
defaults={"current_section": 0, "percentage": 0.0},
)
return Response({"detail": "Joined successfully."}, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], permission_classes=[IsGroupMember]) @action(
def leave(self, request: Request, pk: int | None = None) -> Response: detail=True,
"""Leave a reading group.""" 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() group = self.get_object()
if group.created_by == request.user: 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)
if membership.user == request.user:
return Response( return Response(
{"error": "Group creator cannot leave. Transfer ownership or delete the group."}, {"error": "Admins cannot remove themselves. Use leave instead, or transfer admin first."},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
GroupMembership.objects.filter(group=group, user=request.user).delete()
membership.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get"], permission_classes=[IsGroupMember]) @action(
def members(self, request: Request, pk: int | None = None) -> Response: detail=True,
"""List group members with their progress.""" 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() group = self.get_object()
memberships = ( serializer = RoleUpdateSerializer(data=request.data, context={"group": group})
group.memberships.select_related("user") serializer.is_valid(raise_exception=True)
.prefetch_related("progress")
.all()
)
serializer = GroupMembershipSerializer(memberships, many=True)
return Response(serializer.data)
# ── Progress ───────────────────────────────────────────────────────── 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)
@action(detail=True, methods=["get"], permission_classes=[IsGroupMember], membership.role = serializer.validated_data["role"]
url_path="members/progress") membership.save(update_fields=["role"])
def members_progress(self, request: Request, pk: int | None = None) -> Response:
"""Get progress for all group members.
Other members' progress shows section label only (no last_position). if serializer.validated_data["role"] == GroupRole.ADMIN and membership.user != request.user:
Your own progress shows full detail. # 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() group = self.get_object()
progress_qs = ( membership = GroupMember.objects.filter(group=group, user=request.user).first()
MemberProgress.objects.filter(group=group)
.select_related("user")
.order_by("-percentage")
)
result: list[dict] = []
for mp in progress_qs:
if mp.user == request.user:
result.append(MemberProgressSerializer(mp).data)
elif mp.is_public:
result.append(MemberProgressPublicSerializer(mp).data)
else:
result.append({
"user_id": mp.user.id,
"user_email": mp.user.email,
"is_public": False,
"note": "Private",
})
return Response(result)
@action(detail=True, methods=["get", "patch"], permission_classes=[IsGroupMember], if not membership:
url_path="progress") return Response({"error": "You are not a member of this group."}, status=status.HTTP_400_BAD_REQUEST)
def my_progress(self, request: Request, pk: int | None = None) -> Response:
"""Get or update my own progress in the group.""" 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() group = self.get_object()
membership = GroupMembership.objects.get(group=group, user=request.user)
progress_obj, _created = MemberProgress.objects.get_or_create(
membership=membership,
group=group,
user=request.user,
)
if request.method == "GET": if request.method == "GET":
serializer = MemberProgressSerializer(progress_obj) 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) return Response(serializer.data)
# PATCH — update progress serializer = GroupInviteCreateSerializer(
update_serializer = MemberProgressUpdateSerializer(data=request.data) data=request.data,
update_serializer.is_valid(raise_exception=True) context={"group": group, "request": request},
data = update_serializer.validated_data
progress_obj.update_progress(
current_section=data["current_section"],
percentage=data.get("percentage"),
time_spent_delta=data.get("time_spent_delta", 0),
last_position=data.get("last_position"),
) )
if "is_public" in data:
progress_obj.is_public = data["is_public"]
progress_obj.save(update_fields=["is_public"])
serializer = MemberProgressSerializer(progress_obj)
return Response(serializer.data)
# ── Admin ────────────────────────────────────────────────────────────
@action(detail=True, methods=["get"], permission_classes=[IsGroupAdmin],
url_path="progress/summary")
def progress_summary(self, request: Request, pk: int | None = None) -> Response:
"""Admin summary of all member progress."""
group = self.get_object()
progress_qs = MemberProgress.objects.filter(group=group).select_related("user")
total_members = group.memberships.count()
members_started = progress_qs.filter(current_section__gt=0).count()
members_finished = progress_qs.filter(percentage__gte=100.0).count()
avg_pct = progress_qs.aggregate(avg=models.Avg("percentage"))["avg"] or 0.0
avg_time = progress_qs.aggregate(avg=models.Avg("time_spent_seconds"))["avg"] or 0.0
member_details: list[dict] = []
for mp in progress_qs:
member_details.append({
"user_id": mp.user.id,
"user_email": mp.user.email,
"current_section": mp.current_section,
"percentage": mp.percentage,
"time_spent_seconds": mp.time_spent_seconds,
"is_public": mp.is_public,
"updated_at": mp.updated_at,
})
summary_data = {
"group_id": group.id,
"group_name": group.name,
"ebook_title": group.ebook.title,
"total_members": total_members,
"members_started": members_started,
"members_finished": members_finished,
"average_percentage": round(avg_pct, 1),
"average_time_spent_hours": round(avg_time / 3600.0, 1) if avg_time else 0.0,
"member_details": member_details,
}
serializer = AdminProgressSummarySerializer(data=summary_data)
serializer.is_valid(raise_exception=True) 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) 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
@@ -8,7 +8,7 @@ urlpatterns = [
path("api/books/", include("apps.books.urls")), path("api/books/", include("apps.books.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/groups/", include("apps.groups.urls")), path("api/", include("apps.groups.urls")),
] ]
if settings.DEBUG: if settings.DEBUG:
+7 -3
View File
@@ -12,8 +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 ReadingGroupsPage = lazy(() => import("./pages/ReadingGroups").then((m) => ({ default: m.ReadingGroupsPage }))); const GroupsListPage = lazy(() => import("./pages/GroupsListPage").then((m) => ({ default: m.GroupsListPage })));
const GroupDetailPage = lazy(() => import("./pages/GroupDetail").then((m) => ({ default: m.GroupDetailPage }))); const GroupDetailPage = lazy(() => import("./pages/GroupDetailPage").then((m) => ({ default: m.GroupDetailPage })));
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,7 +49,9 @@ function AppRoutes() {
<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="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} /> <Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
<Route path="/groups" element={<ProtectedRoute><ReadingGroupsPage /></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="/groups/:id" element={<ProtectedRoute><GroupDetailPage /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+63 -44
View File
@@ -1,30 +1,36 @@
import api from "./client"; import api from "./client";
import type { import type {
AdminProgressSummary,
CreateGroupPayload, CreateGroupPayload,
MemberProgressDetail, CreateInvitePayload,
MemberProgressEntry, GroupDetail,
ReadingGroupDetail, GroupInvite,
ReadingGroupSummary, GroupListItem,
UpdateProgressPayload, InviteValidation,
} from "../types/groups"; JoinRequest,
UpdateGroupPayload,
} from "../../packages/shared/src/types";
export const groupsApi = { export const groupsApi = {
/* ── Groups ── */ // ---- Group CRUD ----
async listGroups(): Promise<ReadingGroupSummary[]> { async listGroups(): Promise<GroupListItem[]> {
const { data } = await api.get<ReadingGroupSummary[]>("/groups/"); const { data } = await api.get<{ count: number; results: GroupListItem[] } | GroupListItem[]>("/groups/");
if (Array.isArray(data)) return data; if (Array.isArray(data)) return data;
return (data as { results: ReadingGroupSummary[] }).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<ReadingGroupDetail> { async createGroup(payload: CreateGroupPayload): Promise<GroupDetail> {
const { data } = await api.post<ReadingGroupDetail>("/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;
}, },
@@ -32,50 +38,63 @@ export const groupsApi = {
await api.delete(`/groups/${id}/`); await api.delete(`/groups/${id}/`);
}, },
/* ── Membership ── */ // ---- Members ----
async joinGroup(id: number): Promise<{ detail: string }> { async removeMember(groupId: number, userId: number): Promise<void> {
const { data } = await api.post<{ detail: string }>(`/groups/${id}/join/`); await api.delete(`/groups/${groupId}/members/${userId}/`);
},
async updateMemberRole(groupId: number, userId: number, role: "admin" | "member"): Promise<void> {
await api.patch(`/groups/${groupId}/members/${userId}/role/`, { role });
},
async leaveGroup(groupId: number): Promise<{ detail: string }> {
const { data } = await api.post<{ detail: string }>(`/groups/${groupId}/leave/`);
return data; return data;
}, },
async leaveGroup(id: number): Promise<void> { // ---- Invites ----
await api.post(`/groups/${id}/leave/`);
},
/* ── Progress ── */ async listInvites(groupId: number): Promise<GroupInvite[]> {
const { data } = await api.get<GroupInvite[]>(`/groups/${groupId}/invites/`);
async getMembersProgress(groupId: number): Promise<MemberProgressEntry[]> {
const { data } = await api.get<MemberProgressEntry[]>(
`/groups/${groupId}/members/progress/`,
);
return data; return data;
}, },
async getMyProgress(groupId: number): Promise<MemberProgressDetail> { async createInvite(groupId: number, payload: CreateInvitePayload = {}): Promise<GroupInvite> {
const { data } = await api.get<MemberProgressDetail>( const { data } = await api.post<GroupInvite>(`/groups/${groupId}/invites/`, payload);
`/groups/${groupId}/progress/`,
);
return data; return data;
}, },
async updateMyProgress( async revokeInvite(groupId: number, inviteId: number): Promise<void> {
groupId: number, await api.delete(`/groups/${groupId}/invites/${inviteId}/`);
payload: UpdateProgressPayload, },
): Promise<MemberProgressDetail> {
const { data } = await api.patch<MemberProgressDetail>( // ---- Join Requests ----
`/groups/${groupId}/progress/`,
payload, async listJoinRequests(groupId: number): Promise<JoinRequest[]> {
); const { data } = await api.get<JoinRequest[]>(`/groups/${groupId}/requests/`);
return data; return data;
}, },
/* ── Admin ── */ async approveRequest(groupId: number, requestId: number): Promise<JoinRequest> {
const { data } = await api.post<JoinRequest>(`/groups/${groupId}/requests/${requestId}/approve/`);
return data;
},
async getProgressSummary(groupId: number): Promise<AdminProgressSummary> { async rejectRequest(groupId: number, requestId: number): Promise<JoinRequest> {
const { data } = await api.get<AdminProgressSummary>( const { data } = await api.post<JoinRequest>(`/groups/${groupId}/requests/${requestId}/reject/`);
`/groups/${groupId}/progress/summary/`, return data;
); },
// ---- Join via Invite ----
async validateInvite(code: string): Promise<InviteValidation> {
const { data } = await api.get<InviteValidation>(`/join/${code}/`);
return data;
},
async joinViaInvite(code: string): Promise<GroupDetail> {
const { data } = await api.post<GroupDetail>(`/join/${code}/`);
return data; return data;
}, },
}; };
+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>
);
}
-381
View File
@@ -1,381 +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 type {
AdminProgressSummary,
MemberProgressEntry,
ReadingGroupDetail,
} from "../types/groups";
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
function isDetail(entry: MemberProgressEntry): entry is { user_id: number; is_public: boolean; id: number } {
return "id" in entry && "is_public" in entry;
}
export function GroupDetailPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isMobile = useMediaQuery(BREAKPOINTS.md);
const [group, setGroup] = useState<ReadingGroupDetail | null>(null);
const [progressEntries, setProgressEntries] = useState<MemberProgressEntry[]>([]);
const [adminSummary, setAdminSummary] = useState<AdminProgressSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"progress" | "admin">("progress");
const isAdmin = group?.members.some(
(m) => m.user_id === (group as ReadingGroupDetail & { _myUserId?: number })._myUserId && m.role === "admin",
);
const loadGroup = useCallback(async () => {
if (!id) return;
setLoading(true);
setError(null);
try {
const groupId = Number(id);
if (Number.isNaN(groupId)) {
setError(t("groupDetail.invalidId") ?? "Invalid group ID");
return;
}
const [groupData, progressData] = await Promise.all([
groupsApi.getGroup(groupId),
groupsApi.getMembersProgress(groupId).catch(() => [] as MemberProgressEntry[]),
]);
setGroup(groupData);
setProgressEntries(progressData);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load group");
} finally {
setLoading(false);
}
}, [id, t]);
const loadAdminSummary = useCallback(async () => {
if (!id || !isAdmin) return;
try {
const summary = await groupsApi.getProgressSummary(Number(id));
setAdminSummary(summary);
} catch {
// Admin summary is optional
}
}, [id, isAdmin]);
useEffect(() => {
void loadGroup();
}, [loadGroup]);
useEffect(() => {
if (tab === "admin") {
void loadAdminSummary();
}
}, [tab, loadAdminSummary]);
const handleJoin = useCallback(async () => {
if (!id) return;
try {
await groupsApi.joinGroup(Number(id));
void loadGroup();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to join");
}
}, [id, loadGroup]);
const handleLeave = useCallback(async () => {
if (!id) return;
try {
await groupsApi.leaveGroup(Number(id));
navigate("/groups");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to leave");
}
}, [id, navigate]);
const containerStyle: React.CSSProperties = {
maxWidth: 720,
margin: "0 auto",
padding: isMobile ? 16 : 24,
minHeight: "100vh",
background: "#f8f9fa",
};
const backButtonStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: isMobile ? "10px 16px" : "8px 16px",
borderRadius: 8,
border: "1px solid #e5e7eb",
background: "#fff",
color: "#374151",
fontSize: isMobile ? 15 : 14,
cursor: "pointer",
marginBottom: 20,
minHeight: 44,
};
const tabBarStyle: React.CSSProperties = {
display: "flex",
gap: 0,
marginBottom: 20,
borderBottom: "2px solid #e5e7eb",
};
const tabStyle = (active: boolean): React.CSSProperties => ({
padding: "10px 20px",
fontSize: 14,
fontWeight: active ? 600 : 400,
color: active ? "#4f46e5" : "#6b7280",
background: "transparent",
border: "none",
borderBottom: active ? "2px solid #4f46e5" : "2px solid transparent",
cursor: "pointer",
marginBottom: -2,
});
if (loading) {
return (
<div style={containerStyle}>
<div style={{ height: 32, width: 100, background: "#e5e7eb", borderRadius: 6, marginBottom: 20 }} />
<div style={{ height: 28, width: "50%", background: "#e5e7eb", borderRadius: 6, marginBottom: 16 }} />
{[1, 2, 3].map((i) => (
<div key={i} style={{ height: 60, background: "#e5e7eb", borderRadius: 8, marginBottom: 8 }} />
))}
</div>
);
}
if (error || !group) {
return (
<div style={containerStyle}>
<button onClick={() => navigate("/groups")} style={backButtonStyle}>
{t("groupDetail.back") ?? "Back"}
</button>
<div style={{ textAlign: "center", padding: "60px 20px" }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>😕</div>
<p style={{ color: "#6b7280", fontSize: 16 }}>
{error ?? (t("groupDetail.notFound") ?? "Group not found")}
</p>
</div>
</div>
);
}
const isMember = group.members.some((m) => {
// Determine membership by matching progress data — user_id can come from members list
// We check if there's a progress entry with an "id" (indicates own data)
return progressEntries.some((e) => "id" in e);
});
return (
<div style={containerStyle}>
<button onClick={() => navigate("/groups")} style={backButtonStyle}>
{t("groupDetail.back") ?? "Back to Groups"}
</button>
{/* Header */}
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: "#1f2937", margin: "0 0 4px" }}>
{group.name}
</h1>
<p style={{ fontSize: 14, color: "#6b7280", margin: 0 }}>
{group.ebook_title} {group.ebook_author ? `${group.ebook_author}` : ""}
</p>
</div>
{/* Group actions */}
<div style={{ display: "flex", gap: 10, marginBottom: 20, flexWrap: "wrap" }}>
{!isMember ? (
<button
onClick={() => void handleJoin()}
style={{
padding: "10px 20px", borderRadius: 10, border: "none", background: "#4f46e5",
color: "#fff", fontSize: 14, fontWeight: 600, cursor: "pointer",
}}
>
{t("groupDetail.join") ?? "Join Group"}
</button>
) : (
<>
<span style={{
fontSize: 13, color: "#16a34a", background: "#dcfce7",
padding: "4px 12px", borderRadius: 999, display: "inline-flex", alignItems: "center",
}}>
{t("groupDetail.member") ?? "Member"}
</span>
{group.created_by_email !== undefined /* can't check identity here, but leave is always available for non-creators */ && (
<button
onClick={() => void handleLeave()}
style={{
padding: "10px 20px", borderRadius: 10, border: "1px solid #fca5a5",
background: "#fff", color: "#b91c1c", fontSize: 14, cursor: "pointer",
}}
>
{t("groupDetail.leave") ?? "Leave Group"}
</button>
)}
</>
)}
</div>
{group.description && (
<p style={{ fontSize: 14, color: "#4b5563", marginBottom: 20, lineHeight: 1.6, background: "#fff", padding: isMobile ? 12 : 16, borderRadius: 10, border: "1px solid #e5e7eb" }}>
{group.description}
</p>
)}
{/* Tabs */}
<div style={tabBarStyle}>
<button style={tabStyle(tab === "progress")} onClick={() => setTab("progress")}>
{t("groupDetail.progressTab") ?? "Progress"}
</button>
{isAdmin && (
<button style={tabStyle(tab === "admin")} onClick={() => setTab("admin")}>
{t("groupDetail.adminTab") ?? "Admin Summary"}
</button>
)}
</div>
{/* Progress tab */}
{tab === "progress" && (
<div>
{progressEntries.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px 20px", color: "#9ca3af" }}>
<p>{t("groupDetail.noProgress") ?? "No progress data yet."}</p>
</div>
) : (
progressEntries.map((entry) => {
const isPrivate = "is_public" in entry && entry.is_public === false && "note" in entry;
const hasDetail = isDetail(entry);
return (
<div
key={entry.user_id}
style={{
background: "#fff", borderRadius: 10, padding: isMobile ? 12 : 16,
marginBottom: 10, border: "1px solid #e5e7eb",
boxShadow: "0 1px 3px rgba(0,0,0,0.04)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
<span style={{ fontSize: 15, fontWeight: 600, color: "#1f2937" }}>
{entry.user_email}
{hasDetail && " (you)"}
</span>
{isPrivate ? (
<span style={{ fontSize: 12, color: "#9ca3af", fontStyle: "italic" }}>
🔒 {t("groupDetail.private") ?? "Private"}
</span>
) : "section_label" in entry && (
<span style={{ fontSize: 13, color: "#4f46e5", fontWeight: 500 }}>
{entry.section_label}
</span>
)}
</div>
{!isPrivate && "percentage" in entry && (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
flex: 1, height: 8, borderRadius: 4, background: "#e5e7eb", overflow: "hidden",
}}>
<div style={{
height: "100%",
width: `${Math.min(100, entry.percentage)}%`,
background: hasDetail ? "#4f46e5" : "#818cf8",
borderRadius: 4,
transition: "width 0.3s ease",
}} />
</div>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151", whiteSpace: "nowrap" }}>
{Math.round(entry.percentage)}%
</span>
</div>
)}
{hasDetail && entry.last_position && Object.keys(entry.last_position).length > 0 && (
<div style={{ marginTop: 8, fontSize: 12, color: "#9ca3af" }}>
{t("groupDetail.lastPosition") ?? "Last position"}: {JSON.stringify(entry.last_position)}
</div>
)}
</div>
);
})
)}
</div>
)}
{/* Admin tab */}
{tab === "admin" && adminSummary && (
<div>
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))",
gap: 12,
marginBottom: 24,
}}>
<StatCard
label={t("groupDetail.totalMembers") ?? "Members"}
value={String(adminSummary.total_members)}
/>
<StatCard
label={t("groupDetail.started") ?? "Started"}
value={String(adminSummary.members_started)}
/>
<StatCard
label={t("groupDetail.finished") ?? "Finished"}
value={String(adminSummary.members_finished)}
/>
<StatCard
label={t("groupDetail.avgProgress") ?? "Avg Progress"}
value={`${Math.round(adminSummary.average_percentage)}%`}
/>
<StatCard
label={t("groupDetail.avgTime") ?? "Avg Time"}
value={`${adminSummary.average_time_spent_hours}h`}
/>
</div>
<h3 style={{ fontSize: 15, fontWeight: 600, color: "#1f2937", marginBottom: 12 }}>
{t("groupDetail.memberDetails") ?? "Member Details"}
</h3>
{adminSummary.member_details.map((m) => (
<div
key={m.user_id}
style={{
background: "#fff", borderRadius: 10, padding: isMobile ? 12 : 14,
marginBottom: 8, border: "1px solid #e5e7eb",
display: "flex", justifyContent: "space-between", alignItems: "center",
}}
>
<div>
<span style={{ fontSize: 14, fontWeight: 500, color: "#1f2937" }}>{m.user_email}</span>
<span style={{ fontSize: 12, color: "#9ca3af", marginLeft: 8 }}>
{m.is_public ? "👁" : "🔒"}
</span>
</div>
<div style={{ display: "flex", gap: 16, fontSize: 13, color: "#6b7280" }}>
<span>Section {m.current_section}</span>
<span style={{ fontWeight: 600, color: "#4f46e5" }}>{Math.round(m.percentage)}%</span>
<span> {Math.round(m.time_spent_seconds / 60)}m</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div style={{
background: "#fff", borderRadius: 10, padding: "14px 16px",
border: "1px solid #e5e7eb", textAlign: "center",
}}>
<div style={{ fontSize: 22, fontWeight: 700, color: "#4f46e5" }}>{value}</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>{label}</div>
</div>
);
}
+334
View File
@@ -0,0 +1,334 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { groupsApi } from "../api/groups";
import { useAuth } from "../context/AuthContext";
import { useToast } from "../hooks/useToast";
import type { GroupDetail, GroupInvite, GroupMember } from "../../packages/shared/src/types";
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() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { showToast } = useToast();
const [group, setGroup] = useState<GroupDetail | null>(null);
const [invites, setInvites] = useState<GroupInvite[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingName, setEditingName] = useState(false);
const [editName, setEditName] = useState("");
const [showInviteSection, setShowInviteSection] = useState(false);
const groupId = Number(id);
const isAdmin = group?.user_role === "admin";
const loadGroup = useCallback(async () => {
if (!groupId) return;
setLoading(true);
setError(null);
try {
const data = await groupsApi.getGroup(groupId);
setGroup(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load group");
} finally {
setLoading(false);
}
}, [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(() => {
loadGroup();
}, [loadGroup]);
useEffect(() => {
if (group && isAdmin) loadInvites();
}, [group, isAdmin, loadInvites]);
const handleRemoveMember = async (userId: number, memberEmail: string) => {
if (!confirm(`Remove ${memberEmail} from the group?`)) return;
try {
await groupsApi.removeMember(groupId, userId);
showToast({ message: "Member removed", variant: "success" });
loadGroup();
} catch (err: unknown) {
showToast({ message: "Failed to remove member", variant: "error" });
}
};
const handleTransferAdmin = async (userId: number, memberEmail: string) => {
if (!confirm(`Transfer admin role to ${memberEmail}? You will become a regular member.`)) return;
try {
await groupsApi.updateMemberRole(groupId, userId, "admin");
showToast({ message: "Admin role transferred", variant: "success" });
loadGroup();
} catch (err: unknown) {
showToast({ message: "Failed to transfer admin", variant: "error" });
}
};
const handleLeave = async () => {
if (!confirm("Are you sure you want to leave this group?")) return;
try {
const result = await groupsApi.leaveGroup(groupId);
showToast({ message: result.detail || "Left group", variant: "success" });
navigate("/groups");
} catch (err: unknown) {
showToast({ message: "Failed to leave group", variant: "error" });
}
};
const handleCreateInvite = async () => {
try {
const invite = await groupsApi.createInvite(groupId);
setInvites((prev) => [invite, ...prev]);
showToast({ message: "Invite link created!", variant: "success" });
} catch (err: unknown) {
showToast({ message: "Failed to create invite", variant: "error" });
}
};
const handleRevokeInvite = async (inviteId: number) => {
try {
await groupsApi.revokeInvite(groupId, inviteId);
setInvites((prev) => prev.map((i) => (i.id === inviteId ? { ...i, is_active: false } : i)));
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 (loading) return <div style={S.loading}>Loading group...</div>;
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>;
if (!group) return <div style={S.error}>Group not found</div>;
return (
<div style={S.container}>
<button style={S.backBtn} onClick={() => navigate("/groups")}>
Back to Groups
</button>
{/* Group Header */}
{editingName ? (
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<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>
)}
{/* Members Section */}
<div style={S.section}>
<h2 style={S.sectionTitle}>Members ({group.member_count})</h2>
{group.members.map((member) => (
<div key={member.id} style={S.memberItem}>
<div style={S.memberInfo}>
<span style={S.memberName}>
{member.user_username || member.user_email}{" "}
<span style={S.badge(member.role)}>{member.role}</span>
</span>
<span style={S.memberEmail}>{member.user_email}</span>
</div>
{isAdmin && member.user_id !== Number(user?.email ? undefined : undefined) && (
<div style={{ display: "flex", gap: 8 }}>
{member.role === "member" && (
<button
style={S.transferBtn}
onClick={() => handleTransferAdmin(member.user_id, member.user_email)}
>
Make Admin
</button>
)}
<button
style={S.removeBtn}
onClick={() => handleRemoveMember(member.user_id, member.user_email)}
>
Remove
</button>
</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>
)}
</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>
);
}
+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 -2
View File
@@ -241,16 +241,16 @@ 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("/groups")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Reading Groups">👥</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>
</> </>
) : ( ) : (
<> <>
<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("/groups")} className="btn btn-secondary">👥 Reading Groups</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>
</> </>
-288
View File
@@ -1,288 +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 { booksApi } from "../api/books";
import type { ReadingGroupSummary } from "../types/groups";
import type { EBookListItem } from "../types/book";
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
export function ReadingGroupsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useMediaQuery(BREAKPOINTS.md);
const [groups, setGroups] = useState<ReadingGroupSummary[]>([]);
const [ebooks, setEbooks] = useState<EBookListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState("");
const [createEbook, setCreateEbook] = useState<number | null>(null);
const [createDesc, setCreateDesc] = useState("");
const [creating, setCreating] = useState(false);
const loadGroups = useCallback(async () => {
try {
const data = await groupsApi.listGroups();
setGroups(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load groups");
} finally {
setLoading(false);
}
}, []);
const loadEbooks = useCallback(async () => {
try {
const data = await booksApi.getEBooks();
setEbooks(data);
} catch {
// Ebook list is optional; groups page still works without it
}
}, []);
useEffect(() => {
void loadGroups();
void loadEbooks();
}, [loadGroups, loadEbooks]);
const handleCreate = useCallback(async () => {
if (!createName.trim() || createEbook === null) return;
setCreating(true);
try {
await groupsApi.createGroup({
name: createName.trim(),
ebook: createEbook,
description: createDesc.trim(),
});
setShowCreate(false);
setCreateName("");
setCreateEbook(null);
setCreateDesc("");
void loadGroups();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create group");
} finally {
setCreating(false);
}
}, [createName, createEbook, createDesc, loadGroups]);
const containerStyle: React.CSSProperties = {
maxWidth: 720,
margin: "0 auto",
padding: isMobile ? 16 : 24,
minHeight: "100vh",
background: "#f8f9fa",
};
const headerBar: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
};
const cardStyle: React.CSSProperties = {
background: "#fff",
borderRadius: 12,
padding: isMobile ? 14 : 18,
marginBottom: 12,
cursor: "pointer",
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
border: "1px solid #e5e7eb",
};
if (loading) {
return (
<div style={containerStyle}>
<div style={{ height: 24, width: 200, background: "#e5e7eb", borderRadius: 6, marginBottom: 24 }} />
{[1, 2, 3].map((i) => (
<div key={i} style={{ ...cardStyle, cursor: "default" }}>
<div style={{ height: 20, width: "60%", background: "#e5e7eb", borderRadius: 4, marginBottom: 8 }} />
<div style={{ height: 14, width: "40%", background: "#e5e7eb", borderRadius: 4 }} />
</div>
))}
</div>
);
}
return (
<div style={containerStyle}>
<div style={headerBar}>
<h1 style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: "#1f2937", margin: 0 }}>
📚 {t("readingGroups.title") ?? "Reading Groups"}
</h1>
<button
onClick={() => setShowCreate(true)}
style={{
padding: isMobile ? "10px 18px" : "8px 18px",
borderRadius: 10,
border: "none",
background: "#4f46e5",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
minHeight: 40,
}}
>
+ {t("readingGroups.create") ?? "New Group"}
</button>
</div>
{error && (
<div style={{ padding: "12px 16px", background: "#fef2f2", borderRadius: 8, color: "#b91c1c", marginBottom: 16, fontSize: 14 }}>
{error}
</div>
)}
{/* Create modal */}
{showCreate && (
<div style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
}}>
<div style={{
background: "#fff", borderRadius: 16, padding: 24, maxWidth: 420, width: "90%",
boxShadow: "0 10px 40px rgba(0,0,0,0.15)",
}}>
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, color: "#1f2937" }}>
{t("readingGroups.createTitle") ?? "Create Reading Group"}
</h2>
<label style={{ fontSize: 13, color: "#6b7280", display: "block", marginBottom: 4 }}>
{t("readingGroups.groupName") ?? "Group Name"}
</label>
<input
type="text"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder={t("readingGroups.namePlaceholder") ?? "e.g., Book Club June"}
style={{
width: "100%", padding: "10px 14px", borderRadius: 8, border: "1px solid #d1d5db",
fontSize: 14, marginBottom: 14, boxSizing: "border-box",
}}
/>
<label style={{ fontSize: 13, color: "#6b7280", display: "block", marginBottom: 4 }}>
{t("readingGroups.selectBook") ?? "Select Book"}
</label>
<select
value={createEbook ?? ""}
onChange={(e) => setCreateEbook(e.target.value ? Number(e.target.value) : null)}
style={{
width: "100%", padding: "10px 14px", borderRadius: 8, border: "1px solid #d1d5db",
fontSize: 14, marginBottom: 14, boxSizing: "border-box", background: "#fff",
}}
>
<option value="">-- {t("readingGroups.choose") ?? "Choose"} --</option>
{ebooks.map((eb) => (
<option key={eb.id} value={eb.id}>
{eb.title} {eb.author ? `${eb.author}` : ""}
</option>
))}
</select>
<label style={{ fontSize: 13, color: "#6b7280", display: "block", marginBottom: 4 }}>
{t("readingGroups.description") ?? "Description"} ({t("common.optional") ?? "optional"})
</label>
<textarea
value={createDesc}
onChange={(e) => setCreateDesc(e.target.value)}
rows={3}
style={{
width: "100%", padding: "10px 14px", borderRadius: 8, border: "1px solid #d1d5db",
fontSize: 14, marginBottom: 18, boxSizing: "border-box", resize: "vertical",
}}
/>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button
onClick={() => setShowCreate(false)}
style={{
padding: "10px 20px", borderRadius: 8, border: "1px solid #d1d5db",
background: "#fff", color: "#374151", fontSize: 14, cursor: "pointer",
}}
>
{t("common.cancel") ?? "Cancel"}
</button>
<button
onClick={() => void handleCreate()}
disabled={creating || !createName.trim() || createEbook === null}
style={{
padding: "10px 20px", borderRadius: 8, border: "none",
background: (!createName.trim() || createEbook === null) ? "#9ca3af" : "#4f46e5",
color: "#fff", fontSize: 14, fontWeight: 600, cursor: (!createName.trim() || createEbook === null) ? "not-allowed" : "pointer",
}}
>
{creating ? (t("common.creating") ?? "Creating...") : (t("readingGroups.create") ?? "Create")}
</button>
</div>
</div>
</div>
)}
{/* Group list */}
{groups.length === 0 ? (
<div style={{ textAlign: "center", padding: "60px 20px", color: "#6b7280" }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>📖</div>
<p style={{ fontSize: 16, marginBottom: 16 }}>
{t("readingGroups.empty") ?? "No reading groups yet. Create one to read together!"}
</p>
</div>
) : (
groups.map((g) => (
<div
key={g.id}
style={cardStyle}
onClick={() => navigate(`/groups/${g.id}`)}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h2 style={{ fontSize: isMobile ? 16 : 17, fontWeight: 600, color: "#1f2937", margin: "0 0 4px" }}>
{g.name}
</h2>
<p style={{ fontSize: 13, color: "#6b7280", margin: "0 0 6px" }}>
{g.ebook_title} {g.ebook_author ? `${g.ebook_author}` : ""}
</p>
</div>
<span style={{
fontSize: 12, color: "#4f46e5", background: "#eef2ff",
padding: "2px 10px", borderRadius: 999, whiteSpace: "nowrap",
minHeight: 24, display: "inline-flex", alignItems: "center",
}}>
{g.member_count} {g.member_count === 1
? (t("readingGroups.member") ?? "member")
: (t("readingGroups.members") ?? "members")}
</span>
</div>
{g.my_progress && (
<div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8 }}>
<div style={{
flex: 1, height: 6, borderRadius: 3, background: "#e5e7eb", overflow: "hidden",
}}>
<div style={{
height: "100%", width: `${Math.min(100, g.my_progress.percentage)}%`,
background: "#4f46e5", borderRadius: 3, transition: "width 0.3s ease",
}} />
</div>
<span style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}>
{g.my_progress.section_label}
</span>
</div>
)}
{g.description && (
<p style={{ fontSize: 13, color: "#9ca3af", margin: "8px 0 0", lineHeight: 1.5 }}>
{g.description}
</p>
)}
</div>
))
)}
</div>
);
}
-104
View File
@@ -1,104 +0,0 @@
/* ── Reading Groups ── */
export interface MemberProgressPublic {
user_id: number;
user_email: string;
current_section: number;
total_sections: number;
section_label: string;
percentage: number;
time_spent_seconds: number;
updated_at: string;
}
export interface MemberProgressDetail {
id: number;
user_id: number;
user_email: string;
current_section: number;
total_sections: number;
section_label: string;
percentage: number;
time_spent_seconds: number;
last_position: Record<string, unknown>;
is_public: boolean;
updated_at: string;
}
export interface MemberProgressPrivateStub {
user_id: number;
user_email: string;
is_public: false;
note: string;
}
export type MemberProgressEntry =
| MemberProgressDetail
| MemberProgressPublic
| MemberProgressPrivateStub;
export interface GroupMember {
id: number;
user_id: number;
user_email: string;
role: "member" | "admin";
joined_at: string;
progress: MemberProgressPublic | { is_public: false; note: string } | null;
}
export interface ReadingGroupSummary {
id: number;
name: string;
ebook: number;
ebook_title: string;
ebook_author: string;
created_by_email: string;
description: string;
member_count: number;
my_progress: {
current_section: number;
percentage: number;
time_spent_seconds: number;
section_label: string;
} | null;
created_at: string;
updated_at: string;
}
export interface ReadingGroupDetail extends ReadingGroupSummary {
members: GroupMember[];
}
export interface CreateGroupPayload {
name: string;
ebook: number;
description?: string;
}
export interface UpdateProgressPayload {
current_section: number;
percentage?: number;
time_spent_delta?: number;
last_position?: Record<string, unknown>;
is_public?: boolean;
}
export interface AdminProgressSummary {
group_id: number;
group_name: string;
ebook_title: string;
total_members: number;
members_started: number;
members_finished: number;
average_percentage: number;
average_time_spent_hours: number;
member_details: {
user_id: number;
user_email: string;
current_section: number;
percentage: number;
time_spent_seconds: number;
is_public: boolean;
updated_at: string;
}[];
}
+82 -18
View File
@@ -217,34 +217,98 @@ export interface ApiError {
[key: string]: unknown; [key: string]: unknown;
} }
// ---- Reading Groups ---- // ---- Groups ----
export interface ReadingGroupSummary { export type GroupRole = "admin" | "member";
export type JoinRequestStatus = "pending" | "approved" | "rejected";
export interface GroupListItem {
id: number; id: number;
name: string; name: string;
ebook: number;
ebook_title: string;
ebook_author: string;
created_by_email: string;
description: string; description: string;
created_by: number;
member_count: number; member_count: number;
my_progress: { user_role: GroupRole | null;
current_section: number;
percentage: number;
time_spent_seconds: number;
section_label: string;
} | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export interface MemberProgressPublic { export interface GroupMember {
id: number;
user_id: number; user_id: number;
user_email: string; user_email: string;
current_section: number; user_username: string;
total_sections: number; role: GroupRole;
section_label: string; joined_at: string;
percentage: number; }
time_spent_seconds: number;
export interface GroupDetail {
id: number;
name: string;
description: string;
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; 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;
}
export interface CreateInvitePayload {
max_uses?: number;
}
export interface JoinRequest {
id: number;
group: number;
group_name: string;
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;
};
}
+9 -4
View File
@@ -117,10 +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}/`,
membersProgress: (id: number) => `/api/groups/${id}/members/progress/`, members: (id: number) => `/api/groups/${id}/members/`,
myProgress: (id: number) => `/api/groups/${id}/progress/`, removeMember: (id: number, userId: number) => `/api/groups/${id}/members/${userId}/`,
progressSummary: (id: number) => `/api/groups/${id}/progress/summary/`, updateRole: (id: number, userId: number) => `/api/groups/${id}/members/${userId}/role/`,
join: (id: number) => `/api/groups/${id}/join/`,
leave: (id: number) => `/api/groups/${id}/leave/`, leave: (id: number) => `/api/groups/${id}/leave/`,
invites: (id: number) => `/api/groups/${id}/invites/`,
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;