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:
@@ -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">
|
||||
<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>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{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'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
{/* Only show deploy button if NEVER deployed before */}
|
||||
|
||||
<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,73 +34,140 @@ 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">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
return (
|
||||
<tr key={app.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="font-medium text-primary-600 hover:text-primary-800">
|
||||
{app.name}
|
||||
</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">
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[latestStatus] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{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">
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { if (confirm('Delete this application?')) deleteMutation.mutate(app.id); }}
|
||||
className="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block table-wrapper">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50/80">
|
||||
<tr>
|
||||
<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-100">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
return (
|
||||
<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="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 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={`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">
|
||||
<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-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user