multica/apps/web/app/(dashboard)/_components/tab-bar.tsx
Naiyuan Qing 6185b7571e refactor(web): extract shared components, add tab system, and restructure issues page
- Extract AppSidebar, TabBar, TabLink into dashboard _components
- Add tab-store for browser-like tab navigation state
- Move StatusIcon/PriorityIcon to issues/_components, config to _config
- Replace inline CreateIssueForm with Dialog (status/priority selection)
- Add calendar component to packages/ui
- Simplify dashboard layout with SidebarProvider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:19:59 +08:00

271 lines
7 KiB
TypeScript

"use client";
import { useCallback, useState, useEffect, useRef } from "react";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Plus,
X,
Inbox,
Bot,
ListTodo,
BookOpen,
Settings,
FileText,
} from "lucide-react";
import { useTabStore, type Tab } from "../../../lib/tab-store";
// ---------------------------------------------------------------------------
// Icon lookup
// ---------------------------------------------------------------------------
const TAB_ICONS: Record<string, typeof Inbox> = {
inbox: Inbox,
agents: Bot,
issues: ListTodo,
"knowledge-base": BookOpen,
settings: Settings,
};
function TabIcon({ iconKey }: { iconKey?: string }) {
const Icon = iconKey ? TAB_ICONS[iconKey] : undefined;
if (!Icon) return <FileText className="h-3.5 w-3.5 shrink-0" />;
return <Icon className="h-3.5 w-3.5 shrink-0" />;
}
// ---------------------------------------------------------------------------
// Context Menu
// ---------------------------------------------------------------------------
function TabContextMenu({
x,
y,
tabId,
onClose,
}: {
x: number;
y: number;
tabId: string;
onClose: () => void;
}) {
const { tabs, closeTab } = useTabStore();
const menuRef = useRef<HTMLDivElement>(null);
const canClose = tabs.length > 1;
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleEsc);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleEsc);
};
}, [onClose]);
const handleClose = () => {
if (canClose) closeTab(tabId);
onClose();
};
const handleCloseOthers = () => {
tabs.forEach((t) => {
if (t.id !== tabId && tabs.length > 1) closeTab(t.id);
});
onClose();
};
return (
<div
ref={menuRef}
className="fixed z-50 min-w-[140px] rounded-md border bg-popover p-1 shadow-md"
style={{ left: x, top: y }}
>
<button
onClick={handleClose}
disabled={!canClose}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
>
Close
</button>
<button
onClick={handleCloseOthers}
disabled={tabs.length <= 1}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
>
Close others
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// SortableTab
// ---------------------------------------------------------------------------
function SortableTab({
tab,
isActive,
canClose,
onContextMenu,
}: {
tab: Tab;
isActive: boolean;
canClose: boolean;
onContextMenu: (e: React.MouseEvent, tabId: string) => void;
}) {
const { activateTab, closeTab } = useTabStore();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tab.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const handleClick = () => {
if (!isDragging) {
activateTab(tab.id);
}
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
closeTab(tab.id);
};
return (
<button
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
onContextMenu={(e) => onContextMenu(e, tab.id)}
className={`group flex h-7 max-w-[200px] items-center gap-1.5 rounded-lg px-2.5 text-[13px] transition-all select-none ${
isDragging ? "opacity-30" : ""
} ${
isActive
? "bg-background text-foreground shadow-sm ring-1 ring-border/60"
: "bg-background/50 text-foreground ring-1 ring-border/30 opacity-60 hover:opacity-85"
}`}
>
<TabIcon iconKey={tab.iconKey} />
<span className="truncate">{tab.title}</span>
{canClose && (
<span
onClick={handleClose}
className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity hover:bg-foreground/10 group-hover:opacity-100"
>
<X className="h-3 w-3" />
</span>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// TabBar
// ---------------------------------------------------------------------------
export function TabBar() {
const { tabs, activeTabId, reorderTabs, openTab } = useTabStore();
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
tabId: string;
} | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
})
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = tabs.findIndex((t) => t.id === active.id);
const newIndex = tabs.findIndex((t) => t.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderTabs(oldIndex, newIndex);
}
},
[tabs, reorderTabs]
);
const handleNewTab = () => {
openTab("/issues", "All Issues", { replace: false, iconKey: "issues" });
};
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
};
return (
<div className="flex h-10 shrink-0 items-center gap-1 bg-sidebar px-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={tabs.map((t) => t.id)}
strategy={horizontalListSortingStrategy}
>
{tabs.map((tab) => (
<SortableTab
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
canClose={tab.closeable && tabs.length > 1}
onContextMenu={handleContextMenu}
/>
))}
</SortableContext>
</DndContext>
<button
onClick={handleNewTab}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" />
</button>
{contextMenu && (
<TabContextMenu
x={contextMenu.x}
y={contextMenu.y}
tabId={contextMenu.tabId}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
}