From 021163cf44f747400e51818538d1674a9fa67f21 Mon Sep 17 00:00:00 2001 From: KodekaTeam Date: Mon, 22 Jun 2026 21:22:02 +0700 Subject: [PATCH] feat: show GitHub commit history in topbar notifications --- src/components/Topbar.tsx | 401 ++++++++++++---------------- src/lib/api.ts | 26 ++ src/server/index.ts | 12 +- src/server/routes/commit-history.ts | 188 +++++++++++++ 4 files changed, 388 insertions(+), 239 deletions(-) create mode 100644 src/server/routes/commit-history.ts diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index 48e98b3..6c51ce2 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -1,6 +1,10 @@ "use client"; -import { HEALTH_URL } from "@/lib/api"; +import { + HEALTH_URL, + topbarNotificationsApi, + type CommitHistoryNotificationRecord, +} from "@/lib/api"; import { useCurrentUser } from "@/lib/auth-state"; import { navigation } from "@/lib/navigation"; import { @@ -26,20 +30,28 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; type ConnectionState = "checking" | "online" | "offline" | "degraded"; -type NotificationTone = "info" | "success" | "warning" | "error"; - -interface TopbarNotification { - id: string; - title: string; - message: string; - tone: NotificationTone; - timestamp: number; - read: boolean; -} - -const TOPBAR_NOTIFICATIONS_KEY = "portainer-topbar-notifications"; +const TOPBAR_COMMIT_READ_KEY = "doktainer-topbar-read-commit-notifications"; const MAX_TOPBAR_NOTIFICATIONS = 5; +function readStoredCommitNotificationIds() { + if (typeof window === "undefined") { + return new Set(); + } + + try { + const stored = window.localStorage.getItem(TOPBAR_COMMIT_READ_KEY); + if (!stored) { + return new Set(); + } + + const parsed = JSON.parse(stored) as string[]; + return new Set(parsed.filter(Boolean)); + } catch { + window.localStorage.removeItem(TOPBAR_COMMIT_READ_KEY); + return new Set(); + } +} + interface TopbarProps { title: string; subtitle?: string; @@ -55,141 +67,70 @@ export default function Topbar({ const pathname = usePathname(); const router = useRouter(); const [time, setTime] = useState(""); + const [notificationNow, setNotificationNow] = useState(0); const [connectionState, setConnectionState] = useState("checking"); const [query, setQuery] = useState(""); const [searchOpen, setSearchOpen] = useState(false); const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">("dark"); - const [notifications, setNotifications] = useState([]); + const [notifications, setNotifications] = useState< + CommitHistoryNotificationRecord[] + >([]); + const [notificationsLoading, setNotificationsLoading] = useState(true); + const [notificationsError, setNotificationsError] = useState( + null, + ); + const [readNotificationIds, setReadNotificationIds] = useState>( + readStoredCommitNotificationIds, + ); const [notificationOpen, setNotificationOpen] = useState(false); const searchRef = useRef(null); const searchShellRef = useRef(null); const notificationShellRef = useRef(null); - const notificationsRef = useRef([]); - const lastConnectionNotificationRef = useRef(null); - - const updateNotifications = useCallback( - (updater: (current: TopbarNotification[]) => TopbarNotification[]) => { - setNotifications((current) => { - const next = updater(current).slice(0, MAX_TOPBAR_NOTIFICATIONS); - notificationsRef.current = next; - window.localStorage.setItem( - TOPBAR_NOTIFICATIONS_KEY, - JSON.stringify(next), - ); - return next; - }); - }, - [], - ); - - const pushNotification = useCallback( - ({ - title: nextTitle, - message, - tone = "info", - }: Omit) => { - updateNotifications((current) => [ - { - id: `topbar-notification-${Date.now()}-${current.length}`, - title: nextTitle, - message, - tone, - timestamp: Date.now(), - read: false, - }, - ...current, - ]); - }, - [updateNotifications], - ); const markNotificationsRead = useCallback(() => { - updateNotifications((current) => { - if (current.every((notification) => notification.read)) { - return current; - } - - return current.map((notification) => ({ - ...notification, - read: true, - })); + setReadNotificationIds((current) => { + const next = new Set(current); + notifications.forEach((notification) => next.add(notification.id)); + window.localStorage.setItem( + TOPBAR_COMMIT_READ_KEY, + JSON.stringify([...next].slice(0, 100)), + ); + return next; }); - }, [updateNotifications]); + }, [notifications]); + + const loadNotifications = useCallback(async () => { + setNotificationsLoading(true); + setNotificationsError(null); + + try { + const response = await topbarNotificationsApi.listCommits(); + setNotifications(response.data.slice(0, MAX_TOPBAR_NOTIFICATIONS)); + } catch (error) { + setNotifications([]); + setNotificationsError( + error instanceof Error + ? error.message + : "Failed to load commit history", + ); + } finally { + setNotificationsLoading(false); + } + }, []); useEffect(() => { - try { - const stored = window.localStorage.getItem(TOPBAR_NOTIFICATIONS_KEY); + const timeout = window.setTimeout(() => { + void loadNotifications(); + }, 0); - if (stored) { - const parsed = JSON.parse(stored) as TopbarNotification[]; - const initial = parsed.slice(0, MAX_TOPBAR_NOTIFICATIONS); - setNotifications(initial); - notificationsRef.current = initial; - return; - } - } catch { - window.localStorage.removeItem(TOPBAR_NOTIFICATIONS_KEY); - } - - const now = Date.now(); - const seeded: TopbarNotification[] = [ - { - id: "seed-1", - title: "Welcome back", - message: currentUser?.name - ? `${currentUser.name}, your workspace is ready.` - : "Your workspace is ready.", - tone: "info", - timestamp: now - 5 * 60_000, - read: false, - }, - { - id: "seed-2", - title: "Quick search enabled", - message: "Press Ctrl/Cmd + K to jump between dashboard pages.", - tone: "info", - timestamp: now - 4 * 60_000, - read: false, - }, - { - id: "seed-3", - title: "Theme switch ready", - message: "Use the sun or moon toggle to change the panel theme.", - tone: "info", - timestamp: now - 3 * 60_000, - read: false, - }, - { - id: "seed-4", - title: "Connection monitor active", - message: - "The topbar now checks browser and API availability automatically.", - tone: "success", - timestamp: now - 2 * 60_000, - read: false, - }, - { - id: "seed-5", - title: "Notification center active", - message: "The bell button shows the latest 5 topbar notifications.", - tone: "success", - timestamp: now - 60_000, - read: false, - }, - ]; - - setNotifications(seeded); - notificationsRef.current = seeded; - window.localStorage.setItem( - TOPBAR_NOTIFICATIONS_KEY, - JSON.stringify(seeded), - ); - }, [currentUser?.name]); + return () => window.clearTimeout(timeout); + }, [loadNotifications]); useEffect(() => { const update = () => { const now = new Date(); + setNotificationNow(now.getTime()); setTime( now.toLocaleTimeString("en-US", { hour: "2-digit", @@ -284,7 +225,9 @@ export default function Topbar({ }, []); useEffect(() => { - void checkConnection(); + const initialCheck = window.setTimeout(() => { + void checkConnection(); + }, 0); const handleOnline = () => { void checkConnection(); @@ -302,6 +245,7 @@ export default function Topbar({ window.addEventListener("offline", handleOffline); return () => { + window.clearTimeout(initialCheck); window.clearInterval(interval); window.removeEventListener("online", handleOnline); window.removeEventListener("offline", handleOffline); @@ -352,9 +296,13 @@ export default function Topbar({ }, []); useEffect(() => { - setQuery(""); - setSearchOpen(false); - setNotificationOpen(false); + const timeout = window.setTimeout(() => { + setQuery(""); + setSearchOpen(false); + setNotificationOpen(false); + }, 0); + + return () => window.clearTimeout(timeout); }, [pathname]); useEffect(() => { @@ -362,7 +310,8 @@ export default function Topbar({ return; } - markNotificationsRead(); + const timeout = window.setTimeout(markNotificationsRead, 0); + return () => window.clearTimeout(timeout); }, [markNotificationsRead, notificationOpen]); const shortcutLabel = useMemo(() => { @@ -411,87 +360,32 @@ export default function Topbar({ router.push(href); }; - useEffect(() => { - if (connectionState === "checking") { - return; - } - - if (lastConnectionNotificationRef.current === connectionState) { - return; - } - - lastConnectionNotificationRef.current = connectionState; - - if (connectionState === "online") { - pushNotification({ - title: "Connection restored", - message: "Browser is online and the backend API is reachable.", - tone: "success", - }); - return; - } - - if (connectionState === "offline") { - pushNotification({ - title: "Browser offline", - message: - "Network access was lost. Reconnect to restore dashboard features.", - tone: "error", - }); - return; - } - - pushNotification({ - title: "Backend degraded", - message: - "Browser is online, but the API health check did not complete cleanly.", - tone: "warning", - }); - }, [connectionState, pushNotification]); - const handleThemeToggle = () => { const nextTheme = resolvedTheme === "light" ? "dark" : "light"; setStoredTheme(nextTheme); - pushNotification({ - title: "Theme updated", - message: `Dashboard theme switched to ${nextTheme} mode.`, - tone: "info", - }); }; const unreadCount = useMemo( - () => notifications.filter((notification) => !notification.read).length, - [notifications], + () => + notifications.filter( + (notification) => !readNotificationIds.has(notification.id), + ).length, + [notifications, readNotificationIds], ); - const notificationToneMeta: Record< - NotificationTone, - { dot: string; border: string; background: string } - > = { - info: { - dot: "#3b82f6", - border: "rgba(59, 130, 246, 0.2)", - background: "rgba(59, 130, 246, 0.08)", - }, - success: { - dot: "#10b981", - border: "rgba(16, 185, 129, 0.2)", - background: "rgba(16, 185, 129, 0.08)", - }, - warning: { - dot: "#f59e0b", - border: "rgba(245, 158, 11, 0.2)", - background: "rgba(245, 158, 11, 0.08)", - }, - error: { - dot: "#ef4444", - border: "rgba(239, 68, 68, 0.2)", - background: "rgba(239, 68, 68, 0.08)", - }, + const notificationToneMeta = { + dot: "#3b82f6", + border: "rgba(59, 130, 246, 0.2)", + background: "rgba(59, 130, 246, 0.08)", }; - const formatNotificationTimestamp = (timestamp: number) => { - const diff = Date.now() - timestamp; + const formatNotificationTimestamp = (timestamp: string) => { + const timeMs = new Date(timestamp).getTime(); + if (!Number.isFinite(timeMs)) { + return ""; + } + + const diff = notificationNow - timeMs; if (diff < 60_000) { return "Just now"; @@ -505,31 +399,13 @@ export default function Topbar({ return `${Math.floor(diff / 3_600_000)}h ago`; } - return new Date(timestamp).toLocaleDateString("en-US", { + return new Date(timeMs).toLocaleDateString("en-US", { month: "short", day: "numeric", }); }; const handleRefresh = () => { - const nextNotifications = [ - { - id: `topbar-notification-${Date.now()}-${notificationsRef.current.length}`, - title: "Dashboard refresh", - message: "The current page is reloading.", - tone: "info" as const, - timestamp: Date.now(), - read: false, - }, - ...notificationsRef.current, - ].slice(0, MAX_TOPBAR_NOTIFICATIONS); - - notificationsRef.current = nextNotifications; - window.localStorage.setItem( - TOPBAR_NOTIFICATIONS_KEY, - JSON.stringify(nextNotifications), - ); - setNotifications(nextNotifications); window.location.reload(); }; @@ -987,19 +863,24 @@ export default function Topbar({ color: "var(--text-muted)", }} > - Latest {notifications.length} updates + Latest {notifications.length} commits