diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d59eddf..22de4c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Build and Push Docker Images +name: CI on: push: @@ -11,7 +11,27 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: - build-and-push: + lint: + name: Lint Check + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run Biome Check + run: bun x @biomejs/biome ci . + + build: + name: Build & Push Docker + needs: lint runs-on: ubuntu-latest permissions: contents: read @@ -22,6 +42,7 @@ jobs: uses: actions/checkout@v4 - name: Log in to the Container registry + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -33,7 +54,7 @@ jobs: with: context: . file: Dockerfile - push: true + push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a5db873..57c690d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 本文件的格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 并且本项目遵循 [语义化版本 (Semantic Versioning)](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。 + +## [1.2.2] - 2026-01-14 + +### 变更 +- **Linting**: 强化了 Biome 配置,启用了更严格的 `a11y` (可访问性), `suspicious` (可疑代码), `style` (代码规范) 和 `correctness` (正确性) 检查规则。 +- **配置**: 配置 `noUnknownAtRules` 规则以忽略 Tailwind CSS 特有的 At-rules。 +- **CI/CD**: 集成 Biome 检查到 GitHub Actions 工作流,确保在所有 Pull Request 中强制执行代码规范检查。 + +### 修复 +- **Web 可访问性**: 为所有按钮添加了显式的 `type="button"` 以符合规范。 +- **语义化/ARAI**: 修正了 `Modal` 背景的交互逻辑,将非语义化的 `div` 替换为 ` - - - - ) - } + if (!user) { + return ( +
+
+
+ +

+ Alert Message Center +

+

+ Please sign in with Feishu to continue +

+ +
+
+
+ ); + } - return ( -
- -
- {activeTab === 'topics' && } - {activeTab === 'users' && user.isAdmin && } - {activeTab === 'admin' && user.isAdmin && } -
-
- ) +
+ {activeTab === "topics" && } + {activeTab === "users" && user.isAdmin && } + {activeTab === "admin" && user.isAdmin && } +
+ + ); } -export default App +export default App; diff --git a/apps/web/src/components/GroupBindingsModal.tsx b/apps/web/src/components/GroupBindingsModal.tsx index f61421e..bb138aa 100644 --- a/apps/web/src/components/GroupBindingsModal.tsx +++ b/apps/web/src/components/GroupBindingsModal.tsx @@ -1,196 +1,229 @@ -import { useState, useEffect } from 'react'; -import { Trash2, Plus, MessageCircle } from 'lucide-react'; -import Modal from './Modal'; -import { client } from '../lib/client'; +import { MessageCircle, Plus, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { client } from "../lib/client"; +import Modal from "./Modal"; interface GroupBinding { - id: string; - chatId: string; - name: string; + id: string; + chatId: string; + name: string; } interface KnownGroup { - chatId: string; - name: string; - lastActiveAt: string; + chatId: string; + name: string; + lastActiveAt: string; } interface GroupBindingsModalProps { - isOpen: boolean; - onClose: () => void; - topicId: string; - topicName: string; + isOpen: boolean; + onClose: () => void; + topicId: string; + topicName: string; } -export default function GroupBindingsModal({ isOpen, onClose, topicId, topicName }: GroupBindingsModalProps) { - // const { user } = useAuth(); // Unused - const [bindings, setBindings] = useState([]); - const [knownGroups, setKnownGroups] = useState([]); - const [selectedChatId, setSelectedChatId] = useState(''); - const [loading, setLoading] = useState(false); - const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null); +export default function GroupBindingsModal({ + isOpen, + onClose, + topicId, + topicName, +}: GroupBindingsModalProps) { + // const { user } = useAuth(); // Unused + const [bindings, setBindings] = useState([]); + const [knownGroups, setKnownGroups] = useState([]); + const [selectedChatId, setSelectedChatId] = useState(""); + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); - useEffect(() => { - if (isOpen && topicId) { - fetchBindings(); - fetchKnownGroups(); - setStatus(null); - setSelectedChatId(''); - } - }, [isOpen, topicId]); + const fetchBindings = useCallback(async () => { + try { + const res = await client.api.topics[":id"].groups.$get( + { + param: { id: topicId }, + }, + { + init: { credentials: "include" }, + }, + ); + const data = await res.json(); + setBindings(data as any); + } catch (err) { + console.error(err); + } + }, [topicId]); - const fetchBindings = async () => { - try { - const res = await client.api.topics[':id'].groups.$get({ - param: { id: topicId } - }, { - init: { credentials: 'include' } - }); - const data = await res.json(); - setBindings(data as any); - } catch (err) { - console.error(err); - } - }; + const fetchKnownGroups = useCallback(async () => { + try { + const res = await client.api.groups.$get(undefined, { + init: { credentials: "include" }, + }); + const data = await res.json(); + setKnownGroups(data as any); + } catch (err) { + console.error(err); + } + }, []); - const fetchKnownGroups = async () => { - try { - const res = await client.api.groups.$get(undefined, { - init: { credentials: 'include' } - }); - const data = await res.json(); - // Only verify uniqueness if needed, but here we just list what server returns - setKnownGroups(data as any); - } catch (err) { - console.error(err); - } - }; + useEffect(() => { + if (isOpen && topicId) { + fetchBindings(); + fetchKnownGroups(); + setStatus(null); + setSelectedChatId(""); + } + }, [isOpen, topicId, fetchBindings, fetchKnownGroups]); - const handleBind = async () => { - if (!selectedChatId) return; - setLoading(true); - setStatus(null); + const handleBind = async () => { + if (!selectedChatId) return; + setLoading(true); + setStatus(null); - const group = knownGroups.find(g => g.chatId === selectedChatId); - if (!group) return; + const group = knownGroups.find((g) => g.chatId === selectedChatId); + if (!group) return; - try { - const res = await client.api.topics[':id'].groups.$post({ - param: { id: topicId }, - json: { - chatId: group.chatId, - name: group.name, - } - }, { - init: { credentials: 'include' } - }); + try { + const res = await client.api.topics[":id"].groups.$post( + { + param: { id: topicId }, + json: { + chatId: group.chatId, + name: group.name, + }, + }, + { + init: { credentials: "include" }, + }, + ); - if (res.ok) { - setStatus({ type: 'success', message: 'Group bound successfully!' }); - fetchBindings(); - setSelectedChatId(''); - } else { - await res.json(); // Consume body - setStatus({ type: 'error', message: 'Failed to bind group' }); - } - } catch (_) { // Ignore error - setStatus({ type: 'error', message: 'An error occurred' }); - } finally { - setLoading(false); - } - }; + if (res.ok) { + setStatus({ type: "success", message: "Group bound successfully!" }); + fetchBindings(); + setSelectedChatId(""); + } else { + await res.json(); // Consume body + setStatus({ type: "error", message: "Failed to bind group" }); + } + } catch (_) { + // Ignore error + setStatus({ type: "error", message: "An error occurred" }); + } finally { + setLoading(false); + } + }; - const handleUnbind = async (bindingId: string) => { - if (!confirm('Are you sure you want to remove this group binding?')) return; + const handleUnbind = async (bindingId: string) => { + if (!confirm("Are you sure you want to remove this group binding?")) return; - try { - const res = await client.api.topics[':id'].groups[':bindingId'].$delete({ - param: { id: topicId, bindingId } - }, { - init: { credentials: 'include' } - }); + try { + const res = await client.api.topics[":id"].groups[":bindingId"].$delete( + { + param: { id: topicId, bindingId }, + }, + { + init: { credentials: "include" }, + }, + ); - if (res.ok) { - setBindings(prev => prev.filter(b => b.id !== bindingId)); - } - } catch (err) { - console.error(err); - } - }; + if (res.ok) { + setBindings((prev) => prev.filter((b) => b.id !== bindingId)); + } + } catch (err) { + console.error(err); + } + }; - // Filter out groups that are already bound - const availableGroups = knownGroups.filter( - kg => !bindings.some(b => b.chatId === kg.chatId) - ); + // Filter out groups that are already bound + const availableGroups = knownGroups.filter( + (kg) => !bindings.some((b) => b.chatId === kg.chatId), + ); - return ( - -
-
-

Bound Groups

- {bindings.length === 0 ? ( -

No groups bound to this topic yet.

- ) : ( -
    - {bindings.map(binding => ( -
  • -
    - - {binding.name} -
    - -
  • - ))} -
- )} -
+ return ( + +
+
+

+ Bound Groups +

+ {bindings.length === 0 ? ( +

+ No groups bound to this topic yet. +

+ ) : ( +
    + {bindings.map((binding) => ( +
  • +
    + + + {binding.name} + +
    + +
  • + ))} +
+ )} +
-
-

Add Group Binding

-

- 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. -

+
+

+ Add Group Binding +

+

+ 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. +

-
- - -
- {status && ( -

- {status.message} -

- )} -
-
- - ); +
+ + +
+ {status && ( +

+ {status.message} +

+ )} +
+
+
+ ); } diff --git a/apps/web/src/components/Modal.tsx b/apps/web/src/components/Modal.tsx index b58e338..da980a9 100644 --- a/apps/web/src/components/Modal.tsx +++ b/apps/web/src/components/Modal.tsx @@ -1,44 +1,62 @@ -import React from 'react'; -import { X } from 'lucide-react'; +import { X } from "lucide-react"; +import type React from "react"; interface ModalProps { - isOpen: boolean; - onClose: () => void; - title: string; - children: React.ReactNode; + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; } -export default function Modal({ isOpen, onClose, title, children }: ModalProps) { - if (!isOpen) return null; +export default function Modal({ + isOpen, + onClose, + title, + children, +}: ModalProps) { + if (!isOpen) return null; - return ( -
-
- + return ( +
+
+ - + -
-
-
- - -
-
- {children} -
-
-
-
-
- ); +
+
+
+ + +
+
{children}
+
+
+
+
+ ); } diff --git a/apps/web/src/contexts/AuthContext.tsx b/apps/web/src/contexts/AuthContext.tsx index 2f74b8b..db948ee 100644 --- a/apps/web/src/contexts/AuthContext.tsx +++ b/apps/web/src/contexts/AuthContext.tsx @@ -1,85 +1,92 @@ -import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; -import { client } from '../lib/client'; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { client } from "../lib/client"; interface User { - id: string; - name: string; - email: string | null; - isAdmin: boolean; - personalToken: string; + id: string; + name: string; + email: string | null; + isAdmin: boolean; + personalToken: string; } interface AuthContextType { - user: User | null; - loading: boolean; - login: () => void; - logout: () => void; - checkAuth: () => Promise; + user: User | null; + loading: boolean; + login: () => void; + logout: () => void; + checkAuth: () => Promise; } const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); - const checkAuth = useCallback(async () => { - try { - const res = await client.api.auth.me.$get(undefined, { - init: { credentials: 'include' } - }); - if (res.ok) { - const data = await res.json(); - setUser(data.user); - } else { - setUser(null); - } - } catch (error) { - console.error('Auth check failed:', error); - setUser(null); - } finally { - setLoading(false); - } - }, []); + const checkAuth = useCallback(async () => { + try { + const res = await client.api.auth.me.$get(undefined, { + init: { credentials: "include" }, + }); + if (res.ok) { + const data = await res.json(); + setUser(data.user); + } else { + setUser(null); + } + } catch (error) { + console.error("Auth check failed:", error); + setUser(null); + } finally { + setLoading(false); + } + }, []); - useEffect(() => { - checkAuth(); - }, [checkAuth]); + useEffect(() => { + checkAuth(); + }, [checkAuth]); - const login = useCallback(async () => { - try { - const res = await client.api.auth['login-url'].$get(undefined, { - init: { credentials: 'include' } - }); - const data = await res.json(); - window.location.href = data.loginUrl; - } catch (error) { - console.error('Login failed:', error); - } - }, []); + const login = useCallback(async () => { + try { + const res = await client.api.auth["login-url"].$get(undefined, { + init: { credentials: "include" }, + }); + const data = await res.json(); + window.location.href = data.loginUrl; + } catch (error) { + console.error("Login failed:", error); + } + }, []); - const logout = useCallback(async () => { - try { - await client.api.auth.logout.$post(undefined, { - init: { credentials: 'include' } - }); - setUser(null); - } catch (error) { - console.error('Logout failed:', error); - } - }, []); + const logout = useCallback(async () => { + try { + await client.api.auth.logout.$post(undefined, { + init: { credentials: "include" }, + }); + setUser(null); + } catch (error) { + console.error("Logout failed:", error); + } + }, []); - return ( - - {children} - - ); + return ( + + {children} + + ); } export function useAuth() { - const context = useContext(AuthContext); - if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 483323e..07d07f9 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -3,12 +3,18 @@ @tailwind utilities; @layer utilities { - .animate-fade-in { - animation: fadeIn 0.5s ease-out forwards; - } + .animate-fade-in { + animation: fadeIn 0.5s ease-out forwards; + } } @keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } diff --git a/apps/web/src/lib/client.ts b/apps/web/src/lib/client.ts index 1946f61..38bc26c 100644 --- a/apps/web/src/lib/client.ts +++ b/apps/web/src/lib/client.ts @@ -1,4 +1,5 @@ -import { hc } from 'hono/client'; -import type { AppType } from '../../../server/src/index'; +import { hc } from "hono/client"; +import type { AppType } from "../../../server/src/index"; -export const client = hc('/') as any; +// biome-ignore lint/suspicious/noExplicitAny: Hono client types can be complex +export const client = hc("/") as any; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index f47f434..b49c687 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,17 +1,20 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import AuthCallback from './views/AuthCallback.tsx' -import { AuthProvider } from './contexts/AuthContext.tsx' -import './index.css' +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import { AuthProvider } from "./contexts/AuthContext.tsx"; +import AuthCallback from "./views/AuthCallback.tsx"; +import "./index.css"; // Simple routing based on pathname const pathname = window.location.pathname; -ReactDOM.createRoot(document.getElementById('root')!).render( - - - {pathname === '/auth/callback' ? : } - - , -) +const rootElement = document.getElementById("root"); +if (rootElement) { + ReactDOM.createRoot(rootElement).render( + + + {pathname === "/auth/callback" ? : } + + , + ); +} diff --git a/apps/web/src/views/AdminView.tsx b/apps/web/src/views/AdminView.tsx index de82f2b..83b39b9 100644 --- a/apps/web/src/views/AdminView.tsx +++ b/apps/web/src/views/AdminView.tsx @@ -1,236 +1,292 @@ -import { useState, useEffect } from 'react'; -import { client } from '../lib/client'; -import SystemLoadView from './SystemLoadView'; +import { useCallback, useEffect, useState } from "react"; +import { client } from "../lib/client"; +import SystemLoadView from "./SystemLoadView"; export default function AdminView() { - const [activeTab, setActiveTab] = useState('load'); + const [activeTab, setActiveTab] = useState("load"); - return ( -
-
-

