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