feat: add group name search

Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
2026-01-17 14:20:02 +08:00
parent 8cee2197cb
commit 2ec8a9e7f7
2 changed files with 195 additions and 58 deletions

View File

@@ -270,12 +270,21 @@ api.delete("/topics/:topicId/subscribe/:userId", requireAuth, async (c) => {
// Get list of known groups (for selection) // Get list of known groups (for selection)
api.get("/groups", requireAuth, async (c) => { api.get("/groups", requireAuth, async (c) => {
const query = c.req.query("q")?.trim();
const limit = Math.min(Number(c.req.query("limit") || 100), 200);
let whereClause = undefined;
if (query) {
whereClause = sql`${knownGroupChats.name} ilike ${`%${query}%`}`;
}
// Return recent active groups // Return recent active groups
const groups = await db const groups = await db
.select() .select()
.from(knownGroupChats) .from(knownGroupChats)
.where(whereClause)
.orderBy(desc(knownGroupChats.lastActiveAt)) .orderBy(desc(knownGroupChats.lastActiveAt))
.limit(50); .limit(limit);
return c.json(groups); return c.json(groups);
}); });

View File

@@ -1,5 +1,5 @@
import { MessageCircle, Plus, Trash2 } from "lucide-react"; import { MessageCircle, Plus, Search, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { client } from "../lib/client"; import { client } from "../lib/client";
import Modal from "./Modal"; import Modal from "./Modal";
@@ -29,16 +29,21 @@ export default function GroupBindingsModal({
topicId, topicId,
topicName, topicName,
}: GroupBindingsModalProps) { }: GroupBindingsModalProps) {
// const { user } = useAuth(); // Unused
const [bindings, setBindings] = useState<GroupBinding[]>([]); const [bindings, setBindings] = useState<GroupBinding[]>([]);
const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]); const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [selectedChatId, setSelectedChatId] = useState(""); const [selectedChatId, setSelectedChatId] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [status, setStatus] = useState<{ const [status, setStatus] = useState<{
type: "success" | "error"; type: "success" | "error";
message: string; message: string;
} | null>(null); } | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchBindings = useCallback(async () => { const fetchBindings = useCallback(async () => {
try { try {
const res = await client.api.topics[":id"].groups.$get( const res = await client.api.topics[":id"].groups.$get(
@@ -58,17 +63,25 @@ export default function GroupBindingsModal({
} }
}, [topicId]); }, [topicId]);
const fetchKnownGroups = useCallback(async () => { const fetchKnownGroups = useCallback(async (q?: string) => {
setIsSearching(true);
try { try {
const res = await client.api.groups.$get(undefined, { const res = await client.api.groups.$get(
init: { credentials: "include" }, {
}); query: q ? { q } : undefined,
},
{
init: { credentials: "include" },
},
);
const data = (await res.json()) as KnownGroup[]; const data = (await res.json()) as KnownGroup[];
if (Array.isArray(data)) { if (Array.isArray(data)) {
setKnownGroups(data); setKnownGroups(data);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally {
setIsSearching(false);
} }
}, []); }, []);
@@ -78,24 +91,60 @@ export default function GroupBindingsModal({
fetchKnownGroups(); fetchKnownGroups();
setStatus(null); setStatus(null);
setSelectedChatId(""); setSelectedChatId("");
setSearchQuery("");
setShowDropdown(false);
} }
}, [isOpen, topicId, fetchBindings, fetchKnownGroups]); }, [isOpen, topicId, fetchBindings, fetchKnownGroups]);
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setShowDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchQuery(value);
setSelectedChatId("");
setShowDropdown(true);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
fetchKnownGroups(value);
}, 300);
};
const handleSelectGroup = (group: KnownGroup) => {
setSelectedChatId(group.chatId);
setSearchQuery(group.name);
setShowDropdown(false);
};
const handleBind = async () => { const handleBind = async () => {
if (!selectedChatId) return; if (!selectedChatId) return;
setLoading(true); setLoading(true);
setStatus(null); setStatus(null);
const group = knownGroups.find((g) => g.chatId === selectedChatId); const groupName = searchQuery;
if (!group) return;
try { try {
const res = await client.api.topics[":id"].groups.$post( const res = await client.api.topics[":id"].groups.$post(
{ {
param: { id: topicId }, param: { id: topicId },
json: { json: {
chatId: group.chatId, chatId: selectedChatId,
name: group.name, name: groupName,
}, },
}, },
{ {
@@ -114,12 +163,12 @@ export default function GroupBindingsModal({
}); });
fetchBindings(); fetchBindings();
setSelectedChatId(""); setSelectedChatId("");
setSearchQuery("");
} else { } else {
await res.json(); // Consume body await res.json();
setStatus({ type: "error", message: "Failed to bind group" }); setStatus({ type: "error", message: "Failed to bind group" });
} }
} catch (_) { } catch (_) {
// Ignore error
setStatus({ type: "error", message: "An error occurred" }); setStatus({ type: "error", message: "An error occurred" });
} finally { } finally {
setLoading(false); setLoading(false);
@@ -147,7 +196,6 @@ export default function GroupBindingsModal({
} }
}; };
// Filter out groups that are already bound
const availableGroups = knownGroups.filter( const availableGroups = knownGroups.filter(
(kg) => !bindings.some((b) => b.chatId === kg.chatId), (kg) => !bindings.some((b) => b.chatId === kg.chatId),
); );
@@ -160,31 +208,41 @@ export default function GroupBindingsModal({
> >
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h4 className="text-sm font-medium text-gray-900 mb-2"> <h4 className="text-sm font-semibold text-gray-900 mb-3 flex items-center">
<MessageCircle className="w-4 h-4 mr-2 text-indigo-500" />
Bound Groups Bound Groups
</h4> </h4>
{bindings.length === 0 ? ( {bindings.length === 0 ? (
<p className="text-sm text-gray-500 italic"> <div className="text-center py-6 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200">
No groups bound to this topic yet. <p className="text-sm text-gray-400">
</p> No groups bound to this topic yet.
</p>
</div>
) : ( ) : (
<ul className="divide-y divide-gray-200 border rounded-md"> <ul className="divide-y divide-gray-100 border rounded-lg overflow-hidden bg-white shadow-sm">
{bindings.map((binding) => ( {bindings.map((binding) => (
<li <li
key={binding.id} key={binding.id}
className="flex justify-between items-center p-3" className="flex justify-between items-center p-3 hover:bg-gray-50 transition-colors"
> >
<div className="flex items-center flex-1"> <div className="flex items-center flex-1 min-w-0">
<MessageCircle className="w-4 h-4 text-gray-400 mr-2" /> <div className="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center mr-3 flex-shrink-0">
<span className="text-sm text-gray-700 mr-2"> <MessageCircle className="w-4 h-4 text-indigo-600" />
{binding.name} </div>
</span> <div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-gray-900 truncate">
{binding.name}
</span>
<span className="text-[10px] text-gray-400 font-mono truncate">
{binding.chatId}
</span>
</div>
<span <span
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium ${binding.status === "approved" className={`ml-3 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wider uppercase ${binding.status === "approved"
? "bg-green-100 text-green-800" ? "bg-green-100 text-green-700"
: binding.status === "rejected" : binding.status === "rejected"
? "bg-red-100 text-red-800" ? "bg-red-100 text-red-700"
: "bg-yellow-100 text-yellow-800" : "bg-amber-100 text-amber-700"
}`} }`}
> >
{binding.status} {binding.status}
@@ -193,10 +251,10 @@ export default function GroupBindingsModal({
<button <button
type="button" type="button"
onClick={() => handleUnbind(binding.id)} onClick={() => handleUnbind(binding.id)}
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50" className="ml-2 text-gray-400 hover:text-red-500 p-1.5 rounded-md hover:bg-red-50 transition-colors"
title="Remove binding" title="Remove binding"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4.5 h-4.5" />
</button> </button>
</li> </li>
))} ))}
@@ -204,45 +262,115 @@ export default function GroupBindingsModal({
)} )}
</div> </div>
<div className="bg-gray-50 p-4 rounded-md border border-gray-200"> <div className="bg-indigo-50/50 p-5 rounded-xl border border-indigo-100 shadow-sm">
<h4 className="text-sm font-medium text-gray-900 mb-3"> <h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center">
<Plus className="w-4 h-4 mr-2 text-indigo-500" />
Add Group Binding Add Group Binding
</h4> </h4>
<p className="text-xs text-gray-500 mb-3"> <p className="text-xs text-gray-500 mb-4 leading-relaxed">
Select a group where the Feishu Bot has been added. If your group is Search and select a group where the <strong>Alert Messenger</strong> bot
not listed, try removing and re-adding the bot to the group. has been added.
</p> </p>
<div className="flex gap-2"> <div className="flex flex-col gap-3">
<select <div className="relative" ref={dropdownRef}>
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900" <div className="relative">
value={selectedChatId} <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
onChange={(e) => setSelectedChatId(e.target.value)} {isSearching ? (
disabled={loading} <div className="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
> ) : (
<option value="">Select a group...</option> <Search className="h-4 w-4 text-gray-400" />
{availableGroups.map((group) => ( )}
<option key={group.chatId} value={group.chatId}> </div>
{group.name} <input
</option> type="text"
))} className="block w-full pl-10 pr-10 py-2.5 bg-white border border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm text-gray-900 placeholder-gray-400 transition-all"
</select> placeholder="Search for a group name..."
value={searchQuery}
onChange={handleSearchChange}
onFocus={() => knownGroups.length > 0 && setShowDropdown(true)}
disabled={loading}
/>
{searchQuery && (
<button
type="button"
onClick={() => {
setSearchQuery("");
setSelectedChatId("");
fetchKnownGroups();
}}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{showDropdown && (
<div className="absolute z-10 mt-1 w-full bg-white shadow-xl max-h-60 rounded-lg py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm border border-gray-100">
{availableGroups.length > 0 ? (
availableGroups.map((group) => (
<button
key={group.chatId}
type="button"
className="w-full text-left px-4 py-2.5 hover:bg-indigo-50 cursor-pointer flex items-center group transition-colors"
onClick={() => handleSelectGroup(group)}
>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate group-hover:text-indigo-700">
{group.name}
</div>
<div className="text-xs text-gray-400 font-mono truncate">
{group.chatId}
</div>
</div>
{selectedChatId === group.chatId && (
<Plus className="w-4 h-4 text-indigo-600 ml-2" />
)}
</button>
))
) : (
<div className="px-4 py-6 text-center text-gray-500">
<p className="text-sm font-medium">No results found</p>
<p className="text-xs mt-1">
Try a different search term or check if the bot is in
the group.
</p>
</div>
)}
</div>
)}
</div>
<button <button
type="button" type="button"
onClick={handleBind} onClick={handleBind}
disabled={!selectedChatId || loading} disabled={!selectedChatId || loading}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed" className="w-full inline-flex items-center justify-center px-4 py-2.5 border border-transparent text-sm font-semibold rounded-lg shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform active:scale-[0.98]"
> >
<Plus className="w-4 h-4 mr-1" /> {loading ? (
Add <>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
Processing...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Bind This Group
</>
)}
</button> </button>
</div> </div>
{status && ( {status && (
<p <div
className={`mt-2 text-xs ${status.type === "success" ? "text-green-600" : "text-red-600"}`} className={`mt-4 p-3 rounded-lg flex items-start gap-2 ${status.type === "success"
? "bg-green-50 text-green-700 border border-green-100"
: "bg-red-50 text-red-700 border border-red-100"
}`}
> >
{status.message} <div className="text-sm font-medium">{status.message}</div>
</p> </div>
)} )}
</div> </div>
</div> </div>