feat: comprehensive UI polish with responsive design, animations, and modern styling
- Rewrite globals.css with professional utility classes (btn-primary/secondary/danger/ghost, input-field, card/card-hover, badge system, stat-card, skeleton, page-header, animations) - Responsive dashboard layout with mobile hamburger menu, slide-out sidebar, backdrop overlay - Polish login/register pages with gradient backgrounds, slide-up animations, loading spinners - Dashboard home with stat cards grid (2-col mobile, 4-col desktop), skeleton loading, empty states - Apps list with desktop table + mobile card views, proper badges - Deploy wizard with responsive step indicator (checkmarks, progress line), mobile-friendly forms - App detail page with responsive header/action buttons, improved logs tabs, resource scaling - Admin users page with desktop table + mobile cards, skeleton loading - Admin clusters page with responsive layout, animated resource panel - Admin pools page with responsive form grids, improved pool cards - Consistent rounded-2xl cards, rounded-xl buttons/inputs, active:scale feedback throughout
This commit is contained in:
@@ -25,9 +25,9 @@ function ResourcePanel({ clusterId }: { clusterId: string }) {
|
||||
const memUsedPct = memCap > 0 ? ((memCap - memAlloc) / memCap * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 space-y-4">
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 space-y-4 animate-fade-in">
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-700">{data.nodeCount}</div>
|
||||
<div className="text-xs text-blue-600">Nodes</div>
|
||||
@@ -47,7 +47,7 @@ function ResourcePanel({ clusterId }: { clusterId: string }) {
|
||||
</div>
|
||||
|
||||
{/* CPU & Memory bars */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600">CPU Reserved</span>
|
||||
@@ -83,7 +83,7 @@ function ResourcePanel({ clusterId }: { clusterId: string }) {
|
||||
{/* Nodes table */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">Nodes</h4>
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<div className="overflow-x-auto -mx-2 px-2 rounded-xl border border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
@@ -194,21 +194,24 @@ export default function AdminClustersPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Cluster Management</h1>
|
||||
<button onClick={() => setShowForm(!showForm)} className="btn-primary">
|
||||
{showForm ? 'Cancel' : '+ Add Cluster'}
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Cluster Management</h1>
|
||||
<p className="page-subtitle">{clusters.length} cluster{clusters.length !== 1 ? 's' : ''} registered</p>
|
||||
</div>
|
||||
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
|
||||
{showForm ? '✕ Cancel' : '+ Add Cluster'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="card space-y-4">
|
||||
<h2 className="text-lg font-semibold">Register New Cluster</h2>
|
||||
<div className="card space-y-4 animate-slide-up">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Register New Cluster</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
The system will verify the Kubernetes connection before registering. Only clusters with valid kubeconfig will be marked as active.
|
||||
The system will verify the Kubernetes connection before registering.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input className="input-field" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
@@ -266,62 +269,71 @@ export default function AdminClustersPage() {
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card text-center py-12 text-gray-500">Loading clusters...</div>
|
||||
<div className="space-y-3">
|
||||
{[1,2].map(i => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-40" />
|
||||
<div className="skeleton h-3 w-64" />
|
||||
</div>
|
||||
<div className="skeleton h-8 w-24 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : clusters.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-gray-500">No clusters registered yet.</p>
|
||||
<div className="card text-center py-16">
|
||||
<div className="text-5xl mb-4">🖥️</div>
|
||||
<p className="text-gray-600 font-medium">No clusters registered yet.</p>
|
||||
<p className="text-gray-400 text-sm mt-1">Add a Kubernetes cluster to start deploying applications.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{clusters.map((cluster) => (
|
||||
<div key={cluster.id} className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
cluster.status === 'active' ? 'bg-green-100' : 'bg-red-100'
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
cluster.status === 'active' ? 'bg-emerald-50' : 'bg-red-50'
|
||||
}`}>
|
||||
{cluster.status === 'active' ? '✅' : '❌'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900">{cluster.name}</h3>
|
||||
{cluster.isDefault && (
|
||||
<span className="px-2 py-0.5 bg-primary-100 text-primary-700 text-xs rounded-full font-medium">Default</span>
|
||||
<span className="badge badge-blue">Default</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
cluster.status === 'active' ? 'bg-green-100 text-green-700'
|
||||
: cluster.status === 'maintenance' ? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
<span className={`badge ${
|
||||
cluster.status === 'active' ? 'badge-green'
|
||||
: cluster.status === 'maintenance' ? 'badge-yellow'
|
||||
: 'badge-red'
|
||||
}`}>
|
||||
{cluster.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<button
|
||||
onClick={() => toggleResources(cluster.id)}
|
||||
className={`text-sm px-3 py-1.5 rounded-md transition-colors ${
|
||||
expandedResources.has(cluster.id)
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-purple-50 text-purple-600 hover:bg-purple-100'
|
||||
}`}
|
||||
className={`btn-ghost text-sm ${expandedResources.has(cluster.id) ? 'bg-purple-50 text-purple-700' : ''}`}
|
||||
>
|
||||
📊 Resources
|
||||
</button>
|
||||
<button
|
||||
onClick={() => testMutation.mutate(cluster.id)}
|
||||
disabled={testingId === cluster.id}
|
||||
className="text-sm px-3 py-1.5 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors disabled:opacity-50"
|
||||
className="btn-ghost text-sm disabled:opacity-50"
|
||||
>
|
||||
{testingId === cluster.id ? '🔄 Testing...' : '🔌 Test Connection'}
|
||||
{testingId === cluster.id ? '⏳ Testing...' : '🔌 Test'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Remove this cluster?')) deleteMutation.mutate(cluster.id); }}
|
||||
className="text-sm text-red-600 hover:text-red-800"
|
||||
className="text-sm text-red-600 hover:text-red-800 font-medium"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
||||
@@ -95,30 +95,30 @@ export default function AdminPoolsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Cluster Pools</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Create load-balanced groups of clusters for automatic app distribution
|
||||
<h1 className="page-title">Cluster Pools</h1>
|
||||
<p className="page-subtitle">
|
||||
Load-balanced groups of clusters for automatic app distribution
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { showForm ? resetForm() : setShowForm(true); }}
|
||||
className="btn-primary"
|
||||
className={showForm ? 'btn-ghost' : 'btn-primary'}
|
||||
>
|
||||
{showForm ? 'Cancel' : '+ Create Pool'}
|
||||
{showForm ? '✕ Cancel' : '+ Create Pool'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{showForm && (
|
||||
<div className="card space-y-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
<div className="card space-y-4 animate-slide-up">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{editingPool ? `Edit "${editingPool.name}"` : 'Create New Cluster Pool'}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pool Name</label>
|
||||
<input
|
||||
@@ -237,11 +237,25 @@ export default function AdminPoolsPage() {
|
||||
|
||||
{/* Pool List */}
|
||||
{isLoading ? (
|
||||
<div className="card text-center py-12 text-gray-500">Loading pools...</div>
|
||||
<div className="space-y-3">
|
||||
{[1,2].map(i => (
|
||||
<div key={i} className="card space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="skeleton h-5 w-32" />
|
||||
<div className="skeleton h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="skeleton h-3 w-64" />
|
||||
<div className="flex gap-2">
|
||||
<div className="skeleton h-8 w-28 rounded-lg" />
|
||||
<div className="skeleton h-8 w-28 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : pools.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<div className="text-4xl mb-3">⚖️</div>
|
||||
<p className="text-gray-500">No cluster pools created yet</p>
|
||||
<div className="card text-center py-16">
|
||||
<div className="text-5xl mb-4">⚖️</div>
|
||||
<p className="text-gray-600 font-medium">No cluster pools created yet</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Create a pool to enable load-balanced deployment across multiple clusters
|
||||
</p>
|
||||
@@ -253,16 +267,14 @@ export default function AdminPoolsPage() {
|
||||
const activeClusters = poolClusters.filter((c) => c.status === 'active');
|
||||
return (
|
||||
<div key={pool.id} className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{pool.name}</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
pool.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
<span className={`badge ${pool.isActive ? 'badge-green' : 'badge-gray'}`}>
|
||||
{pool.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full font-medium">
|
||||
<span className="badge badge-purple">
|
||||
{pool.strategy === 'least-apps' ? '📊 Least Apps' : '🔄 Round Robin'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -275,9 +287,9 @@ export default function AdminPoolsPage() {
|
||||
{poolClusters.length > 0 ? poolClusters.map((cluster) => (
|
||||
<div
|
||||
key={cluster.id}
|
||||
className={`inline-flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-medium ${
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium ${
|
||||
cluster.status === 'active'
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
@@ -297,10 +309,10 @@ export default function AdminPoolsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 ml-4">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => startEdit(pool)}
|
||||
className="text-sm px-3 py-1.5 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors"
|
||||
className="btn-ghost text-sm"
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
|
||||
@@ -56,23 +56,27 @@ export default function AdminUsersPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
||||
<button onClick={() => setShowForm(!showForm)} className="btn-primary">
|
||||
{showForm ? 'Cancel' : '+ Add User'}
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">User Management</h1>
|
||||
<p className="page-subtitle">{users.length} user{users.length !== 1 ? 's' : ''} registered</p>
|
||||
</div>
|
||||
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
|
||||
{showForm ? '✕ Cancel' : '+ Add User'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create user form */}
|
||||
{showForm && (
|
||||
<div className="card space-y-4">
|
||||
<h2 className="text-lg font-semibold">Create New User</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="card space-y-4 animate-slide-up">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Create New User</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">First Name</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="John"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
|
||||
/>
|
||||
@@ -81,6 +85,7 @@ export default function AdminUsersPage() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="Doe"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
||||
/>
|
||||
@@ -90,6 +95,7 @@ export default function AdminUsersPage() {
|
||||
<input
|
||||
className="input-field"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
@@ -108,7 +114,7 @@ export default function AdminUsersPage() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
|
||||
<select
|
||||
className="input-field w-48"
|
||||
className="input-field w-full sm:w-48"
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as 'user' | 'admin' })}
|
||||
>
|
||||
@@ -119,9 +125,9 @@ export default function AdminUsersPage() {
|
||||
<button
|
||||
onClick={() => createUser.mutate(form)}
|
||||
disabled={!form.email || !form.password || !form.firstName || !form.lastName || createUser.isPending}
|
||||
className="btn-primary"
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{createUser.isPending ? '🔄 Creating...' : 'Create User'}
|
||||
{createUser.isPending ? '⏳ Creating...' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -145,36 +151,49 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card text-center py-12 text-gray-500">Loading users...</div>
|
||||
<div className="space-y-3">
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-10 h-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-3 w-48" />
|
||||
</div>
|
||||
<div className="skeleton h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-gray-500">{search ? 'No users found matching your search.' : 'No users yet.'}</p>
|
||||
<div className="card text-center py-16">
|
||||
<div className="text-5xl mb-4">👥</div>
|
||||
<p className="text-gray-600 font-medium">{search ? 'No users found matching your search.' : 'No users yet.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden lg:block table-wrapper">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<thead className="bg-gray-50/80">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Apps</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Namespace</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Role</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Apps</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<tr key={user.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
<select
|
||||
className="text-sm border border-gray-300 rounded px-2 py-1"
|
||||
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
value={user.role}
|
||||
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
|
||||
>
|
||||
@@ -183,25 +202,20 @@ export default function AdminUsersPage() {
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
|
||||
{user.appCount ?? 0}
|
||||
</span>
|
||||
<span className="badge badge-blue">{user.appCount ?? 0}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 font-mono">{user.namespace || '—'}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
|
||||
className={`text-sm ${user.isActive ? 'text-red-600 hover:text-red-800' : 'text-green-600 hover:text-green-800'}`}
|
||||
className={`text-sm font-medium ${user.isActive ? 'text-red-600 hover:text-red-800' : 'text-green-600 hover:text-green-800'}`}
|
||||
>
|
||||
{user.isActive ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
@@ -211,6 +225,45 @@ export default function AdminUsersPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="lg:hidden grid gap-3">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="card space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{user.firstName} {user.lastName}</h3>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span className="badge badge-blue">{user.appCount ?? 0} apps</span>
|
||||
<span className={`badge ${user.role === 'admin' ? 'badge-purple' : 'badge-gray'}`}>{user.role}</span>
|
||||
<span>{new Date(user.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-gray-100">
|
||||
<select
|
||||
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
value={user.role}
|
||||
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
|
||||
className={`text-sm font-medium ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
|
||||
>
|
||||
{user.isActive ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,13 +8,13 @@ import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPool
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
building: 'bg-blue-100 text-blue-700',
|
||||
deploying: 'bg-blue-100 text-blue-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
build_failed: 'bg-red-100 text-red-700',
|
||||
stopped: 'bg-gray-100 text-gray-700',
|
||||
running: 'badge-green',
|
||||
pending: 'badge-yellow',
|
||||
building: 'badge-blue',
|
||||
deploying: 'badge-blue',
|
||||
failed: 'badge-red',
|
||||
build_failed: 'badge-red',
|
||||
stopped: 'badge-gray',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -255,7 +255,25 @@ export default function AppDetailPage() {
|
||||
}, []);
|
||||
|
||||
if (isLoading || !app) {
|
||||
return <div className="card text-center py-12 text-gray-500">Loading...</div>;
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="skeleton w-14 h-14 rounded-2xl" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="skeleton h-6 w-48" />
|
||||
<div className="skeleton h-4 w-72" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="card space-y-3">
|
||||
{[1,2,3,4,5].map(i => <div key={i} className="skeleton h-4 w-full" />)}
|
||||
</div>
|
||||
<div className="card space-y-3">
|
||||
{[1,2,3].map(i => <div key={i} className="skeleton h-12 w-full rounded-lg" />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const latestStatus = deployments[0]?.status || 'pending';
|
||||
@@ -271,102 +289,65 @@ export default function AppDetailPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center text-2xl">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center text-2xl shrink-0">
|
||||
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{app.name}</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{app.runtime} · {app.subdomain}.apps.cloudhost.local
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${statusColors[latestStatus] || 'bg-gray-100 text-gray-600'}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 truncate">{app.name}</h1>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
{/* Only show deploy button if NEVER deployed before */}
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{app.runtime} · {app.subdomain}.apps.cloudhost.local
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 shrink-0">
|
||||
{!hasDeployments && (
|
||||
<button
|
||||
onClick={() => deployMutation.mutate()}
|
||||
disabled={deployMutation.isPending || (!app.codePath && !app.gitUrl)}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
<button onClick={() => deployMutation.mutate()} disabled={deployMutation.isPending || (!app.codePath && !app.gitUrl)} className="btn-primary text-sm disabled:opacity-50">
|
||||
{deployMutation.isPending ? '⏳ Deploying...' : '🚀 Deploy'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* After first deploy: show start/stop/restart */}
|
||||
{hasDeployments && (
|
||||
<>
|
||||
{isStopped ? (
|
||||
<button
|
||||
onClick={() => startMutation.mutate()}
|
||||
disabled={startMutation.isPending}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{startMutation.isPending ? '⏳ Starting...' : '▶️ Start'}
|
||||
<button onClick={() => startMutation.mutate()} disabled={startMutation.isPending} className="btn-primary text-sm disabled:opacity-50">
|
||||
{startMutation.isPending ? '⏳...' : '▶️ Start'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => stopMutation.mutate()}
|
||||
disabled={stopMutation.isPending || isInProgress}
|
||||
className="btn-secondary text-sm disabled:opacity-50"
|
||||
>
|
||||
{stopMutation.isPending ? '⏳ Stopping...' : '⏹️ Stop'}
|
||||
<button onClick={() => stopMutation.mutate()} disabled={stopMutation.isPending || isInProgress} className="btn-secondary text-sm disabled:opacity-50">
|
||||
{stopMutation.isPending ? '⏳...' : '⏹️ Stop'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={() => restartMutation.mutate()}
|
||||
disabled={restartMutation.isPending}
|
||||
className="btn-secondary text-sm disabled:opacity-50"
|
||||
>
|
||||
<button onClick={() => restartMutation.mutate()} disabled={restartMutation.isPending} className="btn-secondary text-sm disabled:opacity-50">
|
||||
{restartMutation.isPending ? '⏳...' : '🔄 Restart'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Redeploy: rebuild from latest git/code */}
|
||||
{!isInProgress && (
|
||||
<button
|
||||
onClick={() => redeployMutation.mutate()}
|
||||
disabled={redeployMutation.isPending}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
title="Rebuild from latest source code and deploy new version"
|
||||
>
|
||||
{redeployMutation.isPending ? '⏳ Rebuilding...' : '🔄 Redeploy'}
|
||||
<button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title="Rebuild from latest source code">
|
||||
{redeployMutation.isPending ? '⏳...' : '🔄 Redeploy'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Preview: open the running app in a new tab */}
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={() => previewMutation.mutate()}
|
||||
disabled={previewMutation.isPending}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 disabled:opacity-50 transition-colors"
|
||||
title="Open the running application in a new browser tab"
|
||||
>
|
||||
{previewMutation.isPending ? '⏳ Loading...' : '🌐 Preview'}
|
||||
<button onClick={() => previewMutation.mutate()} disabled={previewMutation.isPending} className="text-sm px-4 py-2 rounded-xl font-medium bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 disabled:opacity-50 transition-all active:scale-[0.98]">
|
||||
{previewMutation.isPending ? '⏳...' : '🌐 Preview'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 border border-red-200 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleteMutation.isPending ? '⏳ Deleting...' : '�️ Delete'}
|
||||
<button onClick={handleDelete} disabled={deleteMutation.isPending} className="btn-danger text-sm disabled:opacity-50">
|
||||
{deleteMutation.isPending ? '⏳...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status & Config */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
@@ -434,19 +415,19 @@ export default function AppDetailPage() {
|
||||
) : (
|
||||
<div className="space-y-3 max-h-72 overflow-y-auto">
|
||||
{deployments.slice(0, 10).map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{d.version || d.imageTag}</p>
|
||||
<div key={d.id} className="flex items-center justify-between p-3 bg-gray-50/80 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{d.version || d.imageTag}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(d.createdAt).toLocaleString()}
|
||||
</p>
|
||||
{d.errorMessage && (
|
||||
<p className="text-xs text-red-500 mt-1 truncate max-w-[250px]" title={d.errorMessage}>
|
||||
<p className="text-xs text-red-500 mt-1 truncate" title={d.errorMessage}>
|
||||
❌ {d.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap ${statusColors[d.status] || 'bg-gray-100'}`}>
|
||||
<span className={`badge ${statusColors[d.status] || 'badge-gray'} ml-2 shrink-0`}>
|
||||
{d.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -667,10 +648,10 @@ export default function AppDetailPage() {
|
||||
{resourceUsage.pods.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">Pod Status</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto -mx-2 px-2">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<tr className="text-left text-gray-500 border-b border-gray-200">
|
||||
<th className="pb-2 font-medium">Pod</th>
|
||||
<th className="pb-2 font-medium">Status</th>
|
||||
<th className="pb-2 font-medium">Ready</th>
|
||||
@@ -705,14 +686,14 @@ export default function AppDetailPage() {
|
||||
{/* Scaling Controls */}
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">⚙️ Scale Resources</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">CPU Request</label>
|
||||
<input
|
||||
type="text"
|
||||
value={resourceForm.cpuRequest}
|
||||
onChange={(e) => setResourceForm((f) => ({ ...f, cpuRequest: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
className="input-field text-sm"
|
||||
placeholder="100m"
|
||||
/>
|
||||
</div>
|
||||
@@ -722,7 +703,7 @@ export default function AppDetailPage() {
|
||||
type="text"
|
||||
value={resourceForm.cpuLimit}
|
||||
onChange={(e) => setResourceForm((f) => ({ ...f, cpuLimit: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
className="input-field text-sm"
|
||||
placeholder="500m"
|
||||
/>
|
||||
</div>
|
||||
@@ -732,7 +713,7 @@ export default function AppDetailPage() {
|
||||
type="text"
|
||||
value={resourceForm.memoryRequest}
|
||||
onChange={(e) => setResourceForm((f) => ({ ...f, memoryRequest: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
className="input-field text-sm"
|
||||
placeholder="128Mi"
|
||||
/>
|
||||
</div>
|
||||
@@ -742,23 +723,23 @@ export default function AppDetailPage() {
|
||||
type="text"
|
||||
value={resourceForm.memoryLimit}
|
||||
onChange={(e) => setResourceForm((f) => ({ ...f, memoryLimit: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
className="input-field text-sm"
|
||||
placeholder="512Mi"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Replicas</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.max(1, f.replicas - 1) }))}
|
||||
className="w-8 h-8 flex items-center justify-center bg-gray-100 rounded-lg hover:bg-gray-200 text-sm font-bold"
|
||||
className="btn-icon w-9 h-9"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="text-lg font-bold text-gray-800 w-8 text-center">{resourceForm.replicas}</span>
|
||||
<button
|
||||
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.min(10, f.replicas + 1) }))}
|
||||
className="w-8 h-8 flex items-center justify-center bg-gray-100 rounded-lg hover:bg-gray-200 text-sm font-bold"
|
||||
className="btn-icon w-9 h-9"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -815,10 +796,10 @@ export default function AppDetailPage() {
|
||||
{showLogs && (
|
||||
<div className="space-y-3">
|
||||
{/* Tab switcher */}
|
||||
<div className="flex space-x-1 bg-gray-100 rounded-lg p-1">
|
||||
<div className="flex gap-1 bg-gray-100 rounded-xl p-1">
|
||||
<button
|
||||
onClick={() => setLogTab('pod')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
logTab === 'pod'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
@@ -828,7 +809,7 @@ export default function AppDetailPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLogTab('build')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
logTab === 'build'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
@@ -842,7 +823,7 @@ export default function AppDetailPage() {
|
||||
{logTab === 'pod' && (
|
||||
<pre
|
||||
ref={logsEndRef}
|
||||
className="bg-gray-900 text-green-400 p-4 rounded-lg text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
|
||||
className="bg-gray-900 text-green-400 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
|
||||
>
|
||||
{logsData?.logs || (isRunning ? 'Loading logs...' : isStopped ? 'Application is stopped. Start it to see logs.' : 'Waiting for pod to be ready...')}
|
||||
</pre>
|
||||
@@ -852,14 +833,14 @@ export default function AppDetailPage() {
|
||||
{logTab === 'build' && (
|
||||
<div>
|
||||
{buildLogsData?.version && (
|
||||
<div className="flex items-center space-x-3 mb-2 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-3 mb-2 text-xs text-gray-500">
|
||||
<span>📌 {buildLogsData.version}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full font-medium ${statusColors[buildLogsData.status] || 'bg-gray-100'}`}>
|
||||
<span className={`badge ${statusColors[buildLogsData.status] || 'badge-gray'}`}>
|
||||
{buildLogsData.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<pre className="bg-gray-900 text-blue-300 p-4 rounded-lg text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words">
|
||||
<pre className="bg-gray-900 text-blue-300 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{buildLogsData?.buildLog || (
|
||||
buildLogsData?.status === 'building'
|
||||
? 'Build in progress... Logs will appear when complete.'
|
||||
|
||||
@@ -7,13 +7,13 @@ import toast from 'react-hot-toast';
|
||||
import type { Application } from '@/types';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
building: 'bg-blue-100 text-blue-700',
|
||||
deploying: 'bg-blue-100 text-blue-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
build_failed: 'bg-red-100 text-red-700',
|
||||
stopped: 'bg-gray-100 text-gray-700',
|
||||
running: 'badge-green',
|
||||
pending: 'badge-yellow',
|
||||
building: 'badge-blue',
|
||||
deploying: 'badge-blue',
|
||||
failed: 'badge-red',
|
||||
build_failed: 'badge-red',
|
||||
stopped: 'badge-gray',
|
||||
};
|
||||
|
||||
export default function AppsPage() {
|
||||
@@ -34,66 +34,99 @@ export default function AppsPage() {
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="card text-center py-12 text-gray-500">Loading applications...</div>;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div className="skeleton h-8 w-48" />
|
||||
<div className="skeleton h-10 w-40 rounded-xl" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-36" />
|
||||
<div className="skeleton h-3 w-56" />
|
||||
</div>
|
||||
<div className="skeleton h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">My Applications</h1>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">My Applications</h1>
|
||||
<p className="page-subtitle">{apps.length} application{apps.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<Link href="/dashboard/deploy" className="btn-primary">
|
||||
+ New Application
|
||||
🚀 New Application
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{apps.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<p className="text-gray-500 text-lg">No applications yet</p>
|
||||
<Link href="/dashboard/deploy" className="btn-primary mt-4 inline-block">
|
||||
Deploy your first app
|
||||
<div className="text-5xl mb-4">📦</div>
|
||||
<p className="text-gray-600 text-lg font-medium">No applications yet</p>
|
||||
<p className="text-gray-400 mt-1 text-sm">Deploy your first application to get started.</p>
|
||||
<Link href="/dashboard/deploy" className="btn-primary mt-6 inline-flex">
|
||||
🚀 Deploy your first app
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block table-wrapper">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<thead className="bg-gray-50/80">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Runtime</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Database</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Replicas</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Application</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Runtime</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Database</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Replicas</th>
|
||||
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
return (
|
||||
<tr key={app.id} className="hover:bg-gray-50">
|
||||
<tr key={app.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="font-medium text-primary-600 hover:text-primary-800">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center text-base shrink-0">
|
||||
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">
|
||||
{app.name}
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{app.runtime}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{app.databaseType}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.runtime}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.databaseType}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[latestStatus] || 'bg-gray-100 text-gray-600'}`}>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{app.replicas}</td>
|
||||
<td className="px-6 py-4 text-right space-x-2">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="text-sm text-primary-600 hover:text-primary-800">
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { if (confirm('Delete this application?')) deleteMutation.mutate(app.id); }}
|
||||
className="text-sm text-red-600 hover:text-red-800"
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -101,6 +134,40 @@ export default function AppsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="md:hidden grid gap-3">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
return (
|
||||
<Link
|
||||
key={app.id}
|
||||
href={`/dashboard/apps/${app.id}`}
|
||||
className="card-hover"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center text-lg">
|
||||
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{app.name}</h3>
|
||||
<p className="text-xs text-gray-500 capitalize">{app.runtime}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>💾 {app.databaseType}</span>
|
||||
<span>📦 {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -140,25 +140,37 @@ export default function DeployPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Deploy New Application</h1>
|
||||
<p className="mt-1 text-gray-500">Follow the steps to deploy your app to the cloud.</p>
|
||||
<h1 className="page-title">Deploy New Application</h1>
|
||||
<p className="page-subtitle">Follow the steps to deploy your app to the cloud.</p>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((label, i) => (
|
||||
<div key={label} className="flex items-center">
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
|
||||
i <= step ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-500'
|
||||
<div key={label} className="flex items-center flex-1 last:flex-none">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`flex items-center justify-center w-9 h-9 rounded-full text-sm font-bold transition-all duration-300 ${
|
||||
i < step
|
||||
? 'bg-emerald-500 text-white shadow-md'
|
||||
: i === step
|
||||
? 'bg-primary-600 text-white shadow-lg ring-4 ring-primary-100'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{i + 1}
|
||||
{i < step ? '✓' : i + 1}
|
||||
</div>
|
||||
<span className={`ml-2 text-sm ${i <= step ? 'text-gray-900 font-medium' : 'text-gray-400'}`}>
|
||||
<span className={`mt-1.5 text-xs font-medium hidden sm:block ${
|
||||
i <= step ? 'text-gray-900' : 'text-gray-400'
|
||||
}`}>
|
||||
{label}
|
||||
</span>
|
||||
{i < steps.length - 1 && <div className="w-8 h-0.5 bg-gray-200 mx-3" />}
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 sm:mx-3 rounded-full transition-colors duration-300 ${
|
||||
i < step ? 'bg-emerald-400' : 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -166,8 +178,8 @@ export default function DeployPage() {
|
||||
<div className="card">
|
||||
{/* Step 0: Basic Info */}
|
||||
{step === 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Basic Information</h2>
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Application Name</label>
|
||||
<input
|
||||
@@ -326,10 +338,10 @@ export default function DeployPage() {
|
||||
{/* Step 1: Runtime & Database */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold">Runtime & Database</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Runtime & Database</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Application Runtime</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[
|
||||
{ value: 'nodejs', label: 'Node.js', icon: '🟩', desc: 'Express, NestJS, Fastify...' },
|
||||
{ value: 'laravel', label: 'Laravel', icon: '🟧', desc: 'PHP 8.3, Composer, Artisan' },
|
||||
@@ -351,7 +363,7 @@ export default function DeployPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Database</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-3 gap-3 sm:gap-4">
|
||||
{[
|
||||
{ value: 'none', label: 'None', icon: '❌' },
|
||||
{ value: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
|
||||
@@ -377,12 +389,12 @@ export default function DeployPage() {
|
||||
{/* Step 2: Resources */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold">Resources & Configuration</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Resources & Configuration</h2>
|
||||
|
||||
{/* Cluster Assignment Mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Cluster Assignment</label>
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -542,7 +554,7 @@ export default function DeployPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Request</label>
|
||||
<select className="input-field" value={form.cpuRequest} onChange={(e) => setForm({ ...form, cpuRequest: e.target.value })}>
|
||||
@@ -580,7 +592,7 @@ export default function DeployPage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Replicas</label>
|
||||
<input
|
||||
@@ -606,7 +618,7 @@ export default function DeployPage() {
|
||||
{/* Environment Variables */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Environment Variables</label>
|
||||
<div className="flex space-x-2 mb-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2 mb-3">
|
||||
<input
|
||||
className="input-field flex-1"
|
||||
placeholder="KEY"
|
||||
@@ -619,7 +631,7 @@ export default function DeployPage() {
|
||||
value={envVal}
|
||||
onChange={(e) => setEnvVal(e.target.value)}
|
||||
/>
|
||||
<button type="button" onClick={addEnvVar} className="btn-secondary">Add</button>
|
||||
<button type="button" onClick={addEnvVar} className="btn-secondary shrink-0">Add</button>
|
||||
</div>
|
||||
{Object.entries(form.envVars || {}).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 mb-2">
|
||||
@@ -636,8 +648,8 @@ export default function DeployPage() {
|
||||
{/* Step 3: Review */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Review & Deploy</h2>
|
||||
<div className="bg-gray-50 rounded-xl p-6 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Review & Deploy</h2>
|
||||
<div className="bg-gray-50 rounded-xl p-5 sm:p-6 space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Name</span>
|
||||
<span className="text-sm font-medium">{form.name}</span>
|
||||
@@ -709,11 +721,11 @@ export default function DeployPage() {
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between mt-8 pt-6 border-t border-gray-200">
|
||||
<div className="flex justify-between mt-8 pt-6 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setStep(step - 1)}
|
||||
disabled={step === 0}
|
||||
className="btn-secondary disabled:opacity-30"
|
||||
className="btn-ghost disabled:opacity-0 disabled:pointer-events-none"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
@@ -721,7 +733,7 @@ export default function DeployPage() {
|
||||
<button
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={!canNext()}
|
||||
className="btn-primary"
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
@@ -729,12 +741,12 @@ export default function DeployPage() {
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={createMutation.isPending}
|
||||
className="btn-primary"
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending
|
||||
? uploadProgress > 0 && uploadProgress < 100
|
||||
? `Uploading... ${uploadProgress}%`
|
||||
: 'Deploying...'
|
||||
? `⏳ Uploading... ${uploadProgress}%`
|
||||
: '⏳ Deploying...'
|
||||
: '🚀 Deploy Application'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
@@ -21,6 +21,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
const { user, isAuthenticated, isLoading, logout } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
@@ -28,95 +29,154 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
// Close sidebar on route change (mobile)
|
||||
useEffect(() => {
|
||||
setSidebarOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-2 border-primary-600 border-t-transparent" />
|
||||
<span className="text-sm text-gray-500">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
const NavLink = ({ item }: { item: { href: string; label: string; icon: string } }) => {
|
||||
const isActive = pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href + '/'));
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700 shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarContent = () => (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="space-y-1 flex-1">
|
||||
{userNavItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} />
|
||||
))}
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<div className="pt-5 pb-2">
|
||||
<p className="px-3 text-[11px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
Admin
|
||||
</p>
|
||||
</div>
|
||||
{adminNavItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar footer */}
|
||||
<div className="pt-4 mt-4 border-t border-gray-200">
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-xs font-semibold text-gray-700 truncate">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/dashboard" className="text-xl font-bold text-primary-600">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-gray-200 p-4 transform transition-transform duration-200 ease-in-out lg:hidden ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Link href="/dashboard" className="text-lg font-bold text-primary-600">
|
||||
☁️ CloudHost
|
||||
</Link>
|
||||
<button onClick={() => setSidebarOpen(false)} className="btn-icon">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white/80 backdrop-blur-lg border-b border-gray-200/80 sticky top-0 z-30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="btn-icon lg:hidden"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<Link href="/dashboard" className="text-xl font-bold text-primary-600 flex items-center gap-2">
|
||||
<span>☁️</span>
|
||||
<span className="hidden sm:inline">CloudHost</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden sm:flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-600 font-medium">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</span>
|
||||
{user?.role === 'admin' && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full font-medium">
|
||||
Admin
|
||||
</span>
|
||||
<span className="badge-purple">Admin</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { logout(); router.push('/login'); }}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
className="btn-ghost text-gray-500 hover:text-red-600"
|
||||
>
|
||||
Sign out
|
||||
<svg className="w-4 h-4 sm:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-8">
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar */}
|
||||
<nav className="w-56 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
{userNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center space-x-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
pathname === item.href
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<div className="pt-4 pb-2">
|
||||
<p className="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Admin
|
||||
</p>
|
||||
</div>
|
||||
{adminNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center space-x-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
pathname === item.href
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Desktop sidebar */}
|
||||
<nav className="hidden lg:block w-56 flex-shrink-0">
|
||||
<div className="sticky top-24">
|
||||
<SidebarContent />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0">{children}</main>
|
||||
<main className="flex-1 min-w-0 animate-fade-in">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,18 @@ import { useAuthStore } from '@/lib/store';
|
||||
import type { Application } from '@/types';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
running: 'bg-emerald-100 text-emerald-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
building: 'bg-blue-100 text-blue-700',
|
||||
deploying: 'bg-blue-100 text-blue-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
build_failed: 'bg-red-100 text-red-700',
|
||||
stopped: 'bg-gray-100 text-gray-700',
|
||||
stopped: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
|
||||
const statusIcons: Record<string, string> = {
|
||||
running: '🟢', pending: '🟡', building: '🔵', deploying: '🔵',
|
||||
failed: '🔴', build_failed: '🔴', stopped: '⚪',
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -27,59 +32,79 @@ export default function DashboardPage() {
|
||||
const runningApps = apps.filter(
|
||||
(a) => a.deployments?.some((d) => d.status === 'running'),
|
||||
);
|
||||
const failedApps = apps.filter(
|
||||
(a) => a.deployments?.some((d) => d.status === 'failed' || d.status === 'build_failed'),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Welcome */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
<h1 className="page-title">
|
||||
Welcome back, {user?.firstName}! 👋
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
<p className="page-subtitle">
|
||||
Here's an overview of your applications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="card">
|
||||
<p className="text-sm font-medium text-gray-500">Total Apps</p>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-900">{apps.length}</p>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Apps</div>
|
||||
<div className="stat-value text-gray-900">{isLoading ? '—' : apps.length}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-sm font-medium text-gray-500">Running</p>
|
||||
<p className="mt-2 text-3xl font-bold text-green-600">{runningApps.length}</p>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Running</div>
|
||||
<div className="stat-value text-emerald-600">{isLoading ? '—' : runningApps.length}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Failed</div>
|
||||
<div className="stat-value text-red-600">{isLoading ? '—' : failedApps.length}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Deploys</div>
|
||||
<div className="stat-value text-primary-600">
|
||||
{isLoading ? '—' : apps.reduce((sum, a) => sum + (a.deployments?.length || 0), 0)}
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-sm font-medium text-gray-500">Deployments (7d)</p>
|
||||
<p className="mt-2 text-3xl font-bold text-primary-600">
|
||||
{apps.reduce((sum, a) => sum + (a.deployments?.length || 0), 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Applications */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="page-header mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent Applications</h2>
|
||||
<Link href="/dashboard/deploy" className="btn-primary text-sm">
|
||||
+ New Application
|
||||
🚀 New Application
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card text-center py-12 text-gray-500">Loading...</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-40" />
|
||||
<div className="skeleton h-3 w-64" />
|
||||
</div>
|
||||
<div className="skeleton h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-gray-500 text-lg">No applications yet</p>
|
||||
<p className="text-gray-400 mt-2">
|
||||
<div className="card text-center py-16">
|
||||
<div className="text-5xl mb-4">📦</div>
|
||||
<p className="text-gray-600 text-lg font-medium">No applications yet</p>
|
||||
<p className="text-gray-400 mt-1 text-sm">
|
||||
Deploy your first application to get started.
|
||||
</p>
|
||||
<Link href="/dashboard/deploy" className="btn-primary mt-4 inline-block">
|
||||
Deploy your first app
|
||||
<Link href="/dashboard/deploy" className="btn-primary mt-6 inline-flex">
|
||||
🚀 Deploy your first app
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3">
|
||||
{apps.slice(0, 5).map((app) => {
|
||||
const latestDeploy = app.deployments?.[0];
|
||||
const status = latestDeploy?.status || 'pending';
|
||||
@@ -87,30 +112,39 @@ export default function DashboardPage() {
|
||||
<Link
|
||||
key={app.id}
|
||||
href={`/dashboard/apps/${app.id}`}
|
||||
className="card hover:border-primary-300 transition-colors flex items-center justify-between"
|
||||
className="card-hover flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-100 flex items-center justify-center text-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-11 h-11 rounded-xl bg-primary-50 flex items-center justify-center text-lg shrink-0">
|
||||
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{app.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-primary-700 transition-colors truncate">
|
||||
{app.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{app.runtime} · {app.replicas} replica{app.replicas > 1 ? 's' : ''}
|
||||
{app.databaseType !== 'none' && ` · ${app.databaseType}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
statusColors[status] || 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-sm">{statusIcons[status]}</span>
|
||||
<span className={`badge ${statusColors[status] || 'badge-gray'}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{apps.length > 5 && (
|
||||
<Link
|
||||
href="/dashboard/apps"
|
||||
className="text-center text-sm text-primary-600 hover:text-primary-700 font-medium py-3"
|
||||
>
|
||||
View all {apps.length} applications →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+114
-13
@@ -6,34 +6,135 @@
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
}
|
||||
|
||||
/* Smooth scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5 h-1.5;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded-full hover:bg-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ─── Buttons ──────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white px-4 py-2 rounded-lg font-medium
|
||||
hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500
|
||||
focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@apply inline-flex items-center justify-center gap-2 bg-primary-600 text-white
|
||||
px-4 py-2.5 rounded-xl font-semibold text-sm
|
||||
hover:bg-primary-700 active:scale-[0.98]
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2
|
||||
transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
shadow-sm hover:shadow-md;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-gray-700 px-4 py-2 rounded-lg font-medium border border-gray-300
|
||||
hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500
|
||||
focus:ring-offset-2 transition-colors;
|
||||
@apply inline-flex items-center justify-center gap-2 bg-white text-gray-700
|
||||
px-4 py-2.5 rounded-xl font-semibold text-sm border border-gray-300
|
||||
hover:bg-gray-50 active:scale-[0.98]
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2
|
||||
transition-all duration-150 shadow-sm;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white px-4 py-2 rounded-lg font-medium
|
||||
hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500
|
||||
focus:ring-offset-2 transition-colors;
|
||||
@apply inline-flex items-center justify-center gap-2 bg-red-600 text-white
|
||||
px-4 py-2.5 rounded-xl font-semibold text-sm
|
||||
hover:bg-red-700 active:scale-[0.98]
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2
|
||||
transition-all duration-150 shadow-sm hover:shadow-md
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center justify-center gap-2 text-gray-600
|
||||
px-3 py-2 rounded-xl font-medium text-sm
|
||||
hover:bg-gray-100 active:scale-[0.98]
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2
|
||||
transition-all duration-150;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
@apply inline-flex items-center justify-center w-9 h-9 rounded-xl
|
||||
text-gray-500 hover:bg-gray-100 hover:text-gray-700
|
||||
active:scale-[0.95] transition-all duration-150;
|
||||
}
|
||||
|
||||
/* ─── Inputs ──────────────────────────────────────────── */
|
||||
.input-field {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm
|
||||
placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500
|
||||
focus:border-primary-500 transition-colors;
|
||||
@apply w-full px-3.5 py-2.5 border border-gray-300 rounded-xl shadow-sm text-sm
|
||||
placeholder-gray-400 bg-white
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500
|
||||
transition-all duration-150;
|
||||
}
|
||||
|
||||
/* ─── Cards ──────────────────────────────────────────── */
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-6;
|
||||
@apply bg-white rounded-2xl shadow-sm border border-gray-200/80 p-6
|
||||
transition-shadow duration-200;
|
||||
}
|
||||
.card-hover {
|
||||
@apply card hover:shadow-md hover:border-gray-300;
|
||||
}
|
||||
|
||||
/* ─── Badges ──────────────────────────────────────────── */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold;
|
||||
}
|
||||
.badge-green { @apply badge bg-emerald-100 text-emerald-700; }
|
||||
.badge-red { @apply badge bg-red-100 text-red-700; }
|
||||
.badge-blue { @apply badge bg-blue-100 text-blue-700; }
|
||||
.badge-yellow { @apply badge bg-amber-100 text-amber-700; }
|
||||
.badge-gray { @apply badge bg-gray-100 text-gray-600; }
|
||||
.badge-purple { @apply badge bg-purple-100 text-purple-700; }
|
||||
|
||||
/* ─── Stat card ──────────────────────────────────────── */
|
||||
.stat-card {
|
||||
@apply bg-white rounded-2xl border border-gray-200/80 p-5 shadow-sm
|
||||
flex flex-col gap-1;
|
||||
}
|
||||
.stat-value {
|
||||
@apply text-3xl font-bold tracking-tight;
|
||||
}
|
||||
.stat-label {
|
||||
@apply text-sm font-medium text-gray-500;
|
||||
}
|
||||
|
||||
/* ─── Table ──────────────────────────────────────────── */
|
||||
.table-wrapper {
|
||||
@apply overflow-hidden rounded-2xl border border-gray-200/80 bg-white shadow-sm;
|
||||
}
|
||||
|
||||
/* ─── Loading skeleton ──────────────────────────────── */
|
||||
.skeleton {
|
||||
@apply animate-pulse bg-gray-200 rounded-xl;
|
||||
}
|
||||
|
||||
/* ─── Page header ──────────────────────────────────── */
|
||||
.page-header {
|
||||
@apply flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4;
|
||||
}
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-gray-900 tracking-tight;
|
||||
}
|
||||
.page-subtitle {
|
||||
@apply text-sm text-gray-500 mt-0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Animations ─────────────────────────────────────── */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
@@ -28,22 +28,21 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 via-white to-primary-50 py-12 px-4">
|
||||
<div className="max-w-md w-full space-y-8 animate-slide-up">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-primary-600">☁️ CloudHost</h1>
|
||||
<h2 className="mt-4 text-2xl font-semibold text-gray-900">Sign in to your account</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Or{' '}
|
||||
<Link href="/register" className="text-primary-600 hover:text-primary-500 font-medium">
|
||||
create a new account
|
||||
</Link>
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-100 mb-4">
|
||||
<span className="text-3xl">☁️</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">Welcome back</h1>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Sign in to your CloudHost account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="card space-y-6" onSubmit={handleSubmit}>
|
||||
<form className="card space-y-5" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
@@ -58,7 +57,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
@@ -73,8 +72,22 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading} className="btn-primary w-full">
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="text-primary-600 hover:text-primary-500 font-semibold">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,37 +27,38 @@ export default function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 via-white to-primary-50 py-12 px-4">
|
||||
<div className="max-w-md w-full space-y-8 animate-slide-up">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-primary-600">☁️ CloudHost</h1>
|
||||
<h2 className="mt-4 text-2xl font-semibold text-gray-900">Create your account</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-primary-600 hover:text-primary-500 font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-100 mb-4">
|
||||
<span className="text-3xl">☁️</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">Create your account</h1>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Start deploying apps in minutes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="card space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<form className="card space-y-5" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">First name</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">First name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="input-field"
|
||||
placeholder="John"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Last name</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Last name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="input-field"
|
||||
placeholder="Doe"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
||||
/>
|
||||
@@ -65,7 +66,7 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
@@ -77,7 +78,7 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
@@ -90,8 +91,22 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading} className="btn-primary w-full">
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
'Create account'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-primary-600 hover:text-primary-500 font-semibold">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user