diff --git a/app/admin/allocations/page.tsx b/app/admin/allocations/page.tsx deleted file mode 100644 index 32c65ce..0000000 --- a/app/admin/allocations/page.tsx +++ /dev/null @@ -1,345 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { useTranslations } from "next-intl" -import { - Network, - Search, - RefreshCw, - ChevronLeft, - ChevronRight, - CheckCircle, - XCircle, - Server, - HardDrive, - Filter, -} from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle } from "@/packages/ui/components/ui/card" -import { Button } from "@/packages/ui/components/ui/button" -import { Badge } from "@/packages/ui/components/ui/badge" -import { Input } from "@/packages/ui/components/ui/input" -import { Skeleton } from "@/packages/ui/components/ui/skeleton" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/packages/ui/components/ui/table" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/packages/ui/components/ui/select" -import { cn } from "@/packages/core/lib/utils" -import { useAdminAllocations, useAdminNodes } from "@/packages/core" -import { useAuth } from "@/packages/auth" - -interface AllocationRow { - id: number - ip: string - port: number - alias: string | null - isAssigned: boolean - nodeId: number - nodeName: string - nodeFqdn: string - serverId: string | null - serverPterodactylId: number | null - serverName: string | null -} - -type AssignedFilter = "all" | "yes" | "no" - -export default function AllocationsPage() { - const t = useTranslations("admin") - const { user } = useAuth() - - const [searchQuery, setSearchQuery] = useState("") - const [debouncedSearch, setDebouncedSearch] = useState("") - const [currentPage, setCurrentPage] = useState(1) - const [perPage, setPerPage] = useState(25) - const [assignedFilter, setAssignedFilter] = useState("all") - const [nodeFilter, setNodeFilter] = useState("all") - - // Debounce search - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearch(searchQuery) - setCurrentPage(1) - }, 300) - return () => clearTimeout(timer) - }, [searchQuery]) - - const { data: allocResponse, isLoading, refetch } = useAdminAllocations( - { - page: currentPage, - perPage, - assigned: assignedFilter !== "all" ? assignedFilter : undefined, - nodeId: nodeFilter !== "all" ? nodeFilter : undefined, - search: debouncedSearch || undefined, - }, - { enabled: !!user } - ) - - const { data: nodesResponse } = useAdminNodes({ perPage: 200 }, { enabled: !!user }) - - const allocData = allocResponse as any - const allocations: AllocationRow[] = allocData?.allocations || [] - const meta = allocData?.pagination || null - - const nodesData = nodesResponse as any - const nodes: { id: number; name: string }[] = nodesData?.nodes || [] - - const stats = { - total: meta?.total || 0, - assigned: allocations.filter((a) => a.isAssigned).length, - unassigned: allocations.filter((a) => !a.isAssigned).length, - } - - return ( -
- {/* Header */} -
-
-

- - Allocations -

-

- View and manage all port allocations across nodes -

-
- -
- - {/* Stats Cards */} -
- - - Total Allocations - - - - {isLoading ? :
{stats.total}
} -
-
- - - Assigned - - - - {isLoading ? :
{stats.assigned}
} -
-
- - - Unassigned - - - - {isLoading ? :
{stats.unassigned}
} -
-
-
- - {/* Filters */} - - - Filters - - -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
-
- {/* Node filter */} - - - {/* Assigned filter */} - - - {/* Per-page */} - -
-
-
-
- - {/* Table */} - - -
- - - - IP : Port - Alias - Node - Status - Server - - - - {isLoading ? ( - Array.from({ length: 5 }).map((_, i) => ( - - - - - - - - )) - ) : allocations.length === 0 ? ( - - -
- -

No allocations found

- {debouncedSearch &&

Try a different search term

} -
-
-
- ) : ( - allocations.map((alloc) => ( - - -
-
- -
-
- - {alloc.ip}:{alloc.port} - -
-
-
- - {alloc.alias ? ( - {alloc.alias} - ) : ( - - )} - - -
- {alloc.nodeName} -
{alloc.nodeFqdn}
-
-
- - {alloc.isAssigned ? ( - - - Assigned - - ) : ( - - - Unassigned - - )} - - - {alloc.serverName ? ( -
- -
-
{alloc.serverName}
- {alloc.serverPterodactylId && ( -
#{alloc.serverPterodactylId}
- )} -
-
- ) : ( - - )} -
-
- )) - )} -
-
-
- - {/* Pagination */} - {meta && meta.totalPages > 1 && ( -
-

- Showing {(currentPage - 1) * perPage + 1}–{Math.min(currentPage * perPage, meta.total)} of {meta.total} -

-
- -
- {currentPage} - / - {meta.totalPages} -
- -
-
- )} -
-
-
- ) -} diff --git a/app/admin/eggs/page.tsx b/app/admin/eggs/page.tsx deleted file mode 100644 index 910e294..0000000 --- a/app/admin/eggs/page.tsx +++ /dev/null @@ -1,464 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { useTranslations } from "next-intl" -import { - Layers, - Search, - RefreshCw, - Server, - ChevronLeft, - ChevronRight, - Filter, - Package, -} from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle } from "@/packages/ui/components/ui/card" -import { Button } from "@/packages/ui/components/ui/button" -import { Badge } from "@/packages/ui/components/ui/badge" -import { Input } from "@/packages/ui/components/ui/input" -import { Skeleton } from "@/packages/ui/components/ui/skeleton" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/packages/ui/components/ui/table" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/packages/ui/components/ui/select" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/packages/ui/components/ui/tabs" -import { cn } from "@/packages/core/lib/utils" -import { useAdminNests, useAdminEggs } from "@/packages/core" -import { useAuth } from "@/packages/auth" - -interface NestData { - id: number - uuid: string - name: string - description: string - author: string - eggCount: number - serverCount: number - createdAt: string - updatedAt: string -} - -interface EggData { - id: number - uuid: string - name: string - description: string - author: string - nestId: number - nestName: string - serverCount: number - createdAt: string - updatedAt: string -} - -function NestsTab() { - const t = useTranslations("admin") - const { user } = useAuth() - const [searchQuery, setSearchQuery] = useState("") - const [debouncedSearch, setDebouncedSearch] = useState("") - const [currentPage, setCurrentPage] = useState(1) - const [perPage, setPerPage] = useState(25) - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearch(searchQuery) - setCurrentPage(1) - }, 300) - return () => clearTimeout(timer) - }, [searchQuery]) - - const { data: response, isLoading, refetch } = useAdminNests({ - page: currentPage, - perPage, - search: debouncedSearch || undefined, - }, { enabled: !!user }) - - const nestsData = response as any - const nests: NestData[] = nestsData?.nests || [] - const meta = nestsData?.pagination || null - - const formatDate = (dateString: string) => - new Date(dateString).toLocaleDateString(undefined, { - year: "numeric", month: "short", day: "numeric", - }) - - return ( -
- {/* Filters */} - - -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
-
- - -
-
-
-
- - - -
- - - - Nest - Author - Eggs - Servers - Created - - - - {isLoading ? ( - Array.from({ length: 5 }).map((_, i) => ( - - - - - - - - )) - ) : nests.length === 0 ? ( - - -
- -

No nests found

-
-
-
- ) : ( - nests.map((nest) => ( - - -
-
- -
-
-
{nest.name}
- {nest.description && ( -
{nest.description}
- )} -
-
-
- - {nest.author} - - - {nest.eggCount} - - -
- - {nest.serverCount} -
-
- - {formatDate(nest.createdAt)} - -
- )) - )} -
-
-
- {meta && meta.totalPages > 1 && ( -
-

- Showing {(currentPage - 1) * perPage + 1}–{Math.min(currentPage * perPage, meta.total)} of {meta.total} nests -

-
- -
- {currentPage} - / - {meta.totalPages} -
- -
-
- )} -
-
-
- ) -} - -function EggsTab({ nests }: { nests: NestData[] }) { - const t = useTranslations("admin") - const { user } = useAuth() - const [searchQuery, setSearchQuery] = useState("") - const [debouncedSearch, setDebouncedSearch] = useState("") - const [currentPage, setCurrentPage] = useState(1) - const [perPage, setPerPage] = useState(25) - const [nestFilter, setNestFilter] = useState("all") - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearch(searchQuery) - setCurrentPage(1) - }, 300) - return () => clearTimeout(timer) - }, [searchQuery]) - - const { data: response, isLoading, refetch } = useAdminEggs({ - page: currentPage, - perPage, - search: debouncedSearch || undefined, - nestId: nestFilter !== "all" ? parseInt(nestFilter) : undefined, - }, { enabled: !!user }) - - const eggsData = response as any - const eggs: EggData[] = eggsData?.eggs || [] - const meta = eggsData?.pagination || null - - const formatDate = (dateString: string) => - new Date(dateString).toLocaleDateString(undefined, { - year: "numeric", month: "short", day: "numeric", - }) - - return ( -
- {/* Filters */} - - -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
-
- - - -
-
-
-
- - - -
- - - - Egg - Nest - Author - Servers - Created - - - - {isLoading ? ( - Array.from({ length: 5 }).map((_, i) => ( - - - - - - - - )) - ) : eggs.length === 0 ? ( - - -
- -

No eggs found

- {debouncedSearch &&

Try a different search term

} -
-
-
- ) : ( - eggs.map((egg) => ( - - -
-
- -
-
-
{egg.name}
- {egg.description && ( -
{egg.description}
- )} -
-
-
- - {egg.nestName} - - - {egg.author} - - -
- - {egg.serverCount} -
-
- - {formatDate(egg.createdAt)} - -
- )) - )} -
-
-
- {meta && meta.totalPages > 1 && ( -
-

- Showing {(currentPage - 1) * perPage + 1}–{Math.min(currentPage * perPage, meta.total)} of {meta.total} eggs -

-
- -
- {currentPage} - / - {meta.totalPages} -
- -
-
- )} -
-
-
- ) -} - -export default function EggsPage() { - const { user } = useAuth() - const { data: nestsResponse } = useAdminNests({ perPage: 100 }, { enabled: !!user }) - const nestsData = nestsResponse as any - const nests: NestData[] = nestsData?.nests || [] - - return ( -
- {/* Header */} -
-
-

- - Eggs & Nests -

-

Browse server configuration templates (eggs) and their containers (nests)

-
-
- - {/* Stats */} -
- - - Total Nests - - - -
{nests.length}
-
-
- - - Total Eggs - - - -
{nests.reduce((sum, n) => sum + n.eggCount, 0)}
-
-
-
- - {/* Tabs */} - - - - - Eggs - - - - Nests - - - - - - - - - -
- ) -} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx deleted file mode 100644 index bfdb2b0..0000000 --- a/app/admin/layout.tsx +++ /dev/null @@ -1,363 +0,0 @@ -"use client" - -import { useAuth } from "@/packages/auth" -import { useRouter, usePathname } from "next/navigation" -import { useEffect, useState } from "react" -import { useTranslations } from "next-intl" -import Link from "next/link" -import { - LayoutDashboard, - Users, - Server, - RefreshCw, - Settings, - ChevronLeft, - ChevronRight, - Loader2, - Menu, - Home, - LogOut, - HardDrive, - Layers, - Network, - MapPin, - Package, -} from "lucide-react" -import { cn } from "@/packages/core/lib/utils" -import { Button } from "@/packages/ui/components/ui/button" -import { ScrollArea } from "@/packages/ui/components/ui/scroll-area" -import { Separator } from "@/packages/ui/components/ui/separator" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/packages/ui/components/ui/tooltip" -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/packages/ui/components/ui/sheet" -import { LanguageSelector } from "@/packages/ui/components/ui/language-selector" -import { UserMenu } from "@/packages/auth/components/user-menu" -import { ThemeToggle } from "@/packages/ui/components/theme-toggle" -import { Logo } from "@/packages/ui/components/logo" -import { canAccessAdmin } from "@/packages/auth" - -interface NavItem { - title: string - href: string - icon: React.ComponentType<{ className?: string }> -} - -export default function AdminLayout({ - children, -}: { - children: React.ReactNode -}) { - const { user, isLoading } = useAuth() - const router = useRouter() - const pathname = usePathname() - const t = useTranslations("admin") - const tAuth = useTranslations("auth") - const [collapsed, setCollapsed] = useState(false) - const [mobileOpen, setMobileOpen] = useState(false) - - // Close mobile menu on route change - useEffect(() => { - setMobileOpen(false) - }, [pathname]) - - // Redirect to login if not authenticated - useEffect(() => { - if (!isLoading && !user) { - router.push(`/auth/login?callbackUrl=/admin`) - } - }, [user, isLoading, router]) - - // Redirect to home if not a system admin - useEffect(() => { - if (!isLoading && user && !canAccessAdmin(user)) { - router.push("/") - } - }, [user, isLoading, router]) - - if (isLoading) { - return ( -
- -
- ) - } - - // Redirect if not authenticated - if (!user) { - return ( -
-
-

