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
This commit is contained in:
Marko
2026-06-20 19:25:46 +00:00
parent 26c5f6f06b
commit d5def297b9
21 changed files with 1813 additions and 0 deletions
+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>
);
}