Admin Dashboard

-
+ return ( +
+
+

Admin Dashboard

+
-
-
- -
+
+
+ +
- {activeTab === 'load' && } - {activeTab === 'requests' && } - {activeTab === 'topics' && } -
-
- ); + {activeTab === "load" && } + {activeTab === "requests" && } + {activeTab === "topics" && } +
+
+ ); } function TopicsManagement() { - const [topics, setTopics] = useState([]); - const [loading, setLoading] = useState(true); + const [topics, setTopics] = useState([]); + const [loading, setLoading] = useState(true); - const fetchAllTopics = async () => { - setLoading(true); - try { - const res = await client.api.topics.all.$get(undefined, { - init: { credentials: 'include' } - }); - const data = await res.json(); - setTopics(data); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } - }; + const fetchAllTopics = useCallback(async () => { + setLoading(true); + try { + const res = await client.api.topics.all.$get(undefined, { + init: { credentials: "include" }, + }); + const data = await res.json(); + setTopics(data); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }, []); - useEffect(() => { - fetchAllTopics(); - }, []); + useEffect(() => { + fetchAllTopics(); + }, [fetchAllTopics]); - const handleDelete = async (id: string, name: string) => { - if (!confirm(`Are you sure you want to delete topic "${name}"? This will also remove all subscriptions.`)) { - return; - } + const handleDelete = async (id: string, name: string) => { + if ( + !confirm( + `Are you sure you want to delete topic "${name}"? This will also remove all subscriptions.`, + ) + ) { + return; + } - try { - await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } }); - fetchAllTopics(); - } catch (error) { - console.error(error); - } - }; + try { + await client.api.topics[":id"].$delete( + { param: { id } }, + { init: { credentials: "include" } }, + ); + fetchAllTopics(); + } catch (error) { + console.error(error); + } + }; - if (loading) return
Loading topics...
; + if (loading) return
Loading topics...
; - return ( -
- - - - - - - - - - - - - {topics.map((topic) => ( - - - - - - - - - ))} - -
TopicStatusSubscribersCreated ByApproved ByActions
-
{topic.name}
-
{topic.slug}
-
- - {topic.status} - - - {topic.subscriptions?.length || 0} - - {topic.creator?.name || 'Unknown'} - - {topic.approver?.name || '-'} - - -
-
- ); + return ( +
+ + + + + + + + + + + + + {topics.map((topic) => ( + + + + + + + + + ))} + +
+ Topic + + Status + + Subscribers + + Created By + + Approved By + + Actions +
+
+ {topic.name} +
+
+ {topic.slug} +
+
+ + {topic.status} + + + {topic.subscriptions?.length || 0} + + {topic.creator?.name || "Unknown"} + + {topic.approver?.name || "-"} + + +
+
+ ); } function TopicRequestsList() { - const [requests, setRequests] = useState([]); - const [loading, setLoading] = useState(true); + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); - const fetchRequests = async () => { - setLoading(true); - try { - const res = await client.api.topics.requests.$get(undefined, { - init: { credentials: 'include' } - }); - const data = await res.json(); - setRequests(data); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } - }; + const fetchRequests = useCallback(async () => { + setLoading(true); + try { + const res = await client.api.topics.requests.$get(undefined, { + init: { credentials: "include" }, + }); + const data = await res.json(); + setRequests(data); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }, []); - useEffect(() => { - fetchRequests(); - }, []); + useEffect(() => { + fetchRequests(); + }, [fetchRequests]); - const handleAction = async (id: string, action: 'approve' | 'reject' | 'delete', name?: string) => { - try { - if (action === 'approve') { - await client.api.topics[':id'].approve.$post({ param: { id } }, { init: { credentials: 'include' } }); - } else if (action === 'reject') { - await client.api.topics[':id'].reject.$post({ param: { id } }, { init: { credentials: 'include' } }); - } else if (action === 'delete') { - if (!confirm(`Are you sure you want to delete request "${name}"?`)) return; - await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } }); - } - fetchRequests(); - } catch (error) { - console.error(error); - } - }; + const handleAction = async ( + id: string, + action: "approve" | "reject" | "delete", + name?: string, + ) => { + try { + if (action === "approve") { + await client.api.topics[":id"].approve.$post( + { param: { id } }, + { init: { credentials: "include" } }, + ); + } else if (action === "reject") { + await client.api.topics[":id"].reject.$post( + { param: { id } }, + { init: { credentials: "include" } }, + ); + } else if (action === "delete") { + if (!confirm(`Are you sure you want to delete request "${name}"?`)) + return; + await client.api.topics[":id"].$delete( + { param: { id } }, + { init: { credentials: "include" } }, + ); + } + fetchRequests(); + } catch (error) { + console.error(error); + } + }; - if (loading) return
Loading requests...
; + if (loading) return
Loading requests...
; - if (requests.length === 0) { - return ( -
- No pending topic requests. -
- ); - } + if (requests.length === 0) { + return ( +
+ No pending topic requests. +
+ ); + } - return ( -
-
    - {requests.map(req => ( -
  • -
    -

    {req.name}

    -

    Slug: {req.slug}

    -

    - Requested by: {req.creator?.name || 'Unknown'} - {req.creator?.email ? ` (${req.creator.email})` : ''} -

    - {req.description && ( -

    "{req.description}"

    - )} -
    -
    - - - -
    -
  • - ))} -
-
- ); + return ( +
+
    + {requests.map((req) => ( +
  • +
    +

    {req.name}

    +

    + Slug: {req.slug} +

    +

    + Requested by: {req.creator?.name || "Unknown"} + {req.creator?.email ? ` (${req.creator.email})` : ""} +

    + {req.description && ( +

    + "{req.description}" +

    + )} +
    +
    + + + +
    +
  • + ))} +
+
+ ); } diff --git a/apps/web/src/views/AuthCallback.tsx b/apps/web/src/views/AuthCallback.tsx index 41bf7da..f6871e4 100644 --- a/apps/web/src/views/AuthCallback.tsx +++ b/apps/web/src/views/AuthCallback.tsx @@ -1,76 +1,86 @@ -import { useEffect, useState, useRef } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import { client } from '../lib/client'; +import { useEffect, useRef, useState } from "react"; +import { useAuth } from "../contexts/AuthContext"; +import { client } from "../lib/client"; export default function AuthCallback() { - const [error, setError] = useState(null); - const { checkAuth } = useAuth(); - const processed = useRef(false); + const [error, setError] = useState(null); + const { checkAuth } = useAuth(); + const processed = useRef(false); - useEffect(() => { - const handleCallback = async () => { - if (processed.current) return; - processed.current = true; + useEffect(() => { + const handleCallback = async () => { + if (processed.current) return; + processed.current = true; - const params = new URLSearchParams(window.location.search); - const code = params.get('code'); - const state = params.get('state'); + const params = new URLSearchParams(window.location.search); + const code = params.get("code"); + const state = params.get("state"); - if (!code) { - setError('No authorization code received'); - return; - } + if (!code) { + setError("No authorization code received"); + return; + } - try { - const res = await client.api.auth.callback.$get({ - query: { - code, - state: state || undefined - } - }, { - init: { credentials: 'include' } - }); + try { + const res = await client.api.auth.callback.$get( + { + query: { + code, + state: state || undefined, + }, + }, + { + init: { credentials: "include" }, + }, + ); - if (res.ok) { - await checkAuth(); - // Redirect to home - window.location.href = '/'; - } else { - const data = await res.json(); - setError(data.error || 'Authentication failed'); - } - } catch (err) { - setError('Authentication failed'); - console.error(err); - } - }; + if (res.ok) { + await checkAuth(); + // Redirect to home + window.location.href = "/"; + } else { + const data = await res.json(); + setError(data.error || "Authentication failed"); + } + } catch (err) { + setError("Authentication failed"); + console.error(err); + } + }; - handleCallback(); - }, [checkAuth]); + handleCallback(); + }, [checkAuth]); - if (error) { - return ( -
-
-

Authentication Error

-

{error}

- -
-
- ); - } + if (error) { + return ( +
+
+

+ Authentication Error +

+

{error}

+ +
+
+ ); + } - return ( -
-
-

Authenticating...

-
-
-
- ); + return ( +
+
+

+ Authenticating... +

+
+
+
+ ); } diff --git a/apps/web/src/views/SystemLoadView.tsx b/apps/web/src/views/SystemLoadView.tsx index a8fd657..d96f660 100644 --- a/apps/web/src/views/SystemLoadView.tsx +++ b/apps/web/src/views/SystemLoadView.tsx @@ -1,291 +1,380 @@ -import { useState, useEffect } from 'react'; -import { client } from '../lib/client'; -import { Activity, CheckCircle, XCircle, BarChart3, Clock } from 'lucide-react'; +import { Activity, BarChart3, CheckCircle, Clock, XCircle } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { client } from "../lib/client"; interface Stats { - topics: { - topicSlug: string; - totalTasks: number; - totalRecipients: number; - totalSuccess: number; - }[]; - recent: { - alertsReceived: number; - plannedMessages: number; - successCount: number; - failedCount: number; - successRate: number; - }; - tasks: any[]; + topics: { + topicSlug: string; + totalTasks: number; + totalRecipients: number; + totalSuccess: number; + }[]; + recent: { + alertsReceived: number; + plannedMessages: number; + successCount: number; + failedCount: number; + successRate: number; + }; + tasks: any[]; } export default function SystemLoadView() { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [lastUpdated, setLastUpdated] = useState(new Date()); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [lastUpdated, setLastUpdated] = useState(new Date()); - const fetchStats = async () => { - try { - const res = await client.api.stats.$get(undefined, { - init: { credentials: 'include' } - }); - const data = await res.json(); + const fetchStats = useCallback(async () => { + try { + const res = await client.api.stats.$get(undefined, { + init: { credentials: "include" }, + }); + const data = await res.json(); - // Fetch recent tasks as well - const tasksRes = await client.api.alerts.tasks.$get({ query: { limit: '10' } }, { - init: { credentials: 'include' } - }); - const tasks = await tasksRes.json(); + // Fetch recent tasks as well + const tasksRes = await client.api.alerts.tasks.$get( + { query: { limit: "10" } }, + { + init: { credentials: "include" }, + }, + ); + const tasks = await tasksRes.json(); - setStats({ ...data, tasks } as Stats); - setLastUpdated(new Date()); - } catch (error) { - console.error('Failed to fetch stats:', error); - } finally { - setLoading(false); - } - }; + setStats({ ...data, tasks } as Stats); + setLastUpdated(new Date()); + } catch (error) { + console.error("Failed to fetch stats:", error); + } finally { + setLoading(false); + } + }, []); - useEffect(() => { - fetchStats(); - const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel - return () => clearInterval(interval); - }, []); + useEffect(() => { + fetchStats(); + const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel + return () => clearInterval(interval); + }, [fetchStats]); - if (loading) return ( -
-
-
- ); + if (loading) + return ( +
+
+
+ ); - if (!stats) return
Failed to load statistics.
; + if (!stats) + return ( +
+ Failed to load statistics. +
+ ); - return ( -
-
-
- - - - - Live Feedback -
- Last updated: {lastUpdated.toLocaleTimeString()} -
+ return ( +
+
+
+ + + + + + Live Feedback + +
+ + Last updated: {lastUpdated.toLocaleTimeString()} + +
- {/* Top Row: General Metrics */} -
- } - color="purple" - description="Total webhook hits" - /> - } - color="blue" - description="Total subscribers" - /> - } - color="green" - description="Successfully sent" - /> - } - color="red" - description="API errors/failures" - /> -
- Success Rate - -
-
+ {/* Top Row: General Metrics */} +
+ } + color="purple" + description="Total webhook hits" + /> + } + color="blue" + description="Total subscribers" + /> + } + color="green" + description="Successfully sent" + /> + } + color="red" + description="API errors/failures" + /> +
+ + Success Rate + + +
+
- {/* Middle Row: Topic Message Counts */} -
-
-
- -

Historical Topic Metrics

-
-
-
- - - - - - - - - - - - - {stats.topics.map((topic) => { - const rate = topic.totalRecipients > 0 ? (topic.totalSuccess / topic.totalRecipients) * 100 : 100; - return ( - - - - - - - - - ); - })} - -
TopicAlerts (Tasks)Planned (Recipients)Distributed (Success)Health RateStatus
- - {topic.topicSlug || '[Private DM]'} - - {topic.totalTasks}{topic.totalRecipients}{topic.totalSuccess} -
-
-
90 ? 'bg-green-500' : rate > 70 ? 'bg-yellow-500' : 'bg-red-500'}`} - style={{ width: `${rate}%` }} - >
-
- {rate.toFixed(1)}% -
-
- - {rate === 100 ? 'Healthy' : 'Errors'} - -
-
-
+ {/* Middle Row: Topic Message Counts */} +
+
+
+ +

+ Historical Topic Metrics +

+
+
+
+ + + + + + + + + + + + + {stats.topics.map((topic) => { + const rate = + topic.totalRecipients > 0 + ? (topic.totalSuccess / topic.totalRecipients) * 100 + : 100; + return ( + + + + + + + + + ); + })} + +
Topic + Alerts (Tasks) + + Planned (Recipients) + + Distributed (Success) + + Health Rate + Status
+ + {topic.topicSlug || "[Private DM]"} + + + {topic.totalTasks} + + {topic.totalRecipients} + + {topic.totalSuccess} + +
+
+
90 ? "bg-green-500" : rate > 70 ? "bg-yellow-500" : "bg-red-500"}`} + style={{ width: `${rate}%` }} + >
+
+ + {rate.toFixed(1)}% + +
+
+ + {rate === 100 ? "Healthy" : "Errors"} + +
+
+
- {/* Bottom Row: Recent Alerts with Sender Info */} -
-
-
- -

Recent Alerts (Audit Log)

-
-
-
- - - - - - - - - - - - {stats.tasks.map((task: any) => ( - - - - - - - - ))} - {stats.tasks.length === 0 && ( - - - - )} - -
TimeTopicSenderRecipientsStatus
- {new Date(task.createdAt).toLocaleString()} - - - {task.topicSlug || '[Private DM]'} - - -
- {task.sender?.name || 'Unknown'} - {task.sender?.email || 'N/A'} -
-
- {task.successCount} / {task.recipientCount} - - - {task.status} - -
- No alerts sent yet. -
-
-
-
- ); + {/* Bottom Row: Recent Alerts with Sender Info */} +
+
+
+ +

+ Recent Alerts (Audit Log) +

+
+
+
+ + + + + + + + + + + + {stats.tasks.map((task: any) => ( + + + + + + + + ))} + {stats.tasks.length === 0 && ( + + + + )} + +
TimeTopicSender + Recipients + Status
+ {new Date(task.createdAt).toLocaleString()} + + + {task.topicSlug || "[Private DM]"} + + +
+ + {task.sender?.name || "Unknown"} + + + {task.sender?.email || "N/A"} + +
+
+ {task.successCount} / {task.recipientCount} + + + {task.status} + +
+ No alerts sent yet. +
+
+
+
+ ); } -function MetricCard({ title, value, icon, color, description }: { title: string, value: number, icon: React.ReactNode, color: string, description?: string }) { - return ( -
-
-
- {icon} -
-
-

{title}

-

{value.toLocaleString()}

-
-
- {description &&

/ {description}

} -
- ); +function MetricCard({ + title, + value, + icon, + color, + description, +}: { + title: string; + value: number; + icon: React.ReactNode; + color: string; + description?: string; +}) { + return ( +
+
+
{icon}
+
+

+ {title} +

+

+ {value.toLocaleString()} +

+
+
+ {description && ( +

+ / {description} +

+ )} +
+ ); } function Gauge({ value }: { value: number }) { - const radius = 40; - const circumference = 2 * Math.PI * radius; - const offset = circumference - (value / 100) * circumference; + const radius = 40; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (value / 100) * circumference; - // Determine color based on value - const getColor = (v: number) => { - if (v >= 95) return '#10b981'; // green-500 - if (v >= 80) return '#f59e0b'; // yellow-500 - return '#ef4444'; // red-500 - }; + // Determine color based on value + const getColor = (v: number) => { + if (v >= 95) return "#10b981"; // green-500 + if (v >= 80) return "#f59e0b"; // yellow-500 + return "#ef4444"; // red-500 + }; - return ( -
- - - - -
- {value.toFixed(1)}% -
-
- ); + return ( +
+ + + + +
+ + {value.toFixed(1)}% + +
+
+ ); } diff --git a/apps/web/src/views/TopicsView.tsx b/apps/web/src/views/TopicsView.tsx index 19b3439..8534d9d 100644 --- a/apps/web/src/views/TopicsView.tsx +++ b/apps/web/src/views/TopicsView.tsx @@ -1,570 +1,741 @@ -import { useState, useEffect } from 'react'; -import { Plus, Settings, UserPlus, UserMinus, Copy, Check, User, ShieldCheck, Users } from 'lucide-react'; -import Modal from '../components/Modal'; -import GroupBindingsModal from '../components/GroupBindingsModal'; -import { useAuth } from '../contexts/AuthContext'; -import { client } from '../lib/client'; +import { + Check, + Copy, + Plus, + Settings, + ShieldCheck, + User, + UserMinus, + UserPlus, + Users, +} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import GroupBindingsModal from "../components/GroupBindingsModal"; +import Modal from "../components/Modal"; +import { useAuth } from "../contexts/AuthContext"; +import { client } from "../lib/client"; -interface User { - id: string; - name: string; - email?: string | null; +interface TopicUser { + id: string; + name: string; + email?: string | null; } interface Subscription { - userId: string; - user: User; + userId: string; + user: TopicUser; } interface Topic { - id: string; - name: string; - slug: string; - description?: string; - subscriptions: Subscription[]; - creator?: User; - approver?: User; - createdBy?: string; + id: string; + name: string; + slug: string; + description?: string; + subscriptions: Subscription[]; + creator?: TopicUser; + approver?: TopicUser; + createdBy?: string; } export default function TopicsView() { - const { user: currentUser } = useAuth(); - const [topics, setTopics] = useState([]); - const [myRequests, setMyRequests] = useState([]); - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [isModalOpen, setIsModalOpen] = useState(false); - const [isSubModalOpen, setIsSubModalOpen] = useState(false); - const [selectedTopic, setSelectedTopic] = useState(null); - const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); - const [copiedId, setCopiedId] = useState(null); + const { user: currentUser } = useAuth(); + const [topics, setTopics] = useState([]); + const [myRequests, setMyRequests] = useState([]); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isSubModalOpen, setIsSubModalOpen] = useState(false); + const [selectedTopic, setSelectedTopic] = useState(null); + const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); + const [copiedId, setCopiedId] = useState(null); - const [formData, setFormData] = useState>({ - name: '', - slug: '', - description: '', - }); - const [submitStatus, setSubmitStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null); + const [formData, setFormData] = useState>({ + name: "", + slug: "", + description: "", + }); + const [submitStatus, setSubmitStatus] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); - const fetchTopics = async () => { - setLoading(true); - try { - const res = await client.api.topics.$get(undefined, { - init: { credentials: 'include' } - }); - const data = await res.json(); - setTopics(data as unknown as Topic[]); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }; + const fetchTopics = useCallback(async () => { + setLoading(true); + try { + const res = await client.api.topics.$get(undefined, { + init: { credentials: "include" }, + }); + const data = await res.json(); + setTopics(data as unknown as Topic[]); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }, []); - const fetchMyRequests = async () => { - try { - const res = await client.api.topics['my-requests'].$get(undefined, { - init: { credentials: 'include' } - }); - const data = await res.json(); - setMyRequests(data); - } catch (err) { - console.error(err); - } - }; + const fetchMyRequests = useCallback(async () => { + try { + const res = await client.api.topics["my-requests"].$get(undefined, { + init: { credentials: "include" }, + }); + const data = await res.json(); + setMyRequests(data); + } catch (err) { + console.error(err); + } + }, []); - const fetchUsers = async () => { - try { - const res = await client.api.users.$get(undefined, { - init: { credentials: 'include' } - }); - const data = await res.json(); - setUsers(data as unknown as User[]); - } catch (err) { - console.error(err); - } - }; + const fetchUsers = useCallback(async () => { + try { + const res = await client.api.users.$get(undefined, { + init: { credentials: "include" }, + }); + const data = await res.json(); + setUsers(data as unknown as TopicUser[]); + } catch (err) { + console.error(err); + } + }, []); - useEffect(() => { - fetchTopics(); - fetchMyRequests(); - if (currentUser?.isAdmin) { - fetchUsers(); - } - }, [currentUser]); + useEffect(() => { + fetchTopics(); + fetchMyRequests(); + if (currentUser?.isAdmin) { + fetchUsers(); + } + }, [currentUser, fetchMyRequests, fetchTopics, fetchUsers]); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setSubmitStatus(null); - try { - const res = await client.api.topics.$post({ - json: formData as any - }, { - init: { credentials: 'include' } - }); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitStatus(null); + try { + const res = await client.api.topics.$post( + { + json: formData as any, + }, + { + init: { credentials: "include" }, + }, + ); - if (res.ok) { - setSubmitStatus({ - type: 'success', - message: currentUser?.isAdmin ? 'Topic created successfully!' : 'Request submitted! Waiting for approval.' - }); - setFormData({ name: '', slug: '', description: '' }); - fetchTopics(); - fetchMyRequests(); - setTimeout(() => { - setIsModalOpen(false); - setSubmitStatus(null); - }, 1500); - } else { - const error = await res.json(); - setSubmitStatus({ type: 'error', message: error.message || 'Failed to submit request.' }); - } - } catch (error) { - console.error('Error creating topic:', error); - setSubmitStatus({ type: 'error', message: 'An unexpected error occurred.' }); - } - }; + if (res.ok) { + setSubmitStatus({ + type: "success", + message: currentUser?.isAdmin + ? "Topic created successfully!" + : "Request submitted! Waiting for approval.", + }); + setFormData({ name: "", slug: "", description: "" }); + fetchTopics(); + fetchMyRequests(); + setTimeout(() => { + setIsModalOpen(false); + setSubmitStatus(null); + }, 1500); + } else { + const error = await res.json(); + setSubmitStatus({ + type: "error", + message: error.message || "Failed to submit request.", + }); + } + } catch (error) { + console.error("Error creating topic:", error); + setSubmitStatus({ + type: "error", + message: "An unexpected error occurred.", + }); + } + }; + const handleSubscriptionClick = (topic: Topic) => { + setSelectedTopic(topic); + setIsSubModalOpen(true); + }; + const handleGroupClick = (topic: Topic) => { + setSelectedTopic(topic); + setIsGroupModalOpen(true); + }; - const handleSubscriptionClick = (topic: Topic) => { - setSelectedTopic(topic); - setIsSubModalOpen(true); - }; + const toggleSubscription = async ( + topicId: string, + userId: string, + isSubscribed: boolean, + ) => { + try { + console.log("Toggling subscription:", { topicId, userId, isSubscribed }); - const handleGroupClick = (topic: Topic) => { - setSelectedTopic(topic); - setIsGroupModalOpen(true); - }; + if (isSubscribed) { + await client.api.topics[":topicId"].subscribe[":userId"].$delete( + { + param: { topicId, userId }, + }, + { + init: { credentials: "include" }, + }, + ); + } else { + await client.api.topics[":topicId"].subscribe[":userId"].$post( + { + param: { topicId, userId }, + }, + { + init: { credentials: "include" }, + }, + ); + } - const toggleSubscription = async (topicId: string, userId: string, isSubscribed: boolean) => { - try { - console.log('Toggling subscription:', { topicId, userId, isSubscribed }); + // Optimistic update for the main list + setTopics((prevTopics) => + prevTopics.map((t) => { + if (t.id === topicId) { + const updatedSubs = isSubscribed + ? t.subscriptions.filter((s) => s.userId !== userId) + : [ + ...t.subscriptions, + { + userId, + user: users.find((u) => u.id === userId) || currentUser!, + }, + ]; + return { ...t, subscriptions: updatedSubs }; + } + return t; + }), + ); - if (isSubscribed) { - await client.api.topics[':topicId'].subscribe[':userId'].$delete({ - param: { topicId, userId } - }, { - init: { credentials: 'include' } - }); - } else { - await client.api.topics[':topicId'].subscribe[':userId'].$post({ - param: { topicId, userId } - }, { - init: { credentials: 'include' } - }); - } + // Also update selectedTopic if it's open + if (selectedTopic && selectedTopic.id === topicId) { + const updatedSubs = isSubscribed + ? selectedTopic.subscriptions.filter((s) => s.userId !== userId) + : [ + ...selectedTopic.subscriptions, + { + userId, + user: users.find((u) => u.id === userId) || currentUser!, + }, + ]; + setSelectedTopic({ ...selectedTopic, subscriptions: updatedSubs }); + } - // Optimistic update for the main list - setTopics(prevTopics => - prevTopics.map(t => { - if (t.id === topicId) { - const updatedSubs = isSubscribed - ? t.subscriptions.filter(s => s.userId !== userId) - : [...t.subscriptions, { userId, user: users.find(u => u.id === userId) || currentUser! }]; - return { ...t, subscriptions: updatedSubs }; - } - return t; - }) - ); + fetchTopics(); // Re-fetch to ensure consistency + } catch (error) { + console.error("Error toggling subscription:", error); + } + }; - // Also update selectedTopic if it's open - if (selectedTopic && selectedTopic.id === topicId) { - const updatedSubs = isSubscribed - ? selectedTopic.subscriptions.filter(s => s.userId !== userId) - : [...selectedTopic.subscriptions, { userId, user: users.find(u => u.id === userId) || currentUser! }]; - setSelectedTopic({ ...selectedTopic, subscriptions: updatedSubs }); - } + const isSubscribed = (topic: Topic) => { + return topic.subscriptions.some((sub) => sub.userId === currentUser?.id); + }; - fetchTopics(); // Re-fetch to ensure consistency - } catch (error) { - console.error('Error toggling subscription:', error); - } - }; + const handleSelfSubscribe = async (topic: Topic) => { + if (!currentUser) return; + const subscribed = isSubscribed(topic); + await toggleSubscription(topic.id, currentUser.id, subscribed); + }; - const isSubscribed = (topic: Topic) => { - return topic.subscriptions.some(sub => sub.userId === currentUser?.id); - }; + const copyToClipboard = (text: string, topicId: string) => { + navigator.clipboard.writeText(text); + setCopiedId(topicId); + setTimeout(() => setCopiedId(null), 2000); + }; - const handleSelfSubscribe = async (topic: Topic) => { - if (!currentUser) return; - const subscribed = isSubscribed(topic); - await toggleSubscription(topic.id, currentUser.id, subscribed); - }; + const getWebhookUrl = (topicSlug: string) => { + if (!currentUser?.personalToken) return ""; + // Use an environment variable if available, otherwise fallback to current origin + const baseUrl = ( + (import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin + ).replace(/\/$/, ""); + return `${baseUrl}/webhook/${currentUser.personalToken}/topic/${topicSlug}`; + }; - const copyToClipboard = (text: string, topicId: string) => { - navigator.clipboard.writeText(text); - setCopiedId(topicId); - setTimeout(() => setCopiedId(null), 2000); - }; + const getDmWebhookUrl = () => { + if (!currentUser?.personalToken) return ""; + const baseUrl = ( + (import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin + ).replace(/\/$/, ""); + return `${baseUrl}/webhook/${currentUser.personalToken}/dm`; + }; - const getWebhookUrl = (topicSlug: string) => { - if (!currentUser?.personalToken) return ''; - // Use an environment variable if available, otherwise fallback to current origin - const baseUrl = ((import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin).replace(/\/$/, ''); - return `${baseUrl}/webhook/${currentUser.personalToken}/topic/${topicSlug}`; - }; + if (loading) return
Loading...
; - const getDmWebhookUrl = () => { - if (!currentUser?.personalToken) return ''; - const baseUrl = ((import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin).replace(/\/$/, ''); - return `${baseUrl}/webhook/${currentUser.personalToken}/dm`; - }; + return ( +
+
+
+
+

How it works?

+
+
    +
  • + Subscribe: Click{" "} + + Subscribe + {" "} + on any topic to start receiving alerts via Feishu private + message. +
  • +
  • + Personal Webhook: Use topic-specific URLs to + notify all subscribers, or use your{" "} + + Personal Inbox + {" "} + to notify only yourself. +
  • +
  • + Need more? If you can't find a suitable + topic, click{" "} + Request Topic to ask + admins for a new one. +
  • +
+
+
+
+
- if (loading) return
Loading...
; +
+
+ +

Personal Inbox

+
+
+
+
+

+ Your private alert endpoint. No topic required. +

+
+
+ + Inbox Webhook URL + + +
+
+ {getDmWebhookUrl()} +
+
+
+
+
+ +
+
+
Direct Push
+
+ Always delivered to you +
+
+
+
+
+
- return ( -
-
-
-
-

How it works?

-
-
    -
  • Subscribe: Click Subscribe on any topic to start receiving alerts via Feishu private message.
  • -
  • Personal Webhook: Use topic-specific URLs to notify all subscribers, or use your Personal Inbox to notify only yourself.
  • -
  • Need more? If you can't find a suitable topic, click Request Topic to ask admins for a new one.
  • -
-
-
-
-
+
+

Topics

+
+ {currentUser && ( + + )} +
+
-
-
- -

Personal Inbox

-
-
-
-
-

Your private alert endpoint. No topic required.

-
-
- Inbox Webhook URL - -
-
- {getDmWebhookUrl()} -
-
-
-
-
- -
-
-
Direct Push
-
Always delivered to you
-
-
-
-
-
+
+
    + {topics.map((topic) => ( +
  • +
    +
    +
    +
    +

    + {topic.name} +

    +
    + + {currentUser && + (currentUser.isAdmin || + currentUser.id === topic.createdBy) && ( + <> + {currentUser.isAdmin && ( + + )} + + + )} +
    +
    +
    +
    +

    + Slug:{" "} + + {topic.slug} + +

    +

    + {topic.description} +

    +
    + {topic.creator && ( +
    + + + Created by:{" "} + + {topic.creator.name} + + +
    + )} + {topic.approver && ( +
    + + + Approved by:{" "} + + {topic.approver.name} + + +
    + )} +
    + {currentUser && ( +
    +
    + + Your Personal Webhook + + +
    +
    + {getWebhookUrl(topic.slug)} +
    +
    + )} +
    +
    +
    +
    +
    +
  • + ))} + {topics.length === 0 && ( +
  • +
    +
    + +
    +

    + No topics available yet. +

    +

    + {currentUser?.isAdmin + ? "Click 'Add Topic' above to create the first alert topic for your team." + : "There are no approved topics yet. You can request one by clicking 'Request Topic' above."} +

    +
    +
  • + )} +
+
-
-

Topics

-
- {currentUser && ( - - )} -
-
+ {myRequests.length > 0 && ( +
+

My Requests

+
+
    + {myRequests.map((req) => ( +
  • +
    +
    +
    +
    +

    + {req.name} +

    +
    + + {req.status === "approved" + ? "Approved" + : req.status === "rejected" + ? "Rejected" + : "Pending"} + +
    +
    +
    +

    + Slug: {req.slug} +

    + {req.description && ( +

    {req.description}

    + )} +

    + Requested on:{" "} + {new Date(req.createdAt).toLocaleDateString()} + {req.approver && ( + + | Approved by: {req.approver.name} + + )} +

    +
    +
    +
    +
    +
  • + ))} +
+
+
+ )} + setIsModalOpen(false)} + title={currentUser?.isAdmin ? "Add New Topic" : "Request New Topic"} + > +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + /> +
+
+ + + setFormData({ ...formData, slug: e.target.value }) + } + /> +
+
+ +