mirror of
https://github.com/d0zingcat/alert-message-center.git
synced 2026-05-13 15:09:19 +00:00
feat: add group name search
Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
@@ -270,12 +270,21 @@ api.delete("/topics/:topicId/subscribe/:userId", requireAuth, async (c) => {
|
||||
|
||||
// Get list of known groups (for selection)
|
||||
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
|
||||
const groups = await db
|
||||
.select()
|
||||
.from(knownGroupChats)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(knownGroupChats.lastActiveAt))
|
||||
.limit(50);
|
||||
.limit(limit);
|
||||
return c.json(groups);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MessageCircle, Plus, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { MessageCircle, Plus, Search, Trash2, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { client } from "../lib/client";
|
||||
import Modal from "./Modal";
|
||||
|
||||
@@ -29,16 +29,21 @@ export default function GroupBindingsModal({
|
||||
topicId,
|
||||
topicName,
|
||||
}: GroupBindingsModalProps) {
|
||||
// const { user } = useAuth(); // Unused
|
||||
const [bindings, setBindings] = useState<GroupBinding[]>([]);
|
||||
const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedChatId, setSelectedChatId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [status, setStatus] = useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const fetchBindings = useCallback(async () => {
|
||||
try {
|
||||
const res = await client.api.topics[":id"].groups.$get(
|
||||
@@ -58,17 +63,25 @@ export default function GroupBindingsModal({
|
||||
}
|
||||
}, [topicId]);
|
||||
|
||||
const fetchKnownGroups = useCallback(async () => {
|
||||
const fetchKnownGroups = useCallback(async (q?: string) => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await client.api.groups.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const res = await client.api.groups.$get(
|
||||
{
|
||||
query: q ? { q } : undefined,
|
||||
},
|
||||
{
|
||||
init: { credentials: "include" },
|
||||
},
|
||||
);
|
||||
const data = (await res.json()) as KnownGroup[];
|
||||
if (Array.isArray(data)) {
|
||||
setKnownGroups(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -78,24 +91,60 @@ export default function GroupBindingsModal({
|
||||
fetchKnownGroups();
|
||||
setStatus(null);
|
||||
setSelectedChatId("");
|
||||
setSearchQuery("");
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}, [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 () => {
|
||||
if (!selectedChatId) return;
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
|
||||
const group = knownGroups.find((g) => g.chatId === selectedChatId);
|
||||
if (!group) return;
|
||||
const groupName = searchQuery;
|
||||
|
||||
try {
|
||||
const res = await client.api.topics[":id"].groups.$post(
|
||||
{
|
||||
param: { id: topicId },
|
||||
json: {
|
||||
chatId: group.chatId,
|
||||
name: group.name,
|
||||
chatId: selectedChatId,
|
||||
name: groupName,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -114,12 +163,12 @@ export default function GroupBindingsModal({
|
||||
});
|
||||
fetchBindings();
|
||||
setSelectedChatId("");
|
||||
setSearchQuery("");
|
||||
} else {
|
||||
await res.json(); // Consume body
|
||||
await res.json();
|
||||
setStatus({ type: "error", message: "Failed to bind group" });
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore error
|
||||
setStatus({ type: "error", message: "An error occurred" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -147,7 +196,6 @@ export default function GroupBindingsModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Filter out groups that are already bound
|
||||
const availableGroups = knownGroups.filter(
|
||||
(kg) => !bindings.some((b) => b.chatId === kg.chatId),
|
||||
);
|
||||
@@ -160,31 +208,41 @@ export default function GroupBindingsModal({
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<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
|
||||
</h4>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
No groups bound to this topic yet.
|
||||
</p>
|
||||
<div className="text-center py-6 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200">
|
||||
<p className="text-sm text-gray-400">
|
||||
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) => (
|
||||
<li
|
||||
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">
|
||||
<MessageCircle className="w-4 h-4 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-700 mr-2">
|
||||
{binding.name}
|
||||
</span>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<MessageCircle className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<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
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium ${binding.status === "approved"
|
||||
? "bg-green-100 text-green-800"
|
||||
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-700"
|
||||
: binding.status === "rejected"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{binding.status}
|
||||
@@ -193,10 +251,10 @@ export default function GroupBindingsModal({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Trash2 className="w-4.5 h-4.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -204,45 +262,115 @@ export default function GroupBindingsModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">
|
||||
<div className="bg-indigo-50/50 p-5 rounded-xl border border-indigo-100 shadow-sm">
|
||||
<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
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Select a group where the Feishu Bot has been added. If your group is
|
||||
not listed, try removing and re-adding the bot to the group.
|
||||
<p className="text-xs text-gray-500 mb-4 leading-relaxed">
|
||||
Search and select a group where the <strong>Alert Messenger</strong> bot
|
||||
has been added.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
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"
|
||||
value={selectedChatId}
|
||||
onChange={(e) => setSelectedChatId(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Select a group...</option>
|
||||
{availableGroups.map((group) => (
|
||||
<option key={group.chatId} value={group.chatId}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
{isSearching ? (
|
||||
<div className="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
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"
|
||||
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
|
||||
type="button"
|
||||
onClick={handleBind}
|
||||
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" />
|
||||
Add
|
||||
{loading ? (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${status.type === "success" ? "text-green-600" : "text-red-600"}`}
|
||||
<div
|
||||
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}
|
||||
</p>
|
||||
<div className="text-sm font-medium">{status.message}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user