| // src/components/threads/ThreadHeader.tsx | |
| import { useState, useRef, useEffect } from "react"; | |
| import type { Thread, ThreadStatus } from "@/types/thread"; | |
| import { | |
| MoreHorizontal, Pin, Archive, CheckCircle2, Bell, BellOff, | |
| Trash2, Sparkles, ChevronDown, Loader2, X, | |
| } from "lucide-react"; | |
| import { toast } from "react-toastify"; | |
| interface ThreadHeaderProps { | |
| thread: Thread; | |
| onStatusChange: (status: ThreadStatus) => void; | |
| onDelete: () => void; | |
| onToggleSubscribe: () => void; | |
| onToggleAiPanel: () => void; | |
| aiPanelOpen: boolean; | |
| onSummarize: () => Promise<string>; | |
| isAiResponding: boolean; | |
| } | |
| export function ThreadHeader({ | |
| thread, | |
| onStatusChange, | |
| onDelete, | |
| onToggleSubscribe, | |
| onToggleAiPanel, | |
| aiPanelOpen, | |
| onSummarize, | |
| isAiResponding, | |
| }: ThreadHeaderProps) { | |
| const [menuOpen, setMenuOpen] = useState(false); | |
| const menuRef = useRef<HTMLDivElement>(null); | |
| // Close menu when clicking outside | |
| useEffect(() => { | |
| if (!menuOpen) return; | |
| const handleClickOutside = (e: MouseEvent) => { | |
| if (menuRef.current && !menuRef.current.contains(e.target as Node)) { | |
| setMenuOpen(false); | |
| } | |
| }; | |
| document.addEventListener("mousedown", handleClickOutside); | |
| return () => document.removeEventListener("mousedown", handleClickOutside); | |
| }, [menuOpen]); | |
| // Close menu on Escape key | |
| useEffect(() => { | |
| if (!menuOpen) return; | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if (e.key === "Escape") setMenuOpen(false); | |
| }; | |
| document.addEventListener("keydown", handleKeyDown); | |
| return () => document.removeEventListener("keydown", handleKeyDown); | |
| }, [menuOpen]); | |
| const statusActions: { status: ThreadStatus; icon: typeof Pin; label: string }[] = [ | |
| { status: "pinned", icon: Pin, label: "Pin" }, | |
| { status: "resolved", icon: CheckCircle2, label: "Resolve" }, | |
| { status: "archived", icon: Archive, label: "Archive" }, | |
| { status: "active", icon: CheckCircle2, label: "Reopen" }, | |
| ]; | |
| const handleSummarize = async () => { | |
| try { | |
| const summary = await onSummarize(); | |
| toast.success(summary.slice(0, 200) + (summary.length > 200 ? "…" : "")); | |
| } catch { | |
| toast.error("Failed to generate summary"); | |
| } | |
| }; | |
| const statusColor = { | |
| active: "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400", | |
| resolved: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400", | |
| pinned: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400", | |
| archived: "bg-gray-100 dark:bg-gray-700 text-gray-500", | |
| }[thread.status] || "bg-gray-100 dark:bg-gray-700 text-gray-500"; | |
| return ( | |
| <div className="border-b border-gray-100 bg-gradient-to-r from-primary/5 to-transparent px-4 py-3 dark:border-gray-700"> | |
| <div className="flex items-center justify-between"> | |
| <div className="min-w-0 flex-1"> | |
| <h3 className="truncate font-semibold text-gray-900 dark:text-gray-100"> | |
| {thread.title} | |
| </h3> | |
| <div className="mt-0.5 flex items-center gap-2"> | |
| <span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${statusColor}`}> | |
| {thread.status} | |
| </span> | |
| <span className="text-xs text-gray-400"> | |
| {thread.messageCount} message{thread.messageCount !== 1 ? "s" : ""} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| {/* AI Context Panel Toggle */} | |
| <button | |
| onClick={onToggleAiPanel} | |
| className={`rounded-lg p-1.5 transition ${ | |
| aiPanelOpen | |
| ? "bg-primary/10 text-primary" | |
| : "text-gray-400 hover:bg-gray-100 hover:text-primary dark:hover:bg-gray-700" | |
| }`} | |
| title="AI Context Assistant" | |
| aria-label="Toggle AI assistant panel" | |
| aria-pressed={aiPanelOpen} | |
| > | |
| {isAiResponding ? ( | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| ) : ( | |
| <Sparkles className="h-4 w-4" /> | |
| )} | |
| </button> | |
| {/* Subscribe/Unsubscribe */} | |
| <button | |
| onClick={onToggleSubscribe} | |
| className={`rounded-lg p-1.5 transition ${ | |
| thread.isSubscribed | |
| ? "text-primary" | |
| : "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" | |
| }`} | |
| title={thread.isSubscribed ? "Unsubscribe from updates" : "Subscribe to updates"} | |
| aria-label={thread.isSubscribed ? "Unsubscribe" : "Subscribe"} | |
| > | |
| {thread.isSubscribed ? ( | |
| <Bell className="h-4 w-4" /> | |
| ) : ( | |
| <BellOff className="h-4 w-4" /> | |
| )} | |
| </button> | |
| {/* Dropdown Menu */} | |
| <div className="relative" ref={menuRef}> | |
| <button | |
| onClick={() => setMenuOpen(!menuOpen)} | |
| className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700" | |
| aria-label="Thread actions" | |
| aria-haspopup="true" | |
| aria-expanded={menuOpen} | |
| > | |
| <MoreHorizontal className="h-4 w-4" /> | |
| </button> | |
| {menuOpen && ( | |
| <div | |
| className="absolute right-0 mt-1 z-30 w-44 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800" | |
| role="menu" | |
| > | |
| {statusActions | |
| .filter((action) => action.status !== thread.status) | |
| .map((action) => ( | |
| <button | |
| key={action.status} | |
| onClick={() => { | |
| onStatusChange(action.status); | |
| setMenuOpen(false); | |
| }} | |
| className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700" | |
| role="menuitem" | |
| > | |
| <action.icon className="h-4 w-4" /> | |
| {action.label} | |
| </button> | |
| ))} | |
| <button | |
| onClick={handleSummarize} | |
| disabled={isAiResponding} | |
| className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50" | |
| role="menuitem" | |
| > | |
| <Sparkles className="h-4 w-4" /> | |
| Summarize | |
| </button> | |
| <hr className="my-1 border-gray-100 dark:border-gray-700" /> | |
| <button | |
| onClick={() => { | |
| onDelete(); | |
| setMenuOpen(false); | |
| }} | |
| className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20" | |
| role="menuitem" | |
| > | |
| <Trash2 className="h-4 w-4" /> Delete Thread | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } |
Xet Storage Details
- Size:
- 7.27 kB
- Xet hash:
- 765cca4bd34fc15ee8042f24280cf649fad3565c3f946fdeeec35cce1213d3d5
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.