feat: create network, and manage members, invitations (#74)

This commit was merged in pull request #74.
This commit is contained in:
Arjun Patel
2026-03-24 19:50:06 -07:00
committed by GitHub
parent bf0147d542
commit 3d52fa6ccc
14 changed files with 655 additions and 39 deletions
+230 -22
View File
@@ -1,19 +1,36 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Users } from "lucide-react";
import { Check, Plus, Settings, Users } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Small } from "@/components/ui/typography";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useNetworks } from "@/hooks/use-networks";
import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-invitations";
import { apiClient } from "@/api/client";
import { Progress } from "@/components/ui/progress";
import type { Network } from "@/api/types";
import type { Network, Invitation } from "@/api/types";
function NetworkRow({
network,
onClick,
onSettingsClick,
}: {
network: Network;
onClick: () => void;
onSettingsClick: () => void;
}) {
const initials = network.name.slice(0, 2).toUpperCase();
const memberCount = network.humans.length;
@@ -22,7 +39,7 @@ function NetworkRow({
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
>
<Avatar>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
@@ -38,14 +55,142 @@ function NetworkRow({
</Small>
</div>
</div>
<span
role="button"
tabIndex={0}
className="text-muted-foreground hover:text-foreground opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onSettingsClick();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onSettingsClick();
}
}}
>
<Settings className="size-4" />
</span>
</button>
);
}
function InvitationRow({ invitation }: { invitation: Invitation }) {
const acceptInvitation = useAcceptInvitation();
const initials = invitation.network_name.slice(0, 2).toUpperCase();
const handleAccept = () => {
acceptInvitation.mutate(invitation.network_id, {
onSuccess: () => {
toast.success(`Joined ${invitation.network_name}`);
},
onError: (err) => {
toast.error(err.message || "Failed to accept invitation");
},
});
};
return (
<div className="flex w-full items-center gap-3 px-4 py-3">
<Avatar>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{invitation.network_name}
</p>
<Small className="text-muted-foreground">You've been invited</Small>
</div>
<Button
size="sm"
onClick={handleAccept}
disabled={acceptInvitation.isPending}
>
<Check className="mr-1 size-3.5" />
{acceptInvitation.isPending ? "Joining..." : "Accept"}
</Button>
</div>
);
}
function CreateNetworkDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [name, setName] = useState("");
const navigate = useNavigate();
const queryClient = useQueryClient();
const createNetwork = useMutation({
mutationFn: (networkName: string) =>
apiClient.createNetwork({ name: networkName }),
onSuccess: (network) => {
queryClient.invalidateQueries({ queryKey: ["networks"] });
toast.success(`Created ${network.name}`);
onOpenChange(false);
setName("");
navigate(`/${network.id}/settings`);
},
onError: (err) => {
toast.error(err.message || "Failed to create network");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
createNetwork.mutate(trimmed);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Network</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="py-4">
<Input
placeholder="Network name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={!name.trim() || createNetwork.isPending}
>
{createNetwork.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export default function NetworkSelector() {
const navigate = useNavigate();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const { data, isPending, error } = useNetworks();
const { data: networks, isPending, error } = useNetworks();
const { data: invitations } = useMyInvitations();
if (isPending) {
return <Progress />;
@@ -60,33 +205,96 @@ export default function NetworkSelector() {
);
}
if (data?.length === 0) {
const hasInvitations = invitations && invitations.length > 0;
const hasNetworks = networks && networks.length > 0;
if (!hasNetworks && !hasInvitations) {
return (
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-4 px-4 text-center">
<p className="text-muted-foreground">
You don't have access to any networks yet. Please email us to get
started.
You don't have access to any networks yet. Create one or ask your admin for an invite.
</p>
<a href="mailto:[email protected]" className="text-primary underline">
team@flowylabs.ai
</a>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-1 size-3.5" />
Create Network
</Button>
<CreateNetworkDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="py-1">
{data?.map((network, index) => (
<div key={network.id}>
<NetworkRow
network={network}
onClick={() => navigate(`/${network.id}`)}
/>
{index < data.length - 1 && <Separator className="mx-4" />}
<>
<ScrollArea className="h-full">
<div className="py-1">
{hasInvitations && (
<>
<div className="flex items-center gap-2 px-4 pb-1 pt-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wider">
Pending Invitations
</p>
<Badge variant="secondary" className="text-xs">
{invitations.length}
</Badge>
</div>
{invitations.map((inv, index) => (
<div key={`${inv.network_id}-${inv.email}`}>
<InvitationRow invitation={inv} />
{index < invitations.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
<Separator className="mx-4 mt-2" />
</>
)}
{hasNetworks && (
<>
{hasInvitations && (
<p className="text-muted-foreground px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wider">
Your Networks
</p>
)}
{networks.map((network, index) => (
<div key={network.id}>
<NetworkRow
network={network}
onClick={() => navigate(`/${network.id}`)}
onSettingsClick={() =>
navigate(`/${network.id}/settings`)
}
/>
{index < networks.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
</>
)}
<Separator className="mx-4 mt-2" />
<div className="px-4 py-3">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-1 size-3.5" />
New Network
</Button>
</div>
))}
</div>
</ScrollArea>
</div>
</ScrollArea>
<CreateNetworkDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</>
);
}