Please log in to access admin panel

- -
-
- ) - } - - // Redirect if not a system admin - if (!canAccessAdmin(user)) { - return ( -
-
-

Unauthorized access

- -
-
- ) - } - - const navItems: NavItem[] = [ - { title: t("nav.dashboard"), href: "/admin", icon: LayoutDashboard }, - { title: t("nav.users"), href: "/admin/users", icon: Users }, - { title: t("nav.servers"), href: "/admin/servers", icon: Server }, - { title: t("nav.products"), href: "/admin/products", icon: Package }, - { title: t("nav.nodes"), href: "/admin/nodes", icon: HardDrive }, - { title: t("nav.locations"), href: "/admin/locations", icon: MapPin }, - { title: t("nav.allocations"), href: "/admin/allocations", icon: Network }, - { title: t("nav.eggs"), href: "/admin/eggs", icon: Layers }, - { title: t("nav.sync"), href: "/admin/sync", icon: RefreshCw }, - { title: t("nav.syncLogs"), href: "/admin/sync/logs", icon: RefreshCw }, - { title: t("nav.settings"), href: "/admin/settings", icon: Settings }, - ] - - const isActive = (href: string) => { - if (href === "/admin") return pathname === "/admin" - return pathname.startsWith(href) - } - - const NavContent = ({ mobile = false }: { mobile?: boolean }) => ( - - ) - - return ( -
- {/* Mobile Header */} -
-
- - - - - - - - - {t("title")} - - - - - - {/* Back to Site */} - - setMobileOpen(false)}> - - - -
-
-
-
- {t("status.online")} -
-
- - -
-
-
- - - - {t("title")} -
-
- -
-
- - {/* Desktop Sidebar */} - - - {/* Main Content */} -
-
- {children} -
-
-
- ) -} diff --git a/app/admin/locations/page.tsx b/app/admin/locations/page.tsx deleted file mode 100644 index 7ec39e7..0000000 --- a/app/admin/locations/page.tsx +++ /dev/null @@ -1,164 +0,0 @@ -"use client" - -import { useAuth } from "@/packages/auth" -import { useAdminLocations, useTriggerSync, useSyncStatus } from "@/packages/core" -import { MapPin, RefreshCw, HardDrive, Hash } from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle } from "@/packages/ui/components/ui/card" -import { Button } from "@/packages/ui/components/ui/button" -import { Badge } from "@/packages/ui/components/ui/badge" -import { Skeleton } from "@/packages/ui/components/ui/skeleton" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/packages/ui/components/ui/table" -import { cn } from "@/packages/core/lib/utils" - -interface Location { - id: number - shortCode: string - description: string - nodeCount: number -} - -export default function LocationsPage() { - const { user } = useAuth() - - const { data: locResponse, isLoading, refetch } = useAdminLocations({ enabled: !!user }) - const triggerSync = useTriggerSync() - const { data: syncStatus } = useSyncStatus() - - const locData = locResponse as any - const locations: Location[] = locData?.locations || [] - - const syncRunning = (syncStatus as any)?.status === "RUNNING" - - const handleSync = () => { - triggerSync.mutate( - { type: "locations" }, - { onSuccess: () => setTimeout(() => refetch(), 2000) } - ) - } - - const totalNodes = locations.reduce((sum, l) => sum + l.nodeCount, 0) - - return ( -
- {/* Header */} -
-
-

