mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: implement FlowExecutor for executing flow definitions with DAG traversal and node execution
This commit is contained in:
111
ccw/frontend/src/components/layout/AppShell.tsx
Normal file
111
ccw/frontend/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
// ========================================
|
||||
// AppShell Component
|
||||
// ========================================
|
||||
// Root layout component combining Header, Sidebar, and MainContent
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Header } from './Header';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { MainContent } from './MainContent';
|
||||
|
||||
export interface AppShellProps {
|
||||
/** Initial sidebar collapsed state */
|
||||
defaultCollapsed?: boolean;
|
||||
/** Current project path to display in header */
|
||||
projectPath?: string;
|
||||
/** Callback for refresh action */
|
||||
onRefresh?: () => void;
|
||||
/** Whether refresh is in progress */
|
||||
isRefreshing?: boolean;
|
||||
/** Children to render in main content area */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Local storage key for sidebar state
|
||||
const SIDEBAR_COLLAPSED_KEY = 'ccw-sidebar-collapsed';
|
||||
|
||||
export function AppShell({
|
||||
defaultCollapsed = false,
|
||||
projectPath = '',
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
// Sidebar collapse state (persisted)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
|
||||
return stored ? JSON.parse(stored) : defaultCollapsed;
|
||||
}
|
||||
return defaultCollapsed;
|
||||
});
|
||||
|
||||
// Mobile sidebar open state
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// Persist sidebar state
|
||||
useEffect(() => {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(sidebarCollapsed));
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
// Close mobile sidebar on route change or resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= 768) {
|
||||
setMobileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = useCallback(() => {
|
||||
setMobileOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleMobileClose = useCallback(() => {
|
||||
setMobileOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleCollapsedChange = useCallback((collapsed: boolean) => {
|
||||
setSidebarCollapsed(collapsed);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-background">
|
||||
{/* Header - fixed at top */}
|
||||
<Header
|
||||
onMenuClick={handleMenuClick}
|
||||
projectPath={projectPath}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
|
||||
{/* Main layout - sidebar + content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={handleMobileClose}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<MainContent
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
// Adjust padding on mobile when sidebar is hidden
|
||||
'md:ml-0'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</MainContent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppShell;
|
||||
164
ccw/frontend/src/components/layout/Header.tsx
Normal file
164
ccw/frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
// ========================================
|
||||
// Header Component
|
||||
// ========================================
|
||||
// Top navigation bar with theme toggle and user menu
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Workflow,
|
||||
Menu,
|
||||
Moon,
|
||||
Sun,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
User,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useTheme } from '@/hooks';
|
||||
|
||||
export interface HeaderProps {
|
||||
/** Callback to toggle mobile sidebar */
|
||||
onMenuClick?: () => void;
|
||||
/** Current project path */
|
||||
projectPath?: string;
|
||||
/** Callback for refresh action */
|
||||
onRefresh?: () => void;
|
||||
/** Whether refresh is in progress */
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
onMenuClick,
|
||||
projectPath = '',
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
}: HeaderProps) {
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (onRefresh && !isRefreshing) {
|
||||
onRefresh();
|
||||
}
|
||||
}, [onRefresh, isRefreshing]);
|
||||
|
||||
// Get display path (truncate if too long)
|
||||
const displayPath = projectPath.length > 40
|
||||
? '...' + projectPath.slice(-37)
|
||||
: projectPath || 'No project selected';
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex items-center justify-between px-4 md:px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm"
|
||||
role="banner"
|
||||
>
|
||||
{/* Left side - Menu button (mobile) and Logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile menu toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={onMenuClick}
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{/* Logo / Brand */}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 text-lg font-semibold text-primary hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Workflow className="w-6 h-6" />
|
||||
<span className="hidden sm:inline">Claude Code Workflow</span>
|
||||
<span className="sm:hidden">CCW</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Project path indicator */}
|
||||
{projectPath && (
|
||||
<div className="hidden lg:flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md text-sm text-muted-foreground max-w-[300px]">
|
||||
<span className="truncate" title={projectPath}>
|
||||
{displayPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refresh button */}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label="Refresh workspace"
|
||||
title="Refresh workspace"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('w-5 h-5', isRefreshing && 'animate-spin')}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Theme toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* User menu dropdown - simplified version */}
|
||||
<div className="relative group">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
aria-label="User menu"
|
||||
title="User menu"
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-card border border-border rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||
<div className="py-1">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground hover:bg-hover transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
<hr className="my-1 border-border" />
|
||||
<button
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-muted-foreground hover:bg-hover hover:text-foreground transition-colors w-full text-left"
|
||||
onClick={() => {
|
||||
// Placeholder for logout action
|
||||
console.log('Logout clicked');
|
||||
}}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Exit Dashboard</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
31
ccw/frontend/src/components/layout/MainContent.tsx
Normal file
31
ccw/frontend/src/components/layout/MainContent.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
// ========================================
|
||||
// MainContent Component
|
||||
// ========================================
|
||||
// Main content area with scrollable container and Outlet for routes
|
||||
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface MainContentProps {
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
/** Children to render instead of Outlet */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MainContent({ className, children }: MainContentProps) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto min-w-0',
|
||||
'p-4 md:p-6',
|
||||
className
|
||||
)}
|
||||
role="main"
|
||||
>
|
||||
{children ?? <Outlet />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainContent;
|
||||
184
ccw/frontend/src/components/layout/Sidebar.tsx
Normal file
184
ccw/frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
// ========================================
|
||||
// Sidebar Component
|
||||
// ========================================
|
||||
// Collapsible navigation sidebar with route links
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
FolderKanban,
|
||||
Workflow,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Brain,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export interface SidebarProps {
|
||||
/** Whether sidebar is collapsed */
|
||||
collapsed?: boolean;
|
||||
/** Callback when collapse state changes */
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
/** Whether sidebar is open on mobile */
|
||||
mobileOpen?: boolean;
|
||||
/** Callback to close mobile sidebar */
|
||||
onMobileClose?: () => void;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
badge?: number | string;
|
||||
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/', label: 'Home', icon: Home },
|
||||
{ path: '/sessions', label: 'Sessions', icon: FolderKanban },
|
||||
{ path: '/orchestrator', label: 'Orchestrator', icon: Workflow },
|
||||
{ path: '/loops', label: 'Loop Monitor', icon: RefreshCw },
|
||||
{ path: '/issues', label: 'Issues', icon: AlertCircle },
|
||||
{ path: '/skills', label: 'Skills', icon: Sparkles },
|
||||
{ path: '/commands', label: 'Commands', icon: Terminal },
|
||||
{ path: '/memory', label: 'Memory', icon: Brain },
|
||||
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||
{ path: '/help', label: 'Help', icon: HelpCircle },
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
collapsed = false,
|
||||
onCollapsedChange,
|
||||
mobileOpen = false,
|
||||
onMobileClose,
|
||||
}: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(collapsed);
|
||||
|
||||
const isCollapsed = onCollapsedChange ? collapsed : internalCollapsed;
|
||||
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
if (onCollapsedChange) {
|
||||
onCollapsedChange(!collapsed);
|
||||
} else {
|
||||
setInternalCollapsed(!internalCollapsed);
|
||||
}
|
||||
}, [collapsed, internalCollapsed, onCollapsedChange]);
|
||||
|
||||
const handleNavClick = useCallback(() => {
|
||||
// Close mobile sidebar when navigating
|
||||
if (onMobileClose) {
|
||||
onMobileClose();
|
||||
}
|
||||
}, [onMobileClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={onMobileClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'bg-sidebar-background border-r border-border flex flex-col transition-all duration-300',
|
||||
// Desktop styles
|
||||
'hidden md:flex sticky top-14 h-[calc(100vh-56px)]',
|
||||
isCollapsed ? 'w-16' : 'w-64',
|
||||
// Mobile styles
|
||||
'md:translate-x-0',
|
||||
mobileOpen && 'fixed left-0 top-14 flex translate-x-0 z-50 h-[calc(100vh-56px)] w-64 shadow-lg'
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<nav className="flex-1 py-3 overflow-y-auto">
|
||||
<ul className="space-y-1 px-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.path ||
|
||||
(item.path !== '/' && location.pathname.startsWith(item.path));
|
||||
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
onClick={handleNavClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors',
|
||||
'hover:bg-hover hover:text-foreground',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground',
|
||||
isCollapsed && 'justify-center px-2'
|
||||
)}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.badge !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 text-xs font-semibold rounded-full',
|
||||
item.badgeVariant === 'success' && 'bg-success-light text-success',
|
||||
item.badgeVariant === 'warning' && 'bg-warning-light text-warning',
|
||||
item.badgeVariant === 'info' && 'bg-info-light text-info',
|
||||
(!item.badgeVariant || item.badgeVariant === 'default') &&
|
||||
'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Sidebar footer - collapse toggle */}
|
||||
<div className="p-3 border-t border-border hidden md:block">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleCollapse}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 text-muted-foreground hover:text-foreground',
|
||||
isCollapsed && 'justify-center'
|
||||
)}
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpen className="w-4 h-4" />
|
||||
) : (
|
||||
<>
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
<span>Collapse</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
16
ccw/frontend/src/components/layout/index.ts
Normal file
16
ccw/frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// ========================================
|
||||
// Layout Components Barrel Export
|
||||
// ========================================
|
||||
// Re-export all layout components for convenient imports
|
||||
|
||||
export { AppShell } from './AppShell';
|
||||
export type { AppShellProps } from './AppShell';
|
||||
|
||||
export { Header } from './Header';
|
||||
export type { HeaderProps } from './Header';
|
||||
|
||||
export { Sidebar } from './Sidebar';
|
||||
export type { SidebarProps } from './Sidebar';
|
||||
|
||||
export { MainContent } from './MainContent';
|
||||
export type { MainContentProps } from './MainContent';
|
||||
238
ccw/frontend/src/components/shared/IssueCard.tsx
Normal file
238
ccw/frontend/src/components/shared/IssueCard.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
// ========================================
|
||||
// IssueCard Component
|
||||
// ========================================
|
||||
// Card component for displaying issues with actions
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
MoreVertical,
|
||||
Edit,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Dropdown, DropdownTrigger, DropdownContent, DropdownItem } from '@/components/ui/Dropdown';
|
||||
import type { Issue } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface IssueCardProps {
|
||||
issue: Issue;
|
||||
onEdit?: (issue: Issue) => void;
|
||||
onDelete?: (issue: Issue) => void;
|
||||
onClick?: (issue: Issue) => void;
|
||||
onStatusChange?: (issue: Issue, status: Issue['status']) => void;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
draggableProps?: Record<string, unknown>;
|
||||
dragHandleProps?: Record<string, unknown>;
|
||||
innerRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
// ========== Priority Helpers ==========
|
||||
|
||||
const priorityConfig: Record<Issue['priority'], { icon: React.ElementType; color: string; label: string }> = {
|
||||
critical: { icon: AlertCircle, color: 'destructive', label: 'Critical' },
|
||||
high: { icon: AlertTriangle, color: 'warning', label: 'High' },
|
||||
medium: { icon: Info, color: 'info', label: 'Medium' },
|
||||
low: { icon: Info, color: 'secondary', label: 'Low' },
|
||||
};
|
||||
|
||||
const statusConfig: Record<Issue['status'], { icon: React.ElementType; color: string; label: string }> = {
|
||||
open: { icon: AlertCircle, color: 'info', label: 'Open' },
|
||||
in_progress: { icon: Clock, color: 'warning', label: 'In Progress' },
|
||||
resolved: { icon: CheckCircle, color: 'success', label: 'Resolved' },
|
||||
closed: { icon: XCircle, color: 'muted', label: 'Closed' },
|
||||
completed: { icon: CheckCircle, color: 'success', label: 'Completed' },
|
||||
};
|
||||
|
||||
// ========== Priority Badge ==========
|
||||
|
||||
export function PriorityBadge({ priority }: { priority: Issue['priority'] }) {
|
||||
const config = priorityConfig[priority];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Badge variant={config.color as 'default' | 'secondary' | 'destructive' | 'outline'} className="gap-1">
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Status Badge ==========
|
||||
|
||||
export function StatusBadge({ status }: { status: Issue['status'] }) {
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main IssueCard Component ==========
|
||||
|
||||
export function IssueCard({
|
||||
issue,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onClick,
|
||||
onStatusChange,
|
||||
className,
|
||||
compact = false,
|
||||
showActions = true,
|
||||
draggableProps,
|
||||
dragHandleProps,
|
||||
innerRef,
|
||||
}: IssueCardProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isMenuOpen) {
|
||||
onClick?.(issue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsMenuOpen(false);
|
||||
onEdit?.(issue);
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsMenuOpen(false);
|
||||
onDelete?.(issue);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
ref={innerRef}
|
||||
{...draggableProps}
|
||||
{...dragHandleProps}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'p-3 bg-card border border-border rounded-lg cursor-pointer',
|
||||
'hover:shadow-md hover:border-primary/50 transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{issue.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">#{issue.id}</p>
|
||||
</div>
|
||||
<PriorityBadge priority={issue.priority} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={innerRef}
|
||||
{...draggableProps}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'p-4 cursor-pointer hover:shadow-md hover:border-primary/50 transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0" {...dragHandleProps}>
|
||||
<h3 className="text-sm font-medium text-foreground line-clamp-2">
|
||||
{issue.title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">#{issue.id}</p>
|
||||
</div>
|
||||
{showActions && (
|
||||
<Dropdown open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="end">
|
||||
<DropdownItem onClick={handleEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => onStatusChange?.(issue, 'in_progress')}>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
Start Progress
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => onStatusChange?.(issue, 'resolved')}>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Mark Resolved
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={handleDelete} className="text-destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</DropdownItem>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Preview */}
|
||||
{issue.context && (
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||
{issue.context}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{issue.labels && issue.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{issue.labels.slice(0, 3).map((label) => (
|
||||
<Badge key={label} variant="outline" className="text-xs">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
{issue.labels.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{issue.labels.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
|
||||
<PriorityBadge priority={issue.priority} />
|
||||
<StatusBadge status={issue.status} />
|
||||
</div>
|
||||
|
||||
{/* Solutions Count */}
|
||||
{issue.solutions && issue.solutions.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{issue.solutions.length} solution{issue.solutions.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default IssueCard;
|
||||
265
ccw/frontend/src/components/shared/KanbanBoard.tsx
Normal file
265
ccw/frontend/src/components/shared/KanbanBoard.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
// ========================================
|
||||
// KanbanBoard Component
|
||||
// ========================================
|
||||
// Drag-and-drop kanban board for loops and tasks
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
type DropResult,
|
||||
type DraggableProvided,
|
||||
type DroppableProvided,
|
||||
} from '@hello-pangea/dnd';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface KanbanItem {
|
||||
id: string;
|
||||
title?: string;
|
||||
status: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface KanbanColumn<T extends KanbanItem = KanbanItem> {
|
||||
id: string;
|
||||
title: string;
|
||||
items: T[];
|
||||
color?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface KanbanBoardProps<T extends KanbanItem = KanbanItem> {
|
||||
columns: KanbanColumn<T>[];
|
||||
onDragEnd?: (result: DropResult, sourceColumn: string, destColumn: string) => void;
|
||||
onItemClick?: (item: T) => void;
|
||||
renderItem?: (item: T, provided: DraggableProvided) => React.ReactNode;
|
||||
className?: string;
|
||||
columnClassName?: string;
|
||||
itemClassName?: string;
|
||||
emptyColumnMessage?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// ========== Default Item Renderer ==========
|
||||
|
||||
function DefaultItemRenderer<T extends KanbanItem>({
|
||||
item,
|
||||
provided,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
item: T;
|
||||
provided: DraggableProvided;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'p-3 bg-card border border-border rounded-lg shadow-sm cursor-pointer',
|
||||
'hover:shadow-md hover:border-primary/50 transition-all',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{item.title || item.id}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Column Component ==========
|
||||
|
||||
function KanbanColumnComponent<T extends KanbanItem>({
|
||||
column,
|
||||
onItemClick,
|
||||
renderItem,
|
||||
itemClassName,
|
||||
emptyMessage,
|
||||
}: {
|
||||
column: KanbanColumn<T>;
|
||||
onItemClick?: (item: T) => void;
|
||||
renderItem?: (item: T, provided: DraggableProvided) => React.ReactNode;
|
||||
itemClassName?: string;
|
||||
emptyMessage?: string;
|
||||
}) {
|
||||
return (
|
||||
<Droppable droppableId={column.id}>
|
||||
{(provided: DroppableProvided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={cn(
|
||||
'min-h-[200px] p-2 space-y-2 rounded-lg transition-colors',
|
||||
snapshot.isDraggingOver && 'bg-primary/5'
|
||||
)}
|
||||
>
|
||||
{column.items.length === 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-8">
|
||||
{emptyMessage || 'No items'}
|
||||
</p>
|
||||
) : (
|
||||
column.items.map((item, index) => (
|
||||
<Draggable key={item.id} draggableId={item.id} index={index}>
|
||||
{(dragProvided: DraggableProvided) =>
|
||||
renderItem ? (
|
||||
renderItem(item, dragProvided)
|
||||
) : (
|
||||
<DefaultItemRenderer
|
||||
item={item}
|
||||
provided={dragProvided}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
className={itemClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Draggable>
|
||||
))
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Kanban Board Component ==========
|
||||
|
||||
export function KanbanBoard<T extends KanbanItem = KanbanItem>({
|
||||
columns,
|
||||
onDragEnd,
|
||||
onItemClick,
|
||||
renderItem,
|
||||
className,
|
||||
columnClassName,
|
||||
itemClassName,
|
||||
emptyColumnMessage,
|
||||
isLoading = false,
|
||||
}: KanbanBoardProps<T>) {
|
||||
const handleDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
if (
|
||||
source.droppableId === destination.droppableId &&
|
||||
source.index === destination.index
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onDragEnd?.(result, source.droppableId, destination.droppableId);
|
||||
},
|
||||
[onDragEnd]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('grid gap-4', className)} style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}>
|
||||
{columns.map((column) => (
|
||||
<Card key={column.id} className={cn('p-4', columnClassName)}>
|
||||
<div className="h-6 w-24 bg-muted animate-pulse rounded mb-4" />
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div
|
||||
className={cn('grid gap-4', className)}
|
||||
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<Card key={column.id} className={cn('p-4', columnClassName)}>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{column.icon}
|
||||
<h3 className="font-medium text-foreground">{column.title}</h3>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(column.color && `bg-${column.color}/10 text-${column.color}`)}
|
||||
>
|
||||
{column.items.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Column Content */}
|
||||
<KanbanColumnComponent
|
||||
column={column}
|
||||
onItemClick={onItemClick}
|
||||
renderItem={renderItem}
|
||||
itemClassName={itemClassName}
|
||||
emptyMessage={emptyColumnMessage}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Loop-specific Kanban ==========
|
||||
|
||||
export interface LoopKanbanItem extends KanbanItem {
|
||||
status: 'created' | 'running' | 'paused' | 'completed' | 'failed';
|
||||
currentStep?: number;
|
||||
totalSteps?: number;
|
||||
prompt?: string;
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
export function useLoopKanbanColumns(loopsByStatus: Record<string, LoopKanbanItem[]>): KanbanColumn<LoopKanbanItem>[] {
|
||||
return [
|
||||
{
|
||||
id: 'created',
|
||||
title: 'Pending',
|
||||
items: loopsByStatus.created || [],
|
||||
color: 'muted',
|
||||
},
|
||||
{
|
||||
id: 'running',
|
||||
title: 'Running',
|
||||
items: loopsByStatus.running || [],
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'paused',
|
||||
title: 'Paused',
|
||||
items: loopsByStatus.paused || [],
|
||||
color: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'completed',
|
||||
title: 'Completed',
|
||||
items: loopsByStatus.completed || [],
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
id: 'failed',
|
||||
title: 'Failed',
|
||||
items: loopsByStatus.failed || [],
|
||||
color: 'destructive',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default KanbanBoard;
|
||||
287
ccw/frontend/src/components/shared/SessionCard.tsx
Normal file
287
ccw/frontend/src/components/shared/SessionCard.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
// ========================================
|
||||
// SessionCard Component
|
||||
// ========================================
|
||||
// Session card with status badge and action menu
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import {
|
||||
Calendar,
|
||||
ListChecks,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Archive,
|
||||
Trash2,
|
||||
Play,
|
||||
Pause,
|
||||
} from 'lucide-react';
|
||||
import type { SessionMetadata } from '@/types/store';
|
||||
|
||||
export interface SessionCardProps {
|
||||
/** Session data */
|
||||
session: SessionMetadata;
|
||||
/** Called when view action is triggered */
|
||||
onView?: (sessionId: string) => void;
|
||||
/** Called when archive action is triggered */
|
||||
onArchive?: (sessionId: string) => void;
|
||||
/** Called when delete action is triggered */
|
||||
onDelete?: (sessionId: string) => void;
|
||||
/** Called when card is clicked */
|
||||
onClick?: (sessionId: string) => void;
|
||||
/** Optional className */
|
||||
className?: string;
|
||||
/** Show actions dropdown */
|
||||
showActions?: boolean;
|
||||
/** Disabled state for actions */
|
||||
actionsDisabled?: boolean;
|
||||
}
|
||||
|
||||
// Status badge configuration
|
||||
const statusConfig: Record<
|
||||
SessionMetadata['status'],
|
||||
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info' }
|
||||
> = {
|
||||
planning: { label: 'Planning', variant: 'info' },
|
||||
in_progress: { label: 'In Progress', variant: 'warning' },
|
||||
completed: { label: 'Completed', variant: 'success' },
|
||||
archived: { label: 'Archived', variant: 'secondary' },
|
||||
paused: { label: 'Paused', variant: 'default' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date to localized string
|
||||
*/
|
||||
function formatDate(dateString: string | undefined): string {
|
||||
if (!dateString) return 'Unknown';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return 'Invalid date';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress percentage from tasks
|
||||
*/
|
||||
function calculateProgress(tasks: SessionMetadata['tasks']): {
|
||||
completed: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
} {
|
||||
if (!tasks || tasks.length === 0) {
|
||||
return { completed: 0, total: 0, percentage: 0 };
|
||||
}
|
||||
|
||||
const completed = tasks.filter((t) => t.status === 'completed').length;
|
||||
const total = tasks.length;
|
||||
const percentage = Math.round((completed / total) * 100);
|
||||
|
||||
return { completed, total, percentage };
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionCard component for displaying session information
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SessionCard
|
||||
* session={session}
|
||||
* onView={(id) => navigate(`/sessions/${id}`)}
|
||||
* onArchive={(id) => archiveSession(id)}
|
||||
* onDelete={(id) => deleteSession(id)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function SessionCard({
|
||||
session,
|
||||
onView,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onClick,
|
||||
className,
|
||||
showActions = true,
|
||||
actionsDisabled = false,
|
||||
}: SessionCardProps) {
|
||||
const { label: statusLabel, variant: statusVariant } = statusConfig[session.status] || {
|
||||
label: 'Unknown',
|
||||
variant: 'default' as const,
|
||||
};
|
||||
|
||||
const progress = calculateProgress(session.tasks);
|
||||
const isPlanning = session.status === 'planning';
|
||||
const isArchived = session.status === 'archived' || session.location === 'archived';
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
// Don't trigger if clicking on dropdown
|
||||
if ((e.target as HTMLElement).closest('[data-radix-popper-content-wrapper]')) {
|
||||
return;
|
||||
}
|
||||
onClick?.(session.session_id);
|
||||
};
|
||||
|
||||
const handleAction = (
|
||||
e: React.MouseEvent,
|
||||
action: 'view' | 'archive' | 'delete'
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
switch (action) {
|
||||
case 'view':
|
||||
onView?.(session.session_id);
|
||||
break;
|
||||
case 'archive':
|
||||
onArchive?.(session.session_id);
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete?.(session.session_id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'group cursor-pointer transition-all duration-200 hover:shadow-md hover:border-primary/30',
|
||||
isPlanning && 'border-info/30 bg-info/5',
|
||||
className
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-card-foreground truncate">
|
||||
{session.title || session.session_id}
|
||||
</h3>
|
||||
{session.title && session.title !== session.session_id && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{session.session_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||
{showActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => handleAction(e, 'view')}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
{!isArchived && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={(e) => handleAction(e, 'archive')}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleAction(e, 'delete')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{formatDate(session.created_at)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
{progress.total} tasks
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar (only show if not planning and has tasks) */}
|
||||
{progress.total > 0 && !isPlanning && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="text-card-foreground font-medium">
|
||||
{progress.completed}/{progress.total} ({progress.percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (if exists) */}
|
||||
{session.description && (
|
||||
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
|
||||
{session.description}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton loader for SessionCard
|
||||
*/
|
||||
export function SessionCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<Card className={cn('animate-pulse', className)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 rounded bg-muted" />
|
||||
<div className="mt-1 h-3 w-24 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-5 w-16 rounded-full bg-muted" />
|
||||
</div>
|
||||
<div className="mt-3 flex gap-4">
|
||||
<div className="h-4 w-20 rounded bg-muted" />
|
||||
<div className="h-4 w-16 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="h-1.5 w-full rounded-full bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
257
ccw/frontend/src/components/shared/SkillCard.tsx
Normal file
257
ccw/frontend/src/components/shared/SkillCard.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
// ========================================
|
||||
// SkillCard Component
|
||||
// ========================================
|
||||
// Card component for displaying skills with enable/disable toggle
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
MoreVertical,
|
||||
Info,
|
||||
Settings,
|
||||
Power,
|
||||
PowerOff,
|
||||
Tag,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Dropdown, DropdownTrigger, DropdownContent, DropdownItem } from '@/components/ui/Dropdown';
|
||||
import type { Skill } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface SkillCardProps {
|
||||
skill: Skill;
|
||||
onToggle?: (skill: Skill, enabled: boolean) => void;
|
||||
onClick?: (skill: Skill) => void;
|
||||
onConfigure?: (skill: Skill) => void;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
isToggling?: boolean;
|
||||
}
|
||||
|
||||
// ========== Source Badge ==========
|
||||
|
||||
const sourceConfig: Record<NonNullable<Skill['source']>, { color: string; label: string }> = {
|
||||
builtin: { color: 'default', label: 'Built-in' },
|
||||
custom: { color: 'secondary', label: 'Custom' },
|
||||
community: { color: 'outline', label: 'Community' },
|
||||
};
|
||||
|
||||
export function SourceBadge({ source }: { source?: Skill['source'] }) {
|
||||
const config = sourceConfig[source ?? 'builtin'];
|
||||
return (
|
||||
<Badge variant={config.color as 'default' | 'secondary' | 'destructive' | 'outline'}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main SkillCard Component ==========
|
||||
|
||||
export function SkillCard({
|
||||
skill,
|
||||
onToggle,
|
||||
onClick,
|
||||
onConfigure,
|
||||
className,
|
||||
compact = false,
|
||||
showActions = true,
|
||||
isToggling = false,
|
||||
}: SkillCardProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isMenuOpen) {
|
||||
onClick?.(skill);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onToggle?.(skill, !skill.enabled);
|
||||
};
|
||||
|
||||
const handleConfigure = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsMenuOpen(false);
|
||||
onConfigure?.(skill);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'p-3 bg-card border border-border rounded-lg cursor-pointer',
|
||||
'hover:shadow-md hover:border-primary/50 transition-all',
|
||||
!skill.enabled && 'opacity-60',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Sparkles className={cn('w-4 h-4 flex-shrink-0', skill.enabled ? 'text-primary' : 'text-muted-foreground')} />
|
||||
<span className="text-sm font-medium text-foreground truncate">{skill.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant={skill.enabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleToggle}
|
||||
disabled={isToggling}
|
||||
>
|
||||
{skill.enabled ? (
|
||||
<>
|
||||
<Power className="w-3 h-3 mr-1" />
|
||||
On
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PowerOff className="w-3 h-3 mr-1" />
|
||||
Off
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'p-4 cursor-pointer hover:shadow-md hover:border-primary/50 transition-all',
|
||||
!skill.enabled && 'opacity-75',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg flex-shrink-0',
|
||||
skill.enabled ? 'bg-primary/10' : 'bg-muted'
|
||||
)}>
|
||||
<Sparkles className={cn('w-5 h-5', skill.enabled ? 'text-primary' : 'text-muted-foreground')} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-foreground">{skill.name}</h3>
|
||||
{skill.version && (
|
||||
<p className="text-xs text-muted-foreground">v{skill.version}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showActions && (
|
||||
<Dropdown open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="end">
|
||||
<DropdownItem onClick={() => onClick?.(skill)}>
|
||||
<Info className="w-4 h-4 mr-2" />
|
||||
View Details
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={handleConfigure}>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Configure
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={handleToggle}>
|
||||
{skill.enabled ? (
|
||||
<>
|
||||
<PowerOff className="w-4 h-4 mr-2" />
|
||||
Disable
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Power className="w-4 h-4 mr-2" />
|
||||
Enable
|
||||
</>
|
||||
)}
|
||||
</DropdownItem>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground mt-3 line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
{/* Triggers */}
|
||||
{skill.triggers && skill.triggers.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||
<Tag className="w-3 h-3" />
|
||||
Triggers
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skill.triggers.slice(0, 4).map((trigger) => (
|
||||
<Badge key={trigger} variant="outline" className="text-xs">
|
||||
{trigger}
|
||||
</Badge>
|
||||
))}
|
||||
{skill.triggers.length > 4 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{skill.triggers.length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<SourceBadge source={skill.source} />
|
||||
{skill.category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{skill.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={skill.enabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleToggle}
|
||||
disabled={isToggling}
|
||||
>
|
||||
{skill.enabled ? (
|
||||
<>
|
||||
<Power className="w-4 h-4 mr-1" />
|
||||
Enabled
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PowerOff className="w-4 h-4 mr-1" />
|
||||
Disabled
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Author */}
|
||||
{skill.author && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
|
||||
<User className="w-3 h-3" />
|
||||
{skill.author}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillCard;
|
||||
161
ccw/frontend/src/components/shared/StatCard.tsx
Normal file
161
ccw/frontend/src/components/shared/StatCard.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
// ========================================
|
||||
// StatCard Component
|
||||
// ========================================
|
||||
// Reusable stat card for dashboard metrics
|
||||
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { TrendingUp, TrendingDown, Minus, type LucideIcon } from 'lucide-react';
|
||||
|
||||
const statCardVariants = cva(
|
||||
'transition-all duration-200 hover:shadow-md',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-border',
|
||||
primary: 'border-primary/30 bg-primary/5',
|
||||
success: 'border-success/30 bg-success/5',
|
||||
warning: 'border-warning/30 bg-warning/5',
|
||||
danger: 'border-destructive/30 bg-destructive/5',
|
||||
info: 'border-info/30 bg-info/5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const iconContainerVariants = cva(
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-muted text-muted-foreground',
|
||||
primary: 'bg-primary/10 text-primary',
|
||||
success: 'bg-success/10 text-success',
|
||||
warning: 'bg-warning/10 text-warning',
|
||||
danger: 'bg-destructive/10 text-destructive',
|
||||
info: 'bg-info/10 text-info',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface StatCardProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof statCardVariants> {
|
||||
/** Card title */
|
||||
title: string;
|
||||
/** Stat value to display */
|
||||
value: number | string;
|
||||
/** Optional icon component */
|
||||
icon?: LucideIcon;
|
||||
/** Optional trend direction */
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
/** Optional trend value (e.g., "+12%") */
|
||||
trendValue?: string;
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* StatCard component for displaying dashboard metrics
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <StatCard
|
||||
* title="Total Sessions"
|
||||
* value={42}
|
||||
* icon={FolderIcon}
|
||||
* variant="primary"
|
||||
* trend="up"
|
||||
* trendValue="+5"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function StatCard({
|
||||
className,
|
||||
variant,
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
trend,
|
||||
trendValue,
|
||||
isLoading = false,
|
||||
description,
|
||||
...props
|
||||
}: StatCardProps) {
|
||||
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus;
|
||||
const trendColor =
|
||||
trend === 'up'
|
||||
? 'text-success'
|
||||
: trend === 'down'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground';
|
||||
|
||||
return (
|
||||
<Card className={cn(statCardVariants({ variant }), className)} {...props}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-muted-foreground truncate">
|
||||
{title}
|
||||
</p>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
{isLoading ? (
|
||||
<div className="h-8 w-16 animate-pulse rounded bg-muted" />
|
||||
) : (
|
||||
<p className="text-2xl font-semibold text-card-foreground">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</p>
|
||||
)}
|
||||
{trend && trendValue && !isLoading && (
|
||||
<span className={cn('flex items-center text-xs font-medium', trendColor)}>
|
||||
<TrendIcon className="mr-0.5 h-3 w-3" />
|
||||
{trendValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<div className={cn(iconContainerVariants({ variant }))}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton loader for StatCard
|
||||
*/
|
||||
export function StatCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<Card className={cn('animate-pulse', className)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="h-4 w-20 rounded bg-muted" />
|
||||
<div className="mt-3 h-8 w-16 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-10 w-10 rounded-lg bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
42
ccw/frontend/src/components/ui/Badge.tsx
Normal file
42
ccw/frontend/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground",
|
||||
outline:
|
||||
"text-foreground",
|
||||
success:
|
||||
"border-transparent bg-success text-white",
|
||||
warning:
|
||||
"border-transparent bg-warning text-white",
|
||||
info:
|
||||
"border-transparent bg-info text-white",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
56
ccw/frontend/src/components/ui/Button.tsx
Normal file
56
ccw/frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
link:
|
||||
"text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
85
ccw/frontend/src/components/ui/Card.tsx
Normal file
85
ccw/frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
119
ccw/frontend/src/components/ui/Dialog.tsx
Normal file
119
ccw/frontend/src/components/ui/Dialog.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight text-card-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
197
ccw/frontend/src/components/ui/Dropdown.tsx
Normal file
197
ccw/frontend/src/components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
27
ccw/frontend/src/components/ui/Input.tsx
Normal file
27
ccw/frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, error, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
error && "border-destructive focus-visible:ring-destructive",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
156
ccw/frontend/src/components/ui/Select.tsx
Normal file
156
ccw/frontend/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card text-card-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
52
ccw/frontend/src/components/ui/Tabs.tsx
Normal file
52
ccw/frontend/src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
128
ccw/frontend/src/components/ui/Toast.tsx
Normal file
128
ccw/frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as React from "react";
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-border bg-card text-card-foreground",
|
||||
success: "border-success bg-success text-white",
|
||||
warning: "border-warning bg-warning text-white",
|
||||
error: "border-destructive bg-destructive text-destructive-foreground",
|
||||
info: "border-info bg-info text-white",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.success]:border-white/20 group-[.success]:hover:bg-white/20 group-[.warning]:border-white/20 group-[.warning]:hover:bg-white/20 group-[.error]:border-white/20 group-[.error]:hover:bg-white/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.success]:text-white/50 group-[.success]:hover:text-white group-[.warning]:text-white/50 group-[.warning]:hover:text-white group-[.error]:text-white/50 group-[.error]:hover:text-white",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
87
ccw/frontend/src/components/ui/index.ts
Normal file
87
ccw/frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// UI Component Library - Barrel Export
|
||||
// All components follow shadcn/ui patterns with Radix UI primitives and Tailwind CSS
|
||||
|
||||
// Button
|
||||
export { Button, buttonVariants } from "./Button";
|
||||
export type { ButtonProps } from "./Button";
|
||||
|
||||
// Input
|
||||
export { Input } from "./Input";
|
||||
export type { InputProps } from "./Input";
|
||||
|
||||
// Select (Radix)
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
} from "./Select";
|
||||
|
||||
// Dialog (Radix)
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "./Dialog";
|
||||
|
||||
// Dropdown (Radix)
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
} from "./Dropdown";
|
||||
|
||||
// Tabs (Radix)
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./Tabs";
|
||||
|
||||
// Card
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "./Card";
|
||||
|
||||
// Badge
|
||||
export { Badge, badgeVariants } from "./Badge";
|
||||
export type { BadgeProps } from "./Badge";
|
||||
|
||||
// Toast (Radix)
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
} from "./Toast";
|
||||
Reference in New Issue
Block a user