feat: Enable Feishu file and image uploads via webhook, supporting multipart requests and introducing a new alert sending UI component.

This commit is contained in:
2026-02-09 14:57:59 +08:00
parent 5dc5c330ca
commit 0bdd4f4213
11 changed files with 750 additions and 281 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@alertmessagecenter/server",
"version": "1.4.0",
"version": "1.5.0",
"scripts": {
"dev": "bun run --env-file .env --watch src/index.ts",
"start": "bun run src/index.ts",
@@ -25,4 +25,4 @@
"drizzle-kit": "^0.31.8",
"pino-pretty": "^13.1.3"
}
}
}

View File

@@ -1,4 +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 { logger } from "./lib/logger";
export interface UserAccessTokenData {
@@ -64,6 +67,70 @@ export class FeishuClient {
}
}
async uploadFile(
fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream",
fileName: string,
fileBuffer: Buffer,
): Promise<string> {
const tempPath = path.join(os.tmpdir(), `feishu_upload_${Date.now()}_${fileName}`);
try {
fs.writeFileSync(tempPath, fileBuffer);
const response = await this.client.im.file.create({
data: {
file_type: fileType,
file_name: fileName,
file: fs.createReadStream(tempPath),
},
});
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");
}
return response.file_key;
} catch (e) {
console.error("Feishu upload file SDK error:", e);
throw e;
} finally {
// Clean up after a short delay to ensure stream is processed
setTimeout(() => {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}, 10000);
}
}
async uploadImage(imageBuffer: Buffer): Promise<string> {
const tempPath = path.join(os.tmpdir(), `feishu_upload_img_${Date.now()}`);
try {
fs.writeFileSync(tempPath, imageBuffer);
const response = await this.client.im.image.create({
data: {
image_type: "message",
image: fs.createReadStream(tempPath),
},
});
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");
}
return response.image_key;
} catch (e) {
console.error("Feishu upload image SDK error:", e);
throw e;
} finally {
setTimeout(() => {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}, 10000);
}
}
async getUserAccessToken(
code: string,
): Promise<UserAccessTokenData | undefined> {

View File

@@ -17,6 +17,67 @@ interface Recipient {
const webhook = new Hono();
const getRequestBody = async (c: any) => {
const contentType = c.req.header("Content-Type") || "";
let body: Record<string, any>;
if (contentType.includes("application/json")) {
try {
body = await c.req.json();
} catch (_e) {
throw new Error("Invalid JSON body");
}
} else if (
contentType.includes("multipart/form-data") ||
contentType.includes("application/x-www-form-urlencoded")
) {
body = await c.req.parseBody();
// Handle stringified JSON fields in multipart
const complexFields = ["content", "card", "post"];
for (const field of complexFields) {
if (typeof body[field] === "string") {
try {
body[field] = JSON.parse(body[field]);
} catch {
// Not JSON, leave as is
}
}
}
} else {
// Fallback: try parsing as JSON
try {
const text = await c.req.text();
if (!text || text.trim() === "") {
throw new Error("Empty body");
}
body = JSON.parse(text);
} catch (_e) {
throw new Error("Invalid or missing request body");
}
}
// Proxy upload if files are present
const file = Array.isArray(body.file) ? body.file[0] : body.file;
if (file instanceof File) {
const buffer = Buffer.from(await file.arrayBuffer());
const fileType = (body.file_type as any) || "stream";
const fileName = (body.file_name as string) || file.name;
const fileKey = await feishuClient.uploadFile(fileType, fileName, buffer);
body.file_key = fileKey;
delete body.file;
}
const image = Array.isArray(body.image) ? body.image[0] : body.image;
if (image instanceof File) {
const buffer = Buffer.from(await image.arrayBuffer());
const imageKey = await feishuClient.uploadImage(buffer);
body.image_key = imageKey;
delete body.image;
}
return { ...body };
};
const dispatchAlert = async (
c: any,
topic: any,
@@ -90,65 +151,91 @@ const dispatchAlert = async (
Promise.allSettled(
allRecipients.map(async (recipient) => {
try {
// Construct message content
let msgType = body.msg_type || "text";
let content = body.content;
// Construct messages list
const messagesToSend: { type: string; content: any }[] = [];
// 1. Text content
if (body.content) {
const content = JSON.parse(JSON.stringify(body.content));
const msgType = body.msg_type || "text";
// Add prefix for text
if (msgType === "text" && content.text) {
content.text = `[${topic.name}]\n${content.text}`;
}
// Add prefix for interactive
if (msgType === "interactive" && content.header) {
content.header.title.content = `[${topic.name}] ${content.header.title.content}`;
}
messagesToSend.push({ type: msgType, content });
}
// 2. Image
if (body.image_key) {
messagesToSend.push({
type: "image",
content: { image_key: body.image_key },
});
}
// 3. File
if (body.file_key) {
messagesToSend.push({
type: "file",
content: { file_key: body.file_key },
});
}
// 4. Fallback for no explicit content/attachment keys
if (messagesToSend.length === 0) {
let msgType = body.msg_type || "text";
let content = body.content;
// Special handling for incomplete payloads (missing 'content')
if (!content) {
// 1. Special case: Unwrap 'card' if provided (convenience for user)
if (body.card) {
content = body.card;
if (!msgType) msgType = "interactive";
} else {
// 2. Pass-through strategy: Use rest of body as content
// Exclude keys that are definitely not part of content
const { msg_type, token, ...rest } = body;
content = rest;
// 3. Infer msgType if missing
if (!msgType) {
if (body.post) msgType = "post";
else if (body.file_key && body.image_key)
msgType = "media"; // Media has both
else if (body.file_key && body.image_key) msgType = "media";
else if (body.image_key) msgType = "image";
else if (body.file_key) msgType = "file";
else if (body.audio_key) msgType = "audio";
else if (body.sticker_key) msgType = "sticker";
else if (body.chat_id) msgType = "share_chat";
else if (body.user_id) msgType = "share_user";
else if (body.header || body.elements)
msgType = "interactive"; // Unwrapped card
else if (body.header || body.elements) msgType = "interactive";
else {
// Fallback to text
msgType = "text";
// For text, content must be simple or stringified
content = { text: JSON.stringify(body, null, 2) };
}
}
}
} else {
// Deep clone content to avoid mutating shared object for parallel requests if we modify it
content = JSON.parse(JSON.stringify(content));
// Add prefix for inferred types
if (msgType === "text" && content.text) {
content.text = `[${topic.name}]\n${content.text}`;
}
messagesToSend.push({ type: msgType, content });
}
// Add metadata
if (msgType === "text" && content.text) {
content.text = `[${topic.name}]\n${content.text}`;
}
if (msgType === "interactive" && content.header) {
content.header.title.content = `[${topic.name}] ${content.header.title.content}`;
let successCount = 0;
for (const msg of messagesToSend) {
await feishuClient.sendMessage(
recipient.feishuId,
recipient.idType,
msg.type,
msg.content,
body.uuid,
);
successCount++;
}
await feishuClient.sendMessage(
recipient.feishuId,
recipient.idType,
msgType,
content,
body.uuid,
);
return { recipientId: recipient.id, status: "sent", error: null };
return {
recipientId: recipient.id,
status: successCount > 0 ? "sent" : "failed",
error: null,
};
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
@@ -277,13 +364,9 @@ webhook.post("/topic/:slug", async (c) => {
// biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON
let body: Record<string, any>;
try {
const rawBody = await c.req.text();
if (!rawBody || rawBody.trim() === "") {
return c.json({ error: "Empty body" }, 400);
}
body = JSON.parse(rawBody);
} catch (_e) {
return c.json({ error: "Invalid JSON body" }, 400);
body = await getRequestBody(c);
} catch (e) {
return c.json({ error: (e as Error).message }, 400);
}
return dispatchAlert(c, topic, body, null);
@@ -328,13 +411,9 @@ webhook.post("/:token/topic/:slug", async (c) => {
// biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON
let body: Record<string, any>;
try {
const rawBody = await c.req.text();
if (!rawBody || rawBody.trim() === "") {
return c.json({ error: "Empty body" }, 400);
}
body = JSON.parse(rawBody);
} catch (_e) {
return c.json({ error: "Invalid JSON body" }, 400);
body = await getRequestBody(c);
} catch (e) {
return c.json({ error: (e as Error).message }, 400);
}
return dispatchAlert(c, topic, body, user);
@@ -374,13 +453,9 @@ webhook.post("/:token/dm", async (c) => {
// biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON
let body: Record<string, any>;
try {
const rawBody = await c.req.text();
if (!rawBody || rawBody.trim() === "") {
return c.json({ error: "Empty body" }, 400);
}
body = JSON.parse(rawBody);
} catch (_e) {
return c.json({ error: "Invalid JSON body" }, 400);
body = await getRequestBody(c);
} catch (e) {
return c.json({ error: (e as Error).message }, 400);
}
// 1. Create Task (topicSlug is null for DM)
@@ -399,107 +474,100 @@ webhook.post("/:token/dm", async (c) => {
// 2. Send Message
(async () => {
try {
let msgType = body.msg_type || "text";
let content = body.content;
const messagesToSend: { type: string; content: any }[] = [];
// Text content
if (body.content) {
const content = JSON.parse(JSON.stringify(body.content));
messagesToSend.push({ type: body.msg_type || "text", content });
}
// Image
if (body.image_key) {
messagesToSend.push({
type: "image",
content: { image_key: body.image_key },
});
}
// File
if (body.file_key) {
messagesToSend.push({
type: "file",
content: { file_key: body.file_key },
});
}
// Fallback: if no explicit content/attachment keys, check other fields
if (messagesToSend.length === 0) {
let msgType = body.msg_type || "text";
let content = body.content;
// Special handling for incomplete payloads (missing 'content')
if (!content) {
// 1. Interactive / Card
if ((msgType === "interactive" || !msgType) && body.card) {
msgType = "interactive";
content = body.card;
}
// 2. Post (Rich Text)
else if ((msgType === "post" || !msgType) && body.post) {
} else if ((msgType === "post" || !msgType) && body.post) {
msgType = "post";
content = { post: body.post };
}
// 3. Image
else if ((msgType === "image" || !msgType) && body.image_key) {
msgType = "image";
content = { image_key: body.image_key };
}
// 4. File
else if ((msgType === "file" || !msgType) && body.file_key) {
msgType = "file";
content = { file_key: body.file_key };
}
// 5. Audio
else if ((msgType === "audio" || !msgType) && body.audio_key) {
} else if ((msgType === "audio" || !msgType) && body.audio_key) {
msgType = "audio";
content = { file_key: body.audio_key };
}
// 6. Media (Video)
else if (
} else if (
(msgType === "media" || !msgType) &&
body.file_key &&
body.image_key
) {
msgType = "media";
content = { file_key: body.file_key, image_key: body.image_key };
}
// 7. Sticker
else if ((msgType === "sticker" || !msgType) && body.sticker_key) {
} else if ((msgType === "sticker" || !msgType) && body.sticker_key) {
msgType = "sticker";
content = { file_key: body.sticker_key };
}
// 8. Share Chat
else if ((msgType === "share_chat" || !msgType) && body.chat_id) {
} else if ((msgType === "share_chat" || !msgType) && body.chat_id) {
msgType = "share_chat";
content = { chat_id: body.chat_id };
}
// 9. Share User
else if ((msgType === "share_user" || !msgType) && body.user_id) {
} else if ((msgType === "share_user" || !msgType) && body.user_id) {
msgType = "share_user";
content = { user_id: body.user_id };
}
// Fallback
else {
} else {
const { msg_type, token, ...rest } = body;
content = rest;
if (!msgType || msgType === "text") {
msgType = "text";
content = { text: JSON.stringify(body, null, 2) };
content = { text: JSON.stringify(rest, null, 2) };
}
}
} else {
// Deep clone content to avoid mutating shared object for parallel requests if we modify it
content = JSON.parse(JSON.stringify(content));
messagesToSend.push({ type: msgType, content });
}
// Add metadata
if (msgType === "text" && content.text) {
content.text = `[Direct Message]\n${content.text}`;
}
if (msgType === "interactive" && content.header) {
content.header.title.content = `[DM] ${content.header.title.content}`;
let totalSuccess = 0;
for (const msg of messagesToSend) {
await feishuClient.sendMessage(
user.feishuUserId,
"open_id",
msg.type,
msg.content,
body.uuid,
);
totalSuccess++;
}
const idType = user.feishuUserId.startsWith("ou_")
? "open_id"
: "user_id";
const uuid = body.uuid || crypto.randomUUID();
await feishuClient.sendMessage(
user.feishuUserId,
idType,
msgType,
content,
uuid,
);
const finalStatus = totalSuccess > 0 ? "completed" : "failed";
// Update Task
await db
.update(alertTasks)
.set({
status: "completed",
successCount: 1,
status: finalStatus,
successCount: totalSuccess === messagesToSend.length ? 1 : 0, // In DM case, 1 recipient
updatedAt: new Date(),
})
.where(eq(alertTasks.id, task.id));
// Insert Log
// Log Sent
await db.insert(alertLogs).values({
taskId: task.id,
userId: user.id,
status: "sent" as const,
status: totalSuccess > 0 ? "sent" : "failed",
});
} catch (error: unknown) {
const errorMessage =

View File

@@ -1,6 +1,6 @@
{
"name": "@alertmessagecenter/web",
"version": "1.4.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "bun run --env-file .env vite",
@@ -29,4 +29,4 @@
"@types/node": "^20.0.0",
"bun-types": "latest"
}
}
}

View File

@@ -0,0 +1,194 @@
import {
AlertCircle,
CheckCircle2,
Loader2,
Paperclip,
Send,
X,
} from "lucide-react";
import { useRef, useState } from "react";
interface SendAlertFormProps {
webhookUrl: string;
onSuccess?: () => void;
placeholder?: string;
title?: string;
}
export default function SendAlertForm({
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 handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
setStatus(null);
}
};
const removeFile = () => {
setFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim() && !file) return;
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");
}
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,
});
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>
<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>
<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>
);
}

View File

@@ -10,10 +10,12 @@ import {
UserMinus,
UserPlus,
Users,
Send,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import GroupBindingsModal from "../components/GroupBindingsModal";
import Modal from "../components/Modal";
import SendAlertForm from "../components/SendAlertForm";
import { useAuth } from "../contexts/AuthContext";
import { client } from "../lib/client";
@@ -64,6 +66,8 @@ export default function TopicsView() {
type: "success" | "error";
message: string;
} | null>(null);
const [showPersonalSend, setShowPersonalSend] = useState(false);
const [activeSendTopic, setActiveSendTopic] = useState<string | null>(null);
const fetchTopics = useCallback(async () => {
setLoading(true);
@@ -224,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;
@@ -249,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 });
}
@@ -398,17 +402,38 @@ export default function TopicsView() {
</div>
</div>
<div className="flex items-center gap-4 bg-white/10 p-4 rounded-xl backdrop-blur-sm border border-white/10">
<div className="bg-indigo-500/30 p-2.5 rounded-lg border border-white/20">
<Copy className="w-6 h-6" />
</div>
<div className="text-sm">
<div className="font-bold">Direct Push</div>
<div className="text-indigo-200 text-xs">
Always delivered to you
<div className="text-right">
<p className="text-xs text-indigo-300 uppercase tracking-widest font-bold mb-1">
Status
</p>
<div className="flex items-center text-white font-semibold">
<div className="w-2 h-2 bg-green-400 rounded-full mr-2 animate-pulse" />
Active
</div>
</div>
</div>
</div>
<div className="mt-6 pt-6 border-t border-white/10">
<button
type="button"
onClick={() => setShowPersonalSend(!showPersonalSend)}
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"}
</button>
{showPersonalSend && (
<div className="mt-4 text-gray-900 max-w-2xl">
<SendAlertForm
webhookUrl={getDmWebhookUrl()}
title="Send to Personal Inbox"
placeholder="What would you like to notify yourself about?"
/>
</div>
)}
</div>
</div>
</div>
@@ -436,7 +461,7 @@ export default function TopicsView() {
<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 flex items-center">
<div className="text-sm font-medium text-indigo-600 truncate flex items-center">
{topic.name}
{topic.isGlobal ? (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-purple-100 text-purple-700 border border-purple-200 uppercase tracking-tight">
@@ -449,16 +474,15 @@ export default function TopicsView() {
Private
</span>
)}
</p>
</div>
<div className="flex items-center space-x-2">
<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) ? (
<>
@@ -498,8 +522,8 @@ export default function TopicsView() {
)}
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex flex-col">
<div className="mt-2">
<div className="flex flex-col w-full">
<p className="flex items-center text-sm text-gray-500">
Slug:{" "}
<span className="font-mono ml-1 bg-gray-100 px-1 rounded">
@@ -533,6 +557,7 @@ export default function TopicsView() {
</div>
)}
</div>
{currentUser && (
<div
className={`mt-3 ${topic.isGlobal ? "grid grid-cols-1 md:grid-cols-2 gap-4" : "space-y-3"}`}
@@ -543,28 +568,47 @@ export default function TopicsView() {
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">
Your Personal Webhook
</span>
<button
type="button"
onClick={() =>
copyToClipboard(
getWebhookUrl(topic.slug),
topic.id,
)
}
className="text-indigo-600 hover:text-indigo-800 flex items-center text-xs font-semibold bg-white px-2 py-0.5 rounded border border-gray-200 shadow-sm transition-all hover:shadow hover:translate-y-[-1px]"
>
{copiedId === topic.id ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy URL
</>
)}
</button>
<div className="flex items-center space-x-2">
<button
type="button"
onClick={() =>
copyToClipboard(
getWebhookUrl(topic.slug),
topic.id,
)
}
className="text-indigo-600 hover:text-indigo-800 flex items-center text-xs font-semibold bg-white px-2 py-0.5 rounded border border-gray-200 shadow-sm transition-all hover:shadow hover:translate-y-[-1px]"
>
{copiedId === topic.id ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy URL
</>
)}
</button>
<button
type="button"
onClick={() =>
setActiveSendTopic(
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"
}`}
>
<Send className="w-3 h-3 mr-1" />
{activeSendTopic === topic.id
? "Close"
: "Send Message"}
</button>
</div>
</div>
<div className="text-[11px] font-mono text-gray-600 break-all select-all bg-white/60 p-1.5 rounded border border-gray-100/50 leading-relaxed">
{getWebhookUrl(topic.slug)}
@@ -624,6 +668,17 @@ export default function TopicsView() {
)}
</div>
)}
{activeSendTopic === topic.id && (
<div className="mt-4 animate-in fade-in slide-in-from-top-2 max-w-2xl bg-white p-4 rounded-lg border border-indigo-100 shadow-md">
<SendAlertForm
webhookUrl={getWebhookUrl(topic.slug)}
title={`Send Message to ${topic.name}`}
placeholder={`Enter alert content for ${topic.name}...`}
onSuccess={() => setActiveSendTopic(null)}
/>
</div>
)}
</div>
</div>
</div>
@@ -648,71 +703,72 @@ 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"}
</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}
}`}
>
{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>
<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>
</div>
</div>
</div>
</div>
</li>
))}
</ul>
</li>
))}
</ul>
</div>
</div>
</div>
)}
)
}
<Modal
< Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={currentUser?.isAdmin ? "Add New Topic" : "Request New Topic"}
@@ -794,11 +850,10 @@ 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>
@@ -819,7 +874,7 @@ export default function TopicsView() {
</button>
</div>
</form>
</Modal>
</Modal >
<Modal
isOpen={isSubModalOpen}
@@ -879,14 +934,16 @@ 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 >
);
}