refactor: migrate from react-hot-toast to react-toastify and replace all emoji icons with Lucide React

- Replaced react-hot-toast with react-toastify (ToastContainer in providers.tsx)
- Removed @heroicons/react dependency
- Added lucide-react for consistent SVG icon system
- Migrated all 18 frontend pages from emoji icons to Lucide components
- Updated sidebar navigation, login/register, dashboard, apps, deploy,
  tickets, admin users/clusters/pools pages
- All emoji indicators (status, runtime, actions) now use proper SVG icons
- Build passes with zero TypeScript errors
This commit is contained in:
keyhan
2026-04-06 17:20:56 +03:30
parent 5d281b85d6
commit 3c3e0e48fa
18 changed files with 248 additions and 229 deletions
+26 -37
View File
@@ -8,15 +8,15 @@
"name": "cloudhost-frontend", "name": "cloudhost-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@heroicons/react": "^2.1.0",
"@tanstack/react-query": "^5.17.0", "@tanstack/react-query": "^5.17.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"lucide-react": "^1.7.0",
"next": "14.1.0", "next": "14.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.49.0", "react-hook-form": "^7.49.0",
"react-hot-toast": "^2.4.1", "react-toastify": "^11.0.5",
"zustand": "^4.5.0" "zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
@@ -141,15 +141,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
"license": "MIT",
"peerDependencies": {
"react": ">= 16 || ^19.0.0-rc"
}
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -1754,6 +1745,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
@@ -2336,6 +2328,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -3056,15 +3049,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -3924,6 +3908,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/lucide-react": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4819,23 +4812,6 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -4843,6 +4819,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-toastify": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
"integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+2 -2
View File
@@ -9,15 +9,15 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.1.0",
"@tanstack/react-query": "^5.17.0", "@tanstack/react-query": "^5.17.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"lucide-react": "^1.7.0",
"next": "14.1.0", "next": "14.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.49.0", "react-hook-form": "^7.49.0",
"react-hot-toast": "^2.4.1", "react-toastify": "^11.0.5",
"zustand": "^4.5.0" "zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
+13 -12
View File
@@ -4,8 +4,9 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link'; import Link from 'next/link';
import api from '@/lib/api'; import api from '@/lib/api';
import toast from 'react-hot-toast'; import { toast } from 'react-toastify';
import type { Application } from '@/types'; import type { Application } from '@/types';
import { Search, X, Package, Hexagon, User, Database, Box } from 'lucide-react';
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
running: 'badge-green', running: 'badge-green',
@@ -130,7 +131,7 @@ export default function AdminAppsPage() {
{/* Search */} {/* Search */}
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-base">🔍</span> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
<input <input
type="text" type="text"
placeholder="Search by app name, user name, email, or user ID…" placeholder="Search by app name, user name, email, or user ID…"
@@ -141,16 +142,16 @@ export default function AdminAppsPage() {
{search && ( {search && (
<button <button
onClick={() => { setSearch(''); setDebouncedSearch(''); }} onClick={() => { setSearch(''); setDebouncedSearch(''); }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-sm" className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
> >
<X className="w-4 h-4" />
</button> </button>
)} )}
</div> </div>
{apps.length === 0 ? ( {apps.length === 0 ? (
<div className="card text-center py-16"> <div className="card text-center py-16">
<div className="text-5xl mb-4">📦</div> <Package className="w-12 h-12 mx-auto mb-4 text-gray-300" />
{debouncedSearch ? ( {debouncedSearch ? (
<> <>
<p className="text-gray-600 text-lg font-medium">No applications found</p> <p className="text-gray-600 text-lg font-medium">No applications found</p>
@@ -188,8 +189,8 @@ export default function AdminAppsPage() {
<tr key={app.id} className="hover:bg-gray-50/50 transition-colors"> <tr key={app.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-6 py-4"> <td className="px-6 py-4">
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group"> <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"> <div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
{app.runtime === 'nodejs' ? '🟩' : '🟧'} <Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : 'text-orange-500'}`} />
</div> </div>
<div> <div>
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors block"> <span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors block">
@@ -252,8 +253,8 @@ export default function AdminAppsPage() {
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-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"> <div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
{app.runtime === 'nodejs' ? '🟩' : '🟧'} <Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : 'text-orange-500'}`} />
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-900">{app.name}</h3> <h3 className="font-semibold text-gray-900">{app.name}</h3>
@@ -266,14 +267,14 @@ export default function AdminAppsPage() {
</div> </div>
{app.user && ( {app.user && (
<div className="flex items-center gap-2 mb-2 text-xs text-gray-500"> <div className="flex items-center gap-2 mb-2 text-xs text-gray-500">
<span>👤 {app.user.firstName} {app.user.lastName}</span> <span className="flex items-center gap-1"><User className="w-3 h-3" /> {app.user.firstName} {app.user.lastName}</span>
<span className="text-gray-300"></span> <span className="text-gray-300"></span>
<span>{app.user.email}</span> <span>{app.user.email}</span>
</div> </div>
)} )}
<div className="flex items-center gap-4 text-xs text-gray-500"> <div className="flex items-center gap-4 text-xs text-gray-500">
<span>💾 {app.databaseType}</span> <span className="flex items-center gap-1"><Database className="w-3 h-3" /> {app.databaseType}</span>
<span>📦 {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span> <span className="flex items-center gap-1"><Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span>
</div> </div>
</Link> </Link>
); );
@@ -3,8 +3,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import toast from 'react-hot-toast'; import { toast } from 'react-toastify';
import type { Cluster, ClusterResources } from '@/types'; import type { Cluster, ClusterResources } from '@/types';
import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X } from 'lucide-react';
function ResourcePanel({ clusterId }: { clusterId: string }) { function ResourcePanel({ clusterId }: { clusterId: string }) {
const { data, isLoading, error } = useQuery<ClusterResources>({ const { data, isLoading, error } = useQuery<ClusterResources>({
@@ -201,7 +202,7 @@ export default function AdminClustersPage() {
<p className="page-subtitle">{clusters.length} cluster{clusters.length !== 1 ? 's' : ''} registered</p> <p className="page-subtitle">{clusters.length} cluster{clusters.length !== 1 ? 's' : ''} registered</p>
</div> </div>
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}> <button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
{showForm ? '✕ Cancel' : '+ Add Cluster'} {showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Add Cluster'}
</button> </button>
</div> </div>
@@ -263,7 +264,7 @@ export default function AdminClustersPage() {
disabled={!form.name || !form.apiServer || !form.kubeconfig || createMutation.isPending} disabled={!form.name || !form.apiServer || !form.kubeconfig || createMutation.isPending}
className="btn-primary" className="btn-primary"
> >
{createMutation.isPending ? '🔄 Verifying connection & adding...' : 'Add Cluster'} {createMutation.isPending ? <><RotateCw className="w-4 h-4 inline animate-spin" /> Verifying connection & adding...</> : 'Add Cluster'}
</button> </button>
</div> </div>
)} )}
@@ -283,7 +284,7 @@ export default function AdminClustersPage() {
</div> </div>
) : clusters.length === 0 ? ( ) : clusters.length === 0 ? (
<div className="card text-center py-16"> <div className="card text-center py-16">
<div className="text-5xl mb-4">🖥</div> <Server className="w-12 h-12 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600 font-medium">No clusters registered yet.</p> <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> <p className="text-gray-400 text-sm mt-1">Add a Kubernetes cluster to start deploying applications.</p>
</div> </div>
@@ -296,7 +297,7 @@ export default function AdminClustersPage() {
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-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' ? 'bg-emerald-50' : 'bg-red-50'
}`}> }`}>
{cluster.status === 'active' ? '✅' : '❌'} {cluster.status === 'active' ? <CheckCircle className="w-5 h-5 text-emerald-500" /> : <XCircle className="w-5 h-5 text-red-500" />}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -322,14 +323,14 @@ export default function AdminClustersPage() {
onClick={() => toggleResources(cluster.id)} onClick={() => toggleResources(cluster.id)}
className={`btn-ghost text-sm ${expandedResources.has(cluster.id) ? 'bg-purple-50 text-purple-700' : ''}`} className={`btn-ghost text-sm ${expandedResources.has(cluster.id) ? 'bg-purple-50 text-purple-700' : ''}`}
> >
📊 Resources <BarChart3 className="w-4 h-4 inline" /> Resources
</button> </button>
<button <button
onClick={() => testMutation.mutate(cluster.id)} onClick={() => testMutation.mutate(cluster.id)}
disabled={testingId === cluster.id} disabled={testingId === cluster.id}
className="btn-ghost text-sm disabled:opacity-50" className="btn-ghost text-sm disabled:opacity-50"
> >
{testingId === cluster.id ? '⏳ Testing...' : '🔌 Test'} {testingId === cluster.id ? <><Clock className="w-3 h-3 inline animate-spin" /> Testing...</> : <><Plug className="w-3 h-3 inline" /> Test</>}
</button> </button>
<button <button
onClick={() => { if (confirm('Remove this cluster?')) deleteMutation.mutate(cluster.id); }} onClick={() => { if (confirm('Remove this cluster?')) deleteMutation.mutate(cluster.id); }}
+11 -10
View File
@@ -3,8 +3,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import toast from 'react-hot-toast'; import { toast } from 'react-toastify';
import type { Cluster, ClusterPool } from '@/types'; import type { Cluster, ClusterPool } from '@/types';
import { Scale, BarChart3, RotateCw, CheckCircle, XCircle, Pencil, Clock, X } from 'lucide-react';
export default function AdminPoolsPage() { export default function AdminPoolsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -107,7 +108,7 @@ export default function AdminPoolsPage() {
onClick={() => { showForm ? resetForm() : setShowForm(true); }} onClick={() => { showForm ? resetForm() : setShowForm(true); }}
className={showForm ? 'btn-ghost' : 'btn-primary'} className={showForm ? 'btn-ghost' : 'btn-primary'}
> >
{showForm ? '✕ Cancel' : '+ Create Pool'} {showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Create Pool'}
</button> </button>
</div> </div>
@@ -135,8 +136,8 @@ export default function AdminPoolsPage() {
value={form.strategy} value={form.strategy}
onChange={(e) => setForm({ ...form, strategy: e.target.value as any })} onChange={(e) => setForm({ ...form, strategy: e.target.value as any })}
> >
<option value="least-apps">📊 Least Apps deploy to cluster with fewest apps</option> <option value="least-apps">Least Apps deploy to cluster with fewest apps</option>
<option value="round-robin">🔄 Round Robin rotate across clusters evenly</option> <option value="round-robin">Round Robin rotate across clusters evenly</option>
</select> </select>
</div> </div>
</div> </div>
@@ -223,7 +224,7 @@ export default function AdminPoolsPage() {
className="btn-primary disabled:opacity-50" className="btn-primary disabled:opacity-50"
> >
{createMutation.isPending || updateMutation.isPending {createMutation.isPending || updateMutation.isPending
? '⏳ Saving...' ? <><Clock className="w-4 h-4 inline animate-spin" /> Saving...</>
: editingPool : editingPool
? 'Update Pool' ? 'Update Pool'
: 'Create Pool'} : 'Create Pool'}
@@ -254,7 +255,7 @@ export default function AdminPoolsPage() {
</div> </div>
) : pools.length === 0 ? ( ) : pools.length === 0 ? (
<div className="card text-center py-16"> <div className="card text-center py-16">
<div className="text-5xl mb-4"></div> <Scale className="w-12 h-12 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600 font-medium">No cluster pools created yet</p> <p className="text-gray-600 font-medium">No cluster pools created yet</p>
<p className="text-sm text-gray-400 mt-1"> <p className="text-sm text-gray-400 mt-1">
Create a pool to enable load-balanced deployment across multiple clusters Create a pool to enable load-balanced deployment across multiple clusters
@@ -274,8 +275,8 @@ export default function AdminPoolsPage() {
<span className={`badge ${pool.isActive ? 'badge-green' : 'badge-gray'}`}> <span className={`badge ${pool.isActive ? 'badge-green' : 'badge-gray'}`}>
{pool.isActive ? 'Active' : 'Inactive'} {pool.isActive ? 'Active' : 'Inactive'}
</span> </span>
<span className="badge badge-purple"> <span className="badge badge-purple flex items-center gap-1">
{pool.strategy === 'least-apps' ? '📊 Least Apps' : '🔄 Round Robin'} {pool.strategy === 'least-apps' ? <><BarChart3 className="w-3 h-3" /> Least Apps</> : <><RotateCw className="w-3 h-3" /> Round Robin</>}
</span> </span>
</div> </div>
{pool.description && ( {pool.description && (
@@ -293,7 +294,7 @@ export default function AdminPoolsPage() {
: 'bg-red-50 text-red-700 border border-red-200' : 'bg-red-50 text-red-700 border border-red-200'
}`} }`}
> >
<span>{cluster.status === 'active' ? '✅' : '❌'}</span> <span>{cluster.status === 'active' ? <CheckCircle className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}</span>
<span>{cluster.name}</span> <span>{cluster.name}</span>
<span className="text-gray-400"> <span className="text-gray-400">
({cluster.provider || 'N/A'} · {cluster.region || 'N/A'}) ({cluster.provider || 'N/A'} · {cluster.region || 'N/A'})
@@ -314,7 +315,7 @@ export default function AdminPoolsPage() {
onClick={() => startEdit(pool)} onClick={() => startEdit(pool)}
className="btn-ghost text-sm" className="btn-ghost text-sm"
> >
Edit <Pencil className="w-3 h-3 inline" /> Edit
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import Link from 'next/link'; import Link from 'next/link';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Ticket, TicketStats, TicketDepartment, TicketStatus } from '@/types'; import type { Ticket, TicketStats, TicketDepartment, TicketStatus } from '@/types';
import { ClipboardList, Wrench, Briefcase, User, Inbox } from 'lucide-react';
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700', open: 'bg-yellow-100 text-yellow-700',
@@ -48,7 +49,7 @@ export default function AdminTicketsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">📋 All Tickets</h1> <h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"><ClipboardList className="w-6 h-6" /> All Tickets</h1>
<p className="text-sm text-gray-500 mt-1">Overview of all support tickets across departments</p> <p className="text-sm text-gray-500 mt-1">Overview of all support tickets across departments</p>
</div> </div>
@@ -77,7 +78,7 @@ export default function AdminTicketsPage() {
<div className="mt-1 space-y-0.5"> <div className="mt-1 space-y-0.5">
{Object.entries(stats.byDepartment).map(([dept, data]) => ( {Object.entries(stats.byDepartment).map(([dept, data]) => (
<div key={dept} className="flex items-center justify-between text-xs"> <div key={dept} className="flex items-center justify-between text-xs">
<span className="text-gray-600 capitalize">{dept === 'technical' ? '🔧' : '💼'} {dept}</span> <span className="text-gray-600 capitalize flex items-center gap-1">{dept === 'technical' ? <Wrench className="w-3 h-3" /> : <Briefcase className="w-3 h-3" />} {dept}</span>
<span className="font-medium"> <span className="font-medium">
<span className={data.open > 0 ? 'text-red-600' : 'text-green-600'}>{data.open} unanswered</span> <span className={data.open > 0 ? 'text-red-600' : 'text-green-600'}>{data.open} unanswered</span>
<span className="text-gray-400"> / {data.total}</span> <span className="text-gray-400"> / {data.total}</span>
@@ -103,7 +104,7 @@ export default function AdminTicketsPage() {
: 'bg-gray-100 text-gray-600 hover:bg-gray-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`} }`}
> >
{dept === '' ? 'All' : dept === 'technical' ? '🔧 Technical' : '💼 Sales'} {dept === '' ? 'All' : dept === 'technical' ? 'Technical' : 'Sales'}
</button> </button>
))} ))}
</div> </div>
@@ -132,7 +133,7 @@ export default function AdminTicketsPage() {
</div> </div>
) : tickets.length === 0 ? ( ) : tickets.length === 0 ? (
<div className="card p-12 text-center"> <div className="card p-12 text-center">
<span className="text-4xl">📭</span> <Inbox className="w-10 h-10 mx-auto text-gray-300" />
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets found</h3> <h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets found</h3>
</div> </div>
) : ( ) : (
@@ -153,14 +154,14 @@ export default function AdminTicketsPage() {
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
{ticket.priority} {ticket.priority}
</span> </span>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700"> <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 flex items-center gap-1">
{ticket.department === 'technical' ? '🔧 Technical' : '💼 Sales'} {ticket.department === 'technical' ? <Wrench className="w-3 h-3" /> : <Briefcase className="w-3 h-3" />} {ticket.department === 'technical' ? 'Technical' : 'Sales'}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500"> <div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
{ticket.user && ( {ticket.user && (
<span> <span className="flex items-center gap-1">
👤 {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email}) <User className="w-3 h-3" /> {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email})
</span> </span>
)} )}
<span></span> <span></span>
@@ -4,8 +4,9 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import toast from 'react-hot-toast'; import { toast } from 'react-toastify';
import type { AdminUser } from '@/types'; import type { AdminUser } from '@/types';
import { Users, Search, X, Clock } from 'lucide-react';
export default function AdminUsersPage() { export default function AdminUsersPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -66,7 +67,7 @@ export default function AdminUsersPage() {
<p className="page-subtitle">{users.length} user{users.length !== 1 ? 's' : ''} registered</p> <p className="page-subtitle">{users.length} user{users.length !== 1 ? 's' : ''} registered</p>
</div> </div>
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}> <button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
{showForm ? '✕ Cancel' : '+ Add User'} {showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Add User'}
</button> </button>
</div> </div>
@@ -132,7 +133,7 @@ export default function AdminUsersPage() {
disabled={!form.email || !form.password || !form.firstName || !form.lastName || createUser.isPending} disabled={!form.email || !form.password || !form.firstName || !form.lastName || createUser.isPending}
className="btn-primary disabled:opacity-50" className="btn-primary disabled:opacity-50"
> >
{createUser.isPending ? '⏳ Creating...' : 'Create User'} {createUser.isPending ? <><Clock className="w-4 h-4 inline animate-spin" /> Creating...</> : 'Create User'}
</button> </button>
</div> </div>
)} )}
@@ -145,14 +146,7 @@ export default function AdminUsersPage() {
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<svg <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -170,7 +164,7 @@ export default function AdminUsersPage() {
</div> </div>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<div className="card text-center py-16"> <div className="card text-center py-16">
<div className="text-5xl mb-4">👥</div> <Users className="w-12 h-12 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600 font-medium">{search ? 'No users found matching your search.' : 'No users yet.'}</p> <p className="text-gray-600 font-medium">{search ? 'No users found matching your search.' : 'No users yet.'}</p>
</div> </div>
) : ( ) : (
+36 -35
View File
@@ -3,9 +3,10 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import api from '@/lib/api'; import api from '@/lib/api';
import toast from 'react-hot-toast'; import { toast } from 'react-toastify';
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic } from '@/types'; import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic } from '@/types';
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin } from 'lucide-react';
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
running: 'badge-green', running: 'badge-green',
@@ -293,8 +294,8 @@ export default function AppDetailPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center gap-4"> <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="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"> <div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center shrink-0">
{app.runtime === 'nodejs' ? '🟩' : '🟧'} <Hexagon className={`w-7 h-7 ${app.runtime === 'nodejs' ? 'text-green-500' : 'text-orange-500'}`} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -312,39 +313,39 @@ export default function AppDetailPage() {
<div className="flex flex-wrap gap-2 shrink-0"> <div className="flex flex-wrap gap-2 shrink-0">
{!hasDeployments && ( {!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'} {deployMutation.isPending ? <><Clock className="w-4 h-4 inline animate-spin" /> Deploying...</> : <><Rocket className="w-4 h-4 inline" /> Deploy</>}
</button> </button>
)} )}
{hasDeployments && ( {hasDeployments && (
<> <>
{isStopped ? ( {isStopped ? (
<button onClick={() => startMutation.mutate()} disabled={startMutation.isPending} className="btn-primary text-sm disabled:opacity-50"> <button onClick={() => startMutation.mutate()} disabled={startMutation.isPending} className="btn-primary text-sm disabled:opacity-50">
{startMutation.isPending ? '⏳...' : '▶️ Start'} {startMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><Play className="w-3 h-3 inline" /> Start</>}
</button> </button>
) : ( ) : (
<button onClick={() => stopMutation.mutate()} disabled={stopMutation.isPending || isInProgress} className="btn-secondary text-sm disabled:opacity-50"> <button onClick={() => stopMutation.mutate()} disabled={stopMutation.isPending || isInProgress} className="btn-secondary text-sm disabled:opacity-50">
{stopMutation.isPending ? '⏳...' : '⏹️ Stop'} {stopMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><Square className="w-3 h-3 inline" /> Stop</>}
</button> </button>
)} )}
{isRunning && ( {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'} {restartMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RotateCw className="w-3 h-3 inline" /> Restart</>}
</button> </button>
)} )}
{!isInProgress && ( {!isInProgress && (
<button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title="Rebuild from latest source code"> <button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title="Rebuild from latest source code">
{redeployMutation.isPending ? '⏳...' : '🔄 Redeploy'} {redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" /> Redeploy</>}
</button> </button>
)} )}
{isRunning && ( {isRunning && (
<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]"> <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'} {previewMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><Globe className="w-3 h-3 inline" /> Preview</>}
</button> </button>
)} )}
</> </>
)} )}
<button onClick={handleDelete} disabled={deleteMutation.isPending} className="btn-danger text-sm disabled:opacity-50"> <button onClick={handleDelete} disabled={deleteMutation.isPending} className="btn-danger text-sm disabled:opacity-50">
{deleteMutation.isPending ? '⏳...' : 'Delete'} {deleteMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : 'Delete'}
</button> </button>
</div> </div>
</div> </div>
@@ -381,7 +382,7 @@ export default function AppDetailPage() {
<div className="flex justify-between"> <div className="flex justify-between">
<dt className="text-sm text-gray-500">Cluster</dt> <dt className="text-sm text-gray-500">Cluster</dt>
<dd className="text-sm font-medium text-gray-900"> <dd className="text-sm font-medium text-gray-900">
🖥 {clusters.find((c) => c.id === app.clusterId)?.name || 'Unknown'} <Server className="w-4 h-4 inline text-gray-400" /> {clusters.find((c) => c.id === app.clusterId)?.name || 'Unknown'}
</dd> </dd>
</div> </div>
)} )}
@@ -389,7 +390,7 @@ export default function AppDetailPage() {
<div className="flex justify-between"> <div className="flex justify-between">
<dt className="text-sm text-gray-500">Pool</dt> <dt className="text-sm text-gray-500">Pool</dt>
<dd className="text-sm font-medium text-gray-900"> <dd className="text-sm font-medium text-gray-900">
{pools.find((p) => p.id === app.poolId)?.name || 'Unknown'} <Scale className="w-4 h-4 inline text-gray-400" /> {pools.find((p) => p.id === app.poolId)?.name || 'Unknown'}
</dd> </dd>
</div> </div>
)} )}
@@ -408,7 +409,7 @@ export default function AppDetailPage() {
<h2 className="text-lg font-semibold text-gray-900 mb-4">Deployment History</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">Deployment History</h2>
{deployments.length === 0 ? ( {deployments.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="text-3xl mb-2">📦</div> <Package className="w-8 h-8 mx-auto text-gray-300 mb-2" />
<p className="text-gray-500 text-sm">No deployments yet</p> <p className="text-gray-500 text-sm">No deployments yet</p>
<p className="text-gray-400 text-xs mt-1">Upload source code and click Deploy to get started</p> <p className="text-gray-400 text-xs mt-1">Upload source code and click Deploy to get started</p>
</div> </div>
@@ -423,7 +424,7 @@ export default function AppDetailPage() {
</p> </p>
{d.errorMessage && ( {d.errorMessage && (
<p className="text-xs text-red-500 mt-1 truncate" title={d.errorMessage}> <p className="text-xs text-red-500 mt-1 truncate" title={d.errorMessage}>
{d.errorMessage} <XCircle className="w-3 h-3 inline" /> {d.errorMessage}
</p> </p>
)} )}
</div> </div>
@@ -439,13 +440,13 @@ export default function AppDetailPage() {
{/* Source Code Upload */} {/* Source Code Upload */}
<div className="card"> <div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-4">📦 Source Code</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"><Package className="w-5 h-5" /> Source Code</h2>
{app.codePath ? ( {app.codePath ? (
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl mb-4"> <div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl mb-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600"> <div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
<CheckCircle className="w-5 h-5" />
</div> </div>
<div> <div>
<p className="text-sm font-medium text-green-800">Source code uploaded</p> <p className="text-sm font-medium text-green-800">Source code uploaded</p>
@@ -463,20 +464,20 @@ export default function AppDetailPage() {
<div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-xl mb-4"> <div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-xl mb-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600"> <div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600">
🔗 <Link className="w-5 h-5" />
</div> </div>
<div> <div>
<p className="text-sm font-medium text-blue-800">Git repository connected</p> <p className="text-sm font-medium text-blue-800">Git repository connected</p>
<p className="text-xs text-blue-600 font-mono">{app.gitUrl}</p> <p className="text-xs text-blue-600 font-mono">{app.gitUrl}</p>
<div className="flex items-center space-x-3 mt-1"> <div className="flex items-center space-x-3 mt-1">
{app.gitBranch && ( {app.gitBranch && (
<span className="text-xs text-blue-500"> <span className="text-xs text-blue-500 flex items-center gap-1">
🌿 {app.gitBranch} <GitBranch className="w-3 h-3" /> {app.gitBranch}
</span> </span>
)} )}
{app.gitToken && ( {app.gitToken && (
<span className="text-xs text-green-600"> <span className="text-xs text-green-600 flex items-center gap-1">
🔑 Private <KeyRound className="w-3 h-3" /> Private
</span> </span>
)} )}
</div> </div>
@@ -512,7 +513,7 @@ export default function AppDetailPage() {
{uploadMutation.isPending ? ( {uploadMutation.isPending ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-3xl"></div> <Upload className="w-8 h-8 mx-auto text-gray-400 animate-pulse" />
<p className="text-sm font-medium text-gray-700">Uploading... {uploadProgress}%</p> <p className="text-sm font-medium text-gray-700">Uploading... {uploadProgress}%</p>
<div className="w-48 mx-auto bg-gray-200 rounded-full h-2"> <div className="w-48 mx-auto bg-gray-200 rounded-full h-2">
<div <div
@@ -523,7 +524,7 @@ export default function AppDetailPage() {
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-3xl">📁</div> <FolderUp className="w-8 h-8 mx-auto text-gray-400" />
<p className="text-sm font-medium text-gray-700"> <p className="text-sm font-medium text-gray-700">
{app.codePath ? 'Upload new version' : 'Upload your project source code'} {app.codePath ? 'Upload new version' : 'Upload your project source code'}
</p> </p>
@@ -539,12 +540,12 @@ export default function AppDetailPage() {
{/* Resource Monitoring & Scaling */} {/* Resource Monitoring & Scaling */}
<div className="card"> <div className="card">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">📊 Resources & Scaling</h2> <h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><BarChart3 className="w-5 h-5" /> Resources & Scaling</h2>
<button <button
onClick={() => setShowResources(!showResources)} onClick={() => setShowResources(!showResources)}
className="btn-secondary text-sm" className="btn-secondary text-sm"
> >
{showResources ? '🔽 Hide' : '📊 Monitor'} {showResources ? <><ChevronDown className="w-4 h-4 inline" /> Hide</> : <><BarChart3 className="w-4 h-4 inline" /> Monitor</>}
</button> </button>
</div> </div>
@@ -574,7 +575,7 @@ export default function AppDetailPage() {
<div className="bg-purple-50 rounded-xl p-4 text-center"> <div className="bg-purple-50 rounded-xl p-4 text-center">
<p className="text-xs text-purple-500 font-medium">Metrics</p> <p className="text-xs text-purple-500 font-medium">Metrics</p>
<p className="text-2xl font-bold text-purple-700"> <p className="text-2xl font-bold text-purple-700">
{resourceUsage.metrics.length > 0 ? '✅' : '⏳'} {resourceUsage.metrics.length > 0 ? <CheckCircle className="w-6 h-6 mx-auto text-purple-700" /> : <Clock className="w-6 h-6 mx-auto text-purple-400" />}
</p> </p>
<p className="text-xs text-purple-400"> <p className="text-xs text-purple-400">
{resourceUsage.metrics.length > 0 ? 'Available' : 'Waiting...'} {resourceUsage.metrics.length > 0 ? 'Available' : 'Waiting...'}
@@ -599,7 +600,7 @@ export default function AppDetailPage() {
<div key={metric.name} className="bg-gray-50 rounded-xl p-4 space-y-3"> <div key={metric.name} className="bg-gray-50 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-mono text-gray-600 truncate max-w-[250px]" title={metric.name}> <p className="text-xs font-mono text-gray-600 truncate max-w-[250px]" title={metric.name}>
🟢 {metric.name} <Circle className="w-2 h-2 inline fill-green-500 text-green-500" /> {metric.name}
</p> </p>
</div> </div>
@@ -673,7 +674,7 @@ export default function AppDetailPage() {
{pod.status} {pod.status}
</span> </span>
</td> </td>
<td className="py-2">{pod.ready ? '✅' : '⏳'}</td> <td className="py-2">{pod.ready ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Clock className="w-4 h-4 text-yellow-500" />}</td>
<td className="py-2">{pod.restarts}</td> <td className="py-2">{pod.restarts}</td>
</tr> </tr>
))} ))}
@@ -685,7 +686,7 @@ export default function AppDetailPage() {
{/* Scaling Controls */} {/* Scaling Controls */}
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3"> Scale Resources</h3> <h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1"><Settings className="w-4 h-4" /> Scale Resources</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div> <div>
<label className="block text-xs text-gray-500 mb-1">CPU Request</label> <label className="block text-xs text-gray-500 mb-1">CPU Request</label>
@@ -751,7 +752,7 @@ export default function AppDetailPage() {
disabled={scaleMutation.isPending} disabled={scaleMutation.isPending}
className="btn-primary text-sm w-full disabled:opacity-50" className="btn-primary text-sm w-full disabled:opacity-50"
> >
{scaleMutation.isPending ? '⏳ Applying...' : '🔄 Apply Changes'} {scaleMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /> Applying...</> : <><RefreshCw className="w-3 h-3 inline" /> Apply Changes</>}
</button> </button>
</div> </div>
</div> </div>
@@ -771,7 +772,7 @@ export default function AppDetailPage() {
{/* Logs — Pod & Build */} {/* Logs — Pod & Build */}
<div className="card"> <div className="card">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">📋 Logs</h2> <h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><FileText className="w-5 h-5" /> Logs</h2>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{showLogs && logTab === 'pod' && ( {showLogs && logTab === 'pod' && (
<span className="text-xs text-gray-400 flex items-center space-x-1"> <span className="text-xs text-gray-400 flex items-center space-x-1">
@@ -789,7 +790,7 @@ export default function AppDetailPage() {
onClick={() => setShowLogs(!showLogs)} onClick={() => setShowLogs(!showLogs)}
className="btn-secondary text-sm" className="btn-secondary text-sm"
> >
{showLogs ? '🔽 Hide Logs' : '📋 Show Logs'} {showLogs ? <><ChevronDown className="w-4 h-4 inline" /> Hide Logs</> : <><FileText className="w-4 h-4 inline" /> Show Logs</>}
</button> </button>
</div> </div>
</div> </div>
@@ -805,7 +806,7 @@ export default function AppDetailPage() {
: 'text-gray-500 hover:text-gray-700' : 'text-gray-500 hover:text-gray-700'
}`} }`}
> >
🖥 Pod Logs <Monitor className="w-4 h-4 inline" /> Pod Logs
</button> </button>
<button <button
onClick={() => setLogTab('build')} onClick={() => setLogTab('build')}
@@ -815,7 +816,7 @@ export default function AppDetailPage() {
: 'text-gray-500 hover:text-gray-700' : 'text-gray-500 hover:text-gray-700'
}`} }`}
> >
🔨 Build Logs <Hammer className="w-4 h-4 inline" /> Build Logs
</button> </button>
</div> </div>
@@ -834,7 +835,7 @@ export default function AppDetailPage() {
<div> <div>
{buildLogsData?.version && ( {buildLogsData?.version && (
<div className="flex items-center gap-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><Pin className="w-3 h-3 inline" /> {buildLogsData.version}</span>
<span className={`badge ${statusColors[buildLogsData.status] || 'badge-gray'}`}> <span className={`badge ${statusColors[buildLogsData.status] || 'badge-gray'}`}>
{buildLogsData.status} {buildLogsData.status}
</span> </span>
+12 -11
View File
@@ -3,8 +3,9 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link'; import Link from 'next/link';
import api from '@/lib/api'; import api from '@/lib/api';
import toast from 'react-hot-toast'; import { toast } from 'react-toastify';
import type { Application } from '@/types'; import type { Application } from '@/types';
import { Rocket, Package, Hexagon, Database, Box } from 'lucide-react';
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
running: 'badge-green', running: 'badge-green',
@@ -64,17 +65,17 @@ export default function AppsPage() {
<p className="page-subtitle">{apps.length} application{apps.length !== 1 ? 's' : ''}</p> <p className="page-subtitle">{apps.length} application{apps.length !== 1 ? 's' : ''}</p>
</div> </div>
<Link href="/dashboard/deploy" className="btn-primary"> <Link href="/dashboard/deploy" className="btn-primary">
🚀 New Application <Rocket className="w-4 h-4 mr-1 inline" /> New Application
</Link> </Link>
</div> </div>
{apps.length === 0 ? ( {apps.length === 0 ? (
<div className="card text-center py-16"> <div className="card text-center py-16">
<div className="text-5xl mb-4">📦</div> <Package className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<p className="text-gray-600 text-lg font-medium">No applications yet</p> <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> <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"> <Link href="/dashboard/deploy" className="btn-primary mt-6 inline-flex items-center gap-1">
🚀 Deploy your first app <Rocket className="w-4 h-4" /> Deploy your first app
</Link> </Link>
</div> </div>
) : ( ) : (
@@ -99,8 +100,8 @@ export default function AppsPage() {
<tr key={app.id} className="hover:bg-gray-50/50 transition-colors"> <tr key={app.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-6 py-4"> <td className="px-6 py-4">
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group"> <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"> <div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
{app.runtime === 'nodejs' ? '🟩' : '🟧'} <Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : 'text-orange-500'}`} />
</div> </div>
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors"> <span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">
{app.name} {app.name}
@@ -147,8 +148,8 @@ export default function AppsPage() {
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-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"> <div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
{app.runtime === 'nodejs' ? '🟩' : '🟧'} <Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : 'text-orange-500'}`} />
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-900">{app.name}</h3> <h3 className="font-semibold text-gray-900">{app.name}</h3>
@@ -160,8 +161,8 @@ export default function AppsPage() {
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-xs text-gray-500"> <div className="flex items-center gap-4 text-xs text-gray-500">
<span>💾 {app.databaseType}</span> <span className="flex items-center gap-1"><Database className="w-3 h-3" /> {app.databaseType}</span>
<span>📦 {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span> <span className="flex items-center gap-1"><Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span>
</div> </div>
</Link> </Link>
); );
+30 -29
View File
@@ -5,8 +5,9 @@ import { useRouter } from 'next/navigation';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import toast from 'react-hot-toast'; import { toast } from 'react-toastify';
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic } from '@/types'; import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic } from '@/types';
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle } from 'lucide-react';
const steps = ['Basic Info', 'Runtime & Database', 'Resources', 'Review']; const steps = ['Basic Info', 'Runtime & Database', 'Resources', 'Review'];
@@ -219,7 +220,7 @@ export default function DeployPage() {
: 'border-gray-200 hover:border-gray-300' : 'border-gray-200 hover:border-gray-300'
}`} }`}
> >
<span className="text-xl">📁</span> <FolderUp className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">Upload ZIP</p> <p className="mt-1 font-semibold text-sm text-gray-900">Upload ZIP</p>
</button> </button>
<button <button
@@ -231,7 +232,7 @@ export default function DeployPage() {
: 'border-gray-200 hover:border-gray-300' : 'border-gray-200 hover:border-gray-300'
}`} }`}
> >
<span className="text-xl">🔗</span> <LinkIcon className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">Git Repository</p> <p className="mt-1 font-semibold text-sm text-gray-900">Git Repository</p>
</button> </button>
</div> </div>
@@ -280,7 +281,7 @@ export default function DeployPage() {
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl"> <div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600"> <div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
<CheckCircle className="w-5 h-5" />
</div> </div>
<div> <div>
<p className="text-sm font-medium text-green-800">{zipFile.name}</p> <p className="text-sm font-medium text-green-800">{zipFile.name}</p>
@@ -313,7 +314,7 @@ export default function DeployPage() {
}`} }`}
> >
<div className="space-y-2"> <div className="space-y-2">
<div className="text-3xl">📦</div> <Package className="w-8 h-8 mx-auto text-gray-400" />
<p className="text-sm font-medium text-gray-700"> <p className="text-sm font-medium text-gray-700">
Drag & drop your project ZIP here Drag & drop your project ZIP here
</p> </p>
@@ -348,8 +349,8 @@ export default function DeployPage() {
<label className="block text-sm font-medium text-gray-700 mb-3">Application Runtime</label> <label className="block text-sm font-medium text-gray-700 mb-3">Application Runtime</label>
<div className="grid grid-cols-1 sm: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: 'nodejs', label: 'Node.js', icon: <Hexagon className="w-6 h-6 text-green-500" />, desc: 'Express, NestJS, Fastify...' },
{ value: 'laravel', label: 'Laravel', icon: '🟧', desc: 'PHP 8.3, Composer, Artisan' }, { value: 'laravel', label: 'Laravel', icon: <Hexagon className="w-6 h-6 text-orange-500" />, desc: 'PHP 8.3, Composer, Artisan' },
].map((opt) => ( ].map((opt) => (
<button <button
key={opt.value} key={opt.value}
@@ -359,7 +360,7 @@ export default function DeployPage() {
form.runtime === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300' form.runtime === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`} }`}
> >
<span className="text-2xl">{opt.icon}</span> {opt.icon}
<p className="mt-2 font-semibold text-gray-900">{opt.label}</p> <p className="mt-2 font-semibold text-gray-900">{opt.label}</p>
<p className="text-xs text-gray-500">{opt.desc}</p> <p className="text-xs text-gray-500">{opt.desc}</p>
</button> </button>
@@ -370,9 +371,9 @@ export default function DeployPage() {
<label className="block text-sm font-medium text-gray-700 mb-3">Database</label> <label className="block text-sm font-medium text-gray-700 mb-3">Database</label>
<div className="grid grid-cols-3 gap-3 sm:gap-4"> <div className="grid grid-cols-3 gap-3 sm:gap-4">
{[ {[
{ value: 'none', label: 'None', icon: '❌' }, { value: 'none', label: 'None', icon: <XCircle className="w-6 h-6 text-gray-400" /> },
{ value: 'postgresql', label: 'PostgreSQL', icon: '🐘' }, { value: 'postgresql', label: 'PostgreSQL', icon: <svg className="w-6 h-6 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
{ value: 'mysql', label: 'MySQL', icon: '🐬' }, { value: 'mysql', label: 'MySQL', icon: <svg className="w-6 h-6 text-orange-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
].map((opt) => ( ].map((opt) => (
<button <button
key={opt.value} key={opt.value}
@@ -382,7 +383,7 @@ export default function DeployPage() {
form.databaseType === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300' form.databaseType === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`} }`}
> >
<span className="text-2xl">{opt.icon}</span> <div className="flex justify-center">{opt.icon}</div>
<p className="mt-2 font-semibold text-sm text-gray-900">{opt.label}</p> <p className="mt-2 font-semibold text-sm text-gray-900">{opt.label}</p>
</button> </button>
))} ))}
@@ -413,7 +414,7 @@ export default function DeployPage() {
: 'border-gray-200 hover:border-gray-300' : 'border-gray-200 hover:border-gray-300'
}`} }`}
> >
<span className="text-xl">🏠</span> <Home className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">Default</p> <p className="mt-1 font-semibold text-sm text-gray-900">Default</p>
<p className="text-xs text-gray-500">Use default cluster</p> <p className="text-xs text-gray-500">Use default cluster</p>
</button> </button>
@@ -429,7 +430,7 @@ export default function DeployPage() {
: 'border-gray-200 hover:border-gray-300' : 'border-gray-200 hover:border-gray-300'
}`} }`}
> >
<span className="text-xl">🎯</span> <Target className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">Manual</p> <p className="mt-1 font-semibold text-sm text-gray-900">Manual</p>
<p className="text-xs text-gray-500">Pick a specific cluster</p> <p className="text-xs text-gray-500">Pick a specific cluster</p>
</button> </button>
@@ -445,7 +446,7 @@ export default function DeployPage() {
: 'border-gray-200 hover:border-gray-300' : 'border-gray-200 hover:border-gray-300'
}`} }`}
> >
<span className="text-xl"></span> <Scale className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">Load Balanced</p> <p className="mt-1 font-semibold text-sm text-gray-900">Load Balanced</p>
<p className="text-xs text-gray-500">Pick a cluster pool</p> <p className="text-xs text-gray-500">Pick a cluster pool</p>
</button> </button>
@@ -470,7 +471,7 @@ export default function DeployPage() {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<span className="text-lg">🖥</span> <Server className="w-5 h-5 text-gray-400" />
<div> <div>
<p className="font-semibold text-sm text-gray-900"> <p className="font-semibold text-sm text-gray-900">
{cluster.name} {cluster.name}
@@ -517,15 +518,15 @@ export default function DeployPage() {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<span className="text-lg"></span> <Scale className="w-5 h-5 text-gray-400" />
<div> <div>
<p className="font-semibold text-sm text-gray-900">{pool.name}</p> <p className="font-semibold text-sm text-gray-900">{pool.name}</p>
{pool.description && ( {pool.description && (
<p className="text-xs text-gray-500">{pool.description}</p> <p className="text-xs text-gray-500">{pool.description}</p>
)} )}
<div className="flex items-center space-x-2 mt-1"> <div className="flex items-center space-x-2 mt-1">
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded"> <span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded flex items-center gap-1">
{pool.strategy === 'least-apps' ? '📊 Least Apps' : '🔄 Round Robin'} {pool.strategy === 'least-apps' ? <><BarChart3 className="w-3 h-3" /> Least Apps</> : <><RotateCw className="w-3 h-3" /> Round Robin</>}
</span> </span>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{pool.clusters.length} cluster{pool.clusters.length !== 1 ? 's' : ''}: {pool.clusters.length} cluster{pool.clusters.length !== 1 ? 's' : ''}:
@@ -545,7 +546,7 @@ export default function DeployPage() {
{clusterMode === 'default' && ( {clusterMode === 'default' && (
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200"> <div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<span className="text-lg">🏠</span> <Home className="w-5 h-5 text-gray-400" />
<div> <div>
<p className="text-sm font-medium text-gray-700">Default cluster will be used</p> <p className="text-sm font-medium text-gray-700">Default cluster will be used</p>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
@@ -562,7 +563,7 @@ export default function DeployPage() {
) : ( ) : (
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200"> <div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<span className="text-lg">🏠</span> <Home className="w-5 h-5 text-gray-400" />
<div> <div>
<p className="text-sm font-medium text-gray-700">Cluster Assignment</p> <p className="text-sm font-medium text-gray-700">Cluster Assignment</p>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
@@ -686,7 +687,7 @@ export default function DeployPage() {
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{sourceMethod === 'upload' {sourceMethod === 'upload'
? zipFile ? zipFile
? `📁 ${zipFile.name}` ? zipFile.name
: '—' : '—'
: form.gitUrl || '—'} : form.gitUrl || '—'}
</span> </span>
@@ -700,17 +701,17 @@ export default function DeployPage() {
{sourceMethod === 'git' && form.gitToken && ( {sourceMethod === 'git' && form.gitToken && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-gray-500">Auth</span> <span className="text-sm text-gray-500">Auth</span>
<span className="text-sm font-medium text-green-600">🔑 Token provided</span> <span className="text-sm font-medium text-green-600">Token provided</span>
</div> </div>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-gray-500">Cluster</span> <span className="text-sm text-gray-500">Cluster</span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{isAdmin && clusterMode === 'manual' && form.clusterId {isAdmin && clusterMode === 'manual' && form.clusterId
? `🎯 ${clusters.find((c) => c.id === form.clusterId)?.name || form.clusterId}` ? `${clusters.find((c) => c.id === form.clusterId)?.name || form.clusterId}`
: isAdmin && clusterMode === 'pool' && form.poolId : isAdmin && clusterMode === 'pool' && form.poolId
? `⚖️ ${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)` ? `${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)`
: '🏠 Default Cluster'} : 'Default Cluster'}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
@@ -764,9 +765,9 @@ export default function DeployPage() {
> >
{createMutation.isPending {createMutation.isPending
? uploadProgress > 0 && uploadProgress < 100 ? uploadProgress > 0 && uploadProgress < 100
? `Uploading... ${uploadProgress}%` ? `Uploading... ${uploadProgress}%`
: 'Deploying...' : 'Deploying...'
: '🚀 Deploy Application'} : <><Rocket className="w-4 h-4 inline" /> Deploy Application</>}
</button> </button>
)} )}
</div> </div>
+47 -32
View File
@@ -1,38 +1,57 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import api from '@/lib/api'; import api from '@/lib/api';
import {
LayoutDashboard,
Package,
Rocket,
Ticket,
Users,
Server,
Scale,
ClipboardList,
Wrench,
Briefcase,
Cloud,
Menu,
X,
LogOut,
Boxes,
} from 'lucide-react';
const userNavItems = [ type NavItem = { href: string; label: string; icon: ReactNode };
{ href: '/dashboard', label: 'Dashboard', icon: '📊' },
{ href: '/dashboard/apps', label: 'Applications', icon: '📦' }, const userNavItems: NavItem[] = [
{ href: '/dashboard/deploy', label: 'New Deploy', icon: '🚀' }, { href: '/dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> },
{ href: '/dashboard/tickets', label: 'Tickets', icon: '🎫' }, { href: '/dashboard/apps', label: 'Applications', icon: <Package className="w-4 h-4" /> },
{ href: '/dashboard/deploy', label: 'New Deploy', icon: <Rocket className="w-4 h-4" /> },
{ href: '/dashboard/tickets', label: 'Tickets', icon: <Ticket className="w-4 h-4" /> },
]; ];
const adminNavItems = [ const adminNavItems: NavItem[] = [
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' }, { href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: '📦' }, { href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> },
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' }, { href: '/dashboard/admin/clusters', label: 'Clusters', icon: <Server className="w-4 h-4" /> },
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: '⚖️' }, { href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: <Scale className="w-4 h-4" /> },
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: '📋' }, { href: '/dashboard/admin/tickets', label: 'All Tickets', icon: <ClipboardList className="w-4 h-4" /> },
]; ];
const technicalNavItems = [ const technicalNavItems: NavItem[] = [
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' }, { href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: '📦' }, { href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> },
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' }, { href: '/dashboard/admin/clusters', label: 'Clusters', icon: <Server className="w-4 h-4" /> },
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: '⚖️' }, { href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: <Scale className="w-4 h-4" /> },
{ href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: '🔧' }, { href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: <Wrench className="w-4 h-4" /> },
]; ];
const salesNavItems = [ const salesNavItems: NavItem[] = [
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' }, { href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
{ href: '/dashboard/staff/tickets', label: 'Sales Tickets', icon: '💼' }, { href: '/dashboard/staff/tickets', label: 'Sales Tickets', icon: <Briefcase className="w-4 h-4" /> },
]; ];
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
@@ -65,7 +84,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
if (!isAuthenticated) return null; if (!isAuthenticated) return null;
const NavLink = ({ item, badge }: { item: { href: string; label: string; icon: string }; badge?: number }) => { const NavLink = ({ item, badge }: { item: NavItem; badge?: number }) => {
const isActive = pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href + '/')); const isActive = pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href + '/'));
return ( return (
<Link <Link
@@ -76,7 +95,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`} }`}
> >
<span className="text-base">{item.icon}</span> {item.icon}
<span className="flex-1">{item.label}</span> <span className="flex-1">{item.label}</span>
{badge !== undefined && badge > 0 && ( {badge !== undefined && badge > 0 && (
<span className="min-w-[20px] h-5 flex items-center justify-center px-1.5 text-xs font-bold rounded-full bg-red-500 text-white"> <span className="min-w-[20px] h-5 flex items-center justify-center px-1.5 text-xs font-bold rounded-full bg-red-500 text-white">
@@ -186,10 +205,10 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
> >
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<Link href="/dashboard" className="text-lg font-bold text-primary-600"> <Link href="/dashboard" className="text-lg font-bold text-primary-600">
CloudHost <Cloud className="w-5 h-5 inline mr-1" /> CloudHost
</Link> </Link>
<button onClick={() => setSidebarOpen(false)} className="btn-icon"> <button onClick={() => setSidebarOpen(false)} className="btn-icon">
<X className="w-4 h-4" />
</button> </button>
</div> </div>
<SidebarContent /> <SidebarContent />
@@ -205,12 +224,10 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
className="btn-icon lg:hidden" className="btn-icon lg:hidden"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Menu className="w-5 h-5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button> </button>
<Link href="/dashboard" className="text-xl font-bold text-primary-600 flex items-center gap-2"> <Link href="/dashboard" className="text-xl font-bold text-primary-600 flex items-center gap-2">
<span></span> <Cloud className="w-6 h-6" />
<span className="hidden sm:inline">CloudHost</span> <span className="hidden sm:inline">CloudHost</span>
</Link> </Link>
</div> </div>
@@ -233,9 +250,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
onClick={() => { logout(); router.push('/login'); }} onClick={() => { logout(); router.push('/login'); }}
className="btn-ghost text-gray-500 hover:text-red-600" className="btn-ghost text-gray-500 hover:text-red-600"
> >
<svg className="w-4 h-4 sm:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <LogOut className="w-4 h-4 sm:hidden" />
<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> <span className="hidden sm:inline">Sign out</span>
</button> </button>
</div> </div>
+18 -11
View File
@@ -2,9 +2,11 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import Link from 'next/link'; import Link from 'next/link';
import type { ReactNode } from 'react';
import api from '@/lib/api'; import api from '@/lib/api';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import type { Application } from '@/types'; import type { Application } from '@/types';
import { Rocket, Package, Circle, Hexagon } from 'lucide-react';
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
running: 'bg-emerald-100 text-emerald-700', running: 'bg-emerald-100 text-emerald-700',
@@ -16,9 +18,14 @@ const statusColors: Record<string, string> = {
stopped: 'bg-gray-100 text-gray-600', stopped: 'bg-gray-100 text-gray-600',
}; };
const statusIcons: Record<string, string> = { const statusIcons: Record<string, ReactNode> = {
running: '🟢', pending: '🟡', building: '🔵', deploying: '🔵', running: <Circle className="w-3 h-3 fill-emerald-500 text-emerald-500" />,
failed: '🔴', build_failed: '🔴', stopped: '⚪', pending: <Circle className="w-3 h-3 fill-amber-500 text-amber-500" />,
building: <Circle className="w-3 h-3 fill-blue-500 text-blue-500" />,
deploying: <Circle className="w-3 h-3 fill-blue-500 text-blue-500" />,
failed: <Circle className="w-3 h-3 fill-red-500 text-red-500" />,
build_failed: <Circle className="w-3 h-3 fill-red-500 text-red-500" />,
stopped: <Circle className="w-3 h-3 fill-gray-400 text-gray-400" />,
}; };
export default function DashboardPage() { export default function DashboardPage() {
@@ -41,7 +48,7 @@ export default function DashboardPage() {
{/* Welcome */} {/* Welcome */}
<div> <div>
<h1 className="page-title"> <h1 className="page-title">
Welcome back, {user?.firstName}! 👋 Welcome back, {user?.firstName}!
</h1> </h1>
<p className="page-subtitle"> <p className="page-subtitle">
Here&apos;s an overview of your applications. Here&apos;s an overview of your applications.
@@ -75,7 +82,7 @@ export default function DashboardPage() {
<div className="page-header mb-4"> <div className="page-header mb-4">
<h2 className="text-lg font-semibold text-gray-900">Recent Applications</h2> <h2 className="text-lg font-semibold text-gray-900">Recent Applications</h2>
<Link href="/dashboard/deploy" className="btn-primary text-sm"> <Link href="/dashboard/deploy" className="btn-primary text-sm">
🚀 New Application <Rocket className="w-4 h-4 mr-1 inline" /> New Application
</Link> </Link>
</div> </div>
@@ -94,13 +101,13 @@ export default function DashboardPage() {
</div> </div>
) : apps.length === 0 ? ( ) : apps.length === 0 ? (
<div className="card text-center py-16"> <div className="card text-center py-16">
<div className="text-5xl mb-4">📦</div> <Package className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<p className="text-gray-600 text-lg font-medium">No applications yet</p> <p className="text-gray-600 text-lg font-medium">No applications yet</p>
<p className="text-gray-400 mt-1 text-sm"> <p className="text-gray-400 mt-1 text-sm">
Deploy your first application to get started. Deploy your first application to get started.
</p> </p>
<Link href="/dashboard/deploy" className="btn-primary mt-6 inline-flex"> <Link href="/dashboard/deploy" className="btn-primary mt-6 inline-flex items-center gap-1">
🚀 Deploy your first app <Rocket className="w-4 h-4" /> Deploy your first app
</Link> </Link>
</div> </div>
) : ( ) : (
@@ -115,8 +122,8 @@ export default function DashboardPage() {
className="card-hover flex items-center justify-between group" className="card-hover flex items-center justify-between group"
> >
<div className="flex items-center gap-4"> <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"> <div className="w-11 h-11 rounded-xl bg-primary-50 flex items-center justify-center shrink-0">
{app.runtime === 'nodejs' ? '🟩' : '🟧'} <Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : 'text-orange-500'}`} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h3 className="font-semibold text-gray-900 group-hover:text-primary-700 transition-colors truncate"> <h3 className="font-semibold text-gray-900 group-hover:text-primary-700 transition-colors truncate">
@@ -129,7 +136,7 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="text-sm">{statusIcons[status]}</span> {statusIcons[status]}
<span className={`badge ${statusColors[status] || 'badge-gray'}`}> <span className={`badge ${statusColors[status] || 'badge-gray'}`}>
{status} {status}
</span> </span>
@@ -6,6 +6,7 @@ import Link from 'next/link';
import api from '@/lib/api'; import api from '@/lib/api';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import type { Ticket, TicketStatus } from '@/types'; import type { Ticket, TicketStatus } from '@/types';
import { Wrench, Briefcase, User, CheckCircle } from 'lucide-react';
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700', open: 'bg-yellow-100 text-yellow-700',
@@ -26,7 +27,8 @@ export default function StaffTicketsPage() {
// Determine which department this staff member handles // Determine which department this staff member handles
const department = user?.role === 'sales' ? 'sales' : 'technical'; const department = user?.role === 'sales' ? 'sales' : 'technical';
const departmentLabel = department === 'technical' ? '🔧 Technical' : '💼 Sales'; const departmentLabel = department === 'technical' ? 'Technical' : 'Sales';
const DeptIcon = department === 'technical' ? Wrench : Briefcase;
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({ const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
queryKey: ['staff-tickets', department, statusFilter], queryKey: ['staff-tickets', department, statusFilter],
@@ -42,7 +44,7 @@ export default function StaffTicketsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">{departmentLabel} Tickets</h1> <h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"><DeptIcon className="w-5 h-5" /> {departmentLabel} Tickets</h1>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{tickets.length} total tickets {tickets.length} total tickets
</p> </p>
@@ -95,7 +97,7 @@ export default function StaffTicketsPage() {
</div> </div>
) : tickets.length === 0 ? ( ) : tickets.length === 0 ? (
<div className="card p-12 text-center"> <div className="card p-12 text-center">
<span className="text-4xl"></span> <CheckCircle className="w-10 h-10 mx-auto text-gray-300" />
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets</h3> <h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets</h3>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{statusFilter ? `No ${statusFilter} tickets` : 'No tickets in this department'} {statusFilter ? `No ${statusFilter} tickets` : 'No tickets in this department'}
@@ -122,8 +124,8 @@ export default function StaffTicketsPage() {
</div> </div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500"> <div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
{ticket.user && ( {ticket.user && (
<span> <span className="flex items-center gap-1">
👤 {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email}) <User className="w-3 h-3" /> {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email})
</span> </span>
)} )}
<span></span> <span></span>
@@ -5,8 +5,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import api from '@/lib/api'; import api from '@/lib/api';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import toast from 'react-hot-toast'; import { toast } from 'react-toastify';
import type { Ticket } from '@/types'; import type { Ticket } from '@/types';
import { Wrench, Briefcase } from 'lucide-react';
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700', open: 'bg-yellow-100 text-yellow-700',
@@ -83,8 +84,8 @@ export default function TicketDetailPage() {
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
{ticket.status} {ticket.status}
</span> </span>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500 flex items-center gap-1">
{ticket.department === 'technical' ? '🔧 Technical' : '💼 Sales'} {ticket.department === 'technical' ? <><Wrench className="w-3 h-3" /> Technical</> : <><Briefcase className="w-3 h-3" /> Sales</>}
</span> </span>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{new Date(ticket.createdAt).toLocaleString()} {new Date(ticket.createdAt).toLocaleString()}
+10 -9
View File
@@ -5,8 +5,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link'; import Link from 'next/link';
import api from '@/lib/api'; import api from '@/lib/api';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import toast from 'react-hot-toast'; import { toast } from 'react-toastify';
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types'; import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
import { Wrench, Briefcase, Ticket as TicketIcon, X } from 'lucide-react';
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700', open: 'bg-yellow-100 text-yellow-700',
@@ -29,10 +30,10 @@ export default function TicketsPage() {
// Staff can only create tickets to other departments // Staff can only create tickets to other departments
const availableDepartments: { value: TicketDepartment; label: string }[] = []; const availableDepartments: { value: TicketDepartment; label: string }[] = [];
if (user?.role !== 'technical') { if (user?.role !== 'technical') {
availableDepartments.push({ value: 'technical', label: '🔧 Technical Support' }); availableDepartments.push({ value: 'technical', label: 'Technical Support' });
} }
if (user?.role !== 'sales') { if (user?.role !== 'sales') {
availableDepartments.push({ value: 'sales', label: '💼 Sales' }); availableDepartments.push({ value: 'sales', label: 'Sales' });
} }
const canCreateTicket = availableDepartments.length > 0; const canCreateTicket = availableDepartments.length > 0;
const [form, setForm] = useState<CreateTicketDto>({ const [form, setForm] = useState<CreateTicketDto>({
@@ -72,7 +73,7 @@ export default function TicketsPage() {
</div> </div>
{canCreateTicket && ( {canCreateTicket && (
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary"> <button onClick={() => setShowCreate(!showCreate)} className="btn-primary">
{showCreate ? '✕ Cancel' : '+ New Ticket'} {showCreate ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ New Ticket'}
</button> </button>
)} )}
</div> </div>
@@ -115,9 +116,9 @@ export default function TicketsPage() {
value={form.priority} value={form.priority}
onChange={(e) => setForm({ ...form, priority: e.target.value as TicketPriority })} onChange={(e) => setForm({ ...form, priority: e.target.value as TicketPriority })}
> >
<option value="low">🟢 Low</option> <option value="low">Low</option>
<option value="medium">🟡 Medium</option> <option value="medium">Medium</option>
<option value="high">🔴 High</option> <option value="high">High</option>
</select> </select>
</div> </div>
</div> </div>
@@ -149,7 +150,7 @@ export default function TicketsPage() {
</div> </div>
) : tickets.length === 0 ? ( ) : tickets.length === 0 ? (
<div className="card p-12 text-center"> <div className="card p-12 text-center">
<span className="text-4xl">🎫</span> <TicketIcon className="w-10 h-10 mx-auto text-gray-300" />
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets yet</h3> <h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets yet</h3>
<p className="text-sm text-gray-500 mt-1">Create a ticket if you need help</p> <p className="text-sm text-gray-500 mt-1">Create a ticket if you need help</p>
</div> </div>
@@ -173,7 +174,7 @@ export default function TicketsPage() {
</span> </span>
</div> </div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500"> <div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
<span>{ticket.department === 'technical' ? '🔧 Technical' : '💼 Sales'}</span> <span className="flex items-center gap-1">{ticket.department === 'technical' ? <><Wrench className="w-3 h-3" /> Technical</> : <><Briefcase className="w-3 h-3" /> Sales</>}</span>
<span></span> <span></span>
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span> <span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
<span></span> <span></span>
+3 -2
View File
@@ -4,7 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import toast from 'react-hot-toast'; import { toast } from 'react-toastify';
import { Cloud } from 'lucide-react';
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -32,7 +33,7 @@ export default function LoginPage() {
<div className="max-w-md w-full space-y-8 animate-slide-up"> <div className="max-w-md w-full space-y-8 animate-slide-up">
<div className="text-center"> <div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-100 mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-100 mb-4">
<span className="text-3xl"></span> <Cloud className="w-8 h-8 text-primary-600" />
</div> </div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">Welcome back</h1> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">Welcome back</h1>
<p className="mt-2 text-gray-500"> <p className="mt-2 text-gray-500">
+3 -2
View File
@@ -4,7 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import toast from 'react-hot-toast'; import { toast } from 'react-toastify';
import { Cloud } from 'lucide-react';
export default function RegisterPage() { export default function RegisterPage() {
const [form, setForm] = useState({ email: '', password: '', firstName: '', lastName: '' }); const [form, setForm] = useState({ email: '', password: '', firstName: '', lastName: '' });
@@ -31,7 +32,7 @@ export default function RegisterPage() {
<div className="max-w-md w-full space-y-8 animate-slide-up"> <div className="max-w-md w-full space-y-8 animate-slide-up">
<div className="text-center"> <div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-100 mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-100 mb-4">
<span className="text-3xl"></span> <Cloud className="w-8 h-8 text-primary-600" />
</div> </div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">Create your account</h1> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">Create your account</h1>
<p className="mt-2 text-gray-500"> <p className="mt-2 text-gray-500">
+3 -2
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast'; import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
@@ -28,7 +29,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}
<Toaster position="top-right" /> <ToastContainer position="top-right" autoClose={3000} hideProgressBar={false} closeOnClick pauseOnHover theme="light" />
</QueryClientProvider> </QueryClientProvider>
); );
} }