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>
This commit is contained in:
Naiyuan Qing 2026-03-23 20:05:56 +08:00
parent cc2281416e
commit 6185b7571e
24 changed files with 2184 additions and 1162 deletions

View file

@ -0,0 +1,295 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Inbox,
ListTodo,
Bot,
BookOpen,
ChevronDown,
Settings,
LogOut,
Plus,
Check,
} from "lucide-react";
import { MulticaIcon } from "@multica/ui/components/multica-icon";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@multica/ui/components/ui/sidebar";
import { useAuth } from "../../../lib/auth-context";
import { useTabStore } from "../../../lib/tab-store";
const navItems = [
{ href: "/inbox", label: "Inbox", icon: Inbox, iconKey: "inbox" },
{ href: "/agents", label: "Agents", icon: Bot, iconKey: "agents" },
{ href: "/issues", label: "Issues", icon: ListTodo, iconKey: "issues" },
{
href: "/knowledge-base",
label: "Knowledge Base",
icon: BookOpen,
iconKey: "knowledge-base",
},
];
export function AppSidebar() {
const pathname = usePathname();
const {
user,
workspace,
workspaces,
logout,
switchWorkspace,
createWorkspace,
} = useAuth();
const { openTab } = useTabStore();
const [showMenu, setShowMenu] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newName, setNewName] = useState("");
const [newSlug, setNewSlug] = useState("");
const [creating, setCreating] = useState(false);
const handleNameChange = (value: string) => {
setNewName(value);
setNewSlug(
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
);
};
const handleCreateWorkspace = async () => {
if (!newName.trim() || !newSlug.trim()) return;
setCreating(true);
try {
const ws = await createWorkspace({
name: newName.trim(),
slug: newSlug.trim(),
});
setShowCreateDialog(false);
setNewName("");
setNewSlug("");
await switchWorkspace(ws.id);
} catch (err) {
console.error("Failed to create workspace:", err);
} finally {
setCreating(false);
}
};
return (
<>
<Sidebar variant="inset">
{/* Workspace Switcher */}
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" onClick={() => setShowMenu(!showMenu)}>
<MulticaIcon className="size-4" noSpin />
<span className="flex-1 truncate font-semibold">
{workspace?.name ?? "Multica"}
</span>
<ChevronDown className="size-4" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{showMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowMenu(false)}
/>
<div className="absolute left-2 top-14 z-50 w-52 rounded-lg border bg-popover p-1 shadow-md">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{user?.email}
</div>
<div className="my-1 border-t" />
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Workspaces
</div>
{workspaces.map((ws) => (
<button
key={ws.id}
onClick={() => {
setShowMenu(false);
if (ws.id !== workspace?.id) {
switchWorkspace(ws.id);
}
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-[10px] font-semibold">
{ws.name.charAt(0).toUpperCase()}
</span>
<span className="flex-1 truncate text-left">
{ws.name}
</span>
{ws.id === workspace?.id && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
))}
<button
onClick={() => {
setShowMenu(false);
setShowCreateDialog(true);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent"
>
<Plus className="h-3.5 w-3.5" />
Create workspace
</button>
<div className="my-1 border-t" />
<Link
href="/settings"
onClick={() => setShowMenu(false)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
>
<Settings className="h-3.5 w-3.5" />
Settings
</Link>
<button
onClick={() => {
setShowMenu(false);
logout();
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-500 hover:bg-accent"
>
<LogOut className="h-3.5 w-3.5" />
Sign out
</button>
</div>
</>
)}
</SidebarHeader>
{/* Navigation */}
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => {
const isActive =
pathname === item.href ||
pathname.startsWith(item.href + "/");
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
isActive={isActive}
render={<Link href={item.href} />}
onClick={() =>
openTab(item.href, item.label, {
replace: true,
iconKey: item.iconKey,
})
}
>
<item.icon />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{/* User */}
<SidebarFooter>
{user && (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="sm">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[9px] font-medium">
{user.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<span className="truncate">{user.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
)}
</SidebarFooter>
</Sidebar>
{/* Create Workspace Dialog */}
{showCreateDialog && (
<>
<div
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
onClick={() => setShowCreateDialog(false)}
/>
<div className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
<div className="flex flex-col gap-1.5">
<h2 className="text-lg font-semibold leading-none">
Create workspace
</h2>
<p className="text-sm text-muted-foreground">
Create a new workspace for your team.
</p>
</div>
<div className="mt-4 space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">
Name
</label>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
Slug
</label>
<input
type="text"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
placeholder="my-workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setShowCreateDialog(false)}
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleCreateWorkspace}
disabled={creating || !newName.trim() || !newSlug.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? "Creating..." : "Create"}
</button>
</div>
</div>
</>
)}
</>
);
}

View file

@ -0,0 +1,271 @@
"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>
);
}

View file

@ -0,0 +1,30 @@
"use client";
import Link from "next/link";
import { useTabStore } from "../../../lib/tab-store";
export function TabLink({
href,
title,
iconKey,
children,
...props
}: {
href: string;
title: string;
iconKey?: string;
children: React.ReactNode;
} & Omit<React.ComponentProps<typeof Link>, "onClick" | "href">) {
const { openTab } = useTabStore();
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
openTab(href, title, { replace: false, iconKey });
};
return (
<Link href={href} onClick={handleClick} {...props}>
{children}
</Link>
);
}

View file

@ -0,0 +1,2 @@
export { StatusIcon } from "./status-icon";
export { PriorityIcon } from "./priority-icon";

View file

@ -0,0 +1,57 @@
import type { IssuePriority } from "@multica/types";
import { PRIORITY_CONFIG } from "../../_config";
export function PriorityIcon({
priority,
className = "",
}: {
priority: IssuePriority;
className?: string;
}) {
const cfg = PRIORITY_CONFIG[priority];
// "none" — simple horizontal dashes
if (cfg.bars === 0) {
return (
<svg
viewBox="0 0 16 16"
className={`h-3.5 w-3.5 text-muted-foreground shrink-0 ${className}`}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<line x1="3" y1="8" x2="13" y2="8" />
</svg>
);
}
const isUrgent = priority === "urgent";
return (
<svg
viewBox="0 0 16 16"
className={`h-3.5 w-3.5 ${cfg.color} shrink-0 ${className}`}
fill="currentColor"
style={isUrgent ? { animation: "priority-pulse 2s ease-in-out infinite" } : undefined}
>
{[0, 1, 2, 3].map((i) => (
<rect
key={i}
x={1 + i * 4}
width="3"
rx="0.5"
style={{
y: 12 - (i + 1) * 3,
height: (i + 1) * 3,
opacity: i < cfg.bars ? 1 : 0.2,
transition: "y 0.2s ease, height 0.2s ease, opacity 0.2s ease",
}}
/>
))}
{isUrgent && (
<style>{`@keyframes priority-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}}`}</style>
)}
</svg>
);
}

View file

@ -0,0 +1,169 @@
import type { IssueStatus } from "@multica/types";
import { STATUS_CONFIG } from "../../_config";
// ---------------------------------------------------------------------------
// Circle geometry constants (viewBox 0 0 16 16, center 8,8, radius 6)
// ---------------------------------------------------------------------------
const CX = 8;
const CY = 8;
const R = 6;
// ---------------------------------------------------------------------------
// Per-status SVG renderers — Linear-style icons
// ---------------------------------------------------------------------------
/** 16 small dots arranged in a ring */
function BacklogIcon() {
const count = 16;
const dotR = 0.65;
return (
<g>
{Array.from({ length: count }, (_, i) => {
const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
return (
<circle
key={i}
cx={CX + R * Math.cos(angle)}
cy={CY + R * Math.sin(angle)}
r={dotR}
fill="currentColor"
/>
);
})}
</g>
);
}
/** Empty circle, solid outline */
function TodoIcon() {
return (
<circle
cx={CX}
cy={CY}
r={R}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
);
}
/** Circle outline + right half filled (D-shape) */
function InProgressIcon() {
return (
<>
<circle
cx={CX}
cy={CY}
r={R}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d={`M${CX},${CY - R} A${R},${R} 0 0,1 ${CX},${CY + R} Z`}
fill="currentColor"
/>
</>
);
}
/** Circle outline + 75% pie fill (bottom-left quarter empty) */
function InReviewIcon() {
return (
<>
<circle
cx={CX}
cy={CY}
r={R}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d={`M${CX},${CY} L${CX},${CY - R} A${R},${R} 0 1,1 ${CX - R},${CY} Z`}
fill="currentColor"
/>
</>
);
}
/** Solid filled circle + white checkmark */
function DoneIcon() {
return (
<>
<circle cx={CX} cy={CY} r={R} fill="currentColor" />
<path
d="M5.5 8.2 L7.2 9.8 L10.5 6.2"
fill="none"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
);
}
/** Circle outline + X inside */
function CancelledIcon() {
return (
<>
<circle
cx={CX}
cy={CY}
r={R}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M5.75 5.75 L10.25 10.25 M10.25 5.75 L5.75 10.25"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</>
);
}
// ---------------------------------------------------------------------------
// Renderer map
// ---------------------------------------------------------------------------
const STATUS_RENDERERS: Record<IssueStatus, () => React.ReactNode> = {
backlog: BacklogIcon,
todo: TodoIcon,
in_progress: InProgressIcon,
in_review: InReviewIcon,
done: DoneIcon,
blocked: CancelledIcon, // fallback if backend sends blocked
cancelled: CancelledIcon,
};
// ---------------------------------------------------------------------------
// Public component
// ---------------------------------------------------------------------------
export function StatusIcon({
status,
className = "h-4 w-4",
}: {
status: IssueStatus;
className?: string;
}) {
const cfg = STATUS_CONFIG[status];
const Renderer = STATUS_RENDERERS[status];
return (
<svg
viewBox="0 0 16 16"
fill="none"
className={`${className} ${cfg.iconColor} shrink-0`}
>
<Renderer />
</svg>
);
}

View file

@ -0,0 +1,2 @@
export * from "./icons";
export * from "./pickers";

View file

@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { Bot, UserMinus } from "lucide-react";
import type { IssueAssigneeType, UpdateIssueRequest } from "@multica/types";
import { useAuth } from "../../../../../lib/auth-context";
import {
PropertyPicker,
PickerItem,
PickerSection,
PickerEmpty,
} from "./property-picker";
export function AssigneePicker({
assigneeType,
assigneeId,
onUpdate,
}: {
assigneeType: IssueAssigneeType | null;
assigneeId: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const { members, agents, getActorName, getActorInitials } = useAuth();
const query = filter.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
a.name.toLowerCase().includes(query),
);
const isSelected = (type: string, id: string) =>
assigneeType === type && assigneeId === id;
const triggerLabel =
assigneeType && assigneeId
? getActorName(assigneeType, assigneeId)
: "Unassigned";
return (
<PropertyPicker
open={open}
onOpenChange={(v: boolean) => {
setOpen(v);
if (!v) setFilter("");
}}
width="w-52"
searchable
searchPlaceholder="Assign to..."
onSearchChange={setFilter}
trigger={
assigneeType && assigneeId ? (
<>
<div
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] ${
assigneeType === "agent"
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
: "bg-muted text-muted-foreground"
}`}
style={{ width: 18, height: 18 }}
>
{assigneeType === "agent" ? (
<Bot style={{ width: 10, height: 10 }} />
) : (
getActorInitials(assigneeType, assigneeId)
)}
</div>
<span>{triggerLabel}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
)
}
>
{/* Unassigned option */}
<PickerItem
selected={!assigneeType && !assigneeId}
onClick={() => {
onUpdate({ assignee_type: null, assignee_id: null });
setOpen(false);
}}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</PickerItem>
{/* Members */}
{filteredMembers.length > 0 && (
<PickerSection label="Members">
{filteredMembers.map((m) => (
<PickerItem
key={m.user_id}
selected={isSelected("member", m.user_id)}
onClick={() => {
onUpdate({
assignee_type: "member",
assignee_id: m.user_id,
});
setOpen(false);
}}
>
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<span>{m.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{/* Agents */}
{filteredAgents.length > 0 && (
<PickerSection label="Agents">
{filteredAgents.map((a) => (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
onClick={() => {
onUpdate({
assignee_type: "agent",
assignee_id: a.id,
});
setOpen(false);
}}
>
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300">
<Bot style={{ width: 10, height: 10 }} />
</div>
<span>{a.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{filteredMembers.length === 0 &&
filteredAgents.length === 0 &&
filter && <PickerEmpty />}
</PropertyPicker>
);
}

View file

@ -0,0 +1,4 @@
export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./property-picker";
export { StatusPicker } from "./status-picker";
export { PriorityPicker } from "./priority-picker";
export { AssigneePicker } from "./assignee-picker";

View file

@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import type { IssuePriority, UpdateIssueRequest } from "@multica/types";
import { PRIORITY_ORDER, PRIORITY_CONFIG } from "../../_config";
import { PriorityIcon } from "../icons";
import { PropertyPicker, PickerItem } from "./property-picker";
export function PriorityPicker({
priority,
onUpdate,
}: {
priority: IssuePriority;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
}) {
const [open, setOpen] = useState(false);
const cfg = PRIORITY_CONFIG[priority];
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
width="w-44"
trigger={
<>
<PriorityIcon priority={priority} />
<span>{cfg.label}</span>
</>
}
>
{PRIORITY_ORDER.map((p) => {
const c = PRIORITY_CONFIG[p];
return (
<PickerItem
key={p}
selected={p === priority}
onClick={() => {
onUpdate({ priority: p });
setOpen(false);
}}
>
<PriorityIcon priority={p} />
<span>{c.label}</span>
</PickerItem>
);
})}
</PropertyPicker>
);
}

View file

@ -0,0 +1,133 @@
"use client";
import { useState, useCallback } from "react";
import { Check } from "lucide-react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
// ---------------------------------------------------------------------------
// PropertyPicker — generic Popover shell with optional search
// ---------------------------------------------------------------------------
export function PropertyPicker({
open,
onOpenChange,
trigger,
width = "w-48",
align = "end",
searchable = false,
searchPlaceholder = "Filter...",
onSearchChange,
children,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
trigger: React.ReactNode;
width?: string;
align?: "start" | "center" | "end";
searchable?: boolean;
searchPlaceholder?: string;
onSearchChange?: (query: string) => void;
children: React.ReactNode;
}) {
const [query, setQuery] = useState("");
const handleOpenChange = useCallback(
(v: boolean) => {
onOpenChange(v);
if (!v) {
setQuery("");
onSearchChange?.("");
}
},
[onOpenChange, onSearchChange],
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
{trigger}
</PopoverTrigger>
<PopoverContent align={align} className={`${width} gap-0 p-0`}>
{searchable && (
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
onSearchChange?.(e.target.value);
}}
placeholder={searchPlaceholder}
className="w-full bg-transparent text-[13px] placeholder:text-muted-foreground outline-none"
/>
</div>
)}
<div className="p-1 max-h-60 overflow-y-auto">{children}</div>
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// PickerItem — single selectable row
// ---------------------------------------------------------------------------
export function PickerItem({
selected,
onClick,
hoverClassName,
children,
}: {
selected: boolean;
onClick: () => void;
hoverClassName?: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[13px] ${hoverClassName ?? "hover:bg-accent"} transition-colors`}
>
<span className="flex flex-1 items-center gap-2">{children}</span>
{selected && <Check className="h-3.5 w-3.5 text-muted-foreground" />}
</button>
);
}
// ---------------------------------------------------------------------------
// PickerSection — group header
// ---------------------------------------------------------------------------
export function PickerSection({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div>
<div className="px-2 pt-2 pb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{label}
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// PickerEmpty — no results state
// ---------------------------------------------------------------------------
export function PickerEmpty() {
return (
<div className="px-2 py-3 text-center text-[13px] text-muted-foreground">
No results
</div>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import type { IssueStatus, UpdateIssueRequest } from "@multica/types";
import { ALL_STATUSES, STATUS_CONFIG } from "../../_config";
import { StatusIcon } from "../icons";
import { PropertyPicker, PickerItem } from "./property-picker";
export function StatusPicker({
status,
onUpdate,
}: {
status: IssueStatus;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
}) {
const [open, setOpen] = useState(false);
const cfg = STATUS_CONFIG[status];
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
width="w-44"
trigger={
<>
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span>{cfg.label}</span>
</>
}
>
{ALL_STATUSES.map((s) => {
const c = STATUS_CONFIG[s];
return (
<PickerItem
key={s}
selected={s === status}
hoverClassName={c.hoverBg}
onClick={() => {
onUpdate({ status: s });
setOpen(false);
}}
>
<StatusIcon status={s} className="h-3.5 w-3.5" />
<span>{c.label}</span>
</PickerItem>
);
})}
</PropertyPicker>
);
}

View file

@ -0,0 +1,2 @@
export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status";
export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority";

View file

@ -0,0 +1,20 @@
import type { IssuePriority } from "@multica/types";
export const PRIORITY_ORDER: IssuePriority[] = [
"urgent",
"high",
"medium",
"low",
"none",
];
export const PRIORITY_CONFIG: Record<
IssuePriority,
{ label: string; bars: number; color: string }
> = {
urgent: { label: "Urgent", bars: 4, color: "text-orange-500" },
high: { label: "High", bars: 3, color: "text-orange-400" },
medium: { label: "Medium", bars: 2, color: "text-yellow-500" },
low: { label: "Low", bars: 1, color: "text-blue-400" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
};

View file

@ -0,0 +1,32 @@
import type { IssueStatus } from "@multica/types";
export const STATUS_ORDER: IssueStatus[] = [
"backlog",
"todo",
"in_progress",
"in_review",
"done",
"cancelled",
];
export const ALL_STATUSES: IssueStatus[] = [
"backlog",
"todo",
"in_progress",
"in_review",
"done",
"cancelled",
];
export const STATUS_CONFIG: Record<
IssueStatus,
{ label: string; iconColor: string; hoverBg: string }
> = {
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
in_progress: { label: "In Progress", iconColor: "text-yellow-500", hoverBg: "hover:bg-yellow-500/10" },
in_review: { label: "In Review", iconColor: "text-green-500", hoverBg: "hover:bg-green-500/10" },
done: { label: "Done", iconColor: "text-blue-500", hoverBg: "hover:bg-blue-500/10" },
blocked: { label: "Blocked", iconColor: "text-red-500", hoverBg: "hover:bg-red-500/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
};

View file

@ -49,6 +49,20 @@ vi.mock("../../../lib/ws-context", () => ({
WSProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock tab-store
vi.mock("../../../lib/tab-store", () => ({
useTabStore: () => ({
updateTabTitle: vi.fn(),
activeTabId: "tab-1",
openTab: vi.fn(),
}),
}));
// Mock tab-link to avoid TabProvider dependency
vi.mock("../_components/tab-link", () => ({
TabLink: ({ children, href, ...props }: any) => <a href={href} {...props}>{children}</a>,
}));
// Mock api
const mockListIssues = vi.fn();
const mockCreateIssue = vi.fn();
@ -160,13 +174,14 @@ describe("IssuesPage", () => {
render(<IssuesPage />);
await waitFor(() => {
expect(screen.getByText("Backlog")).toBeInTheDocument();
// Status labels appear in both filter dropdown and board columns
expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1);
});
expect(screen.getByText("Todo")).toBeInTheDocument();
expect(screen.getByText("In Progress")).toBeInTheDocument();
expect(screen.getByText("In Review")).toBeInTheDocument();
expect(screen.getByText("Done")).toBeInTheDocument();
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1);
});
it("switches to list view", async () => {
@ -191,7 +206,20 @@ describe("IssuesPage", () => {
expect(screen.getByText("Design landing page")).toBeInTheDocument();
});
it("shows 'New Issue' button and opens create form", async () => {
it("shows 'New Issue' button", async () => {
mockListIssues.mockResolvedValueOnce({
issues: [],
total: 0,
} as ListIssuesResponse);
render(<IssuesPage />);
await waitFor(() => {
expect(screen.getByText("New Issue")).toBeInTheDocument();
});
});
it("shows create dialog when New Issue is clicked", async () => {
mockListIssues.mockResolvedValueOnce({
issues: [],
total: 0,
@ -206,15 +234,14 @@ describe("IssuesPage", () => {
await user.click(screen.getByText("New Issue"));
// Create form should be visible
expect(
screen.getByPlaceholderText("Issue title..."),
).toBeInTheDocument();
expect(screen.getByText("Create")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
// Dialog should open with title input
await waitFor(() => {
expect(screen.getByPlaceholderText("Issue title")).toBeInTheDocument();
});
expect(screen.getByText("Create Issue")).toBeInTheDocument();
});
it("creates an issue via the form", async () => {
it("creates an issue via the dialog", async () => {
mockListIssues.mockResolvedValueOnce({
issues: [],
total: 0,
@ -226,7 +253,7 @@ describe("IssuesPage", () => {
workspace_id: "ws-1",
title: "New test issue",
description: null,
status: "backlog",
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
@ -246,47 +273,21 @@ describe("IssuesPage", () => {
});
await user.click(screen.getByText("New Issue"));
await user.type(
screen.getByPlaceholderText("Issue title..."),
"New test issue",
);
await user.click(screen.getByText("Create"));
await waitFor(() => {
expect(screen.getByPlaceholderText("Issue title")).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText("Issue title"), "New test issue");
await user.click(screen.getByText("Create Issue"));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith({
title: "New test issue",
status: "todo",
priority: "none",
});
});
// New issue should appear
await waitFor(() => {
expect(screen.getByText("New test issue")).toBeInTheDocument();
});
});
it("closes create form on Cancel", async () => {
mockListIssues.mockResolvedValueOnce({
issues: [],
total: 0,
} as ListIssuesResponse);
const user = userEvent.setup();
render(<IssuesPage />);
await waitFor(() => {
expect(screen.getByText("New Issue")).toBeInTheDocument();
});
await user.click(screen.getByText("New Issue"));
expect(
screen.getByPlaceholderText("Issue title..."),
).toBeInTheDocument();
await user.click(screen.getByText("Cancel"));
expect(
screen.queryByPlaceholderText("Issue title..."),
).not.toBeInTheDocument();
expect(screen.getByText("New Issue")).toBeInTheDocument();
});
it("handles API error gracefully", async () => {

View file

@ -2,19 +2,13 @@
import { useState, useCallback, useEffect } from "react";
import Link from "next/link";
import { TabLink } from "../_components/tab-link";
import { useTabStore } from "../../../lib/tab-store";
import {
Columns3,
List,
Plus,
Bot,
Circle,
CircleDashed,
CircleDot,
CircleCheck,
CircleX,
CircleAlert,
Eye,
Minus,
} from "lucide-react";
import {
DndContext,
@ -30,70 +24,21 @@ import {
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Issue, IssueStatus, IssuePriority } from "@multica/types";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "./_data/config";
import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER } from "./_config";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from "@multica/ui/components/ui/dialog";
import { StatusIcon, PriorityIcon } from "./_components";
import { api } from "../../../lib/api";
import { useAuth } from "../../../lib/auth-context";
import { useWSEvent } from "../../../lib/ws-context";
import type { IssueCreatedPayload, IssueUpdatedPayload, IssueDeletedPayload } from "@multica/types";
// ---------------------------------------------------------------------------
// Shared icon components
// ---------------------------------------------------------------------------
const STATUS_ICONS: Record<IssueStatus, typeof Circle> = {
backlog: CircleDashed,
todo: Circle,
in_progress: CircleDot,
in_review: Eye,
done: CircleCheck,
blocked: CircleAlert,
cancelled: CircleX,
};
export function StatusIcon({
status,
className = "h-4 w-4",
}: {
status: IssueStatus;
className?: string;
}) {
const Icon = STATUS_ICONS[status];
const cfg = STATUS_CONFIG[status];
return <Icon className={`${className} ${cfg.iconColor}`} />;
}
export function PriorityIcon({
priority,
className = "",
}: {
priority: IssuePriority;
className?: string;
}) {
const cfg = PRIORITY_CONFIG[priority];
if (cfg.bars === 0) {
return <Minus className={`h-3.5 w-3.5 text-muted-foreground ${className}`} />;
}
return (
<svg
viewBox="0 0 16 16"
className={`h-3.5 w-3.5 ${cfg.color} ${className}`}
fill="currentColor"
>
{[0, 1, 2, 3].map((i) => (
<rect
key={i}
x={1 + i * 4}
y={12 - (i + 1) * 3}
width="3"
height={(i + 1) * 3}
rx="0.5"
opacity={i < cfg.bars ? 1 : 0.2}
/>
))}
</svg>
);
}
function AssigneeAvatar({
issue,
size = "sm",
@ -186,16 +131,18 @@ function DraggableBoardCard({ issue }: { issue: Issue }) {
{...attributes}
{...listeners}
className={isDragging ? "opacity-30" : ""}
onClickCapture={(e) => {
if (isDragging) e.stopPropagation();
}}
>
<Link
<TabLink
href={`/issues/${issue.id}`}
onClick={(e) => {
if (isDragging) e.preventDefault();
}}
title={issue.title}
iconKey="issues"
className="block transition-colors hover:opacity-80"
>
<BoardCardContent issue={issue} />
</Link>
</TabLink>
</div>
);
}
@ -330,8 +277,10 @@ function BoardView({
function ListRow({ issue }: { issue: Issue }) {
return (
<Link
<TabLink
href={`/issues/${issue.id}`}
title={issue.title}
iconKey="issues"
className="flex h-9 items-center gap-2 px-4 text-[13px] transition-colors hover:bg-accent/50"
>
<PriorityIcon priority={issue.priority} />
@ -346,7 +295,7 @@ function ListRow({ issue }: { issue: Issue }) {
</span>
)}
<AssigneeAvatar issue={issue} />
</Link>
</TabLink>
);
}
@ -383,65 +332,120 @@ function ListView({ issues }: { issues: Issue[] }) {
}
// ---------------------------------------------------------------------------
// Create Issue Dialog (simple inline)
// Create Issue Dialog
// ---------------------------------------------------------------------------
function CreateIssueForm({ onCreated }: { onCreated: (issue: Issue) => void }) {
function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void }) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [description, setDescription] = useState("");
const [status, setStatus] = useState<IssueStatus>("todo");
const [priority, setPriority] = useState<IssuePriority>("none");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const reset = () => {
setTitle("");
setDescription("");
setStatus("todo");
setPriority("none");
};
const handleSubmit = async () => {
if (!title.trim()) return;
setSubmitting(true);
try {
const issue = await api.createIssue({ title: title.trim() });
const issue = await api.createIssue({
title: title.trim(),
description: description.trim() || undefined,
status,
priority,
});
onCreated(issue);
setTitle("");
setIsOpen(false);
reset();
setOpen(false);
} catch (err) {
console.error("Failed to create issue:", err);
} finally {
setSubmitting(false);
}
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus className="h-3.5 w-3.5" />
New Issue
</button>
);
}
return (
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<input
autoFocus
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setIsOpen(false);
}}
placeholder="Issue title..."
className="rounded-md border bg-background px-2 py-1 text-xs w-48"
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogTrigger
render={
<button className="flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/90">
<Plus className="h-3.5 w-3.5" />
New Issue
</button>
}
/>
<button
type="submit"
className="rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground"
>
Create
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="text-xs text-muted-foreground"
>
Cancel
</button>
</form>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New Issue</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<input
autoFocus
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
placeholder="Issue title"
className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring"
/>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add description..."
rows={3}
className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring resize-none"
/>
<div className="flex items-center gap-3 flex-wrap">
{/* Status selector */}
<div className="flex items-center gap-1.5 text-xs">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<select
value={status}
onChange={(e) => setStatus(e.target.value as IssueStatus)}
className="bg-transparent text-xs outline-none cursor-pointer"
>
{ALL_STATUSES.map((s) => (
<option key={s} value={s}>{STATUS_CONFIG[s].label}</option>
))}
</select>
</div>
{/* Priority selector */}
<div className="flex items-center gap-1.5 text-xs">
<PriorityIcon priority={priority} />
<select
value={priority}
onChange={(e) => setPriority(e.target.value as IssuePriority)}
className="bg-transparent text-xs outline-none cursor-pointer"
>
{PRIORITY_ORDER.map((p) => (
<option key={p} value={p}>{PRIORITY_CONFIG[p].label}</option>
))}
</select>
</div>
</div>
</div>
<DialogFooter>
<button
onClick={handleSubmit}
disabled={!title.trim() || submitting}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? "Creating..." : "Create Issue"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@ -452,19 +456,27 @@ function CreateIssueForm({ onCreated }: { onCreated: (issue: Issue) => void }) {
type ViewMode = "board" | "list";
export default function IssuesPage() {
const { closeTabByPath } = useTabStore();
const [view, setView] = useState<ViewMode>("board");
const [issues, setIssues] = useState<Issue[]>([]);
const [loading, setLoading] = useState(true);
const [filterStatus, setFilterStatus] = useState<IssueStatus | "">("");
const [filterPriority, setFilterPriority] = useState<IssuePriority | "">("");
useEffect(() => {
setLoading(true);
api
.listIssues({ limit: 200 })
.listIssues({
limit: 200,
...(filterStatus ? { status: filterStatus } : {}),
...(filterPriority ? { priority: filterPriority } : {}),
})
.then((res) => {
setIssues(res.issues);
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
}, [filterStatus, filterPriority]);
// Real-time updates
useWSEvent(
@ -491,7 +503,8 @@ export default function IssuesPage() {
useCallback((payload: unknown) => {
const { issue_id } = payload as IssueDeletedPayload;
setIssues((prev) => prev.filter((i) => i.id !== issue_id));
}, []),
closeTabByPath(`/issues/${issue_id}`);
}, [closeTabByPath]),
);
const handleMoveIssue = useCallback(
@ -558,8 +571,30 @@ export default function IssuesPage() {
List
</button>
</div>
<div className="flex items-center gap-2">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as IssueStatus | "")}
className="rounded-md border bg-background px-2 py-1 text-xs outline-none"
>
<option value="">All Status</option>
{ALL_STATUSES.map((s) => (
<option key={s} value={s}>{STATUS_CONFIG[s].label}</option>
))}
</select>
<select
value={filterPriority}
onChange={(e) => setFilterPriority(e.target.value as IssuePriority | "")}
className="rounded-md border bg-background px-2 py-1 text-xs outline-none"
>
<option value="">All Priority</option>
{PRIORITY_ORDER.map((p) => (
<option key={p} value={p}>{PRIORITY_CONFIG[p].label}</option>
))}
</select>
</div>
</div>
<CreateIssueForm onCreated={handleIssueCreated} />
<CreateIssueDialog onCreated={handleIssueCreated} />
</div>
<div className="flex-1 overflow-hidden">

View file

@ -1,69 +1,21 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
Inbox,
ListTodo,
Bot,
BookOpen,
ChevronDown,
Settings,
LogOut,
Plus,
Check,
} from "lucide-react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { MulticaIcon } from "@multica/ui/components/multica-icon";
import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
import { useAuth } from "../../lib/auth-context";
const navItems = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
{ href: "/agents", label: "Agents", icon: Bot },
{ href: "/issues", label: "Issues", icon: ListTodo },
{ href: "/knowledge-base", label: "Knowledge Base", icon: BookOpen },
];
import { TabProvider } from "../../lib/tab-store";
import { AppSidebar } from "./_components/app-sidebar";
import { TabBar } from "./_components/tab-bar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const { user, workspace, workspaces, isLoading, logout, switchWorkspace, createWorkspace } = useAuth();
const [showMenu, setShowMenu] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newName, setNewName] = useState("");
const [newSlug, setNewSlug] = useState("");
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!isLoading && user && workspaces.length === 0) {
setShowCreateDialog(true);
}
}, [isLoading, user, workspaces.length]);
const handleNameChange = (value: string) => {
setNewName(value);
setNewSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""));
};
const handleCreateWorkspace = async () => {
if (!newName.trim() || !newSlug.trim()) return;
setCreating(true);
try {
const ws = await createWorkspace({ name: newName.trim(), slug: newSlug.trim() });
setShowCreateDialog(false);
setNewName("");
setNewSlug("");
await switchWorkspace(ws.id);
} catch (err) {
console.error("Failed to create workspace:", err);
} finally {
setCreating(false);
}
};
const { user, workspace, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && !user) {
@ -79,289 +31,19 @@ export default function DashboardLayout({
);
}
if (!user) return null;
if (!workspace) {
return (
<>
<div className="flex min-h-screen items-center justify-center bg-canvas p-6">
<div className="w-full max-w-md rounded-2xl border bg-background p-8 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<MulticaIcon className="size-5" noSpin />
</div>
<div>
<h1 className="text-lg font-semibold">Create your first workspace</h1>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</div>
<p className="mt-6 text-sm text-muted-foreground">
You need a workspace before you can manage issues, agents, and inbox items.
</p>
<div className="mt-6 flex gap-2">
<button
onClick={() => setShowCreateDialog(true)}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create workspace
</button>
<button
onClick={logout}
className="rounded-md border px-3 py-2 text-sm hover:bg-accent"
>
Sign out
</button>
</div>
</div>
</div>
{showCreateDialog && (
<>
<div
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
onClick={() => setShowCreateDialog(false)}
/>
<div className="fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
<div className="flex flex-col gap-1.5">
<h2 className="text-lg font-semibold leading-none">Create workspace</h2>
<p className="text-sm text-muted-foreground">
Create a new workspace for your team.
</p>
</div>
<div className="mt-4 space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Name</label>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Slug</label>
<input
type="text"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
placeholder="my-workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={logout}
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
>
Sign out
</button>
<button
onClick={handleCreateWorkspace}
disabled={creating || !newName.trim() || !newSlug.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? "Creating..." : "Create"}
</button>
</div>
</div>
</>
)}
</>
);
}
if (!user || !workspace) return null;
return (
<div className="flex h-screen bg-canvas">
{/* Sidebar */}
<aside className="flex w-56 shrink-0 flex-col">
{/* Workspace Switcher */}
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="flex h-12 w-full items-center gap-2 px-3 hover:bg-sidebar-accent/50 transition-colors"
>
<MulticaIcon className="size-4" noSpin />
<span className="flex-1 truncate text-left text-sm font-semibold">
{workspace?.name ?? "Multica"}
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
{showMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowMenu(false)}
/>
<div className="absolute left-2 top-12 z-50 w-52 rounded-lg border bg-popover p-1 shadow-md">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{user.email}
</div>
<div className="my-1 border-t" />
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Workspaces
</div>
{workspaces.map((ws) => (
<button
key={ws.id}
onClick={() => {
setShowMenu(false);
if (ws.id !== workspace?.id) {
switchWorkspace(ws.id);
}
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-[10px] font-semibold">
{ws.name.charAt(0).toUpperCase()}
</span>
<span className="flex-1 truncate text-left">{ws.name}</span>
{ws.id === workspace?.id && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
))}
<button
onClick={() => {
setShowMenu(false);
setShowCreateDialog(true);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent"
>
<Plus className="h-3.5 w-3.5" />
Create workspace
</button>
<div className="my-1 border-t" />
<Link
href="/settings"
onClick={() => setShowMenu(false)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
>
<Settings className="h-3.5 w-3.5" />
Settings
</Link>
<button
onClick={() => {
setShowMenu(false);
logout();
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-500 hover:bg-accent"
>
<LogOut className="h-3.5 w-3.5" />
Sign out
</button>
</div>
</>
)}
<TabProvider workspaceId={workspace.id}>
<SidebarProvider>
<AppSidebar />
<div className="relative flex w-full flex-1 flex-col overflow-hidden">
<TabBar />
<main className="flex-1 overflow-auto rounded-xl bg-background shadow-sm md:mr-2 md:mb-2">
{children}
</main>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-0.5 px-2">
{navItems.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
}`}
>
<item.icon className="h-4 w-4 shrink-0" />
{item.label}
</Link>
);
})}
</nav>
{/* User info at bottom */}
<div className="border-t px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium">
{user.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<span className="truncate text-xs text-muted-foreground">
{user.name}
</span>
</div>
</div>
</aside>
{/* Main content */}
<div className="flex-1 pt-1.5 pr-1.5 pb-1.5">
<main className="h-full overflow-auto rounded-xl bg-background shadow-sm">
{children}
</main>
</div>
{/* Create Workspace Dialog */}
{showCreateDialog && (
<>
<div
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
onClick={() => setShowCreateDialog(false)}
/>
<div className="fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
<div className="flex flex-col gap-1.5">
<h2 className="text-lg font-semibold leading-none">Create workspace</h2>
<p className="text-sm text-muted-foreground">
Create a new workspace for your team.
</p>
</div>
<div className="mt-4 space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Name</label>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Slug</label>
<input
type="text"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
placeholder="my-workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setShowCreateDialog(false)}
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleCreateWorkspace}
disabled={creating || !newName.trim() || !newSlug.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? "Creating..." : "Create"}
</button>
</div>
</div>
</>
)}
</div>
</SidebarProvider>
</TabProvider>
);
}

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { ThemeProvider } from "@multica/ui/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { AuthProvider } from "../lib/auth-context";
import { WSProvider } from "../lib/ws-context";
import "./globals.css";
@ -26,6 +27,7 @@ export default function RootLayout({
<AuthProvider>
<WSProvider>{children}</WSProvider>
</AuthProvider>
<Toaster />
</ThemeProvider>
</body>
</html>

357
apps/web/lib/tab-store.tsx Normal file
View file

@ -0,0 +1,357 @@
"use client";
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useRef,
type ReactNode,
} from "react";
import { usePathname, useRouter } from "next/navigation";
import { arrayMove } from "@dnd-kit/sortable";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Tab {
id: string;
path: string;
title: string;
iconKey?: string;
closeable: boolean;
}
interface TabStoreValue {
tabs: Tab[];
activeTabId: string;
openTab: (
path: string,
title: string,
opts?: { replace?: boolean; iconKey?: string }
) => void;
activateTab: (tabId: string) => void;
closeTab: (tabId: string) => void;
closeTabByPath: (path: string) => void;
updateTabTitle: (tabId: string, title: string) => void;
reorderTabs: (oldIndex: number, newIndex: number) => void;
}
// ---------------------------------------------------------------------------
// Route title mapping (for hydration / fallback)
// ---------------------------------------------------------------------------
const ROUTE_TITLES: Record<string, string> = {
"/inbox": "Inbox",
"/agents": "Agents",
"/issues": "All Issues",
"/knowledge-base": "Knowledge Base",
"/settings": "Settings",
};
const ROUTE_ICON_KEYS: Record<string, string> = {
"/inbox": "inbox",
"/agents": "agents",
"/issues": "issues",
"/knowledge-base": "knowledge-base",
"/settings": "settings",
};
function getTitleForPath(path: string): string {
if (ROUTE_TITLES[path]) return ROUTE_TITLES[path];
if (path.startsWith("/issues/")) return path.split("/")[2]?.slice(0, 8) ?? "Issue";
if (path.startsWith("/agents/")) return "Agent";
return "Tab";
}
function getIconKeyForPath(path: string): string | undefined {
if (ROUTE_ICON_KEYS[path]) return ROUTE_ICON_KEYS[path];
// Sub-paths inherit parent icon
for (const [route, key] of Object.entries(ROUTE_ICON_KEYS)) {
if (path.startsWith(route + "/")) return key;
}
return undefined;
}
// ---------------------------------------------------------------------------
// localStorage helpers
// ---------------------------------------------------------------------------
function storageKey(workspaceId: string): string {
return `multica-tabs-${workspaceId}`;
}
function loadTabs(workspaceId: string): { tabs: Tab[]; activeTabId: string } | null {
try {
const raw = localStorage.getItem(storageKey(workspaceId));
if (!raw) return null;
const data = JSON.parse(raw) as { tabs: Tab[]; activeTabId: string };
if (Array.isArray(data.tabs) && data.tabs.length > 0 && data.activeTabId) {
return data;
}
return null;
} catch {
return null;
}
}
function saveTabs(workspaceId: string, tabs: Tab[], activeTabId: string): void {
try {
localStorage.setItem(
storageKey(workspaceId),
JSON.stringify({ tabs, activeTabId })
);
} catch {
// localStorage full or unavailable
}
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const TabStoreContext = createContext<TabStoreValue | null>(null);
export function useTabStore(): TabStoreValue {
const ctx = useContext(TabStoreContext);
if (!ctx) {
throw new Error("useTabStore must be used within a TabProvider.");
}
return ctx;
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function TabProvider({
workspaceId,
children,
}: {
workspaceId: string;
children: ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
// Suppress URL-sync effect when we are the ones triggering navigation
const navigatingRef = useRef(false);
// Initialize tabs: hydrate from localStorage or create default
const [tabs, setTabs] = useState<Tab[]>(() => {
const saved = loadTabs(workspaceId);
if (saved) return saved.tabs;
return [
{
id: crypto.randomUUID(),
path: pathname,
title: getTitleForPath(pathname),
iconKey: getIconKeyForPath(pathname),
closeable: false,
},
];
});
const [activeTabId, setActiveTabId] = useState<string>(() => {
const saved = loadTabs(workspaceId);
if (saved) {
// If saved active tab still exists, use it
const exists = saved.tabs.find((t) => t.id === saved.activeTabId);
if (exists) return saved.activeTabId;
}
return tabs[0]?.id ?? "";
});
// Persist on change
useEffect(() => {
saveTabs(workspaceId, tabs, activeTabId);
}, [workspaceId, tabs, activeTabId]);
// Sync active tab with initial pathname on mount
const initialSyncDone = useRef(false);
useEffect(() => {
if (initialSyncDone.current) return;
initialSyncDone.current = true;
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab && activeTab.path === pathname) return;
// Try to find a tab matching the current URL
const match = tabs.find((t) => t.path === pathname);
if (match) {
setActiveTabId(match.id);
} else if (activeTab) {
// Replace the active tab with current URL
setTabs((prev) =>
prev.map((t) =>
t.id === activeTabId
? {
...t,
path: pathname,
title: getTitleForPath(pathname),
iconKey: getIconKeyForPath(pathname),
}
: t
)
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// URL sync: when pathname changes externally (back/forward, direct URL)
useEffect(() => {
if (navigatingRef.current) {
navigatingRef.current = false;
return;
}
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab?.path === pathname) return;
// Find existing tab with this path
const match = tabs.find((t) => t.path === pathname);
if (match) {
setActiveTabId(match.id);
} else {
// Replace current tab
setTabs((prev) =>
prev.map((t) =>
t.id === activeTabId
? {
...t,
path: pathname,
title: getTitleForPath(pathname),
iconKey: getIconKeyForPath(pathname),
}
: t
)
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
// -----------------------------------------------------------------------
// Actions
// -----------------------------------------------------------------------
const openTab = useCallback(
(
path: string,
title: string,
opts?: { replace?: boolean; iconKey?: string }
) => {
const replace = opts?.replace ?? false;
const iconKey = opts?.iconKey ?? getIconKeyForPath(path);
if (replace) {
// Sidebar nav click: find existing tab with same path or replace current
const existing = tabs.find((t) => t.path === path);
if (existing) {
setActiveTabId(existing.id);
navigatingRef.current = true;
router.push(path);
return;
}
// Replace current active tab
setTabs((prev) =>
prev.map((t) =>
t.id === activeTabId
? { ...t, path, title, iconKey, closeable: false }
: t
)
);
setActiveTabId(activeTabId); // stays the same
navigatingRef.current = true;
router.push(path);
} else {
// Open new tab (e.g., clicking an issue)
const newTab: Tab = {
id: crypto.randomUUID(),
path,
title,
iconKey,
closeable: true,
};
setTabs((prev) => {
const idx = prev.findIndex((t) => t.id === activeTabId);
const next = [...prev];
next.splice(idx + 1, 0, newTab);
return next;
});
setActiveTabId(newTab.id);
navigatingRef.current = true;
router.push(path);
}
},
[tabs, activeTabId, router]
);
const activateTab = useCallback(
(tabId: string) => {
const tab = tabs.find((t) => t.id === tabId);
if (!tab) return;
setActiveTabId(tabId);
navigatingRef.current = true;
router.push(tab.path);
},
[tabs, router]
);
const closeTab = useCallback(
(tabId: string) => {
if (tabs.length <= 1) return;
const idx = tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return;
const next = tabs.filter((t) => t.id !== tabId);
setTabs(next);
if (tabId === activeTabId) {
// Activate neighbor: prefer left, fallback to first
const newActive = next[Math.max(0, idx - 1)];
if (newActive) {
setActiveTabId(newActive.id);
navigatingRef.current = true;
router.push(newActive.path);
}
}
},
[tabs, activeTabId, router]
);
const closeTabByPath = useCallback(
(path: string) => {
const tab = tabs.find((t) => t.path === path);
if (tab) closeTab(tab.id);
},
[tabs, closeTab]
);
const updateTabTitle = useCallback((tabId: string, title: string) => {
setTabs((prev) =>
prev.map((t) => (t.id === tabId ? { ...t, title } : t))
);
}, []);
const reorderTabs = useCallback((oldIndex: number, newIndex: number) => {
setTabs((prev) => arrayMove(prev, oldIndex, newIndex));
}, []);
const value: TabStoreValue = {
tabs,
activeTabId,
openTab,
activateTab,
closeTab,
closeTabByPath,
updateTabTitle,
reorderTabs,
};
return (
<TabStoreContext.Provider value={value}>{children}</TabStoreContext.Provider>
);
}

View file

@ -13,26 +13,15 @@
"./hooks/*": "./src/hooks/*.ts"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.7",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-hover-card": "^1.1.7",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.1.5",
"@radix-ui/react-tabs": "^1.1.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@base-ui/react": "^1.3.0",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "catalog:",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-day-picker": "^9.14.0",
"react-dom": "catalog:",
"react-markdown": "^10.1.0",
"shiki": "^3.21.0",

View file

@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@multica/ui/lib/utils"
import { Button, buttonVariants } from "@multica/ui/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View file

@ -263,6 +263,11 @@
@apply w-full max-w-4xl mx-auto;
}
/* Shadcn sidebar: remove default padding from inset container */
[data-slot="sidebar-container"] {
padding: 0 !important;
}
@layer base {
* {
@apply border-border outline-ring/50;

751
pnpm-lock.yaml generated
View file

@ -179,7 +179,7 @@ importers:
version: link:../types
zustand:
specifier: 'catalog:'
version: 5.0.12(@types/react@19.2.14)(react@19.2.3)
version: 5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
devDependencies:
typescript:
specifier: 'catalog:'
@ -193,48 +193,9 @@ importers:
packages/ui:
dependencies:
'@radix-ui/react-alert-dialog':
specifier: ^1.1.7
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-checkbox':
specifier: ^1.3.2
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-collapsible':
specifier: ^1.1.7
version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-dialog':
specifier: ^1.1.7
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.7
version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-hover-card':
specifier: ^1.1.7
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-label':
specifier: ^2.1.4
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-popover':
specifier: ^1.1.7
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-select':
specifier: ^2.1.7
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-separator':
specifier: ^1.1.4
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-slot':
specifier: ^1.2.0
version: 1.2.4(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-switch':
specifier: ^1.1.5
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-tabs':
specifier: ^1.1.7
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-tooltip':
specifier: ^1.1.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@base-ui/react':
specifier: ^1.3.0
version: 1.3.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
class-variance-authority:
specifier: 'catalog:'
version: 0.7.1
@ -244,6 +205,9 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
date-fns:
specifier: ^4.1.0
version: 4.1.0
lucide-react:
specifier: 'catalog:'
version: 0.511.0(react@19.2.3)
@ -253,6 +217,9 @@ importers:
react:
specifier: 'catalog:'
version: 19.2.3
react-day-picker:
specifier: ^9.14.0
version: 9.14.0(react@19.2.3)
react-dom:
specifier: 'catalog:'
version: 19.2.3(react@19.2.3)
@ -447,6 +414,27 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@base-ui/react@1.3.0':
resolution: {integrity: sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^19.2.0
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@base-ui/utils@0.2.6':
resolution: {integrity: sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==}
peerDependencies:
'@types/react': ^19.2.0
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
@ -487,6 +475,9 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@ -875,77 +866,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-alert-dialog@1.1.15':
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.12':
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
@ -977,15 +900,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.1':
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
peerDependencies:
'@types/react': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
@ -999,19 +913,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-dropdown-menu@2.1.16':
resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-focus-guards@1.1.3':
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
peerDependencies:
@ -1034,19 +935,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-hover-card@1.1.15':
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
@ -1056,58 +944,6 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-label@2.1.8':
resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-menu@2.1.16':
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
@ -1160,45 +996,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-separator@1.1.8':
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@ -1217,45 +1014,6 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-switch@1.2.6':
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@ -1301,49 +1059,6 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-previous@1.1.1':
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
peerDependencies:
'@types/react': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:
'@types/react': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies:
'@types/react': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@rolldown/binding-android-arm64@1.0.0-rc.10':
resolution: {integrity: sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1479,6 +1194,10 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@tabby_ai/hijri-converter@1.0.5':
resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==}
engines: {node: '>=16.0.0'}
'@tailwindcss/node@4.2.2':
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
@ -1982,6 +1701,12 @@ packages:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -2997,6 +2722,12 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
react-day-picker@9.14.0:
resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8.0'
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
@ -3076,6 +2807,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -3280,6 +3014,9 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@ -3437,6 +3174,11 @@ packages:
'@types/react':
optional: true
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@ -3853,6 +3595,30 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@base-ui/react@1.3.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.29.2
'@base-ui/utils': 0.2.6(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@floating-ui/utils': 0.2.11
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
tabbable: 6.4.0
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@base-ui/utils@0.2.6(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.29.2
'@floating-ui/utils': 0.2.11
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
reselect: 5.1.1
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
@ -3881,6 +3647,8 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@date-fns/tz@1.4.1': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.3)':
dependencies:
react: 19.2.3
@ -4206,77 +3974,8 @@ snapshots:
dependencies:
playwright: 1.58.2
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.3)':
dependencies:
react: 19.2.3
@ -4311,12 +4010,6 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.3)':
dependencies:
react: 19.2.3
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
@ -4330,21 +4023,6 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.3)':
dependencies:
react: 19.2.3
@ -4362,23 +4040,6 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.3)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
@ -4386,82 +4047,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3)
aria-hidden: 1.2.6
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
aria-hidden: 1.2.6
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/rect': 1.1.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -4500,61 +4085,6 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
aria-hidden: 1.2.6
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.3)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
@ -4569,57 +4099,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.3)':
dependencies:
react: 19.2.3
@ -4654,37 +4133,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.3)':
dependencies:
react: 19.2.3
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.3)':
dependencies:
'@radix-ui/rect': 1.1.1
react: 19.2.3
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.3)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
react: 19.2.3
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/rect@1.1.1': {}
'@rolldown/binding-android-arm64@1.0.0-rc.10':
optional: true
@ -4779,6 +4227,8 @@ snapshots:
dependencies:
tslib: 2.8.1
'@tabby_ai/hijri-converter@1.0.5': {}
'@tailwindcss/node@4.2.2':
dependencies:
'@jridgewell/remapping': 2.3.5
@ -5229,6 +4679,10 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
@ -6318,6 +5772,14 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
react-day-picker@9.14.0(react@19.2.3):
dependencies:
'@date-fns/tz': 1.4.1
'@tabby_ai/hijri-converter': 1.0.5
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
react: 19.2.3
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3
@ -6416,6 +5878,8 @@ snapshots:
require-from-string@2.0.2: {}
reselect@5.1.1: {}
resolve-from@4.0.0: {}
restore-cursor@5.1.0:
@ -6709,6 +6173,8 @@ snapshots:
symbol-tree@3.2.4: {}
tabbable@6.4.0: {}
tagged-tag@1.0.0: {}
tailwind-merge@3.5.0: {}
@ -6856,6 +6322,10 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
use-sync-external-store@1.6.0(react@19.2.3):
dependencies:
react: 19.2.3
util-deprecate@1.0.2: {}
validate-npm-package-name@7.0.2: {}
@ -6992,9 +6462,10 @@ snapshots:
zod@3.25.76: {}
zustand@5.0.12(@types/react@19.2.14)(react@19.2.3):
zustand@5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):
optionalDependencies:
'@types/react': 19.2.14
react: 19.2.3
use-sync-external-store: 1.6.0(react@19.2.3)
zwitch@2.0.4: {}