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:
anthonyrawlins
2025-08-27 08:34:48 +10:00
parent 0e9844ef13
commit 268214d971
399 changed files with 57390 additions and 2045 deletions

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View 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;

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View File

@@ -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

View File

@@ -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',
},
},
},

View File

@@ -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',
},
},
},

View File

@@ -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',
},
},
},

View File

@@ -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>
);

View File

@@ -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