shak3008's picture
feat: render in-page interactive elements as Links to support right-click open in new tab
46cb663
Raw
History Blame Contribute Delete
12.1 kB
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("");
// Notifications state
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";
// Fetch notifications
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);
// Count events newer than last time user opened the bell
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 {
// Never opened before — show all as unread
setUnreadCount(events.length);
}
}
} catch {
// silently handle
}
}, [workspace]);
// Poll every 15 seconds for new notifications
useEffect(() => {
fetchNotifications();
pollRef.current = setInterval(fetchNotifications, 15000);
return () => clearInterval(pollRef.current);
}, [fetchNotifications]);
// Close notif dropdown on outside click
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);
// Mark all current notifications as read
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>
);
}