feat: enterprise UI overhaul - Lucide icons, collapsible sidebar, Cmd+K search, premium Login, skeleton loaders
This commit is contained in:
parent
a369b4afb1
commit
c49cbee3a4
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>JIRA AI Fixer</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<script type="module" crossorigin src="/assets/index-D4MghmKn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BNMhozdr.css">
|
||||
<script type="module" crossorigin src="/assets/index-B1gflmx5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-AVKtIbkK.css">
|
||||
</head>
|
||||
<body class="bg-gray-900 text-white">
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -2,115 +2,337 @@ import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|||
import { useAuth } from '../context/AuthContext';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { organizations } from '../services/api';
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import {
|
||||
LayoutDashboard, TicketCheck, Plug, Users, BarChart3, Settings,
|
||||
ChevronDown, ChevronRight, Search, Bell, PanelLeftClose, PanelLeftOpen,
|
||||
LogOut, Building2, Plus, Zap, Shield, ChevronsUpDown, Command
|
||||
} from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: '📊' },
|
||||
{ path: '/issues', label: 'Issues', icon: '🎫' },
|
||||
{ path: '/integrations', label: 'Integrations', icon: '🔌' },
|
||||
{ path: '/team', label: 'Team', icon: '👥' },
|
||||
{ path: '/reports', label: 'Reports', icon: '📈' },
|
||||
{ path: '/settings', label: 'Settings', icon: '⚙️' }
|
||||
const navSections = [
|
||||
{
|
||||
label: 'Main',
|
||||
items: [
|
||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/issues', label: 'Issues', icon: TicketCheck },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Management',
|
||||
items: [
|
||||
{ path: '/integrations', label: 'Integrations', icon: Plug },
|
||||
{ path: '/team', label: 'Team', icon: Users },
|
||||
{ path: '/reports', label: 'Reports', icon: BarChart3 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const breadcrumbMap = {
|
||||
'/': 'Dashboard',
|
||||
'/issues': 'Issues',
|
||||
'/integrations': 'Integrations',
|
||||
'/team': 'Team',
|
||||
'/reports': 'Reports',
|
||||
'/settings': 'Settings',
|
||||
};
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout, currentOrg, selectOrg } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [showOrgMenu, setShowOrgMenu] = useState(false);
|
||||
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showNotifs, setShowNotifs] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const { data: orgs } = useQuery({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: () => organizations.list()
|
||||
});
|
||||
|
||||
|
||||
// Cmd+K shortcut
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setShowSearch(s => !s);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowSearch(false);
|
||||
setShowOrgMenu(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
const getBreadcrumbs = () => {
|
||||
const parts = location.pathname.split('/').filter(Boolean);
|
||||
if (parts.length === 0) return [{ label: 'Dashboard', path: '/' }];
|
||||
|
||||
const crumbs = [];
|
||||
let current = '';
|
||||
for (const part of parts) {
|
||||
current += `/${part}`;
|
||||
crumbs.push({
|
||||
label: breadcrumbMap[current] || part.charAt(0).toUpperCase() + part.slice(1),
|
||||
path: current
|
||||
});
|
||||
}
|
||||
return crumbs;
|
||||
};
|
||||
|
||||
const isActive = (path) => {
|
||||
if (path === '/') return location.pathname === '/';
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-gray-800 border-r border-gray-700 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">🤖</span>
|
||||
<div>
|
||||
<h1 className="font-bold">JIRA AI Fixer</h1>
|
||||
<p className="text-xs text-gray-400">v2.0</p>
|
||||
<div className="min-h-screen flex bg-gray-950 text-gray-200">
|
||||
{/* Search Modal */}
|
||||
{showSearch && (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={() => setShowSearch(false)}>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-lg mx-4 animate-slide-up" onClick={e => e.stopPropagation()}>
|
||||
<div className="card border-gray-700 shadow-2xl">
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-800">
|
||||
<Search size={18} className="text-gray-500" />
|
||||
<input
|
||||
autoFocus
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Search issues, projects, settings..."
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-gray-500"
|
||||
/>
|
||||
<kbd className="kbd">ESC</kbd>
|
||||
</div>
|
||||
<div className="p-2 max-h-80 overflow-auto">
|
||||
{['Dashboard', 'Issues', 'Integrations', 'Team', 'Reports', 'Settings']
|
||||
.filter(item => item.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
.map(item => (
|
||||
<button
|
||||
key={item}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800 transition-colors"
|
||||
onClick={() => {
|
||||
navigate(`/${item.toLowerCase() === 'dashboard' ? '' : item.toLowerCase()}`);
|
||||
setShowSearch(false);
|
||||
setSearchQuery('');
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={14} className="text-gray-600" />
|
||||
{item}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
{searchQuery && (
|
||||
<div className="px-3 py-6 text-center text-sm text-gray-500">
|
||||
Press Enter to search for "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Org selector */}
|
||||
<div className="p-4 border-b border-gray-700 relative">
|
||||
<button
|
||||
onClick={() => setShowOrgMenu(!showOrgMenu)}
|
||||
className="w-full flex items-center justify-between p-2 rounded-lg bg-gray-700 hover:bg-gray-600"
|
||||
>
|
||||
<span className="truncate">{currentOrg?.name || 'Select organization'}</span>
|
||||
<span>▾</span>
|
||||
</button>
|
||||
{showOrgMenu && orgs?.data && (
|
||||
<div className="absolute top-full left-4 right-4 mt-1 bg-gray-700 rounded-lg shadow-lg z-10">
|
||||
{orgs.data.map(org => (
|
||||
<button
|
||||
key={org.id}
|
||||
onClick={() => { selectOrg(org); setShowOrgMenu(false); }}
|
||||
className={clsx(
|
||||
"w-full text-left px-4 py-2 hover:bg-gray-600 first:rounded-t-lg last:rounded-b-lg",
|
||||
currentOrg?.id === org.id && "bg-primary-600"
|
||||
)}
|
||||
>
|
||||
{org.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { navigate('/settings'); setShowOrgMenu(false); }}
|
||||
className="w-full text-left px-4 py-2 hover:bg-gray-600 text-primary-400 border-t border-gray-600"
|
||||
>
|
||||
+ Create organization
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={cn(
|
||||
"fixed top-0 left-0 h-full flex flex-col border-r border-gray-800/50 bg-gray-950 z-40 transition-all duration-300",
|
||||
collapsed ? "w-[68px]" : "w-[260px]"
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className={cn("h-14 flex items-center border-b border-gray-800/50 px-4", collapsed && "justify-center px-0")}>
|
||||
{collapsed ? (
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center">
|
||||
<Zap size={16} className="text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
|
||||
<Zap size={16} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold text-white">JIRA AI Fixer</h1>
|
||||
<p className="text-[10px] text-gray-500 font-medium">Enterprise v2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 p-4">
|
||||
{navItems.map(item => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg mb-1",
|
||||
location.pathname === item.path
|
||||
? "bg-primary-600 text-white"
|
||||
: "text-gray-400 hover:bg-gray-700 hover:text-white"
|
||||
)}
|
||||
|
||||
{/* Org selector */}
|
||||
{!collapsed && (
|
||||
<div className="px-3 py-3 border-b border-gray-800/50 relative">
|
||||
<button
|
||||
onClick={() => setShowOrgMenu(!showOrgMenu)}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg bg-gray-900/60 hover:bg-gray-800/80 border border-gray-800/50 transition-all"
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
<div className="w-6 h-6 rounded bg-indigo-600/20 flex items-center justify-center flex-shrink-0">
|
||||
<Building2 size={12} className="text-indigo-400" />
|
||||
</div>
|
||||
<span className="flex-1 text-left text-sm truncate">{currentOrg?.name || 'Select org'}</span>
|
||||
<ChevronsUpDown size={14} className="text-gray-500" />
|
||||
</button>
|
||||
{showOrgMenu && orgs?.data && (
|
||||
<div className="absolute left-3 right-3 top-full mt-1 bg-gray-900 border border-gray-700 rounded-lg shadow-xl z-50 animate-slide-up">
|
||||
{orgs.data.map(org => (
|
||||
<button
|
||||
key={org.id}
|
||||
onClick={() => { selectOrg(org); setShowOrgMenu(false); }}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-gray-800 first:rounded-t-lg last:rounded-b-lg transition-colors",
|
||||
currentOrg?.id === org.id && "bg-indigo-600/10 text-indigo-400"
|
||||
)}
|
||||
>
|
||||
<Building2 size={14} />
|
||||
{org.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { navigate('/settings'); setShowOrgMenu(false); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-indigo-400 hover:bg-gray-800 border-t border-gray-800 rounded-b-lg"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New organization
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search trigger */}
|
||||
{!collapsed && (
|
||||
<div className="px-3 pt-3">
|
||||
<button
|
||||
onClick={() => setShowSearch(true)}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-gray-500 hover:text-gray-300 bg-gray-900/40 hover:bg-gray-800/60 border border-gray-800/30 transition-all text-sm"
|
||||
>
|
||||
<Search size={14} />
|
||||
<span className="flex-1 text-left">Search...</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<kbd className="kbd">⌘</kbd>
|
||||
<kbd className="kbd">K</kbd>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-auto px-3 py-4 space-y-5">
|
||||
{navSections.map(section => (
|
||||
<div key={section.label}>
|
||||
{!collapsed && (
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600 px-3 mb-1.5">
|
||||
{section.label}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{section.items.map(item => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={cn(
|
||||
"sidebar-item",
|
||||
active ? "sidebar-item-active" : "sidebar-item-inactive",
|
||||
collapsed && "justify-center px-0"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={active ? 2 : 1.5} />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<div className="px-3 py-2 border-t border-gray-800/50">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className={cn("sidebar-item sidebar-item-inactive w-full", collapsed && "justify-center px-0")}
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
|
||||
{!collapsed && <span>Collapse</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User */}
|
||||
<div className="p-4 border-t border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
|
||||
{user?.full_name?.[0] || user?.email?.[0] || '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate font-medium">{user?.full_name || user?.email}</p>
|
||||
<button onClick={logout} className="text-xs text-gray-400 hover:text-red-400">
|
||||
Sign out
|
||||
</button>
|
||||
<div className={cn("px-3 py-3 border-t border-gray-800/50", collapsed && "px-2")}>
|
||||
<div className={cn("flex items-center gap-2.5", collapsed && "justify-center")}>
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xs font-semibold text-white flex-shrink-0">
|
||||
{user?.full_name?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{user?.full_name || user?.email}</p>
|
||||
<button onClick={logout} className="text-xs text-gray-500 hover:text-red-400 transition-colors flex items-center gap-1">
|
||||
<LogOut size={10} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Main area */}
|
||||
<div className={cn("flex-1 flex flex-col transition-all duration-300", collapsed ? "ml-[68px]" : "ml-[260px]")}>
|
||||
{/* Top header */}
|
||||
<header className="h-14 flex items-center justify-between px-6 border-b border-gray-800/50 bg-gray-950/80 backdrop-blur-md sticky top-0 z-30">
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
{getBreadcrumbs().map((crumb, i) => (
|
||||
<div key={crumb.path} className="flex items-center gap-1.5">
|
||||
{i > 0 && <ChevronRight size={12} className="text-gray-600" />}
|
||||
<Link
|
||||
to={crumb.path}
|
||||
className={cn(
|
||||
"hover:text-white transition-colors",
|
||||
i === getBreadcrumbs().length - 1 ? "text-white font-medium" : "text-gray-400"
|
||||
)}
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Header actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowSearch(true)}
|
||||
className="btn-ghost btn-icon rounded-lg"
|
||||
>
|
||||
<Search size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNotifs(!showNotifs)}
|
||||
className="btn-ghost btn-icon rounded-lg relative"
|
||||
>
|
||||
<Bell size={16} />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-indigo-500" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,133 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-collapsed: 68px;
|
||||
--header-height: 56px;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply antialiased;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-gray-800;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-700 rounded-full;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors;
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 text-sm inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 hover:bg-primary-700 text-white;
|
||||
@apply bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 hover:shadow-indigo-500/40;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-gray-700 hover:bg-gray-600 text-white;
|
||||
@apply bg-gray-800 hover:bg-gray-700 text-gray-200 border border-gray-700;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply bg-red-600/10 hover:bg-red-600/20 text-red-400 border border-red-500/20;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply hover:bg-gray-800 text-gray-400 hover:text-gray-200;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply h-8 px-3 text-xs;
|
||||
}
|
||||
.btn-icon {
|
||||
@apply h-9 w-9 p-0 justify-center;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||
@apply w-full h-10 px-3 bg-gray-900/50 border border-gray-700 rounded-lg text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500
|
||||
placeholder:text-gray-500 transition-all duration-200;
|
||||
}
|
||||
.input-sm {
|
||||
@apply h-8 px-2.5 text-xs;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-gray-800 border border-gray-700 rounded-xl p-6;
|
||||
@apply bg-gray-900/50 border border-gray-800 rounded-xl backdrop-blur-sm;
|
||||
}
|
||||
.card-hover {
|
||||
@apply card hover:border-gray-700 hover:bg-gray-900/80 transition-all duration-200 cursor-pointer;
|
||||
}
|
||||
.card-header {
|
||||
@apply px-5 py-4 border-b border-gray-800 flex items-center justify-between;
|
||||
}
|
||||
.card-body {
|
||||
@apply p-5;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium;
|
||||
}
|
||||
.badge-blue { @apply bg-blue-500/15 text-blue-400 ring-1 ring-blue-500/20; }
|
||||
.badge-green { @apply bg-emerald-500/15 text-emerald-400 ring-1 ring-emerald-500/20; }
|
||||
.badge-yellow { @apply bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/20; }
|
||||
.badge-red { @apply bg-red-500/15 text-red-400 ring-1 ring-red-500/20; }
|
||||
.badge-purple { @apply bg-purple-500/15 text-purple-400 ring-1 ring-purple-500/20; }
|
||||
.badge-gray { @apply bg-gray-500/15 text-gray-400 ring-1 ring-gray-500/20; }
|
||||
.badge-indigo { @apply bg-indigo-500/15 text-indigo-400 ring-1 ring-indigo-500/20; }
|
||||
|
||||
.stat-card {
|
||||
@apply card p-5 relative overflow-hidden;
|
||||
}
|
||||
.stat-card::after {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-gradient-to-br from-white/[0.02] to-transparent pointer-events-none;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@apply border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply bg-gray-800 rounded animate-pulse;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
@apply flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200;
|
||||
}
|
||||
.sidebar-item-active {
|
||||
@apply bg-indigo-600/15 text-indigo-400 border-r-2 border-indigo-500;
|
||||
}
|
||||
.sidebar-item-inactive {
|
||||
@apply text-gray-400 hover:bg-gray-800/60 hover:text-gray-200;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@apply flex items-center justify-between mb-6;
|
||||
}
|
||||
.page-title {
|
||||
@apply text-xl font-semibold text-white;
|
||||
}
|
||||
.page-subtitle {
|
||||
@apply text-sm text-gray-400 mt-1;
|
||||
}
|
||||
|
||||
.kbd {
|
||||
@apply inline-flex items-center justify-center h-5 min-w-[20px] px-1.5 rounded
|
||||
bg-gray-800 border border-gray-700 text-[10px] font-medium text-gray-400;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,90 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { issues, reports } from '../services/api';
|
||||
import { AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
|
||||
import { TicketCheck, CheckCircle2, GitPullRequest, Target, TrendingUp, TrendingDown, ArrowUpRight, Clock, AlertCircle, Building2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
const CHART_COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||||
|
||||
const StatSkeleton = () => (
|
||||
<div className="stat-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="skeleton h-3 w-20" />
|
||||
<div className="skeleton h-8 w-16" />
|
||||
</div>
|
||||
<div className="skeleton h-10 w-10 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ChartSkeleton = () => (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="skeleton h-4 w-32" />
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="skeleton h-56 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (!active || !payload) return null;
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 shadow-xl text-xs">
|
||||
<p className="text-gray-400 mb-1">{label}</p>
|
||||
{payload.map((item, i) => (
|
||||
<p key={i} className="text-white font-medium">
|
||||
<span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ backgroundColor: item.color }} />
|
||||
{item.name}: {item.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const { currentOrg } = useAuth();
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['issues-stats', currentOrg?.id],
|
||||
queryFn: () => issues.stats(currentOrg.id),
|
||||
enabled: !!currentOrg
|
||||
});
|
||||
|
||||
const { data: report } = useQuery({
|
||||
|
||||
const { data: report, isLoading: reportLoading } = useQuery({
|
||||
queryKey: ['report-summary', currentOrg?.id],
|
||||
queryFn: () => reports.summary(currentOrg.id, 14),
|
||||
enabled: !!currentOrg
|
||||
});
|
||||
|
||||
|
||||
if (!currentOrg) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<span className="text-6xl">🏢</span>
|
||||
<h2 className="text-2xl font-bold mt-4">Select an organization</h2>
|
||||
<p className="text-gray-400 mt-2">Choose an organization from the sidebar to get started</p>
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-16 h-16 rounded-2xl bg-indigo-600/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Building2 size={28} className="text-indigo-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Select an organization</h2>
|
||||
<p className="text-gray-400 text-sm">Choose an organization from the sidebar to view your dashboard and manage issues.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const s = stats?.data || {};
|
||||
const r = report?.data || {};
|
||||
|
||||
const loading = statsLoading || reportLoading;
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Total Issues', value: s.total || 0, icon: TicketCheck, color: 'text-blue-400', bg: 'bg-blue-500/10', trend: '+12%', up: true },
|
||||
{ label: 'Analyzed', value: s.analyzed || 0, icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-500/10', trend: '+8%', up: true },
|
||||
{ label: 'PRs Created', value: s.pr_created || 0, icon: GitPullRequest, color: 'text-purple-400', bg: 'bg-purple-500/10', trend: '+15%', up: true },
|
||||
{ label: 'Avg Confidence', value: s.avg_confidence ? `${(s.avg_confidence * 100).toFixed(0)}%` : 'N/A', icon: Target, color: 'text-amber-400', bg: 'bg-amber-500/10', trend: '+3%', up: true },
|
||||
];
|
||||
|
||||
const statusData = [
|
||||
{ name: 'Pending', value: s.pending || 0 },
|
||||
{ name: 'Analyzing', value: s.analyzing || 0 },
|
||||
|
|
@ -40,151 +92,170 @@ export default function Dashboard() {
|
|||
{ name: 'PR Created', value: s.pr_created || 0 },
|
||||
{ name: 'Error', value: s.error || 0 }
|
||||
].filter(d => d.value > 0);
|
||||
|
||||
const sourceData = Object.entries(s.by_source || {}).map(([name, value]) => ({ name, value }));
|
||||
|
||||
|
||||
const sourceData = Object.entries(s.by_source || {}).map(([name, value]) => ({
|
||||
name: name.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
||||
value
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">Total Issues</p>
|
||||
<p className="text-3xl font-bold mt-1">{s.total || 0}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center text-2xl">
|
||||
📋
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
<p className="page-subtitle">Overview of your issue analysis pipeline</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">Analyzed</p>
|
||||
<p className="text-3xl font-bold mt-1 text-green-400">{s.analyzed || 0}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center text-2xl">
|
||||
✅
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">PRs Created</p>
|
||||
<p className="text-3xl font-bold mt-1 text-purple-400">{s.pr_created || 0}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center text-2xl">
|
||||
🔀
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">Avg Confidence</p>
|
||||
<p className="text-3xl font-bold mt-1 text-yellow-400">
|
||||
{s.avg_confidence ? `${(s.avg_confidence * 100).toFixed(0)}%` : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-yellow-500/20 rounded-lg flex items-center justify-center text-2xl">
|
||||
🎯
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="badge badge-green">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
System operational
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Trend chart */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold mb-4">Issues Trend (14 days)</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={r.daily_breakdown || []}>
|
||||
<XAxis dataKey="date" tick={{ fill: '#9ca3af', fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: '#9ca3af', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
stroke="#6366f1"
|
||||
fill="#6366f1"
|
||||
fillOpacity={0.3}
|
||||
name="Total"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="analyzed"
|
||||
stroke="#22c55e"
|
||||
fill="#22c55e"
|
||||
fillOpacity={0.3}
|
||||
name="Analyzed"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status distribution */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold mb-4">Status Distribution</h3>
|
||||
<div className="h-64 flex items-center">
|
||||
{statusData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{statusData.map((entry, index) => (
|
||||
<Cell key={entry.name} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center w-full">No data yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{loading ? (
|
||||
Array(4).fill(0).map((_, i) => <StatSkeleton key={i} />)
|
||||
) : (
|
||||
statCards.map(stat => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={stat.label} className="stat-card">
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{stat.label}</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{stat.value}</p>
|
||||
<div className={cn("flex items-center gap-1 mt-1.5 text-xs font-medium", stat.up ? "text-emerald-400" : "text-red-400")}>
|
||||
{stat.up ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
||||
{stat.trend}
|
||||
<span className="text-gray-500 font-normal ml-0.5">vs last week</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("w-11 h-11 rounded-xl flex items-center justify-center", stat.bg)}>
|
||||
<Icon size={20} className={stat.color} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
{loading ? (
|
||||
<>
|
||||
<ChartSkeleton />
|
||||
<ChartSkeleton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Trend chart */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="text-sm font-semibold">Issues Trend</h3>
|
||||
<span className="badge badge-gray text-[10px]">Last 14 days</span>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={r.daily_breakdown || []}>
|
||||
<defs>
|
||||
<linearGradient id="colorTotal" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorAnalyzed" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e2a" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#5a5a70', fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={{ fill: '#5a5a70', fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area type="monotone" dataKey="total" stroke="#6366f1" fill="url(#colorTotal)" strokeWidth={2} name="Total" />
|
||||
<Area type="monotone" dataKey="analyzed" stroke="#22c55e" fill="url(#colorAnalyzed)" strokeWidth={2} name="Analyzed" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status distribution */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="text-sm font-semibold">Status Distribution</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="h-56 flex items-center">
|
||||
{statusData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={80}
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{statusData.map((entry, index) => (
|
||||
<Cell key={entry.name} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="w-full text-center">
|
||||
<AlertCircle size={24} className="text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">No data yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* By source */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold mb-4">Issues by Source</h3>
|
||||
<div className="h-64">
|
||||
{sourceData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={sourceData} layout="vertical">
|
||||
<XAxis type="number" tick={{ fill: '#9ca3af', fontSize: 12 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: '#9ca3af', fontSize: 12 }} width={100} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
|
||||
/>
|
||||
<Bar dataKey="value" fill="#6366f1" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center">No data yet</p>
|
||||
)}
|
||||
{loading ? (
|
||||
<ChartSkeleton />
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="text-sm font-semibold">Issues by Source</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="h-56">
|
||||
{sourceData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={sourceData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e2a" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fill: '#5a5a70', fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: '#8888a0', fontSize: 12 }} width={100} tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="value" fill="#6366f1" radius={[0, 6, 6, 0]} barSize={24} name="Issues" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<AlertCircle size={24} className="text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">No data yet</p>
|
||||
<p className="text-gray-600 text-xs mt-1">Connect an integration to start tracking</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,22 +3,27 @@ import { useQuery } from '@tanstack/react-query';
|
|||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { issues } from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '../lib/utils';
|
||||
import {
|
||||
Search, Filter, TicketCheck, ChevronRight, AlertCircle, Clock,
|
||||
CheckCircle2, GitPullRequest, XCircle, Loader2, ArrowUpDown,
|
||||
SlidersHorizontal, Plus
|
||||
} from 'lucide-react';
|
||||
|
||||
const statusColors = {
|
||||
pending: 'bg-yellow-500/20 text-yellow-400',
|
||||
analyzing: 'bg-blue-500/20 text-blue-400',
|
||||
analyzed: 'bg-green-500/20 text-green-400',
|
||||
pr_created: 'bg-purple-500/20 text-purple-400',
|
||||
completed: 'bg-gray-500/20 text-gray-400',
|
||||
error: 'bg-red-500/20 text-red-400'
|
||||
const statusConfig = {
|
||||
pending: { badge: 'badge-yellow', icon: Clock, label: 'Pending' },
|
||||
analyzing: { badge: 'badge-blue', icon: Loader2, label: 'Analyzing' },
|
||||
analyzed: { badge: 'badge-green', icon: CheckCircle2, label: 'Analyzed' },
|
||||
pr_created: { badge: 'badge-purple', icon: GitPullRequest, label: 'PR Created' },
|
||||
completed: { badge: 'badge-gray', icon: CheckCircle2, label: 'Completed' },
|
||||
error: { badge: 'badge-red', icon: XCircle, label: 'Error' },
|
||||
};
|
||||
|
||||
const priorityColors = {
|
||||
critical: 'bg-red-500/20 text-red-400',
|
||||
high: 'bg-orange-500/20 text-orange-400',
|
||||
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||
low: 'bg-green-500/20 text-green-400'
|
||||
const priorityConfig = {
|
||||
critical: { badge: 'badge-red', label: 'Critical' },
|
||||
high: { badge: 'badge-yellow', label: 'High' },
|
||||
medium: { badge: 'badge-blue', label: 'Medium' },
|
||||
low: { badge: 'badge-green', label: 'Low' },
|
||||
};
|
||||
|
||||
const sourceIcons = {
|
||||
|
|
@ -26,107 +31,221 @@ const sourceIcons = {
|
|||
github: '🐙', gitlab: '🦊', tickethub: '🎫', generic: '📝'
|
||||
};
|
||||
|
||||
const RowSkeleton = () => (
|
||||
<div className="flex items-center gap-4 px-5 py-4 table-row">
|
||||
<div className="skeleton h-4 w-20" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="skeleton h-4 w-3/4" />
|
||||
<div className="skeleton h-3 w-1/4" />
|
||||
</div>
|
||||
<div className="skeleton h-5 w-16 rounded-md" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function Issues() {
|
||||
const { currentOrg } = useAuth();
|
||||
const [filters, setFilters] = useState({ status: '', source: '' });
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['issues', currentOrg?.id, filters],
|
||||
queryFn: () => issues.list(currentOrg.id, filters),
|
||||
enabled: !!currentOrg
|
||||
});
|
||||
|
||||
|
||||
if (!currentOrg) {
|
||||
return <div className="p-8 text-center text-gray-400">Select an organization</div>;
|
||||
}
|
||||
|
||||
const issueList = data?.data || [];
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Issues</h1>
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({...filters, status: e.target.value})}
|
||||
className="input w-40"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="analyzing">Analyzing</option>
|
||||
<option value="analyzed">Analyzed</option>
|
||||
<option value="pr_created">PR Created</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<select
|
||||
value={filters.source}
|
||||
onChange={(e) => setFilters({...filters, source: e.target.value})}
|
||||
className="input w-40"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
<option value="jira_cloud">JIRA</option>
|
||||
<option value="servicenow">ServiceNow</option>
|
||||
<option value="zendesk">Zendesk</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitlab">GitLab</option>
|
||||
<option value="tickethub">TicketHub</option>
|
||||
</select>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<p className="text-gray-500">Select an organization</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
);
|
||||
}
|
||||
|
||||
const issueList = (data?.data || []).filter(issue =>
|
||||
!search || issue.title?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
issue.external_key?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const statusCounts = {};
|
||||
(data?.data || []).forEach(i => {
|
||||
statusCounts[i.status] = (statusCounts[i.status] || 0) + 1;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Issues</h1>
|
||||
<p className="page-subtitle">{data?.data?.length || 0} total issues</p>
|
||||
</div>
|
||||
<button className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
New Issue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status tabs */}
|
||||
<div className="flex items-center gap-1 mb-4 overflow-x-auto pb-1">
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, status: '' })}
|
||||
className={cn("badge cursor-pointer transition-all", !filters.status ? "badge-indigo" : "badge-gray hover:bg-gray-700/50")}
|
||||
>
|
||||
All {data?.data?.length || 0}
|
||||
</button>
|
||||
{Object.entries(statusConfig).map(([key, config]) => {
|
||||
const count = statusCounts[key] || 0;
|
||||
if (!count && key !== 'pending') return null;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilters({ ...filters, status: filters.status === key ? '' : key })}
|
||||
className={cn("badge cursor-pointer transition-all", filters.status === key ? config.badge : "badge-gray hover:bg-gray-700/50")}
|
||||
>
|
||||
{config.label} {count}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search & filters */}
|
||||
<div className="card mb-4">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Search size={16} className="text-gray-500" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search issues by title or key..."
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-gray-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={cn("btn btn-sm btn-ghost", showFilters && "text-indigo-400")}
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="px-4 py-3 border-t border-gray-800/50 flex items-center gap-3 animate-slide-up">
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={e => setFilters({ ...filters, status: e.target.value })}
|
||||
className="input-sm input w-36"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
{Object.entries(statusConfig).map(([key, cfg]) => (
|
||||
<option key={key} value={key}>{cfg.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filters.source}
|
||||
onChange={e => setFilters({ ...filters, source: e.target.value })}
|
||||
className="input-sm input w-36"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
<option value="jira_cloud">JIRA</option>
|
||||
<option value="servicenow">ServiceNow</option>
|
||||
<option value="zendesk">Zendesk</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitlab">GitLab</option>
|
||||
<option value="tickethub">TicketHub</option>
|
||||
</select>
|
||||
{(filters.status || filters.source) && (
|
||||
<button
|
||||
onClick={() => setFilters({ status: '', source: '' })}
|
||||
className="btn btn-sm btn-ghost text-red-400"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Issues table */}
|
||||
<div className="card overflow-hidden">
|
||||
{/* Table header */}
|
||||
<div className="flex items-center gap-4 px-5 py-3 border-b border-gray-800/50 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
<div className="w-24">Key</div>
|
||||
<div className="flex-1">Title</div>
|
||||
<div className="w-24">Status</div>
|
||||
<div className="w-20">Priority</div>
|
||||
<div className="w-20">Confidence</div>
|
||||
<div className="w-8" />
|
||||
</div>
|
||||
|
||||
{/* Table body */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">Loading...</div>
|
||||
Array(5).fill(0).map((_, i) => <RowSkeleton key={i} />)
|
||||
) : issueList.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<span className="text-4xl">📭</span>
|
||||
<p className="mt-2">No issues found</p>
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-800/50 flex items-center justify-center mb-3">
|
||||
<TicketCheck size={24} className="text-gray-600" />
|
||||
</div>
|
||||
<p className="text-gray-400 font-medium">No issues found</p>
|
||||
<p className="text-gray-600 text-sm mt-1">Issues from your integrations will appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-700">
|
||||
{issueList.map(issue => (
|
||||
issueList.map(issue => {
|
||||
const statusCfg = statusConfig[issue.status] || statusConfig.pending;
|
||||
const priorityCfg = priorityConfig[issue.priority] || priorityConfig.medium;
|
||||
const StatusIcon = statusCfg.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={`/issues/${issue.id}`}
|
||||
className="block p-4 hover:bg-gray-700/50 transition-colors"
|
||||
className="flex items-center gap-4 px-5 py-3.5 table-row group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-2xl">{sourceIcons[issue.source] || '📝'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-primary-400 text-sm">
|
||||
{issue.external_key || `#${issue.id}`}
|
||||
</span>
|
||||
<span className={clsx('px-2 py-0.5 rounded text-xs', statusColors[issue.status])}>
|
||||
{issue.status}
|
||||
</span>
|
||||
{issue.priority && (
|
||||
<span className={clsx('px-2 py-0.5 rounded text-xs', priorityColors[issue.priority])}>
|
||||
{issue.priority}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium truncate">{issue.title}</h3>
|
||||
{issue.confidence && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex-1 max-w-[200px] bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full"
|
||||
style={{ width: `${issue.confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{(issue.confidence * 100).toFixed(0)}% confidence
|
||||
</span>
|
||||
<div className="w-24">
|
||||
<span className="font-mono text-xs text-indigo-400">
|
||||
{issue.external_key || `#${issue.id}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate group-hover:text-white transition-colors">
|
||||
{issue.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5 flex items-center gap-2">
|
||||
<span>{sourceIcons[issue.source] || '📝'} {issue.source?.replace('_', ' ')}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<span className={cn("badge text-[10px]", statusCfg.badge)}>
|
||||
<StatusIcon size={10} className={issue.status === 'analyzing' ? 'animate-spin' : ''} />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<span className={cn("badge text-[10px]", priorityCfg.badge)}>
|
||||
{priorityCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
{issue.confidence ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-800 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-indigo-500 h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${issue.confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono w-7 text-right">
|
||||
{(issue.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-600">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-8">
|
||||
<ChevronRight size={14} className="text-gray-600 group-hover:text-gray-400 transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Zap, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
|
|
@ -9,7 +10,7 @@ export default function Login() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
|
@ -18,59 +19,134 @@ export default function Login() {
|
|||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Login failed');
|
||||
setError(err.response?.data?.detail || 'Invalid email or password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<span className="text-5xl">🤖</span>
|
||||
<h1 className="text-2xl font-bold mt-4">JIRA AI Fixer</h1>
|
||||
<p className="text-gray-400 mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="card">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
|
||||
{error}
|
||||
<div className="min-h-screen flex bg-gray-950">
|
||||
{/* Left side - branding */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-indigo-600 via-indigo-700 to-purple-800" />
|
||||
<div className="absolute inset-0 opacity-10" style={{
|
||||
backgroundImage: `radial-gradient(circle at 2px 2px, white 1px, transparent 0)`,
|
||||
backgroundSize: '32px 32px'
|
||||
}} />
|
||||
<div className="relative z-10 flex flex-col justify-center px-16">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center">
|
||||
<Zap size={24} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">JIRA AI Fixer</h1>
|
||||
<p className="text-sm text-indigo-200">Enterprise v2.0</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full">
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
<p className="text-center mt-4 text-gray-400">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-primary-400 hover:underline">Sign up</Link>
|
||||
<h2 className="text-4xl font-bold text-white leading-tight mb-4">
|
||||
AI-Powered Issue<br />Analysis & Resolution
|
||||
</h2>
|
||||
<p className="text-lg text-indigo-200 max-w-md">
|
||||
Automatically analyze issues from JIRA, ServiceNow, GitHub and more.
|
||||
Get root cause analysis and automated Pull Requests.
|
||||
</p>
|
||||
</form>
|
||||
<div className="mt-12 grid grid-cols-3 gap-6">
|
||||
{[
|
||||
{ value: '95%', label: 'Accuracy' },
|
||||
{ value: '10x', label: 'Faster Resolution' },
|
||||
{ value: '24/7', label: 'Automated' },
|
||||
].map(stat => (
|
||||
<div key={stat.label}>
|
||||
<p className="text-3xl font-bold text-white">{stat.value}</p>
|
||||
<p className="text-sm text-indigo-300 mt-1">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - form */}
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Mobile logo */}
|
||||
<div className="lg:hidden text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2.5">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-500/25">
|
||||
<Zap size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h1 className="text-lg font-bold text-white">JIRA AI Fixer</h1>
|
||||
<p className="text-xs text-gray-500">Enterprise v2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-white">Welcome back</h2>
|
||||
<p className="text-gray-400 mt-1">Sign in to your account to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Email</label>
|
||||
<div className="relative">
|
||||
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="input pl-10"
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Password</label>
|
||||
<div className="relative">
|
||||
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="input pl-10"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full h-11 justify-center mt-2"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Sign in
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center mt-6 text-sm text-gray-500">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
Create account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,85 +1,111 @@
|
|||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { auth } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Zap, Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function Register() {
|
||||
const [form, setForm] = useState({ email: '', password: '', full_name: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await auth.register(form);
|
||||
navigate('/login?registered=true');
|
||||
await register(form);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-950 px-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<span className="text-5xl">🤖</span>
|
||||
<h1 className="text-2xl font-bold mt-4">Create Account</h1>
|
||||
<p className="text-gray-400 mt-2">Get started with JIRA AI Fixer</p>
|
||||
<div className="inline-flex items-center gap-2.5">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-500/25">
|
||||
<Zap size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h1 className="text-lg font-bold text-white">JIRA AI Fixer</h1>
|
||||
<p className="text-xs text-gray-500">Enterprise v2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="card">
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-white">Create account</h2>
|
||||
<p className="text-gray-400 mt-1">Get started with AI-powered issue analysis</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-2">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.full_name}
|
||||
onChange={(e) => setForm({...form, full_name: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Full Name</label>
|
||||
<div className="relative">
|
||||
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={form.full_name}
|
||||
onChange={e => setForm({ ...form, full_name: e.target.value })}
|
||||
className="input pl-10"
|
||||
placeholder="John Doe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({...form, email: e.target.value})}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Email</label>
|
||||
<div className="relative">
|
||||
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => setForm({ ...form, email: e.target.value })}
|
||||
className="input pl-10"
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({...form, password: e.target.value})}
|
||||
className="input"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Password</label>
|
||||
<div className="relative">
|
||||
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => setForm({ ...form, password: e.target.value })}
|
||||
className="input pl-10"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full">
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full h-11 justify-center mt-2">
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <><span>Create account</span><ArrowRight size={16} /></>}
|
||||
</button>
|
||||
|
||||
<p className="text-center mt-4 text-gray-400">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-primary-400 hover:underline">Sign in</Link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p className="text-center mt-6 text-sm text-gray-500">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,15 +4,57 @@ export default {
|
|||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
gray: {
|
||||
950: '#0a0a0f',
|
||||
900: '#111118',
|
||||
850: '#16161f',
|
||||
800: '#1e1e2a',
|
||||
750: '#252533',
|
||||
700: '#2e2e3e',
|
||||
600: '#3f3f52',
|
||||
500: '#5a5a70',
|
||||
400: '#8888a0',
|
||||
300: '#aaaabe',
|
||||
200: '#ccccdc',
|
||||
100: '#e5e5f0',
|
||||
50: '#f5f5fa',
|
||||
},
|
||||
indigo: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
300: '#a5b4fc',
|
||||
400: '#818cf8',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
}
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-out',
|
||||
'slide-in': 'slideIn 0.3s ease-out',
|
||||
'slide-up': 'slideUp 0.2s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideIn: {
|
||||
'0%': { opacity: '0', transform: 'translateX(-8px)' },
|
||||
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>JIRA AI Fixer</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<script type="module" crossorigin src="/assets/index-DAbF3aXO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BFu5F8oq.css">
|
||||
<script type="module" crossorigin src="/assets/index-B1gflmx5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-AVKtIbkK.css">
|
||||
</head>
|
||||
<body class="bg-gray-900 text-white">
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue