| import { useCallback, useEffect, useRef, useState } from "react"; |
| import { useNavigate, Link } from "react-router-dom"; |
| import { useAuth } from "../../context/AuthContext"; |
| import { useTheme } from "../../context/ThemeContext"; |
| import { useWorkspaceContext } from "../../context/WorkspaceContext"; |
| import { listActivity } from "../../api/activity"; |
| import "./Topbar.css"; |
|
|
| const NOTIF_ICONS = { |
| workflow_completed: "✅", |
| review_requested: "👁️", |
| workflow_started: "▶️", |
| proposal_approved: "✓", |
| proposal_rejected: "✗", |
| document_uploaded: "📄", |
| commit_created: "💾", |
| proposal_created: "📝", |
| workflow_failed: "❌", |
| }; |
|
|
| export function Topbar() { |
| const { user, logout } = useAuth(); |
| const { darkMode, toggleTheme } = useTheme(); |
| const { workspace, workspaces, loading, selectWorkspace, addWorkspace } = |
| useWorkspaceContext(); |
| const navigate = useNavigate(); |
| const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false); |
| const [creating, setCreating] = useState(false); |
| const [newName, setNewName] = useState(""); |
| const [searchQuery, setSearchQuery] = useState(""); |
|
|
| |
| const [notifOpen, setNotifOpen] = useState(false); |
| const [notifications, setNotifications] = useState([]); |
| const [notifLoading, setNotifLoading] = useState(false); |
| const [unreadCount, setUnreadCount] = useState(0); |
| const lastReadRef = useRef(localStorage.getItem("dw_notif_read_at") || ""); |
| const notifRef = useRef(null); |
| const pollRef = useRef(null); |
|
|
| const displayName = user?.username || user?.email || "Workspace user"; |
|
|
| |
| const fetchNotifications = useCallback(async () => { |
| if (!workspace) return; |
| try { |
| const response = await listActivity(workspace.id, 8); |
| const events = response?.events ?? (Array.isArray(response) ? response : []); |
| if (Array.isArray(events)) { |
| setNotifications(events); |
| |
| const readAt = lastReadRef.current; |
| if (readAt) { |
| const readTime = new Date(readAt).getTime(); |
| const unread = events.filter( |
| (e) => e.timestamp && new Date(e.timestamp).getTime() > readTime |
| ); |
| setUnreadCount(unread.length); |
| } else { |
| |
| setUnreadCount(events.length); |
| } |
| } |
| } catch { |
| |
| } |
| }, [workspace]); |
|
|
| |
| useEffect(() => { |
| fetchNotifications(); |
| pollRef.current = setInterval(fetchNotifications, 15000); |
| return () => clearInterval(pollRef.current); |
| }, [fetchNotifications]); |
|
|
| |
| useEffect(() => { |
| const handleClick = (e) => { |
| if (notifRef.current && !notifRef.current.contains(e.target)) { |
| setNotifOpen(false); |
| } |
| }; |
| document.addEventListener("mousedown", handleClick); |
| return () => document.removeEventListener("mousedown", handleClick); |
| }, []); |
|
|
| const handleOpenNotifs = () => { |
| setNotifOpen((v) => !v); |
| |
| const now = new Date().toISOString(); |
| lastReadRef.current = now; |
| localStorage.setItem("dw_notif_read_at", now); |
| setUnreadCount(0); |
| }; |
|
|
| const handleLogout = () => { |
| logout(); |
| navigate("/login"); |
| }; |
|
|
| const handleSelect = (id) => { |
| selectWorkspace(id); |
| setWorkspaceMenuOpen(false); |
| }; |
|
|
| const handleCreate = async (e) => { |
| e.preventDefault(); |
| const name = newName.trim(); |
| if (!name) return; |
| await addWorkspace(name); |
| setNewName(""); |
| setCreating(false); |
| setWorkspaceMenuOpen(false); |
| }; |
|
|
| const timeAgo = (timestamp) => { |
| if (!timestamp) return ""; |
| const diff = Date.now() - new Date(timestamp).getTime(); |
| const mins = Math.floor(diff / 60000); |
| if (mins < 1) return "just now"; |
| if (mins < 60) return `${mins}m ago`; |
| const hrs = Math.floor(mins / 60); |
| if (hrs < 24) return `${hrs}h ago`; |
| return `${Math.floor(hrs / 24)}d ago`; |
| }; |
|
|
| return ( |
| <header className="dw-topbar"> |
| <div className="dw-topbar__workspace-wrapper"> |
| <button |
| className="dw-topbar__workspace" |
| onClick={() => setWorkspaceMenuOpen((v) => !v)} |
| disabled={loading} |
| > |
| <span className="dw-topbar__workspace-dot" aria-hidden="true" /> |
| <span>{loading ? "Loading…" : workspace?.name || "No workspace"}</span> |
| <span className="dw-topbar__chevron">⌄</span> |
| </button> |
| |
| {workspaceMenuOpen && ( |
| <div |
| className="dw-topbar__menu dw-topbar__menu--workspace" |
| onMouseLeave={() => { |
| setWorkspaceMenuOpen(false); |
| setCreating(false); |
| }} |
| > |
| {workspaces.map((w) => ( |
| <button |
| key={w.id} |
| className="dw-topbar__menu-item" |
| onClick={() => handleSelect(w.id)} |
| > |
| {w.id === workspace?.id ? "✓ " : ""} |
| {w.name} |
| </button> |
| ))} |
| |
| <div className="dw-topbar__menu-divider" /> |
| {creating && ( |
| <form className="dw-topbar__create-form" onSubmit={handleCreate}> |
| <input |
| autoFocus |
| type="text" |
| placeholder="Workspace name" |
| value={newName} |
| onChange={(e) => setNewName(e.target.value)} |
| /> |
| <button type="submit" className="dw-topbar__menu-item"> |
| Create |
| </button> |
| </form> |
| )} |
| </div> |
| )} |
| </div> |
| |
| {/* New workspace button */} |
| <button |
| className="dw-topbar__upload-btn" |
| onClick={() => { setWorkspaceMenuOpen(true); setCreating(true); }} |
| title="New workspace" |
| > |
| + |
| </button> |
| |
| <div className="dw-topbar__search"> |
| <span className="dw-topbar__search-icon" aria-hidden="true"> |
| ⌕ |
| </span> |
| <input |
| type="text" |
| placeholder="Search documents and knowledge…" |
| value={searchQuery} |
| onChange={(e) => setSearchQuery(e.target.value)} |
| onKeyDown={(e) => { |
| if (e.key === "Enter" && searchQuery.trim()) { |
| navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`); |
| setSearchQuery(""); |
| } |
| }} |
| /> |
| <button |
| className="dw-topbar__search-btn" |
| onClick={() => { |
| if (searchQuery.trim()) { |
| navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`); |
| setSearchQuery(""); |
| } |
| }} |
| aria-label="Search" |
| > |
| → |
| </button> |
| </div> |
| |
| <div className="dw-topbar__actions"> |
| {/* Notification Bell */} |
| <div className="dw-topbar__notif-wrapper" ref={notifRef}> |
| <button |
| className="dw-topbar__icon-btn" |
| aria-label="Notifications" |
| onClick={handleOpenNotifs} |
| > |
| 🔔 |
| {unreadCount > 0 && ( |
| <span className="dw-topbar__notif-badge">{unreadCount}</span> |
| )} |
| </button> |
| |
| {notifOpen && ( |
| <div className="dw-topbar__notif-dropdown"> |
| <div className="dw-topbar__notif-header"> |
| <span>Notifications</span> |
| <Link |
| to="/activity" |
| className="dw-topbar__notif-viewall" |
| onClick={() => setNotifOpen(false)} |
| > |
| View all |
| </Link> |
| </div> |
| {notifications.length === 0 ? ( |
| <div className="dw-topbar__notif-empty"> |
| No recent activity. |
| </div> |
| ) : ( |
| <div className="dw-topbar__notif-list"> |
| {notifications.map((n) => { |
| const targetUrl = n.metadata?.workflow_id |
| ? `/workflows/${n.metadata.workflow_id}` |
| : "/activity"; |
| return ( |
| <Link |
| key={n.id} |
| to={targetUrl} |
| className="dw-topbar__notif-item" |
| onClick={() => setNotifOpen(false)} |
| > |
| <span className="dw-topbar__notif-icon"> |
| {NOTIF_ICONS[n.type] || "•"} |
| </span> |
| <span className="dw-topbar__notif-message"> |
| {n.message} |
| </span> |
| <span className="dw-topbar__notif-time"> |
| {timeAgo(n.timestamp)} |
| </span> |
| </Link> |
| ); |
| })} |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| |
| <div className="dw-topbar__profile"> |
| <button |
| className="dw-topbar__icon-btn dw-topbar__theme-btn" |
| onClick={toggleTheme} |
| title={darkMode ? "Switch to light mode" : "Switch to dark mode"} |
| aria-label={darkMode ? "Switch to light mode" : "Switch to dark mode"} |
| > |
| {darkMode ? "☀️" : "🌙"} |
| </button> |
| </div> |
| </div> |
| </header> |
| ); |
| } |
|
|