mirror of
https://github.com/d0zingcat/alert-message-center.git
synced 2026-05-22 07:26:51 +00:00
@@ -25,4 +25,4 @@
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"pino-pretty": "^13.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as lark from "@larksuiteoapi/node-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as lark from "@larksuiteoapi/node-sdk";
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
export interface UserAccessTokenData {
|
||||
@@ -72,7 +72,10 @@ export class FeishuClient {
|
||||
fileName: string,
|
||||
fileBuffer: Buffer,
|
||||
): Promise<string> {
|
||||
const tempPath = path.join(os.tmpdir(), `feishu_upload_${Date.now()}_${fileName}`);
|
||||
const tempPath = path.join(
|
||||
os.tmpdir(),
|
||||
`feishu_upload_${Date.now()}_${fileName}`,
|
||||
);
|
||||
try {
|
||||
fs.writeFileSync(tempPath, fileBuffer);
|
||||
const response = await this.client.im.file.create({
|
||||
@@ -85,7 +88,9 @@ export class FeishuClient {
|
||||
|
||||
if (!response || !response.file_key) {
|
||||
logger.error({ response }, "Feishu upload file error: no file_key");
|
||||
throw new Error("Failed to upload file to Feishu: no file_key returned");
|
||||
throw new Error(
|
||||
"Failed to upload file to Feishu: no file_key returned",
|
||||
);
|
||||
}
|
||||
|
||||
return response.file_key;
|
||||
@@ -115,7 +120,9 @@ export class FeishuClient {
|
||||
|
||||
if (!response || !response.image_key) {
|
||||
logger.error({ response }, "Feishu upload image error: no image_key");
|
||||
throw new Error("Failed to upload image to Feishu: no image_key returned");
|
||||
throw new Error(
|
||||
"Failed to upload image to Feishu: no image_key returned",
|
||||
);
|
||||
}
|
||||
|
||||
return response.image_key;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { type Context, Hono } from "hono";
|
||||
import { db } from "./db";
|
||||
import { alertLogs, alertTasks, topics, users } from "./db/schema";
|
||||
import { feishuClient } from "./feishu";
|
||||
@@ -15,11 +15,43 @@ interface Recipient {
|
||||
idType: FeishuReceiveIdType;
|
||||
}
|
||||
|
||||
interface Topic {
|
||||
slug: string;
|
||||
name: string;
|
||||
isGlobal: boolean;
|
||||
subscriptions?: { user: User }[];
|
||||
groupChats?: { id: string; name: string; chatId: string; status: string }[];
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
feishuUserId: string;
|
||||
}
|
||||
|
||||
interface WebhookBody {
|
||||
msg_type?: string;
|
||||
content?: any;
|
||||
card?: any;
|
||||
post?: any;
|
||||
image_key?: string;
|
||||
file_key?: string;
|
||||
audio_key?: string;
|
||||
sticker_key?: string;
|
||||
chat_id?: string;
|
||||
user_id?: string;
|
||||
uuid?: string;
|
||||
token?: string;
|
||||
file_type?: string;
|
||||
file_name?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const webhook = new Hono();
|
||||
|
||||
const getRequestBody = async (c: any) => {
|
||||
const getRequestBody = async (c: Context): Promise<WebhookBody> => {
|
||||
const contentType = c.req.header("Content-Type") || "";
|
||||
let body: Record<string, any>;
|
||||
let body: WebhookBody;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
@@ -57,10 +89,18 @@ const getRequestBody = async (c: any) => {
|
||||
}
|
||||
|
||||
// Proxy upload if files are present
|
||||
const file = Array.isArray(body.file) ? body.file[0] : body.file;
|
||||
const file = Array.isArray(body.file) ? (body.file[0] as unknown) : body.file;
|
||||
if (file instanceof File) {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const fileType = (body.file_type as any) || "stream";
|
||||
const fileType =
|
||||
(body.file_type as
|
||||
| "opus"
|
||||
| "mp4"
|
||||
| "pdf"
|
||||
| "doc"
|
||||
| "xls"
|
||||
| "ppt"
|
||||
| "stream") || "stream";
|
||||
const fileName = (body.file_name as string) || file.name;
|
||||
const fileKey = await feishuClient.uploadFile(fileType, fileName, buffer);
|
||||
body.file_key = fileKey;
|
||||
@@ -79,15 +119,15 @@ const getRequestBody = async (c: any) => {
|
||||
};
|
||||
|
||||
const dispatchAlert = async (
|
||||
c: any,
|
||||
topic: any,
|
||||
body: any,
|
||||
user: any | null,
|
||||
c: Context,
|
||||
topic: Topic,
|
||||
body: WebhookBody,
|
||||
user: User | null,
|
||||
) => {
|
||||
// 2. Collect recipients
|
||||
const userRecipients: Recipient[] = (topic.subscriptions || [])
|
||||
.map((sub: any) => sub.user)
|
||||
.map((u: any) => {
|
||||
const userRecipients: (Recipient | null)[] = (topic.subscriptions || [])
|
||||
.map((sub) => sub.user)
|
||||
.map((u) => {
|
||||
if (!u || !u.feishuUserId) return null;
|
||||
return {
|
||||
type: "user" as const,
|
||||
@@ -98,12 +138,15 @@ const dispatchAlert = async (
|
||||
? "open_id"
|
||||
: "user_id") as FeishuReceiveIdType,
|
||||
};
|
||||
})
|
||||
.filter((u: any): u is Recipient => u !== null);
|
||||
});
|
||||
|
||||
const validUserRecipients: Recipient[] = userRecipients.filter(
|
||||
(u): u is Recipient => u !== null,
|
||||
);
|
||||
|
||||
const groupRecipients: Recipient[] = (topic.groupChats || [])
|
||||
.filter((g: any) => g.status === "approved")
|
||||
.map((g: any) => ({
|
||||
.filter((g) => g.status === "approved")
|
||||
.map((g) => ({
|
||||
type: "group",
|
||||
id: g.id, // Binding ID
|
||||
name: g.name,
|
||||
@@ -111,7 +154,7 @@ const dispatchAlert = async (
|
||||
idType: "chat_id" as FeishuReceiveIdType,
|
||||
}));
|
||||
|
||||
const allRecipients: Recipient[] = [...userRecipients, ...groupRecipients];
|
||||
const allRecipients: Recipient[] = [...validUserRecipients, ...groupRecipients];
|
||||
|
||||
const [task] = await db
|
||||
.insert(alertTasks)
|
||||
@@ -121,7 +164,7 @@ const dispatchAlert = async (
|
||||
status: "processing",
|
||||
recipientCount: allRecipients.length,
|
||||
successCount: 0,
|
||||
payload: body,
|
||||
payload: body as Record<string, unknown>, // Cast to satisfy Drizzle jsonb type
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -152,7 +195,10 @@ const dispatchAlert = async (
|
||||
allRecipients.map(async (recipient) => {
|
||||
try {
|
||||
// Construct messages list
|
||||
const messagesToSend: { type: string; content: any }[] = [];
|
||||
const messagesToSend: {
|
||||
type: string;
|
||||
content: Record<string, unknown> | string;
|
||||
}[] = [];
|
||||
|
||||
// 1. Text content
|
||||
if (body.content) {
|
||||
@@ -288,6 +334,7 @@ const dispatchAlert = async (
|
||||
const recipient = allRecipients[index];
|
||||
if (r.status === "fulfilled") {
|
||||
const val = r.value as {
|
||||
recipientId: string;
|
||||
status: "sent" | "failed";
|
||||
error: string | null;
|
||||
};
|
||||
@@ -298,14 +345,13 @@ const dispatchAlert = async (
|
||||
status: val.status as "sent" | "failed",
|
||||
error: val.error,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
taskId: task.id,
|
||||
userId: recipient.type === "user" ? recipient.id : null,
|
||||
status: "failed" as const,
|
||||
error: r.status === "rejected" ? String(r.reason) : "Unknown error",
|
||||
};
|
||||
}
|
||||
return {
|
||||
taskId: task.id,
|
||||
userId: recipient.type === "user" ? recipient.id : null,
|
||||
status: "failed" as const,
|
||||
error: r.status === "rejected" ? String(r.reason) : "Unknown error",
|
||||
};
|
||||
});
|
||||
|
||||
if (logs.length > 0) {
|
||||
@@ -395,12 +441,12 @@ webhook.post("/:token/topic/:slug", async (c) => {
|
||||
return c.json({ error: "Topic not found" }, 404);
|
||||
}
|
||||
|
||||
let user: any = null;
|
||||
let user: User | null = null;
|
||||
if (!topic.isGlobal) {
|
||||
// 0. Find the User by Token
|
||||
user = await db.query.users.findFirst({
|
||||
user = (await db.query.users.findFirst({
|
||||
where: eq(users.personalToken, token),
|
||||
});
|
||||
})) || null;
|
||||
|
||||
if (!user) {
|
||||
logger.warn({ token }, "[Webhook] Invalid personal token");
|
||||
@@ -474,7 +520,10 @@ webhook.post("/:token/dm", async (c) => {
|
||||
// 2. Send Message
|
||||
(async () => {
|
||||
try {
|
||||
const messagesToSend: { type: string; content: any }[] = [];
|
||||
const messagesToSend: {
|
||||
type: string;
|
||||
content: Record<string, unknown> | string;
|
||||
}[] = [];
|
||||
|
||||
// Text content
|
||||
if (body.content) {
|
||||
|
||||
@@ -29,4 +29,4 @@
|
||||
"@types/node": "^20.0.0",
|
||||
"bun-types": "latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,194 +1,194 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
Send,
|
||||
X,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
Send,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface SendAlertFormProps {
|
||||
webhookUrl: string;
|
||||
onSuccess?: () => void;
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
webhookUrl: string;
|
||||
onSuccess?: () => void;
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function SendAlertForm({
|
||||
webhookUrl,
|
||||
onSuccess,
|
||||
placeholder = "Type your message here...",
|
||||
title = "Send Quick Alert",
|
||||
webhookUrl,
|
||||
onSuccess,
|
||||
placeholder = "Type your message here...",
|
||||
title = "Send Quick Alert",
|
||||
}: SendAlertFormProps) {
|
||||
const [content, setContent] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [status, setStatus] = useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [content, setContent] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [status, setStatus] = useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFile(e.target.files[0]);
|
||||
setStatus(null);
|
||||
}
|
||||
};
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files?.[0]) {
|
||||
setFile(e.target.files[0]);
|
||||
setStatus(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = () => {
|
||||
setFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
const removeFile = () => {
|
||||
setFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!content.trim() && !file) return;
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!content.trim() && !file) return;
|
||||
|
||||
setIsSending(true);
|
||||
setStatus(null);
|
||||
setIsSending(true);
|
||||
setStatus(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
if (content.trim()) {
|
||||
// We send content as a stringified JSON if it's elaborate,
|
||||
// but for "Pure Proxy", simple text is just a field.
|
||||
// Our backend getRequestBody handles both.
|
||||
formData.append("content", JSON.stringify({ text: content }));
|
||||
formData.append("msg_type", "text");
|
||||
}
|
||||
try {
|
||||
const formData = new FormData();
|
||||
if (content.trim()) {
|
||||
// We send content as a stringified JSON if it's elaborate,
|
||||
// but for "Pure Proxy", simple text is just a field.
|
||||
// Our backend getRequestBody handles both.
|
||||
formData.append("content", JSON.stringify({ text: content }));
|
||||
formData.append("msg_type", "text");
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
formData.append(isImage ? "image" : "file", file);
|
||||
if (!content.trim()) {
|
||||
formData.append("msg_type", isImage ? "image" : "file");
|
||||
}
|
||||
}
|
||||
if (file) {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
formData.append(isImage ? "image" : "file", file);
|
||||
if (!content.trim()) {
|
||||
formData.append("msg_type", isImage ? "image" : "file");
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setStatus({ type: "success", message: "Alert sent successfully!" });
|
||||
setContent("");
|
||||
setFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
onSuccess?.();
|
||||
setTimeout(() => setStatus(null), 3000);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: error.error || "Failed to send alert",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus({ type: "error", message: "Network error. Please try again." });
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
if (response.ok) {
|
||||
setStatus({ type: "success", message: "Alert sent successfully!" });
|
||||
setContent("");
|
||||
setFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
onSuccess?.();
|
||||
setTimeout(() => setStatus(null), 3000);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: error.error || "Failed to send alert",
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
setStatus({ type: "error", message: "Network error. Please try again." });
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-gray-700 uppercase tracking-tight">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-5">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
className="w-full min-h-[100px] p-3 text-sm text-gray-800 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all placeholder:text-gray-400 resize-none"
|
||||
placeholder={placeholder}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
disabled={isSending}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-gray-700 uppercase tracking-tight">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-5">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
className="w-full min-h-[100px] p-3 text-sm text-gray-800 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all placeholder:text-gray-400 resize-none"
|
||||
placeholder={placeholder}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
disabled={isSending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{file && (
|
||||
<div className="flex items-center justify-between p-2 px-3 bg-indigo-50 rounded-lg border border-indigo-100 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex items-center">
|
||||
<Paperclip className="w-4 h-4 text-indigo-500 mr-2" />
|
||||
<span className="text-xs font-medium text-indigo-700 truncate max-w-[200px]">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="ml-2 text-[10px] text-indigo-400">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeFile}
|
||||
className="text-indigo-400 hover:text-indigo-600 p-1 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 space-y-3">
|
||||
{file && (
|
||||
<div className="flex items-center justify-between p-2 px-3 bg-indigo-50 rounded-lg border border-indigo-100 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex items-center">
|
||||
<Paperclip className="w-4 h-4 text-indigo-500 mr-2" />
|
||||
<span className="text-xs font-medium text-indigo-700 truncate max-w-[200px]">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="ml-2 text-[10px] text-indigo-400">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeFile}
|
||||
className="text-indigo-400 hover:text-indigo-600 p-1 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="alert-file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="alert-file-upload"
|
||||
className="cursor-pointer inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors border border-gray-200"
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5 mr-1.5" />
|
||||
Attach File
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="alert-file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="alert-file-upload"
|
||||
className="cursor-pointer inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors border border-gray-200"
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5 mr-1.5" />
|
||||
Attach File
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSending || (!content.trim() && !file)}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-indigo-500/20"
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send Alert
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSending || (!content.trim() && !file)}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-indigo-500/20"
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send Alert
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div
|
||||
className={`mt-4 p-3 rounded-lg flex items-start animate-in fade-in zoom-in-95 ${status.type === "success"
|
||||
? "bg-green-50 text-green-700 border border-green-100"
|
||||
: "bg-red-50 text-red-700 border border-red-100"
|
||||
}`}
|
||||
>
|
||||
{status.type === "success" ? (
|
||||
<CheckCircle2 className="w-4 h-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-xs font-medium">{status.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
{status && (
|
||||
<div
|
||||
className={`mt-4 p-3 rounded-lg flex items-start animate-in fade-in zoom-in-95 ${status.type === "success"
|
||||
? "bg-green-50 text-green-700 border border-green-100"
|
||||
: "bg-red-50 text-red-700 border border-red-100"
|
||||
}`}
|
||||
>
|
||||
{status.type === "success" ? (
|
||||
<CheckCircle2 className="w-4 h-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-xs font-medium">{status.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import {
|
||||
Globe,
|
||||
Lock,
|
||||
Plus,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
User,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Users,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import GroupBindingsModal from "../components/GroupBindingsModal";
|
||||
@@ -228,20 +228,20 @@ export default function TopicsView() {
|
||||
const updatedSubs = isSubscribed
|
||||
? t.subscriptions.filter((s) => s.userId !== userId)
|
||||
: [
|
||||
...t.subscriptions,
|
||||
{
|
||||
userId,
|
||||
user:
|
||||
users.find((u) => u.id === userId) ||
|
||||
(currentUser
|
||||
? {
|
||||
id: currentUser.id,
|
||||
name: currentUser.name,
|
||||
email: currentUser.email,
|
||||
}
|
||||
: { id: "unknown", name: "Unknown" }),
|
||||
},
|
||||
];
|
||||
...t.subscriptions,
|
||||
{
|
||||
userId,
|
||||
user:
|
||||
users.find((u) => u.id === userId) ||
|
||||
(currentUser
|
||||
? {
|
||||
id: currentUser.id,
|
||||
name: currentUser.name,
|
||||
email: currentUser.email,
|
||||
}
|
||||
: { id: "unknown", name: "Unknown" }),
|
||||
},
|
||||
];
|
||||
return { ...t, subscriptions: updatedSubs };
|
||||
}
|
||||
return t;
|
||||
@@ -253,20 +253,20 @@ export default function TopicsView() {
|
||||
const updatedSubs = isSubscribed
|
||||
? selectedTopic.subscriptions.filter((s) => s.userId !== userId)
|
||||
: [
|
||||
...selectedTopic.subscriptions,
|
||||
{
|
||||
userId,
|
||||
user:
|
||||
users.find((u) => u.id === userId) ||
|
||||
(currentUser
|
||||
? {
|
||||
id: currentUser.id,
|
||||
name: currentUser.name,
|
||||
email: currentUser.email,
|
||||
}
|
||||
: { id: "unknown", name: "Unknown" }),
|
||||
},
|
||||
];
|
||||
...selectedTopic.subscriptions,
|
||||
{
|
||||
userId,
|
||||
user:
|
||||
users.find((u) => u.id === userId) ||
|
||||
(currentUser
|
||||
? {
|
||||
id: currentUser.id,
|
||||
name: currentUser.name,
|
||||
email: currentUser.email,
|
||||
}
|
||||
: { id: "unknown", name: "Unknown" }),
|
||||
},
|
||||
];
|
||||
setSelectedTopic({ ...selectedTopic, subscriptions: updatedSubs });
|
||||
}
|
||||
|
||||
@@ -421,7 +421,9 @@ export default function TopicsView() {
|
||||
className="inline-flex items-center text-sm font-bold text-white hover:text-indigo-200 transition-colors"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{showPersonalSend ? "Hide Send Form" : "Send Quick Message to Myself"}
|
||||
{showPersonalSend
|
||||
? "Hide Send Form"
|
||||
: "Send Quick Message to Myself"}
|
||||
</button>
|
||||
|
||||
{showPersonalSend && (
|
||||
@@ -479,10 +481,11 @@ export default function TopicsView() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelfSubscribe(topic)}
|
||||
className={`inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md ${isSubscribedToTopic(topic)
|
||||
? "border-red-300 text-red-700 bg-red-50 hover:bg-red-100"
|
||||
: "border-green-300 text-green-700 bg-green-50 hover:bg-green-100"
|
||||
}`}
|
||||
className={`inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md ${
|
||||
isSubscribedToTopic(topic)
|
||||
? "border-red-300 text-red-700 bg-red-50 hover:bg-red-100"
|
||||
: "border-green-300 text-green-700 bg-green-50 hover:bg-green-100"
|
||||
}`}
|
||||
>
|
||||
{isSubscribedToTopic(topic) ? (
|
||||
<>
|
||||
@@ -595,13 +598,16 @@ export default function TopicsView() {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActiveSendTopic(
|
||||
activeSendTopic === topic.id ? null : topic.id,
|
||||
activeSendTopic === topic.id
|
||||
? null
|
||||
: topic.id,
|
||||
)
|
||||
}
|
||||
className={`flex items-center text-xs font-semibold px-2 py-0.5 rounded border transition-all hover:shadow hover:translate-y-[-1px] ${activeSendTopic === topic.id
|
||||
? "bg-indigo-600 text-white border-indigo-700 shadow-inner"
|
||||
: "bg-white text-indigo-600 border-gray-200 shadow-sm"
|
||||
}`}
|
||||
className={`flex items-center text-xs font-semibold px-2 py-0.5 rounded border transition-all hover:shadow hover:translate-y-[-1px] ${
|
||||
activeSendTopic === topic.id
|
||||
? "bg-indigo-600 text-white border-indigo-700 shadow-inner"
|
||||
: "bg-white text-indigo-600 border-gray-200 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<Send className="w-3 h-3 mr-1" />
|
||||
{activeSendTopic === topic.id
|
||||
@@ -703,72 +709,71 @@ export default function TopicsView() {
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul >
|
||||
</div >
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{
|
||||
myRequests.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">My Requests</h3>
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{myRequests.map((req) => (
|
||||
<li key={req.id}>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-indigo-600 truncate">
|
||||
{req.name}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${req.status === "approved"
|
||||
{myRequests.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">My Requests</h3>
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{myRequests.map((req) => (
|
||||
<li key={req.id}>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-indigo-600 truncate">
|
||||
{req.name}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
req.status === "approved"
|
||||
? "bg-green-100 text-green-800"
|
||||
: req.status === "rejected"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{req.status === "approved"
|
||||
? "Approved"
|
||||
: req.status === "rejected"
|
||||
? "Rejected"
|
||||
: "Pending"}
|
||||
}`}
|
||||
>
|
||||
{req.status === "approved"
|
||||
? "Approved"
|
||||
: req.status === "rejected"
|
||||
? "Rejected"
|
||||
: "Pending"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<p>
|
||||
Slug: <span className="font-mono">{req.slug}</span>
|
||||
</p>
|
||||
{req.description && (
|
||||
<p className="mt-1">{req.description}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Requested on:{" "}
|
||||
{req.createdAt
|
||||
? new Date(req.createdAt).toLocaleDateString()
|
||||
: "Unknown"}
|
||||
{req.approver && (
|
||||
<span className="ml-2">
|
||||
| Approved by: {req.approver.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<p>
|
||||
Slug: <span className="font-mono">{req.slug}</span>
|
||||
</p>
|
||||
{req.description && (
|
||||
<p className="mt-1">{req.description}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Requested on:{" "}
|
||||
{req.createdAt
|
||||
? new Date(req.createdAt).toLocaleDateString()
|
||||
: "Unknown"}
|
||||
{req.approver && (
|
||||
<span className="ml-2">
|
||||
| Approved by: {req.approver.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
< Modal
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={currentUser?.isAdmin ? "Add New Topic" : "Request New Topic"}
|
||||
@@ -850,10 +855,11 @@ export default function TopicsView() {
|
||||
</div>
|
||||
{submitStatus && (
|
||||
<div
|
||||
className={`p-3 rounded-md text-sm ${submitStatus.type === "success"
|
||||
? "bg-green-50 text-green-800"
|
||||
: "bg-red-50 text-red-800"
|
||||
}`}
|
||||
className={`p-3 rounded-md text-sm ${
|
||||
submitStatus.type === "success"
|
||||
? "bg-green-50 text-green-800"
|
||||
: "bg-red-50 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{submitStatus.message}
|
||||
</div>
|
||||
@@ -874,7 +880,7 @@ export default function TopicsView() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal >
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={isSubModalOpen}
|
||||
@@ -934,16 +940,14 @@ export default function TopicsView() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{
|
||||
selectedTopic && (
|
||||
<GroupBindingsModal
|
||||
isOpen={isGroupModalOpen}
|
||||
onClose={() => setIsGroupModalOpen(false)}
|
||||
topicId={selectedTopic.id}
|
||||
topicName={selectedTopic.name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
{selectedTopic && (
|
||||
<GroupBindingsModal
|
||||
isOpen={isGroupModalOpen}
|
||||
onClose={() => setIsGroupModalOpen(false)}
|
||||
topicId={selectedTopic.id}
|
||||
topicName={selectedTopic.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user