Skip to content

Commit ca4d3f4

Browse files
authored
Merge pull request #84 from indrazm/channels-members
feat(channels): add channel member management functionality
2 parents b81f03c + 1f38fe4 commit ca4d3f4

File tree

14 files changed

+338
-7
lines changed

14 files changed

+338
-7
lines changed

apps/admin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "admin",
33
"private": true,
4-
"version": "0.0.13",
4+
"version": "0.0.14",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import type { ChannelMember, User } from "@opencircle/core";
2+
import { Input } from "@opencircle/ui";
3+
import { Loader2, Search } from "lucide-react";
4+
import { ScrollArea } from "radix-ui";
5+
import { useMemo, useState } from "react";
6+
import { useUsers } from "../../user/hooks/useUsers";
7+
import { useAddChannelMember } from "../hooks/useAddChannelMember";
8+
import { useChannelMembers } from "../hooks/useChannelMembers";
9+
import { useRemoveChannelMember } from "../hooks/useRemoveChannelMember";
10+
11+
interface ChannelMembersManagerProps {
12+
channelId: string;
13+
}
14+
15+
export const ChannelMembersManager = ({
16+
channelId,
17+
}: ChannelMembersManagerProps) => {
18+
const { users, isUsersLoading } = useUsers(0, 1000);
19+
const { members, isMembersLoading } = useChannelMembers(channelId);
20+
const { addMember, isAdding } = useAddChannelMember();
21+
const { removeMember, isRemoving } = useRemoveChannelMember();
22+
23+
const [pendingUserId, setPendingUserId] = useState<string | null>(null);
24+
const [availableSearch, setAvailableSearch] = useState("");
25+
const [memberSearch, setMemberSearch] = useState("");
26+
27+
const memberUserIds = useMemo(
28+
() => new Set(members.map((m: ChannelMember) => m.user_id)),
29+
[members],
30+
);
31+
32+
const availableUsers = useMemo(() => {
33+
const filtered = users.filter((user: User) => !memberUserIds.has(user.id));
34+
if (!availableSearch.trim()) return filtered;
35+
const search = availableSearch.toLowerCase();
36+
return filtered.filter(
37+
(user: User) =>
38+
user.name?.toLowerCase().includes(search) ||
39+
user.username.toLowerCase().includes(search),
40+
);
41+
}, [users, memberUserIds, availableSearch]);
42+
43+
const memberUsers = useMemo(() => {
44+
const filtered = users.filter((user: User) => memberUserIds.has(user.id));
45+
if (!memberSearch.trim()) return filtered;
46+
const search = memberSearch.toLowerCase();
47+
return filtered.filter(
48+
(user: User) =>
49+
user.name?.toLowerCase().includes(search) ||
50+
user.username.toLowerCase().includes(search),
51+
);
52+
}, [users, memberUserIds, memberSearch]);
53+
54+
const handleAddMember = (userId: string) => {
55+
setPendingUserId(userId);
56+
addMember(
57+
{ channelId, userId },
58+
{
59+
onSettled: () => setPendingUserId(null),
60+
},
61+
);
62+
};
63+
64+
const handleRemoveMember = (userId: string) => {
65+
setPendingUserId(userId);
66+
removeMember(
67+
{ channelId, userId },
68+
{
69+
onSettled: () => setPendingUserId(null),
70+
},
71+
);
72+
};
73+
74+
if (isUsersLoading || isMembersLoading) {
75+
return (
76+
<div className="flex items-center justify-center py-8">
77+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
78+
</div>
79+
);
80+
}
81+
82+
const isProcessing = isAdding || isRemoving;
83+
84+
return (
85+
<div className="grid grid-cols-2 gap-4">
86+
{/* Available Users (Left) */}
87+
<div className="rounded-lg border border-border bg-background">
88+
<div className="border-border border-b bg-muted/40 px-4 py-3">
89+
<h4 className="font-medium text-sm">
90+
Available Users ({availableUsers.length})
91+
</h4>
92+
</div>
93+
<div className="border-border border-b p-2">
94+
<div className="relative">
95+
<Search className="-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" />
96+
<Input
97+
placeholder="Search by name or username..."
98+
value={availableSearch}
99+
onChange={(e) => setAvailableSearch(e.target.value)}
100+
className="pl-9"
101+
/>
102+
</div>
103+
</div>
104+
<ScrollArea.Root className="h-64">
105+
<ScrollArea.Viewport className="h-full w-full p-2">
106+
{availableUsers.length === 0 ? (
107+
<p className="py-4 text-center text-muted-foreground text-sm">
108+
No available users
109+
</p>
110+
) : (
111+
<div className="space-y-1">
112+
{availableUsers.map((user: User) => (
113+
<button
114+
type="button"
115+
key={user.id}
116+
onClick={() => handleAddMember(user.id)}
117+
disabled={isProcessing}
118+
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
119+
>
120+
{pendingUserId === user.id ? (
121+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
122+
) : (
123+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary font-medium text-xs">
124+
{user.name?.[0]?.toUpperCase() ||
125+
user.username[0].toUpperCase()}
126+
</div>
127+
)}
128+
<div className="min-w-0 flex-1">
129+
<p className="truncate font-medium">
130+
{user.name || user.username}
131+
</p>
132+
<p className="truncate text-muted-foreground text-xs">
133+
@{user.username}
134+
</p>
135+
</div>
136+
</button>
137+
))}
138+
</div>
139+
)}
140+
</ScrollArea.Viewport>
141+
<ScrollArea.Scrollbar
142+
className="flex touch-none select-none bg-muted/50 p-0.5 transition-colors duration-150 ease-out data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col"
143+
orientation="vertical"
144+
>
145+
<ScrollArea.Thumb className="before:-translate-x-1/2 before:-translate-y-1/2 relative flex-1 rounded-full bg-border before:absolute before:top-1/2 before:left-1/2 before:h-full before:min-h-11 before:w-full before:min-w-11" />
146+
</ScrollArea.Scrollbar>
147+
</ScrollArea.Root>
148+
</div>
149+
150+
{/* Channel Members (Right) */}
151+
<div className="rounded-lg border border-border bg-background">
152+
<div className="border-border border-b bg-muted/40 px-4 py-3">
153+
<h4 className="font-medium text-sm">
154+
Channel Members ({memberUsers.length})
155+
</h4>
156+
</div>
157+
<div className="border-border border-b p-2">
158+
<div className="relative">
159+
<Search className="-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" />
160+
<Input
161+
placeholder="Search by name or username..."
162+
value={memberSearch}
163+
onChange={(e) => setMemberSearch(e.target.value)}
164+
className="pl-9"
165+
/>
166+
</div>
167+
</div>
168+
<ScrollArea.Root className="h-64">
169+
<ScrollArea.Viewport className="h-full w-full p-2">
170+
{memberUsers.length === 0 ? (
171+
<p className="py-4 text-center text-muted-foreground text-sm">
172+
No members yet
173+
</p>
174+
) : (
175+
<div className="space-y-1">
176+
{memberUsers.map((user: User) => (
177+
<button
178+
type="button"
179+
key={user.id}
180+
onClick={() => handleRemoveMember(user.id)}
181+
disabled={isProcessing}
182+
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
183+
>
184+
{pendingUserId === user.id ? (
185+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
186+
) : (
187+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary font-medium text-xs">
188+
{user.name?.[0]?.toUpperCase() ||
189+
user.username[0].toUpperCase()}
190+
</div>
191+
)}
192+
<div className="min-w-0 flex-1">
193+
<p className="truncate font-medium">
194+
{user.name || user.username}
195+
</p>
196+
<p className="truncate text-muted-foreground text-xs">
197+
@{user.username}
198+
</p>
199+
</div>
200+
</button>
201+
))}
202+
</div>
203+
)}
204+
</ScrollArea.Viewport>
205+
<ScrollArea.Scrollbar
206+
className="flex touch-none select-none bg-muted/50 p-0.5 transition-colors duration-150 ease-out data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col"
207+
orientation="vertical"
208+
>
209+
<ScrollArea.Thumb className="before:-translate-x-1/2 before:-translate-y-1/2 relative flex-1 rounded-full bg-border before:absolute before:top-1/2 before:left-1/2 before:h-full before:min-h-11 before:w-full before:min-w-11" />
210+
</ScrollArea.Scrollbar>
211+
</ScrollArea.Root>
212+
</div>
213+
</div>
214+
);
215+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
import { api } from "../../../utils/api";
3+
4+
export const useAddChannelMember = () => {
5+
const queryClient = useQueryClient();
6+
7+
const { mutate: addMember, isPending } = useMutation({
8+
mutationFn: async (data: { channelId: string; userId: string }) => {
9+
const response = await api.channels.addMember(
10+
data.channelId,
11+
data.userId,
12+
);
13+
return response;
14+
},
15+
onSuccess: (_, variables) => {
16+
queryClient.invalidateQueries({
17+
queryKey: ["channelMembers", variables.channelId],
18+
});
19+
},
20+
});
21+
22+
return {
23+
addMember,
24+
isAdding: isPending,
25+
};
26+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { api } from "../../../utils/api";
3+
4+
export const useChannelMembers = (channelId: string) => {
5+
const { data, isLoading, isError, error } = useQuery({
6+
queryKey: ["channelMembers", channelId],
7+
queryFn: async () => {
8+
const response = await api.channels.getMembers(channelId);
9+
return response;
10+
},
11+
enabled: !!channelId,
12+
});
13+
14+
return {
15+
members: data || [],
16+
isMembersLoading: isLoading,
17+
isMembersError: isError,
18+
membersError: error,
19+
};
20+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
import { api } from "../../../utils/api";
3+
4+
export const useRemoveChannelMember = () => {
5+
const queryClient = useQueryClient();
6+
7+
const { mutate: removeMember, isPending } = useMutation({
8+
mutationFn: async (data: { channelId: string; userId: string }) => {
9+
const response = await api.channels.removeMember(
10+
data.channelId,
11+
data.userId,
12+
);
13+
return response;
14+
},
15+
onSuccess: (_, variables) => {
16+
queryClient.invalidateQueries({
17+
queryKey: ["channelMembers", variables.channelId],
18+
});
19+
},
20+
});
21+
22+
return {
23+
removeMember,
24+
isRemoving: isPending,
25+
};
26+
};

apps/admin/src/routes/_dashboardLayout/channels/edit.$id.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ChannelCreate } from "@opencircle/core";
22
import { createFileRoute, useRouter } from "@tanstack/react-router";
33
import { METADATA } from "../../../constants/metadata";
44
import { ChannelForm } from "../../../features/channels/components/channelForm";
5+
import { ChannelMembersManager } from "../../../features/channels/components/channelMembersManager";
56
import { useChannel } from "../../../features/channels/hooks/useChannel";
67
import { useUpdateChannel } from "../../../features/channels/hooks/useUpdateChannel";
78

@@ -51,8 +52,12 @@ function RouteComponent() {
5152
return <div>Loading...</div>;
5253
}
5354

55+
const isPrivateChannel = channel.type === "private";
56+
5457
return (
55-
<div className="mx-auto max-w-2xl space-y-6">
58+
<div
59+
className={`mx-auto space-y-6 ${isPrivateChannel ? "max-w-6xl" : "max-w-2xl"}`}
60+
>
5661
<div className="flex items-center justify-between">
5762
<h1 className="font-bold text-3xl">Edit Channel</h1>
5863
</div>
@@ -71,6 +76,13 @@ function RouteComponent() {
7176
submitLabel="Save Changes"
7277
/>
7378
</div>
79+
80+
{isPrivateChannel && (
81+
<div className="rounded-lg border border-border bg-background p-6 shadow-sm">
82+
<h2 className="mb-4 font-semibold text-lg">Manage Members</h2>
83+
<ChannelMembersManager channelId={id} />
84+
</div>
85+
)}
7486
</div>
7587
);
7688
}

apps/platform/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "platform",
33
"private": true,
4-
"version": "0.0.13",
4+
"version": "0.0.14",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

docs/www/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "www",
3-
"version": "0.0.13",
3+
"version": "0.0.14",
44
"type": "module",
55
"private": true,
66
"scripts": {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencircle",
3-
"version": "0.0.13",
3+
"version": "0.0.14",
44
"description": "",
55
"main": "index.js",
66
"scripts": {

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opencircle/core",
3-
"version": "0.0.13",
3+
"version": "0.0.14",
44
"description": "",
55
"main": "dist/index.js",
66
"types": "src/index.ts",

0 commit comments

Comments
 (0)