- - Locations -

-

- Data centre locations synced from the Pterodactyl panel -

-
-
- - -
-
- - {/* Stats */} -
- - - Total Locations - - - - {isLoading ? :
{locations.length}
} -
-
- - - Total Nodes - - - - {isLoading ? :
{totalNodes}
} -
-
-
- - {/* Table */} - - -
- - - - ID - Short Code - Description - Nodes - - - - {isLoading ? ( - Array.from({ length: 3 }).map((_, i) => ( - - - - - - - )) - ) : locations.length === 0 ? ( - - -
- -

No locations found — run a sync to populate

-
-
-
- ) : ( - locations.map((loc) => ( - - -
- - {loc.id} -
-
- - - {loc.shortCode} - - - - {loc.description || No description} - - -
- - {loc.nodeCount} -
-
-
- )) - )} -
-
-
-
-
-
- ) -} diff --git a/app/admin/nodes/page.tsx b/app/admin/nodes/page.tsx deleted file mode 100644 index a951898..0000000 --- a/app/admin/nodes/page.tsx +++ /dev/null @@ -1,417 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { useTranslations } from "next-intl" -import { - HardDrive, - Search, - RefreshCw, - MapPin, - Server, - Network, - ChevronLeft, - ChevronRight, - Filter, - AlertTriangle, - CheckCircle, - Loader2, -} from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle } from "@/packages/ui/components/ui/card" -import { Button } from "@/packages/ui/components/ui/button" -import { Badge } from "@/packages/ui/components/ui/badge" -import { Input } from "@/packages/ui/components/ui/input" -import { Skeleton } from "@/packages/ui/components/ui/skeleton" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/packages/ui/components/ui/table" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/packages/ui/components/ui/select" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/packages/ui/components/ui/tooltip" -import { cn } from "@/packages/core/lib/utils" -import { useAdminNodes, useToggleNodeMaintenance } from "@/packages/core" -import { useAuth } from "@/packages/auth" - -interface NodeData { - id: number - uuid: string - name: string - description: string - fqdn: string - scheme: string - behindProxy: boolean - isPublic: boolean - isMaintenanceMode: boolean - memory: number - memoryOverallocate: number - disk: number - diskOverallocate: number - daemonListenPort: number - daemonSftpPort: number - locationId: number - locationCode: string - serverCount: number - allocationCount: number - createdAt: string - updatedAt: string -} - -type MaintenanceFilter = "all" | "yes" | "no" - -const formatBytes = (mb: number) => { - if (mb === 0) return "Unlimited" - if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB` - return `${mb} MB` -} - -export default function NodesPage() { - const t = useTranslations("admin") - const { user } = useAuth() - const [searchQuery, setSearchQuery] = useState("") - const [debouncedSearch, setDebouncedSearch] = useState("") - const [currentPage, setCurrentPage] = useState(1) - const [perPage, setPerPage] = useState(25) - const [maintenanceFilter, setMaintenanceFilter] = useState("all") - const [togglingId, setTogglingId] = useState(null) - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearch(searchQuery) - setCurrentPage(1) - }, 300) - return () => clearTimeout(timer) - }, [searchQuery]) - - const { data: response, isLoading, refetch } = useAdminNodes({ - page: currentPage, - perPage, - search: debouncedSearch || undefined, - maintenance: maintenanceFilter === "all" ? undefined : maintenanceFilter === "yes", - }, { enabled: !!user }) - - const toggleMaintenance = useToggleNodeMaintenance() - - const nodesData = response as any - const nodes: NodeData[] = nodesData?.nodes || [] - const meta = nodesData?.pagination || null - - const stats = { - total: meta?.total || 0, - maintenance: nodes.filter((n) => n.isMaintenanceMode).length, - totalServers: nodes.reduce((sum, n) => sum + n.serverCount, 0), - totalAllocations: nodes.reduce((sum, n) => sum + n.allocationCount, 0), - } - - const handleToggleMaintenance = async (node: NodeData) => { - setTogglingId(node.id) - try { - await toggleMaintenance.mutateAsync({ nodeId: node.id }) - } finally { - setTogglingId(null) - } - } - - return ( -
- {/* Header */} -
-
-

