Major WHOOSH system refactoring and feature enhancements
- Migrated from HIVE branding to WHOOSH across all components - Enhanced backend API with new services: AI models, BZZZ integration, templates, members - Added comprehensive testing suite with security, performance, and integration tests - Improved frontend with new components for project setup, AI models, and team management - Updated MCP server implementation with WHOOSH-specific tools and resources - Enhanced deployment configurations with production-ready Docker setups - Added comprehensive documentation and setup guides - Implemented age encryption service and UCXL integration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,14 @@ import {
|
||||
UserCircleIcon,
|
||||
ChevronDownIcon,
|
||||
AdjustmentsHorizontalIcon,
|
||||
ChatBubbleLeftRightIcon
|
||||
ChatBubbleLeftRightIcon,
|
||||
CpuChipIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import UserProfile from './auth/UserProfile';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import HiveLogo from '../assets/Hive_symbol.png';
|
||||
import WHOOSHLogo from '../assets/WHOOSH_symbol.png';
|
||||
|
||||
interface NavigationItem {
|
||||
name: string;
|
||||
@@ -30,11 +32,14 @@ interface NavigationItem {
|
||||
const navigation: NavigationItem[] = [
|
||||
{ name: 'Dashboard', href: '/', icon: HomeIcon },
|
||||
{ name: 'Projects', href: '/projects', icon: FolderIcon },
|
||||
{ name: 'Git Repositories', href: '/git-repositories', icon: GitBranch },
|
||||
{ name: 'Workflows', href: '/workflows', icon: Cog6ToothIcon },
|
||||
{ name: 'Cluster', href: '/cluster', icon: ComputerDesktopIcon },
|
||||
{ name: 'Executions', href: '/executions', icon: PlayIcon },
|
||||
{ name: 'Agents', href: '/agents', icon: UserGroupIcon },
|
||||
{ name: 'AI Models', href: '/ai-models', icon: CpuChipIcon },
|
||||
{ name: 'Bzzz Chat', href: '/bzzz-chat', icon: ChatBubbleLeftRightIcon },
|
||||
{ name: 'Bzzz Team', href: '/bzzz-team', icon: UserGroupIcon },
|
||||
{ name: 'Analytics', href: '/analytics', icon: ChartBarIcon },
|
||||
{ name: 'Settings', href: '/settings', icon: AdjustmentsHorizontalIcon },
|
||||
];
|
||||
@@ -82,8 +87,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<div className="fixed inset-y-0 left-0 flex flex-col w-64 bg-white dark:bg-gray-800 shadow-xl">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-2">
|
||||
<img src={HiveLogo} alt="Hive" className="h-8 w-8 object-contain" />
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">Hive</span>
|
||||
<img src={WHOOSHLogo} alt="WHOOSH" className="h-8 w-8 object-contain" />
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">WHOOSH</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
@@ -119,8 +124,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<div className="hidden lg:flex lg:flex-shrink-0">
|
||||
<div className="flex flex-col w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<img src={HiveLogo} alt="Hive" className="h-8 w-8 object-contain mr-2" />
|
||||
<span className="text-xl font-semibold text-gray-900 dark:text-white">Hive</span>
|
||||
<img src={WHOOSHLogo} alt="WHOOSH" className="h-8 w-8 object-contain mr-2" />
|
||||
<span className="text-xl font-semibold text-gray-900 dark:text-white">WHOOSH</span>
|
||||
</div>
|
||||
<nav className="flex-1 px-4 py-4 space-y-1">
|
||||
{navigationWithCurrent.map((item) => (
|
||||
@@ -165,7 +170,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</button>
|
||||
<div className="lg:hidden flex items-center space-x-2">
|
||||
<span className="text-2xl">🐝</span>
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">Hive</span>
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">WHOOSH</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ export const APIKeyManager: React.FC = () => {
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">API Keys</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Manage API keys for programmatic access to Hive
|
||||
Manage API keys for programmatic access to WHOOSH
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -349,7 +349,7 @@ export const APIKeyManager: React.FC = () => {
|
||||
<Key className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No API Keys</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Create your first API key to start using the Hive API programmatically.
|
||||
Create your first API key to start using the WHOOSH API programmatically.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
|
||||
@@ -72,7 +72,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ onSuccess, redirectTo = '/
|
||||
<Lock className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold text-gray-900">
|
||||
Welcome to Hive
|
||||
Welcome to WHOOSH
|
||||
</CardTitle>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Sign in to your account to continue
|
||||
|
||||
264
frontend/src/components/members/MemberDashboard.tsx
Normal file
264
frontend/src/components/members/MemberDashboard.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
UserGroupIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
KeyIcon,
|
||||
EnvelopeIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import MemberList from './MemberList';
|
||||
import MemberInviteForm from './MemberInviteForm';
|
||||
|
||||
interface MemberDashboardProps {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
interface MemberStats {
|
||||
total_members: number;
|
||||
active_members: number;
|
||||
pending_invitations: number;
|
||||
recent_activity: any[];
|
||||
}
|
||||
|
||||
export default function MemberDashboard({ projectId, projectName }: MemberDashboardProps) {
|
||||
const [showInviteForm, setShowInviteForm] = useState(false);
|
||||
|
||||
// Fetch member statistics
|
||||
const { data: members } = useQuery({
|
||||
queryKey: ['project-members', projectId],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/members/projects/${projectId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch project members');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate statistics from members data
|
||||
const stats = {
|
||||
total_members: members?.length || 0,
|
||||
active_members: members?.filter((m: any) => m.status === 'accepted').length || 0,
|
||||
pending_invitations: members?.filter((m: any) => m.status === 'pending').length || 0,
|
||||
recent_activity: [] // Placeholder for activity feed
|
||||
};
|
||||
|
||||
const handleInviteSuccess = () => {
|
||||
setShowInviteForm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Team Management</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Manage team members, roles, and collaboration for <strong>{projectName}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="bg-white overflow-hidden shadow-sm rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<UserGroupIcon className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Total Members
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{stats.total_members}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow-sm rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Active Members
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{stats.active_members}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow-sm rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<ClockIcon className="h-6 w-6 text-yellow-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Pending Invites
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{stats.pending_invitations}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow-sm rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<KeyIcon className="h-6 w-6 text-blue-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Age Encryption
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{members?.filter((m: any) => m.age_access).length || 0}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white shadow-sm rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Quick Actions</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={() => setShowInviteForm(true)}
|
||||
className="flex items-center p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<EnvelopeIcon className="h-6 w-6 text-gray-400 mr-3" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-gray-900">Invite New Member</p>
|
||||
<p className="text-xs text-gray-500">Send an invitation to join the team</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="flex items-center p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-green-400 hover:bg-green-50 transition-colors">
|
||||
<UserGroupIcon className="h-6 w-6 text-gray-400 mr-3" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-gray-900">Bulk Import</p>
|
||||
<p className="text-xs text-gray-500">Import multiple members from CSV</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="flex items-center p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-purple-400 hover:bg-purple-50 transition-colors">
|
||||
<ChartBarIcon className="h-6 w-6 text-gray-400 mr-3" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-gray-900">Member Analytics</p>
|
||||
<p className="text-xs text-gray-500">View detailed member statistics</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Distribution Chart */}
|
||||
{members && members.length > 0 && (
|
||||
<div className="bg-white shadow-sm rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Role Distribution</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{['owner', 'maintainer', 'developer', 'viewer'].map((role) => {
|
||||
const roleCount = members.filter((m: any) => m.role === role).length;
|
||||
const percentage = members.length > 0 ? (roleCount / members.length) * 100 : 0;
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'owner': return 'bg-purple-500';
|
||||
case 'maintainer': return 'bg-blue-500';
|
||||
case 'developer': return 'bg-green-500';
|
||||
case 'viewer': return 'bg-gray-500';
|
||||
default: return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={role} className="flex items-center">
|
||||
<div className="w-20 text-sm text-gray-600 capitalize">
|
||||
{role}
|
||||
</div>
|
||||
<div className="flex-1 ml-4">
|
||||
<div className="bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${getRoleColor(role)}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-12 text-sm text-gray-600 text-right">
|
||||
{roleCount}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white shadow-sm rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Recent Activity</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{/* Placeholder for recent activity */}
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<ClockIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No recent activity</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Member activities will appear here once they start collaborating.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member List */}
|
||||
<MemberList
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
onInviteMember={() => setShowInviteForm(true)}
|
||||
/>
|
||||
|
||||
{/* Invite Form Modal */}
|
||||
<MemberInviteForm
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
isOpen={showInviteForm}
|
||||
onSuccess={handleInviteSuccess}
|
||||
onCancel={() => setShowInviteForm(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
frontend/src/components/members/MemberInviteForm.tsx
Normal file
322
frontend/src/components/members/MemberInviteForm.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
EnvelopeIcon,
|
||||
KeyIcon,
|
||||
InformationCircleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Schema for member invitation form
|
||||
const memberInviteSchema = z.object({
|
||||
project_id: z.string().min(1, 'Project ID is required'),
|
||||
member_email: z.string().email('Valid email address is required'),
|
||||
role: z.enum(['owner', 'maintainer', 'developer', 'viewer']),
|
||||
custom_message: z.string().max(1000, 'Message must be less than 1000 characters').optional(),
|
||||
send_email: z.boolean().default(true),
|
||||
include_age_key: z.boolean().default(true)
|
||||
});
|
||||
|
||||
type MemberInviteData = z.infer<typeof memberInviteSchema>;
|
||||
|
||||
interface MemberInviteFormProps {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
onSuccess?: (invitation: any) => void;
|
||||
onCancel?: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default function MemberInviteForm({
|
||||
projectId,
|
||||
projectName,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
isOpen
|
||||
}: MemberInviteFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
reset
|
||||
} = useForm<MemberInviteData>({
|
||||
resolver: zodResolver(memberInviteSchema),
|
||||
defaultValues: {
|
||||
project_id: projectId,
|
||||
member_email: '',
|
||||
role: 'developer',
|
||||
custom_message: '',
|
||||
send_email: true,
|
||||
include_age_key: true
|
||||
}
|
||||
});
|
||||
|
||||
const selectedRole = watch('role');
|
||||
const sendEmail = watch('send_email');
|
||||
const includeAgeKey = watch('include_age_key');
|
||||
|
||||
const inviteMemberMutation = useMutation({
|
||||
mutationFn: async (data: MemberInviteData) => {
|
||||
const response = await fetch('/api/members/invite', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to send invitation');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Invitation sent to ${result.member_email}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['project-members', projectId] });
|
||||
reset();
|
||||
onSuccess?.(result);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to send invitation: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (data: MemberInviteData) => {
|
||||
inviteMemberMutation.mutate(data);
|
||||
};
|
||||
|
||||
const roleDescriptions = {
|
||||
owner: 'Full administrative access to the project',
|
||||
maintainer: 'Write access with merge permissions and member management',
|
||||
developer: 'Write access for development work and collaboration',
|
||||
viewer: 'Read-only access to project resources'
|
||||
};
|
||||
|
||||
const rolePermissions = {
|
||||
owner: ['Admin', 'Write', 'Read', 'Delete', 'Manage Members', 'Configure Settings', 'Manage Age Keys'],
|
||||
maintainer: ['Write', 'Read', 'Assign Issues', 'Merge PRs', 'Invite Members', 'Decrypt Age Data'],
|
||||
developer: ['Write', 'Read', 'Create Issues', 'Create PRs', 'Decrypt Age Data'],
|
||||
viewer: ['Read', 'View Issues', 'View PRs']
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-lg bg-white">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">Invite Team Member</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Invite a new member to collaborate on <strong>{projectName}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Member Email */}
|
||||
<div>
|
||||
<label htmlFor="member_email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<EnvelopeIcon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
id="member_email"
|
||||
{...register('member_email')}
|
||||
className="block w-full pl-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="colleague@company.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.member_email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.member_email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role Selection */}
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
{...register('role')}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="developer">Developer</option>
|
||||
<option value="maintainer">Maintainer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
{errors.role && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.role.message}</p>
|
||||
)}
|
||||
|
||||
{/* Role Description */}
|
||||
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>{selectedRole.charAt(0).toUpperCase() + selectedRole.slice(1)}:</strong> {roleDescriptions[selectedRole]}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-blue-700 font-medium">Permissions:</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{rolePermissions[selectedRole].map((permission) => (
|
||||
<span
|
||||
key={permission}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{permission}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div>
|
||||
<label htmlFor="custom_message" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Personal Message (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="custom_message"
|
||||
rows={3}
|
||||
{...register('custom_message')}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Add a personal welcome message for this team member..."
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{watch('custom_message')?.length || 0}/1000 characters
|
||||
</p>
|
||||
{errors.custom_message && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.custom_message.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{showAdvanced ? 'Hide' : 'Show'} Advanced Options
|
||||
<InformationCircleIcon className="ml-1 h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-4 space-y-4 p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||
{/* Send Email Option */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="send_email"
|
||||
{...register('send_email')}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="send_email" className="text-sm font-medium text-gray-700">
|
||||
Send email invitation
|
||||
</label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Automatically email the invitation to the member
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Include Age Key Option */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="include_age_key"
|
||||
{...register('include_age_key')}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="include_age_key" className="text-sm font-medium text-gray-700 flex items-center">
|
||||
<KeyIcon className="h-4 w-4 mr-1" />
|
||||
Include Age encryption key
|
||||
</label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Attach the project's Age public key for secure communication
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Notice */}
|
||||
{includeAgeKey && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<InformationCircleIcon className="h-5 w-5 text-yellow-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">
|
||||
Age Encryption Security
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
The Age public key will be attached to the invitation email.
|
||||
This allows the new member to encrypt data for this project.
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Private key access for decryption will be managed separately based on their role.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end space-x-4 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
|
||||
Sending Invitation...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
{sendEmail ? 'Send Invitation' : 'Create Invitation'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
373
frontend/src/components/members/MemberList.tsx
Normal file
373
frontend/src/components/members/MemberList.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
UserGroupIcon,
|
||||
EnvelopeIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
EllipsisVerticalIcon,
|
||||
ArrowPathIcon,
|
||||
TrashIcon,
|
||||
KeyIcon,
|
||||
GitBranchIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ProjectMember {
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
invited_at: string;
|
||||
invited_by: string;
|
||||
accepted_at?: string;
|
||||
permissions: string[];
|
||||
gitea_access: boolean;
|
||||
age_access: boolean;
|
||||
}
|
||||
|
||||
interface MemberListProps {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
onInviteMember?: () => void;
|
||||
}
|
||||
|
||||
export default function MemberList({ projectId, projectName, onInviteMember }: MemberListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedMember, setSelectedMember] = useState<string | null>(null);
|
||||
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
|
||||
|
||||
// Fetch project members
|
||||
const { data: members, isLoading, error } = useQuery({
|
||||
queryKey: ['project-members', projectId],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/members/projects/${projectId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch project members');
|
||||
}
|
||||
return response.json() as ProjectMember[];
|
||||
}
|
||||
});
|
||||
|
||||
// Remove member mutation
|
||||
const removeMemberMutation = useMutation({
|
||||
mutationFn: async (memberEmail: string) => {
|
||||
const response = await fetch(`/api/members/projects/${projectId}/members`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
member_email: memberEmail,
|
||||
reason: 'Removed by project admin'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to remove member');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (_, memberEmail) => {
|
||||
toast.success(`Member ${memberEmail} removed successfully`);
|
||||
queryClient.invalidateQueries({ queryKey: ['project-members', projectId] });
|
||||
setActionMenuOpen(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to remove member: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Resend invitation mutation
|
||||
const resendInvitationMutation = useMutation({
|
||||
mutationFn: async (memberEmail: string) => {
|
||||
// Find the invitation ID for this member
|
||||
const member = members?.find(m => m.email === memberEmail);
|
||||
if (!member) throw new Error('Member not found');
|
||||
|
||||
// For now, we'll assume invitation IDs follow a pattern
|
||||
// In production, you'd store this properly
|
||||
const invitationId = `inv_${projectId}_${memberEmail.replace('@', '_').replace('.', '_')}`;
|
||||
|
||||
const response = await fetch(`/api/members/projects/${projectId}/invitations/${invitationId}/resend`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to resend invitation');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (_, memberEmail) => {
|
||||
toast.success(`Invitation resent to ${memberEmail}`);
|
||||
setActionMenuOpen(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to resend invitation: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
||||
case 'pending':
|
||||
return <ClockIcon className="h-5 w-5 text-yellow-500" />;
|
||||
case 'revoked':
|
||||
return <XCircleIcon className="h-5 w-5 text-red-500" />;
|
||||
default:
|
||||
return <ClockIcon className="h-5 w-5 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'revoked':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'maintainer':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'developer':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'viewer':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white shadow-sm rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Team Members</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center space-x-4">
|
||||
<div className="rounded-full bg-gray-200 h-10 w-10"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white shadow-sm rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Team Members</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="text-center text-red-600">
|
||||
Failed to load team members: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow-sm rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserGroupIcon className="h-5 w-5 text-gray-400" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Team Members</h3>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{members?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
{onInviteMember && (
|
||||
<button
|
||||
onClick={onInviteMember}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<EnvelopeIcon className="h-4 w-4 mr-2" />
|
||||
Invite Member
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member List */}
|
||||
<div className="divide-y divide-gray-200">
|
||||
{!members || members.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-500">
|
||||
<UserGroupIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No team members</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Get started by inviting your first team member.
|
||||
</p>
|
||||
{onInviteMember && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={onInviteMember}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<EnvelopeIcon className="h-4 w-4 mr-2" />
|
||||
Invite Team Member
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<div key={member.email} className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{member.email.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{member.email}
|
||||
</p>
|
||||
{getStatusIcon(member.status)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleColor(member.role)}`}>
|
||||
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(member.status)}`}>
|
||||
{member.status.charAt(0).toUpperCase() + member.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>Invited by {member.invited_by}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(member.invited_at)}</span>
|
||||
{member.accepted_at && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>Joined {formatDate(member.accepted_at)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Access Indicators */}
|
||||
<div className="mt-2 flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-1">
|
||||
<GitBranchIcon className={`h-4 w-4 ${member.gitea_access ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{member.gitea_access ? 'GITEA Access' : 'No GITEA'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<KeyIcon className={`h-4 w-4 ${member.age_access ? 'text-blue-500' : 'text-gray-400'}`} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{member.age_access ? 'Age Encryption' : 'No Encryption'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setActionMenuOpen(actionMenuOpen === member.email ? null : member.email)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{actionMenuOpen === member.email && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-10 border border-gray-200">
|
||||
<div className="py-1">
|
||||
{member.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => resendInvitationMutation.mutate(member.email)}
|
||||
disabled={resendInvitationMutation.isLoading}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<ArrowPathIcon className="h-4 w-4 mr-2" />
|
||||
Resend Invitation
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Are you sure you want to remove ${member.email} from this project?`)) {
|
||||
removeMemberMutation.mutate(member.email);
|
||||
}
|
||||
}}
|
||||
disabled={removeMemberMutation.isLoading}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4 mr-2" />
|
||||
Remove Member
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{selectedMember === member.email && (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Permissions</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{member.permissions.map((permission) => (
|
||||
<span
|
||||
key={permission}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{permission.replace('_', ' ').replace('.', ': ').split(' ').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -182,7 +182,7 @@ export default function ProjectDetail() {
|
||||
</button>
|
||||
<button className="inline-flex items-center px-3 py-2 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-white hover:bg-red-50">
|
||||
<TrashIcon className="h-4 w-4 mr-2" />
|
||||
Archive
|
||||
Arcwhoosh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -7,37 +7,92 @@ import {
|
||||
ArrowLeftIcon,
|
||||
XMarkIcon,
|
||||
PlusIcon,
|
||||
InformationCircleIcon
|
||||
InformationCircleIcon,
|
||||
CheckIcon,
|
||||
ArrowRightIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const projectSchema = z.object({
|
||||
// Updated schema to match the new GITEA-integrated project setup API
|
||||
const projectSetupSchema = z.object({
|
||||
// Basic Information
|
||||
name: z.string().min(1, 'Project name is required').max(100, 'Name must be less than 100 characters'),
|
||||
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
metadata: z.object({
|
||||
owner: z.string().optional(),
|
||||
department: z.string().optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional()
|
||||
template_id: z.string().optional(),
|
||||
|
||||
// Age Key Configuration
|
||||
age_config: z.object({
|
||||
generate_new_key: z.boolean().default(true),
|
||||
master_key_passphrase: z.string().optional(),
|
||||
key_backup_location: z.string().optional()
|
||||
}).optional(),
|
||||
|
||||
// Git Configuration
|
||||
git_config: z.object({
|
||||
repo_type: z.enum(['new', 'existing', 'import']).default('new'),
|
||||
repo_name: z.string().optional(),
|
||||
git_url: z.string().optional(),
|
||||
git_owner: z.string().default('whoosh'),
|
||||
git_branch: z.string().default('main'),
|
||||
auto_initialize: z.boolean().default(true),
|
||||
private: z.boolean().default(false),
|
||||
license_type: z.string().default('MIT')
|
||||
}),
|
||||
|
||||
// BZZZ Configuration
|
||||
bzzz_config: z.object({
|
||||
git_url: z.string().url('Must be a valid Git URL').optional().or(z.literal('')),
|
||||
git_owner: z.string().optional(),
|
||||
git_repository: z.string().optional(),
|
||||
git_branch: z.string().optional(),
|
||||
bzzz_enabled: z.boolean().optional(),
|
||||
ready_to_claim: z.boolean().optional(),
|
||||
private_repo: z.boolean().optional(),
|
||||
github_token_required: z.boolean().optional()
|
||||
enable_bzzz: z.boolean().default(false),
|
||||
task_coordination: z.boolean().default(true),
|
||||
ai_agent_access: z.boolean().default(false),
|
||||
auto_discovery: z.boolean().default(true)
|
||||
}).optional(),
|
||||
|
||||
// Member Configuration
|
||||
member_config: z.object({
|
||||
initial_members: z.array(z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['owner', 'maintainer', 'developer', 'viewer']).default('developer')
|
||||
})).optional()
|
||||
}).optional(),
|
||||
|
||||
// Advanced Configuration
|
||||
advanced_config: z.object({
|
||||
project_visibility: z.enum(['private', 'internal', 'public']).default('private'),
|
||||
security_level: z.enum(['standard', 'high', 'maximum']).default('standard'),
|
||||
backup_enabled: z.boolean().default(true),
|
||||
monitoring_enabled: z.boolean().default(true)
|
||||
}).optional()
|
||||
});
|
||||
|
||||
type ProjectFormData = z.infer<typeof projectSchema>;
|
||||
type ProjectSetupData = z.infer<typeof projectSetupSchema>;
|
||||
|
||||
// Project setup steps
|
||||
type SetupStep = 'basic' | 'template' | 'git' | 'age-keys' | 'bzzz' | 'members' | 'review';
|
||||
|
||||
// Setup progress tracking
|
||||
interface SetupProgress {
|
||||
step: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
// Template interface
|
||||
interface ProjectTemplate {
|
||||
template_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
interface ProjectFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialData?: Partial<ProjectFormData>;
|
||||
initialData?: Partial<ProjectSetupData>;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
@@ -45,88 +100,159 @@ export default function ProjectForm({ mode, initialData, projectId }: ProjectFor
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [currentTag, setCurrentTag] = useState('');
|
||||
const [currentStep, setCurrentStep] = useState<SetupStep>('basic');
|
||||
const [setupProgress, setSetupProgress] = useState<SetupProgress[]>([]);
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
setValue
|
||||
} = useForm<ProjectFormData>({
|
||||
resolver: zodResolver(projectSchema),
|
||||
setValue,
|
||||
trigger
|
||||
} = useForm<ProjectSetupData>({
|
||||
resolver: zodResolver(projectSetupSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
tags: initialData?.tags || [],
|
||||
metadata: {
|
||||
owner: initialData?.metadata?.owner || '',
|
||||
department: initialData?.metadata?.department || '',
|
||||
priority: initialData?.metadata?.priority || 'medium'
|
||||
template_id: initialData?.template_id || '',
|
||||
age_config: {
|
||||
generate_new_key: initialData?.age_config?.generate_new_key ?? true,
|
||||
master_key_passphrase: initialData?.age_config?.master_key_passphrase || '',
|
||||
key_backup_location: initialData?.age_config?.key_backup_location || ''
|
||||
},
|
||||
git_config: {
|
||||
repo_type: initialData?.git_config?.repo_type || 'new',
|
||||
repo_name: initialData?.git_config?.repo_name || '',
|
||||
git_url: initialData?.git_config?.git_url || '',
|
||||
git_owner: initialData?.git_config?.git_owner || 'whoosh',
|
||||
git_branch: initialData?.git_config?.git_branch || 'main',
|
||||
auto_initialize: initialData?.git_config?.auto_initialize ?? true,
|
||||
private: initialData?.git_config?.private ?? false,
|
||||
license_type: initialData?.git_config?.license_type || 'MIT'
|
||||
},
|
||||
bzzz_config: {
|
||||
git_url: initialData?.bzzz_config?.git_url || '',
|
||||
git_owner: initialData?.bzzz_config?.git_owner || '',
|
||||
git_repository: initialData?.bzzz_config?.git_repository || '',
|
||||
git_branch: initialData?.bzzz_config?.git_branch || 'main',
|
||||
bzzz_enabled: initialData?.bzzz_config?.bzzz_enabled || false,
|
||||
ready_to_claim: initialData?.bzzz_config?.ready_to_claim || false,
|
||||
private_repo: initialData?.bzzz_config?.private_repo || false,
|
||||
github_token_required: initialData?.bzzz_config?.github_token_required || false
|
||||
enable_bzzz: initialData?.bzzz_config?.enable_bzzz ?? false,
|
||||
task_coordination: initialData?.bzzz_config?.task_coordination ?? true,
|
||||
ai_agent_access: initialData?.bzzz_config?.ai_agent_access ?? false,
|
||||
auto_discovery: initialData?.bzzz_config?.auto_discovery ?? true
|
||||
},
|
||||
member_config: {
|
||||
initial_members: initialData?.member_config?.initial_members || []
|
||||
},
|
||||
advanced_config: {
|
||||
project_visibility: initialData?.advanced_config?.project_visibility || 'private',
|
||||
security_level: initialData?.advanced_config?.security_level || 'standard',
|
||||
backup_enabled: initialData?.advanced_config?.backup_enabled ?? true,
|
||||
monitoring_enabled: initialData?.advanced_config?.monitoring_enabled ?? true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const currentTags = watch('tags') || [];
|
||||
const gitUrl = watch('bzzz_config.git_url') || '';
|
||||
const bzzzEnabled = watch('bzzz_config.bzzz_enabled') || false;
|
||||
const selectedTemplate = watch('template_id') || '';
|
||||
const repoType = watch('git_config.repo_type') || 'new';
|
||||
const projectName = watch('name') || '';
|
||||
const bzzzEnabled = watch('bzzz_config.enable_bzzz') || false;
|
||||
const generateAgeKeys = watch('age_config.generate_new_key') ?? true;
|
||||
|
||||
// Auto-parse Git URL to extract owner and repository
|
||||
const parseGitUrl = (url: string) => {
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
// Handle GitHub URLs like https://github.com/owner/repo or git@github.com:owner/repo.git
|
||||
const githubMatch = url.match(/github\.com[/:]([\w-]+)\/([\w-]+)(?:\.git)?$/);
|
||||
if (githubMatch) {
|
||||
const [, owner, repo] = githubMatch;
|
||||
setValue('bzzz_config.git_owner', owner);
|
||||
setValue('bzzz_config.git_repository', repo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not parse Git URL:', error);
|
||||
// Fetch available project templates
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ['project-templates'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/project-setup/templates');
|
||||
if (!response.ok) throw new Error('Failed to fetch templates');
|
||||
return response.json();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-generate repository name from project name
|
||||
useEffect(() => {
|
||||
if (projectName && repoType === 'new') {
|
||||
const repoName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
||||
setValue('git_config.repo_name', repoName);
|
||||
}
|
||||
}, [projectName, repoType, setValue]);
|
||||
|
||||
// Setup step validation
|
||||
const isStepValid = async (step: SetupStep): Promise<boolean> => {
|
||||
switch (step) {
|
||||
case 'basic':
|
||||
return await trigger(['name', 'description']);
|
||||
case 'git':
|
||||
return await trigger(['git_config']);
|
||||
case 'age-keys':
|
||||
return await trigger(['age_config']);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for Git URL changes and auto-parse
|
||||
const handleGitUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const url = e.target.value;
|
||||
parseGitUrl(url);
|
||||
// Navigate between steps
|
||||
const goToStep = async (step: SetupStep) => {
|
||||
const valid = await isStepValid(currentStep);
|
||||
if (valid || step === 'basic') {
|
||||
setCurrentStep(step);
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = async () => {
|
||||
const steps: SetupStep[] = ['basic', 'template', 'git', 'age-keys', 'bzzz', 'members', 'review'];
|
||||
const currentIndex = steps.indexOf(currentStep);
|
||||
if (currentIndex < steps.length - 1) {
|
||||
await goToStep(steps[currentIndex + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
const steps: SetupStep[] = ['basic', 'template', 'git', 'age-keys', 'bzzz', 'members', 'review'];
|
||||
const currentIndex = steps.indexOf(currentStep);
|
||||
if (currentIndex > 0) {
|
||||
setCurrentStep(steps[currentIndex - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const createProjectMutation = useMutation({
|
||||
mutationFn: async (data: ProjectFormData) => {
|
||||
// In a real app, this would be an API call
|
||||
const response = await fetch('/api/projects', {
|
||||
mutationFn: async (data: ProjectSetupData) => {
|
||||
setIsCreatingProject(true);
|
||||
setSetupProgress([]);
|
||||
|
||||
const response = await fetch('/api/project-setup/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create project');
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to create project');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (newProject) => {
|
||||
onSuccess: (result) => {
|
||||
setIsCreatingProject(false);
|
||||
setSetupProgress(result.progress || []);
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success('Project created successfully!');
|
||||
navigate(`/projects/${newProject.id}`);
|
||||
|
||||
// Show completion with next steps
|
||||
setTimeout(() => {
|
||||
navigate(`/projects/${result.project_id}`);
|
||||
}, 2000);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to create project');
|
||||
setIsCreatingProject(false);
|
||||
toast.error(`Failed to create project: ${error.message}`);
|
||||
console.error('Create project error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const updateProjectMutation = useMutation({
|
||||
mutationFn: async (data: ProjectFormData) => {
|
||||
mutationFn: async (data: ProjectSetupData) => {
|
||||
const response = await fetch(`/api/projects/${projectId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -147,8 +273,10 @@ export default function ProjectForm({ mode, initialData, projectId }: ProjectFor
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (data: ProjectFormData) => {
|
||||
const onSubmit = (data: ProjectSetupData) => {
|
||||
if (mode === 'create') {
|
||||
// Set final step to show progress
|
||||
setCurrentStep('review');
|
||||
createProjectMutation.mutate(data);
|
||||
} else {
|
||||
updateProjectMutation.mutate(data);
|
||||
@@ -303,6 +431,105 @@ export default function ProjectForm({ mode, initialData, projectId }: ProjectFor
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Age Encryption Configuration */}
|
||||
<div className="bg-white shadow-sm rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h2 className="text-lg font-medium text-gray-900">🔐 Age Encryption Keys</h2>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Secure
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Generate master encryption keys for secure project data and member communication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-6">
|
||||
{/* Generate Age Keys */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="generate_age_keys"
|
||||
{...register('age_config.generate_new_key')}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="generate_age_keys" className="text-sm font-medium text-gray-700">
|
||||
Generate Age master key pair for this project
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1 ml-7">
|
||||
Creates secure encryption keys for project data, member communication, and sensitive information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Age Key Configuration - Only show if enabled */}
|
||||
{generateAgeKeys && (
|
||||
<div className="space-y-4 ml-7">
|
||||
{/* Master Key Passphrase */}
|
||||
<div>
|
||||
<label htmlFor="master_key_passphrase" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Master Key Passphrase (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="master_key_passphrase"
|
||||
{...register('age_config.master_key_passphrase')}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter a strong passphrase for additional security"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Encrypts your private key with a passphrase. Leave empty for unencrypted storage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Backup Location */}
|
||||
<div>
|
||||
<label htmlFor="key_backup_location" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Key Backup Location (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="key_backup_location"
|
||||
{...register('age_config.key_backup_location')}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="/secure/backup/location or cloud storage path"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Automatically create a backup of your encryption keys at this location.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Age Key Features Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">
|
||||
Age Encryption Features
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<p>Your Age master keys will enable:</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>End-to-end encryption of sensitive project data</li>
|
||||
<li>Secure member-to-member communication</li>
|
||||
<li>Encrypted project configuration and secrets</li>
|
||||
<li>12-word recovery phrase generation</li>
|
||||
<li>Automatic key backup and distribution</li>
|
||||
</ul>
|
||||
<p className="mt-2 font-medium">
|
||||
Keys are stored securely with restricted file permissions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Metadata */}
|
||||
<div className="bg-white shadow-sm rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
@@ -380,11 +607,11 @@ export default function ProjectForm({ mode, initialData, projectId }: ProjectFor
|
||||
<input
|
||||
type="checkbox"
|
||||
id="bzzz_enabled"
|
||||
{...register('bzzz_config.bzzz_enabled')}
|
||||
{...register('bzzz_config.enable_bzzz')}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="bzzz_enabled" className="text-sm font-medium text-gray-700">
|
||||
Enable Bzzz P2P coordination for this project
|
||||
Enable BZZZ P2P coordination for this project
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1 ml-7">
|
||||
@@ -392,145 +619,303 @@ export default function ProjectForm({ mode, initialData, projectId }: ProjectFor
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Git Repository Configuration - Only show if Bzzz is enabled */}
|
||||
{bzzzEnabled && (
|
||||
<>
|
||||
{/* Git Repository URL */}
|
||||
<div>
|
||||
<label htmlFor="git_url" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Git Repository URL *
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="git_url"
|
||||
{...register('bzzz_config.git_url')}
|
||||
onChange={handleGitUrlChange}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="https://github.com/owner/repository"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
GitHub repository URL where Bzzz will look for issues labeled with 'bzzz-task'.
|
||||
</p>
|
||||
{errors.bzzz_config?.git_url && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.bzzz_config.git_url.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Git Repository Configuration */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-4">🔗 Git Repository Setup</h3>
|
||||
|
||||
{/* Repository Type */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Repository Type
|
||||
</label>
|
||||
<select
|
||||
{...register('git_config.repo_type')}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="new">Create new repository</option>
|
||||
<option value="existing">Use existing repository</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Auto-parsed Git Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="git_owner" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Repository Owner
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="git_owner"
|
||||
{...register('bzzz_config.git_owner')}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Auto-detected from URL"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="git_repository" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Repository Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="git_repository"
|
||||
{...register('bzzz_config.git_repository')}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Auto-detected from URL"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Git Branch */}
|
||||
{/* Repository Name (auto-generated for new repos) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="git_branch" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Default Branch
|
||||
<label htmlFor="git_owner" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Repository Owner
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="git_branch"
|
||||
{...register('bzzz_config.git_branch')}
|
||||
id="git_owner"
|
||||
{...register('git_config.git_owner')}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="main"
|
||||
placeholder="whoosh"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="git_repository" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Repository Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="git_repository"
|
||||
{...register('git_config.repo_name')}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Auto-generated from project name"
|
||||
readOnly={repoType === 'new'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repository Configuration */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-700">Repository Configuration</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Ready to Claim */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ready_to_claim"
|
||||
{...register('bzzz_config.ready_to_claim')}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="ready_to_claim" className="text-sm text-gray-700">
|
||||
Ready for task claims (agents can start working immediately)
|
||||
</label>
|
||||
</div>
|
||||
{/* Git Branch */}
|
||||
<div className="mt-4">
|
||||
<label htmlFor="git_branch" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Default Branch
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="git_branch"
|
||||
{...register('git_config.git_branch')}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="main"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Private Repository */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="private_repo"
|
||||
{...register('bzzz_config.private_repo')}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="private_repo" className="text-sm text-gray-700">
|
||||
Private repository (requires authentication)
|
||||
</label>
|
||||
</div>
|
||||
{/* Repository Privacy */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="private_repo"
|
||||
{...register('git_config.private')}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="private_repo" className="text-sm text-gray-700">
|
||||
Private repository
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub Token Required */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="github_token_required"
|
||||
{...register('bzzz_config.github_token_required')}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="github_token_required" className="text-sm text-gray-700">
|
||||
Requires GitHub token for API access
|
||||
</label>
|
||||
</div>
|
||||
{/* BZZZ Integration Features - Only show if enabled */}
|
||||
{bzzzEnabled && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-700">BZZZ Task Coordination Features</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Task Coordination */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="task_coordination"
|
||||
{...register('bzzz_config.task_coordination')}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="task_coordination" className="text-sm text-gray-700">
|
||||
Enable automatic task coordination
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* AI Agent Access */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ai_agent_access"
|
||||
{...register('bzzz_config.ai_agent_access')}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="ai_agent_access" className="text-sm text-gray-700">
|
||||
Allow AI agents to access and modify project files
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Auto Discovery */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="auto_discovery"
|
||||
{...register('bzzz_config.auto_discovery')}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="auto_discovery" className="text-sm text-gray-700">
|
||||
Enable automatic peer discovery
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bzzz Integration Info */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<InformationCircleIcon className="h-5 w-5 text-yellow-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">
|
||||
How Bzzz Integration Works
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<p>When enabled, Bzzz agents will:</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Monitor GitHub issues labeled with 'bzzz-task'</li>
|
||||
<li>Coordinate P2P to assign tasks based on agent capabilities</li>
|
||||
<li>Execute tasks using distributed AI reasoning</li>
|
||||
<li>Report progress and escalate when needed</li>
|
||||
</ul>
|
||||
<p className="mt-2 font-medium">
|
||||
Make sure your repository has issues labeled with 'bzzz-task' for agents to discover.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BZZZ Integration Info */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<InformationCircleIcon className="h-5 w-5 text-yellow-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">
|
||||
How BZZZ Integration Works
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<p>When enabled, BZZZ agents will:</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Monitor GITEA issues labeled with 'bzzz-task'</li>
|
||||
<li>Coordinate P2P to assign tasks based on agent capabilities</li>
|
||||
<li>Execute tasks using distributed AI reasoning</li>
|
||||
<li>Report progress and escalate when needed</li>
|
||||
</ul>
|
||||
<p className="mt-2 font-medium">
|
||||
A GITEA repository will be automatically created with proper BZZZ labels configured.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Members Configuration */}
|
||||
<div className="bg-white shadow-sm rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h2 className="text-lg font-medium text-gray-900">👥 Team Members</h2>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Invite team members to collaborate on this project from the start.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-6">
|
||||
{/* Member Invitations */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Initial Team Members
|
||||
</label>
|
||||
|
||||
{/* Current Members */}
|
||||
{watch('member_config.initial_members')?.length > 0 && (
|
||||
<div className="space-y-2 mb-4">
|
||||
{watch('member_config.initial_members').map((member, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm font-medium text-gray-900">{member.email}</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
member.role === 'owner' ? 'bg-purple-100 text-purple-800' :
|
||||
member.role === 'maintainer' ? 'bg-blue-100 text-blue-800' :
|
||||
member.role === 'developer' ? 'bg-green-100 text-green-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const currentMembers = watch('member_config.initial_members') || [];
|
||||
const updatedMembers = currentMembers.filter((_, i) => i !== index);
|
||||
setValue('member_config.initial_members', updatedMembers);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Member Form */}
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="team.member@company.com"
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const emailInput = e.target as HTMLInputElement;
|
||||
const roleSelect = emailInput.parentElement?.nextElementSibling?.querySelector('select') as HTMLSelectElement;
|
||||
if (emailInput.value && roleSelect?.value) {
|
||||
const currentMembers = watch('member_config.initial_members') || [];
|
||||
const newMember = {
|
||||
email: emailInput.value,
|
||||
role: roleSelect.value as 'owner' | 'maintainer' | 'developer' | 'viewer'
|
||||
};
|
||||
setValue('member_config.initial_members', [...currentMembers, newMember]);
|
||||
emailInput.value = '';
|
||||
roleSelect.value = 'developer';
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
defaultValue="developer"
|
||||
>
|
||||
<option value="developer">Developer</option>
|
||||
<option value="maintainer">Maintainer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const container = document.querySelector('.border-dashed');
|
||||
const emailInput = container?.querySelector('input[type="email"]') as HTMLInputElement;
|
||||
const roleSelect = container?.querySelector('select') as HTMLSelectElement;
|
||||
|
||||
if (emailInput?.value && roleSelect?.value) {
|
||||
const currentMembers = watch('member_config.initial_members') || [];
|
||||
const newMember = {
|
||||
email: emailInput.value,
|
||||
role: roleSelect.value as 'owner' | 'maintainer' | 'developer' | 'viewer'
|
||||
};
|
||||
setValue('member_config.initial_members', [...currentMembers, newMember]);
|
||||
emailInput.value = '';
|
||||
roleSelect.value = 'developer';
|
||||
}
|
||||
}}
|
||||
className="w-full inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-1" />
|
||||
Add Member
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Press Enter in the email field or click "Add Member" to add team members
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member Features Info */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<InformationCircleIcon className="h-5 w-5 text-green-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-green-800">
|
||||
Team Member Features
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-green-700">
|
||||
<p>Team members will receive:</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Email invitation with project details and role information</li>
|
||||
<li>Access to GITEA repository based on their role</li>
|
||||
<li>Age encryption keys for secure project communication</li>
|
||||
<li>Role-based permissions for project management</li>
|
||||
<li>Integration with BZZZ task coordination system</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { projectApi } from '../../services/api';
|
||||
|
||||
export default function ProjectList() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive' | 'archived'>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive' | 'arcwhooshd'>('all');
|
||||
const [bzzzFilter, setBzzzFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
|
||||
|
||||
// Fetch real projects from API
|
||||
@@ -52,7 +52,7 @@ export default function ProjectList() {
|
||||
return `${baseClasses} bg-green-100 text-green-800`;
|
||||
case 'inactive':
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
case 'archived':
|
||||
case 'arcwhooshd':
|
||||
return `${baseClasses} bg-red-100 text-red-800`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
@@ -138,7 +138,7 @@ export default function ProjectList() {
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="archived">Archived</option>
|
||||
<option value="arcwhooshd">Arcwhooshd</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -248,10 +248,10 @@ export default function ProjectList() {
|
||||
<button
|
||||
className={`${active ? 'bg-gray-100' : ''} block w-full text-left px-4 py-2 text-sm text-red-700`}
|
||||
onClick={() => {
|
||||
// Handle archive/delete
|
||||
// Handle arcwhoosh/delete
|
||||
}}
|
||||
>
|
||||
Archive Project
|
||||
Arcwhoosh Project
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
|
||||
1092
frontend/src/components/projects/ProjectSetupWizard.tsx
Normal file
1092
frontend/src/components/projects/ProjectSetupWizard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
116
frontend/src/components/setup/ClusterDetector.tsx
Normal file
116
frontend/src/components/setup/ClusterDetector.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Cluster Detector Component
|
||||
* Detects if cluster exists and routes to setup wizard or main app accordingly
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import SetupWizard from './SetupWizard';
|
||||
import { apiConfig } from '../../config/api';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface ClusterDetectorProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ClusterDetector: React.FC<ClusterDetectorProps> = ({ children }) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [clusterExists, setClusterExists] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const API_BASE_URL = apiConfig.baseURL + '/api';
|
||||
|
||||
useEffect(() => {
|
||||
checkClusterStatus();
|
||||
}, []);
|
||||
|
||||
const checkClusterStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/cluster-setup/status`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Check if cluster is fully initialized
|
||||
setClusterExists(data.data.cluster_initialized || false);
|
||||
} else {
|
||||
// If we can't check status, assume no cluster exists
|
||||
setClusterExists(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error checking cluster status:', err);
|
||||
// On error, assume no cluster exists to trigger setup
|
||||
setClusterExists(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${
|
||||
isDarkMode
|
||||
? 'bg-gradient-to-br from-gray-900 to-gray-800'
|
||||
: 'bg-gradient-to-br from-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className="text-center">
|
||||
<Loader2 className={`h-8 w-8 animate-spin mx-auto ${
|
||||
isDarkMode ? 'text-blue-400' : 'text-blue-600'
|
||||
}`} />
|
||||
<p className={`mt-2 ${
|
||||
isDarkMode ? 'text-gray-300' : 'text-gray-600'
|
||||
}`}>Detecting cluster status...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${
|
||||
isDarkMode
|
||||
? 'bg-gradient-to-br from-red-900/20 to-red-800/20'
|
||||
: 'bg-gradient-to-br from-red-50 to-red-100'
|
||||
}`}>
|
||||
<div className="text-center">
|
||||
<div className={`mb-4 ${
|
||||
isDarkMode ? 'text-red-400' : 'text-red-600'
|
||||
}`}>
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 19c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className={`text-2xl font-bold mb-2 ${
|
||||
isDarkMode ? 'text-red-300' : 'text-red-900'
|
||||
}`}>Cluster Detection Error</h1>
|
||||
<p className={`mb-4 ${
|
||||
isDarkMode ? 'text-red-400' : 'text-red-700'
|
||||
}`}>{error}</p>
|
||||
<button
|
||||
onClick={checkClusterStatus}
|
||||
className={`px-4 py-2 rounded ${
|
||||
isDarkMode
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-red-600 text-white hover:bg-red-700'
|
||||
}`}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If cluster doesn't exist, show setup wizard
|
||||
if (!clusterExists) {
|
||||
return <SetupWizard />;
|
||||
}
|
||||
|
||||
// If cluster exists, show the main app
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ClusterDetector;
|
||||
1051
frontend/src/components/setup/SetupWizard.tsx
Normal file
1051
frontend/src/components/setup/SetupWizard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
421
frontend/src/components/templates/TemplateBrowser.tsx
Normal file
421
frontend/src/components/templates/TemplateBrowser.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
FunnelIcon,
|
||||
ClockIcon,
|
||||
TagIcon,
|
||||
UserIcon,
|
||||
CodeBracketIcon,
|
||||
DocumentTextIcon,
|
||||
ChevronRightIcon,
|
||||
StarIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface Template {
|
||||
template_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
difficulty: string;
|
||||
estimated_setup_time: string;
|
||||
features: string[];
|
||||
tech_stack: Record<string, string[]>;
|
||||
requirements?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface TemplateBrowserProps {
|
||||
onSelectTemplate?: (template: Template) => void;
|
||||
onCreateProject?: (templateId: string) => void;
|
||||
mode?: 'browse' | 'select';
|
||||
}
|
||||
|
||||
export default function TemplateBrowser({
|
||||
onSelectTemplate,
|
||||
onCreateProject,
|
||||
mode = 'browse'
|
||||
}: TemplateBrowserProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState<string>('all');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
// Fetch templates
|
||||
const { data: templatesResponse, isLoading, error } = useQuery({
|
||||
queryKey: ['templates', selectedCategory, selectedDifficulty],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedCategory !== 'all') params.append('category', selectedCategory);
|
||||
if (selectedDifficulty !== 'all') params.append('difficulty', selectedDifficulty);
|
||||
|
||||
const response = await fetch(`/api/templates?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch templates');
|
||||
return response.json();
|
||||
}
|
||||
});
|
||||
|
||||
// Create project from template mutation
|
||||
const createProjectMutation = useMutation({
|
||||
mutationFn: async (templateId: string) => {
|
||||
const response = await fetch('/api/templates/create-project', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template_id: templateId,
|
||||
project_name: `${selectedTemplate?.name.replace(/\s+/g, '-').toLowerCase()}-project`,
|
||||
create_repository: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create project');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Project created successfully!`);
|
||||
if (onCreateProject) {
|
||||
onCreateProject(result.project_id);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to create project: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const templates = templatesResponse?.templates || [];
|
||||
const categories = templatesResponse?.categories || [];
|
||||
|
||||
// Filter templates based on search query
|
||||
const filteredTemplates = templates.filter((template: Template) => {
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
return matchesSearch;
|
||||
});
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'beginner': return 'bg-green-100 text-green-800';
|
||||
case 'intermediate': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'advanced': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'web-development': return '🌐';
|
||||
case 'data-science': return '📊';
|
||||
case 'mobile': return '📱';
|
||||
case 'devops': return '🔧';
|
||||
case 'ai-ml': return '🤖';
|
||||
default: return '📁';
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (template: Template) => {
|
||||
setSelectedTemplate(template);
|
||||
if (onSelectTemplate) {
|
||||
onSelectTemplate(template);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = (templateId: string) => {
|
||||
createProjectMutation.mutate(templateId);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-gray-200 rounded-lg h-32"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 text-center text-red-600">
|
||||
Failed to load templates: {error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-gray-900">Project Templates</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Choose from {templates.length} professionally crafted project templates
|
||||
</p>
|
||||
</div>
|
||||
{mode === 'browse' && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-500">
|
||||
{filteredTemplates.length} templates
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search templates..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="sm:w-48">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Difficulty Filter */}
|
||||
<div className="sm:w-36">
|
||||
<select
|
||||
value={selectedDifficulty}
|
||||
onChange={(e) => setSelectedDifficulty(e.target.value)}
|
||||
className="block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">All Levels</option>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template Grid */}
|
||||
<div className="p-6">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<DocumentTextIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No templates found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Try adjusting your search or filter criteria.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredTemplates.map((template: Template) => (
|
||||
<div
|
||||
key={template.template_id}
|
||||
className={`border rounded-lg p-6 hover:shadow-md transition-shadow cursor-pointer ${
|
||||
selectedTemplate?.template_id === template.template_id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
{/* Template Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-2xl">{template.icon}</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">{template.name}</h3>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getDifficultyColor(template.difficulty)}`}>
|
||||
{template.difficulty}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 flex items-center">
|
||||
<ClockIcon className="h-3 w-3 mr-1" />
|
||||
{template.estimated_setup_time}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
|
||||
{/* Tech Stack */}
|
||||
{Object.keys(template.tech_stack).length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Tech Stack:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(template.tech_stack).slice(0, 3).map(([category, techs]) => (
|
||||
<div key={category} className="flex flex-wrap gap-1">
|
||||
{techs.slice(0, 2).map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-800"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{Object.values(template.tech_stack).flat().length > 6 && (
|
||||
<span className="text-xs text-gray-500">+more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{template.tags.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-800"
|
||||
>
|
||||
<TagIcon className="h-3 w-3 mr-1" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{template.tags.length > 3 && (
|
||||
<span className="text-xs text-gray-500">+{template.tags.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Key Features:</p>
|
||||
<ul className="text-xs text-gray-600 space-y-1">
|
||||
{template.features.slice(0, 3).map((feature, index) => (
|
||||
<li key={index} className="flex items-center">
|
||||
<ChevronRightIcon className="h-3 w-3 mr-1 text-gray-400" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
{template.features.length > 3 && (
|
||||
<li className="text-gray-500">... and {template.features.length - 3} more features</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPreview(true);
|
||||
}}
|
||||
className="text-sm text-gray-600 hover:text-gray-800 flex items-center"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4 mr-1" />
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'browse' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCreateProject(template.template_id);
|
||||
}}
|
||||
disabled={createProjectMutation.isLoading}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{createProjectMutation.isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin -ml-1 mr-2 h-3 w-3 border border-white border-t-transparent rounded-full"></div>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CodeBracketIcon className="h-4 w-4 mr-1" />
|
||||
Use Template
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Template Details Modal */}
|
||||
{selectedTemplate && mode === 'select' && (
|
||||
<div className="mt-6 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
{selectedTemplate.name} Details
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Features</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{selectedTemplate.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-center">
|
||||
<ChevronRightIcon className="h-4 w-4 mr-2 text-gray-400" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Technology Stack</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(selectedTemplate.tech_stack).map(([category, techs]) => (
|
||||
<div key={category}>
|
||||
<p className="text-xs font-medium text-gray-700 capitalize">
|
||||
{category.replace('_', ' ')}:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{techs.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
frontend/src/components/templates/TemplateSelector.tsx
Normal file
277
frontend/src/components/templates/TemplateSelector.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
TagIcon,
|
||||
InformationCircleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface Template {
|
||||
template_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
difficulty: string;
|
||||
estimated_setup_time: string;
|
||||
features: string[];
|
||||
tech_stack: Record<string, string[]>;
|
||||
}
|
||||
|
||||
interface TemplateSelectorProps {
|
||||
selectedTemplateId?: string;
|
||||
onSelectTemplate: (templateId: string | null) => void;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export default function TemplateSelector({
|
||||
selectedTemplateId,
|
||||
onSelectTemplate,
|
||||
showDetails = true
|
||||
}: TemplateSelectorProps) {
|
||||
const [expandedTemplate, setExpandedTemplate] = useState<string | null>(null);
|
||||
|
||||
// Fetch templates
|
||||
const { data: templatesResponse, isLoading } = useQuery({
|
||||
queryKey: ['templates'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/templates');
|
||||
if (!response.ok) throw new Error('Failed to fetch templates');
|
||||
return response.json();
|
||||
}
|
||||
});
|
||||
|
||||
const templates = templatesResponse?.templates || [];
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'beginner': return 'bg-green-100 text-green-800';
|
||||
case 'intermediate': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'advanced': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTemplate = templates.find((t: Template) => t.template_id === selectedTemplateId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="animate-pulse bg-gray-200 rounded-lg h-16"></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* No Template Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-all ${
|
||||
selectedTemplateId === null
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => onSelectTemplate(null)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedTemplateId === null
|
||||
? 'border-blue-500 bg-blue-500'
|
||||
: 'border-gray-300'
|
||||
}`}>
|
||||
{selectedTemplateId === null && (
|
||||
<CheckCircleIcon className="w-3 h-3 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">Start from Scratch</h3>
|
||||
<p className="text-sm text-gray-500">Create an empty project with basic structure</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-2xl">📁</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template Options */}
|
||||
{templates.map((template: Template) => (
|
||||
<div key={template.template_id}>
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-all ${
|
||||
selectedTemplateId === template.template_id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => onSelectTemplate(template.template_id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedTemplateId === template.template_id
|
||||
? 'border-blue-500 bg-blue-500'
|
||||
: 'border-gray-300'
|
||||
}`}>
|
||||
{selectedTemplateId === template.template_id && (
|
||||
<CheckCircleIcon className="w-3 h-3 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-medium text-gray-900">{template.name}</h3>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getDifficultyColor(template.difficulty)}`}>
|
||||
{template.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">{template.description}</p>
|
||||
|
||||
{/* Quick info */}
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<span className="text-xs text-gray-500 flex items-center">
|
||||
<ClockIcon className="h-3 w-3 mr-1" />
|
||||
{template.estimated_setup_time}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{template.features.length} features
|
||||
</span>
|
||||
{template.tags.length > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<TagIcon className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-500">
|
||||
{template.tags.slice(0, 2).join(', ')}
|
||||
{template.tags.length > 2 && ` +${template.tags.length - 2}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-2xl">{template.icon}</span>
|
||||
{showDetails && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedTemplate(
|
||||
expandedTemplate === template.template_id ? null : template.template_id
|
||||
);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
expandedTemplate === template.template_id ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{showDetails && expandedTemplate === template.template_id && (
|
||||
<div className="mt-2 ml-8 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Features */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Features</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{template.features.slice(0, 6).map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full mt-2 mr-2 flex-shrink-0"></span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
{template.features.length > 6 && (
|
||||
<li className="text-gray-500 text-xs">
|
||||
... and {template.features.length - 6} more features
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Technology Stack</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(template.tech_stack).slice(0, 4).map(([category, techs]) => (
|
||||
<div key={category}>
|
||||
<p className="text-xs font-medium text-gray-700 capitalize mb-1">
|
||||
{category.replace('_', ' ')}:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{techs.slice(0, 4).map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
{techs.length > 4 && (
|
||||
<span className="text-xs text-gray-500">+{techs.length - 4} more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
{template.requirements && Object.keys(template.requirements).length > 0 && (
|
||||
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div className="flex">
|
||||
<InformationCircleIcon className="h-5 w-5 text-yellow-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">Requirements</h3>
|
||||
<div className="mt-1 text-sm text-yellow-700">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(template.requirements).map(([req, version]) => (
|
||||
<span
|
||||
key={req}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800"
|
||||
>
|
||||
{req} {version}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Selected Template Summary */}
|
||||
{selectedTemplate && (
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<CheckCircleIcon className="h-5 w-5 text-blue-500 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-blue-900">
|
||||
Template Selected: {selectedTemplate.name}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Your project will be created with {selectedTemplate.features.length} pre-configured features
|
||||
and {Object.values(selectedTemplate.tech_stack).flat().length} technology integrations.
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-2">
|
||||
Estimated setup time: {selectedTemplate.estimated_setup_time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Badge } from './badge';
|
||||
import { Button } from './button';
|
||||
|
||||
/**
|
||||
* DataTable component for Hive UI
|
||||
* DataTable component for WHOOSH UI
|
||||
*
|
||||
* A powerful and flexible data table component with sorting, filtering, searching, and pagination.
|
||||
* Perfect for displaying agent lists, task queues, and workflow executions.
|
||||
@@ -17,7 +17,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The DataTable component is a comprehensive solution for displaying tabular data in the Hive application.
|
||||
The DataTable component is a comprehensive solution for displaying tabular data in the WHOOSH application.
|
||||
It provides powerful features for data manipulation and user interaction.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Badge } from './badge';
|
||||
|
||||
/**
|
||||
* Badge component for Hive UI
|
||||
* Badge component for WHOOSH UI
|
||||
*
|
||||
* A small status indicator component used to display labels, statuses, and categories.
|
||||
* Perfect for showing agent statuses, task priorities, and workflow states.
|
||||
@@ -15,7 +15,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The Badge component is used to display small labels and status indicators throughout the Hive application.
|
||||
The Badge component is used to display small labels and status indicators throughout the WHOOSH application.
|
||||
It's commonly used for showing agent statuses, task priorities, and other categorical information.
|
||||
|
||||
## Features
|
||||
@@ -149,7 +149,7 @@ export const AllVariants: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Agent status badges as used in Hive
|
||||
* Agent status badges as used in WHOOSH
|
||||
*/
|
||||
export const AgentStatuses: Story = {
|
||||
render: () => (
|
||||
@@ -164,7 +164,7 @@ export const AgentStatuses: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Common agent status badges used throughout the Hive application',
|
||||
story: 'Common agent status badges used throughout the WHOOSH application',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from './button';
|
||||
|
||||
/**
|
||||
* Button component for Hive UI
|
||||
* Button component for WHOOSH UI
|
||||
*
|
||||
* A versatile button component with multiple variants, sizes, and states.
|
||||
* Supports all standard button functionality with consistent styling.
|
||||
@@ -15,7 +15,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The Button component is a fundamental UI element used throughout the Hive application.
|
||||
The Button component is a fundamental UI element used throughout the WHOOSH application.
|
||||
It provides consistent styling and behavior across different contexts.
|
||||
|
||||
## Features
|
||||
@@ -208,9 +208,9 @@ export const AllSizes: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Common Hive use cases
|
||||
* Common WHOOSH use cases
|
||||
*/
|
||||
export const HiveUseCases: Story = {
|
||||
export const WHOOSHUseCases: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 max-w-md">
|
||||
<div className="flex gap-2">
|
||||
@@ -234,7 +234,7 @@ export const HiveUseCases: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Common button combinations used throughout the Hive application',
|
||||
story: 'Common button combinations used throughout the WHOOSH application',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from './button';
|
||||
import { Badge } from './badge';
|
||||
|
||||
/**
|
||||
* Card component system for Hive UI
|
||||
* Card component system for WHOOSH UI
|
||||
*
|
||||
* A flexible card component system that provides a container for content.
|
||||
* Includes header, title, description, and content sections.
|
||||
@@ -101,7 +101,7 @@ export const ContentOnly: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Agent status card as used in Hive
|
||||
* Agent status card as used in WHOOSH
|
||||
*/
|
||||
export const AgentStatusCard: Story = {
|
||||
render: () => (
|
||||
@@ -140,14 +140,14 @@ export const AgentStatusCard: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Example of how cards are used to display agent information in the Hive dashboard',
|
||||
story: 'Example of how cards are used to display agent information in the WHOOSH dashboard',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Task execution card as used in Hive
|
||||
* Task execution card as used in WHOOSH
|
||||
*/
|
||||
export const TaskCard: Story = {
|
||||
render: () => (
|
||||
@@ -189,14 +189,14 @@ export const TaskCard: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Example of how cards are used to display task information in the Hive dashboard',
|
||||
story: 'Example of how cards are used to display task information in the WHOOSH dashboard',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Workflow card as used in Hive
|
||||
* Workflow card as used in WHOOSH
|
||||
*/
|
||||
export const WorkflowCard: Story = {
|
||||
render: () => (
|
||||
@@ -239,7 +239,7 @@ export const WorkflowCard: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Example of how cards are used to display workflow information in the Hive dashboard',
|
||||
story: 'Example of how cards are used to display workflow information in the WHOOSH dashboard',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,10 +3,14 @@ import React from 'react';
|
||||
interface CardProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ className = '', children }) => (
|
||||
<div className={`bg-white rounded-lg shadow-md border ${className}`}>
|
||||
export const Card: React.FC<CardProps> = ({ className = '', children, onClick }) => (
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-md border ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Label } from './label';
|
||||
import { Button } from './button';
|
||||
|
||||
/**
|
||||
* Input component for Hive UI
|
||||
* Input component for WHOOSH UI
|
||||
*
|
||||
* A versatile input component for forms and user input throughout the Hive application.
|
||||
* A versatile input component for forms and user input throughout the WHOOSH application.
|
||||
* Supports various input types with consistent styling and behavior.
|
||||
*/
|
||||
const meta = {
|
||||
@@ -17,7 +17,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The Input component provides consistent styling and behavior for form inputs across the Hive application.
|
||||
The Input component provides consistent styling and behavior for form inputs across the WHOOSH application.
|
||||
It supports all standard HTML input types with enhanced styling and focus states.
|
||||
|
||||
## Features
|
||||
|
||||
Reference in New Issue
Block a user