Archived
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user