- - Nodes -

-

Manage Pterodactyl nodes and their allocations

-
- -
- - {/* Stats */} -
- - - Total Nodes - - - - {isLoading ? :
{stats.total}
} -
-
- - - Maintenance - - - - {isLoading ? :
{stats.maintenance}
} -
-
- - - Servers - - - - {isLoading ? :
{stats.totalServers}
} -
-
- - - Allocations - - - - {isLoading ? :
{stats.totalAllocations}
} -
-
-
- - {/* Filters */} - - - Filters - - -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
-
- - -
-
-
-
- - {/* Table */} - - -
- - - - Node - Status - Location - Resources - Servers - Ports - Actions - - - - {isLoading ? ( - Array.from({ length: 5 }).map((_, i) => ( - - - - - - - - - - )) - ) : nodes.length === 0 ? ( - - -
- -

No nodes found

- {debouncedSearch &&

Try a different search term

} -
-
-
- ) : ( - nodes.map((node) => ( - - -
-
- -
-
-
{node.name}
-
{node.fqdn}
-
-
-
- - -
- {node.isMaintenanceMode ? ( - - - Maintenance - - ) : ( - - - Online - - )} - {!node.isPublic && ( - - - Private - - This node is not visible to users - - )} -
-
-
- -
- - {node.locationCode || `Location #${node.locationId}`} -
-
- -
-
- RAM: - {formatBytes(node.memory)} - {node.memoryOverallocate > 0 && ( - +{node.memoryOverallocate}% - )} -
-
- Disk: - {formatBytes(node.disk)} - {node.diskOverallocate > 0 && ( - +{node.diskOverallocate}% - )} -
-
-
- -
-
- Servers: - {node.serverCount} -
-
- Allocs: - {node.allocationCount} -
-
-
- -
-
- Daemon: - {node.scheme}://{node.fqdn}:{node.daemonListenPort} -
-
- SFTP: - {node.fqdn}:{node.daemonSftpPort} -
-
-
- - - -
- )) - )} -
-
-
- - {/* Pagination */} - {meta && meta.totalPages > 1 && ( -
-

- Showing {(currentPage - 1) * perPage + 1}–{Math.min(currentPage * perPage, meta.total)} of {meta.total} nodes -

-
- -
- {currentPage} - / - {meta.totalPages} -
- -
-
- )} -
-
-
- ) -} diff --git a/app/admin/page.tsx b/app/admin/page.tsx deleted file mode 100644 index 6ef27cd..0000000 --- a/app/admin/page.tsx +++ /dev/null @@ -1,359 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { useTranslations } from "next-intl" -import { useApiQuery, useApiMutation } from "@/packages/core" -import { - Users, - Server, - HardDrive, - Database, - Layers, - Egg, - Variable, - MapPin, - Network, - RefreshCw, - AlertCircle, - CheckCircle2, - Loader2, -} from "lucide-react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/packages/ui/components/ui/card" -import { Button } from "@/packages/ui/components/ui/button" -import { Badge } from "@/packages/ui/components/ui/badge" -import { Skeleton } from "@/packages/ui/components/ui/skeleton" -import { Progress } from "@/packages/ui/components/ui/progress" -import { useToast } from "@/packages/core/hooks/use-toast" - -interface SyncStats { - success: boolean - status: { - lastSync: string | null - isSyncing: boolean - } | null - counts: { - users: number - migratedUsers: number - servers: number - nodes: number - locations: number - allocations: number - nests: number - eggs: number - eggVariables: number - serverDatabases: number - } - availableTargets: string[] -} - -interface StatCardProps { - title: string - value: number - icon: React.ComponentType<{ className?: string }> - description?: string - loading?: boolean -} - -function StatCard({ title, value, icon: Icon, description, loading }: StatCardProps) { - if (loading) { - return ( - - - - - - - - {description && } - - - ) - } - - return ( - - - {title} - - - -
{value.toLocaleString()}
- {description && ( -

{description}

- )} -
-
- ) -} - -export default function AdminDashboard() { - const t = useTranslations("admin") - const { toast } = useToast() - const [syncProgress, setSyncProgress] = useState(0) - const [currentSyncTarget, setCurrentSyncTarget] = useState(null) - - // Fetch sync stats using React Query - const { data: statsResponse, isLoading: loading, refetch } = useApiQuery("/api/admin/sync") - - const stats = statsResponse ?? null - const error = statsResponse?.error ?? null - - // Start sync mutation - const syncMutation = useApiMutation< - { success: boolean; error?: string }, - { target: string } - >("POST", "/api/admin/sync", { - onSuccess: (data) => { - setSyncProgress(100) - if (!data.success) { - toast({ - title: t("sync.error"), - description: data.error, - variant: "destructive", - }) - } else { - toast({ - title: t("sync.started"), - description: t("sync.running"), - }) - // Refetch stats after a delay - setTimeout(() => refetch(), 2000) - } - }, - onError: (error) => { - toast({ - title: t("sync.error"), - description: error.message, - variant: "destructive", - }) - }, - onSettled: () => { - setSyncProgress(0) - setCurrentSyncTarget(null) - }, - }) - - const runSync = (target: string = "all") => { - setSyncProgress(0) - setCurrentSyncTarget(target) - - // Simulate progress stages for better UX - const progressInterval = setInterval(() => { - setSyncProgress((prev) => { - if (prev >= 90) return prev - return prev + Math.random() * 15 - }) - }, 500) - - syncMutation.mutate({ target }) - } - - const isSyncing = syncMutation.isPending - - const formatLastSync = (value: any) => { - if (!value) return t("sync.never") - - // If it's an object with startedAt property, use that - let dateString = typeof value === "object" && value.startedAt ? value.startedAt : value - - // If dateString is still not a string, return never - if (typeof dateString !== "string") return t("sync.never") - - try { - const date = new Date(dateString) - if (isNaN(date.getTime())) return t("sync.never") - return new Intl.DateTimeFormat("en-US", { - dateStyle: "medium", - timeStyle: "short", - }).format(date) - } catch { - return t("sync.never") - } - } - - return ( -
- {/* Header */} -
-
-

{t("dashboard.title")}

-

{t("dashboard.description")}

-
-
- - -
-
- - {/* Sync Progress */} - {isSyncing && ( - - - - - {t("sync.inProgress")} - - - {t("sync.syncingTarget", { target: currentSyncTarget })} - - - - -

- {Math.round(syncProgress)}% {t("sync.complete")} -

-
-
- )} - - {/* Error State */} - {error && ( - - - - - {t("error.title")} - - - -

{error}

-
-
- )} - - {/* Sync Status */} - {stats && ( - - - - {stats.status?.isSyncing ? ( - - ) : ( - - )} - {t("sync.status")} - - - -
- - {stats.status?.isSyncing ? t("sync.syncing") : t("sync.idle")} - - - {t("sync.lastSync")}: {formatLastSync(stats.status?.lastSync || null)} - -
-
-
- )} - - {/* Stats Grid */} -
- - - - -
- -
- - - - - -
- - {/* Quick Sync Actions */} - - - {t("sync.quickActions")} - {t("sync.quickActionsDescription")} - - -
- {stats?.availableTargets - .filter((target) => target !== "all") - .map((target) => ( - - ))} -
-
-
-
- ) -} diff --git a/app/admin/products/page.tsx b/app/admin/products/page.tsx deleted file mode 100644 index fd389a1..0000000 --- a/app/admin/products/page.tsx +++ /dev/null @@ -1,719 +0,0 @@ -"use client" - -import { useState, useEffect, useCallback } from "react" -import { - Package, - Plus, - Pencil, - Trash2, - ToggleLeft, - ToggleRight, - Gamepad2, - Server, - ExternalLink, - Search, - RefreshCw, - AlertCircle, - ChevronDown, - DollarSign, - Tag, - Loader2, - CheckCircle2, - XCircle, -} from "lucide-react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/packages/ui/components/ui/card" -import { Button } from "@/packages/ui/components/ui/button" -import { Badge } from "@/packages/ui/components/ui/badge" -import { Input } from "@/packages/ui/components/ui/input" -import { Label } from "@/packages/ui/components/ui/label" -import { Switch } from "@/packages/ui/components/ui/switch" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/packages/ui/components/ui/tabs" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/packages/ui/components/ui/table" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/packages/ui/components/ui/dialog" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/packages/ui/components/ui/select" -import { Textarea } from "@/packages/ui/components/ui/textarea" -import { Separator } from "@/packages/ui/components/ui/separator" -import { Alert, AlertDescription } from "@/packages/ui/components/ui/alert" -import { cn } from "@/packages/core/lib/utils" -import { LINKS } from "@/packages/core/constants/links" -import { getAllProducts } from "@/packages/core/products" -import type { ProductEntry } from "@/packages/core/products" -import Link from "next/link" - -// ─── Types ──────────────────────────────────────────────────────────────────── - -type ServiceType = "game" | "vps" -type StockStatus = "in_stock" | "out_of_stock" | "coming_soon" - -interface Product { - id: string - name: string - slug: string - type: ServiceType - description: string - priceGBP: number - billingUrl: string - enabled: boolean - stock: StockStatus - /** VPS only */ - cpuCores?: number - ramGB?: number - storageGB?: number - bandwidthTB?: number | null - /** Game server only */ - game?: string -} - -// ─── Catalog adapter ───────────────────────────────────────────────────────── - -function capitalize(s: string) { - return s.charAt(0).toUpperCase() + s.slice(1) -} - -function catalogToAdminProducts(entries: ProductEntry[]): Product[] { - return entries.map((entry) => ({ - id: entry.id, - name: - entry.type === "game" - ? `${capitalize(entry.category)} ${capitalize(entry.planId)}` - : entry.planId, - slug: entry.id, - type: entry.type, - description: entry.description ?? "", - priceGBP: entry.priceGBP, - billingUrl: entry.billingUrl ?? "", - enabled: entry.stock !== "out_of_stock", - stock: entry.stock, - game: - entry.type === "game" - ? capitalize(entry.category) - : `${capitalize(entry.category)} VPS`, - cpuCores: entry.cpu, - ramGB: entry.ramGB, - storageGB: entry.storageGB, - bandwidthTB: - entry.bandwidth === undefined - ? undefined - : entry.bandwidth === null - ? null - : entry.bandwidth.amount, - })) -} - -/** Seed from the shared product catalog. Replace getAllProducts() with an API call when the backend is ready. */ -const INITIAL_PRODUCTS: Product[] = catalogToAdminProducts(getAllProducts()) - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -const STOCK_LABELS: Record = { - in_stock: "In Stock", - out_of_stock: "Out of Stock", - coming_soon: "Coming Soon", -} - -const STOCK_VARIANTS: Record = { - in_stock: "default", - out_of_stock: "destructive", - coming_soon: "secondary", -} - -const EMPTY_PRODUCT: Omit = { - name: "", - slug: "", - type: "vps", - description: "", - priceGBP: 0, - billingUrl: "", - enabled: true, - stock: "in_stock", -} - -// ─── Sub-components ─────────────────────────────────────────────────────────── - -function ProductRow({ - product, - onEdit, - onToggle, - onDelete, -}: { - product: Product - onEdit: (p: Product) => void - onToggle: (p: Product) => void - onDelete: (p: Product) => void -}) { - return ( - - -
- {product.type === "game" ? ( - - ) : ( - - )} - {product.name} -
-
- {product.description} - - - {product.game ?? (product.type === "vps" ? "VPS" : "—")} - - - £{product.priceGBP.toFixed(2)} - - {STOCK_LABELS[product.stock]} - - - onToggle(product)} - aria-label="Toggle product" - /> - - -
- - - -
-
-
- ) -} - -function ProductTable({ - products, - onEdit, - onToggle, - onDelete, - emptyLabel, -}: { - products: Product[] - onEdit: (p: Product) => void - onToggle: (p: Product) => void - onDelete: (p: Product) => void - emptyLabel: string -}) { - if (products.length === 0) { - return ( -
- -

{emptyLabel}

-
- ) - } - - return ( - - - - Name - Description - Game / Type - Price (GBP) - Stock - Enabled - Actions - - - - {products.map((p) => ( - - ))} - -
- ) -} - -// ─── Edit / Create Dialog ───────────────────────────────────────────────────── - -function ProductDialog({ - open, - product, - onClose, - onSave, -}: { - open: boolean - product: Partial | null - onClose: () => void - onSave: (p: Partial) => void -}) { - const [form, setForm] = useState>(product ?? EMPTY_PRODUCT) - const isNew = !product?.id - - const set = (k: K, v: Product[K]) => - setForm((prev) => ({ ...prev, [k]: v })) - - const handleSave = () => { - if (!form.name || !form.priceGBP) return - onSave(form) - } - - return ( - !o && onClose()}> - - - {isNew ? "Add Product" : "Edit Product"} - - {isNew ? "Create a new product listing." : `Editing: ${product?.name}`} - - - -
- {/* Type toggle */} -
- {(["game", "vps"] as const).map((t) => ( - - ))} -
- -
-
- - set("name", e.target.value)} - placeholder="AMD Starter" - /> -
-
- - set("slug", e.target.value)} - placeholder="amd-starter" - /> -
-
- -
- -