feat: restyle dashboard — card grid (desktop), row list (mobile), status badges
This commit is contained in:
@@ -1,53 +1,16 @@
|
|||||||
import { requireAuth } from '@/lib/auth/server';
|
import { requireAuth } from '@/lib/auth/server';
|
||||||
import { getServers } from '@/lib/api/server/servers';
|
import { getServers } from '@/lib/api/server/servers';
|
||||||
import { hasPermission } from '@/lib/schemas';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { ServerListWithActions } from '@/components/server/ServerListWithActions';
|
import { ServerListWithActions } from '@/components/server/ServerListWithActions';
|
||||||
import { SteamCMDNotification } from '@/components/ui/SteamCMDNotification';
|
import { SteamCMDNotification } from '@/components/ui/SteamCMDNotification';
|
||||||
import LogoutButton from '@/components/ui/LogoutButton';
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
const servers = await getServers(session.token!);
|
const servers = await getServers(session.token!);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white">
|
<main>
|
||||||
<header className="bg-gray-800 shadow-md">
|
|
||||||
<div className="mx-auto flex max-w-[120rem] items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
|
|
||||||
<h1 className="text-2xl font-bold">ACC Server Manager</h1>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
{hasPermission(session.user!, 'membership.view') && (
|
|
||||||
<Link
|
|
||||||
href="/dashboard/membership"
|
|
||||||
className="flex items-center text-gray-300 hover:text-white"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M15 21a6 6 0 00-9-5.197m0 0A5.975 5.975 0 0112 13a5.975 5.975 0 01-3-1.197"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="ml-1 hidden sm:inline">Users</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<LogoutButton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<SteamCMDNotification />
|
<SteamCMDNotification />
|
||||||
|
|
||||||
<main className="mx-auto max-w-[120rem] px-4 py-8 sm:px-6 lg:px-8">
|
|
||||||
<ServerListWithActions servers={servers} user={session.user!} />
|
<ServerListWithActions servers={servers} user={session.user!} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
import { Server, ServiceStatus, getStatusColor, serviceStatusToString } from '@/lib/schemas';
|
import { Server, ServiceStatus, serviceStatusToString } from '@/lib/schemas';
|
||||||
import {
|
import {
|
||||||
startServerEventAction,
|
startServerEventAction,
|
||||||
restartServerEventAction,
|
restartServerEventAction,
|
||||||
stopServerEventAction
|
stopServerEventAction
|
||||||
} from '@/lib/actions/servers';
|
} from '@/lib/actions/servers';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { StatusDot } from '@/components/ui/StatusDot';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { GhButton } from '@/components/ui/GhButton';
|
||||||
|
|
||||||
interface ServerCardProps {
|
interface ServerCardProps {
|
||||||
server: Server;
|
server: Server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusBadgeVariant(status: ServiceStatus): 'green' | 'yellow' | 'gray' {
|
||||||
|
if (status === ServiceStatus.Running) return 'green';
|
||||||
|
if (
|
||||||
|
[ServiceStatus.Starting, ServiceStatus.Restarting, ServiceStatus.Stopping].includes(status)
|
||||||
|
)
|
||||||
|
return 'yellow';
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
export function ServerCard({ server }: ServerCardProps) {
|
export function ServerCard({ server }: ServerCardProps) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -36,82 +50,68 @@ export function ServerCard({ server }: ServerCardProps) {
|
|||||||
ServiceStatus.Restarting,
|
ServiceStatus.Restarting,
|
||||||
ServiceStatus.Starting,
|
ServiceStatus.Starting,
|
||||||
ServiceStatus.Stopping,
|
ServiceStatus.Stopping,
|
||||||
ServiceStatus.Unknown
|
ServiceStatus.Unknown,
|
||||||
].includes(server.status);
|
].includes(server.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="overflow-hidden rounded-lg border border-gh-border bg-gh-canvas transition-colors hover:border-gh-blue/40">
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-lg">
|
<Link href={`/dashboard/server/${server.id}`} className="block p-4">
|
||||||
<Link href={`/dashboard/server/${server.id}`} className="block">
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
|
<span className="text-sm font-semibold text-gh-primary">{server.name}</span>
|
||||||
|
<StatusDot status={server.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gh-muted">
|
||||||
|
{server.state?.track || 'No track'} · {server.state?.playerCount ?? 0} players
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-white">{server.name}</h3>
|
<div className="text-xs text-gh-subtle">Players</div>
|
||||||
<div className="mt-2 flex items-center">
|
<div className="text-sm font-semibold text-gh-primary">
|
||||||
<span
|
{server.state?.playerCount ?? 0}
|
||||||
className={`inline-block h-2 w-2 rounded-full ${getStatusColor(server.status)} mr-2`}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-300 capitalize">
|
|
||||||
{serviceStatusToString(server.status)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div>
|
||||||
<div className="text-gray-400 hover:text-white">
|
<div className="text-xs text-gh-subtle">Track</div>
|
||||||
<svg
|
<div className="truncate text-sm font-semibold text-gh-primary">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
{server.state?.track || 'N/A'}
|
||||||
className="h-5 w-5"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm text-gray-300">
|
<div className="mt-3">
|
||||||
<div>
|
<Badge variant={statusBadgeVariant(server.status)}>
|
||||||
<span className="text-gray-500">Track:</span>
|
{serviceStatusToString(server.status)}
|
||||||
<span className="ml-2">{server.state?.track || 'N/A'}</span>
|
</Badge>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Players:</span>
|
|
||||||
<span className="ml-2">{server.state?.playerCount || 0}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex justify-between gap-2 bg-gray-900 px-4 py-3">
|
<div className="flex gap-1.5 border-t border-gh-border-muted bg-gh-base px-4 py-2">
|
||||||
<button
|
<GhButton
|
||||||
type="button"
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={startServer}
|
onClick={startServer}
|
||||||
disabled={server.status === ServiceStatus.Running || isPending || disabled}
|
disabled={server.status === ServiceStatus.Running || isPending || disabled}
|
||||||
className="rounded bg-green-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
Start
|
Start
|
||||||
</button>
|
</GhButton>
|
||||||
|
<GhButton
|
||||||
<button
|
variant="ghost"
|
||||||
type="button"
|
size="sm"
|
||||||
onClick={restartServer}
|
onClick={restartServer}
|
||||||
disabled={server.status === ServiceStatus.Stopped || isPending || disabled}
|
disabled={server.status === ServiceStatus.Stopped || isPending || disabled}
|
||||||
className="rounded bg-yellow-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
Restart
|
Restart
|
||||||
</button>
|
</GhButton>
|
||||||
|
<GhButton
|
||||||
<button
|
variant="danger-outline"
|
||||||
type="button"
|
size="sm"
|
||||||
onClick={stopServer}
|
onClick={stopServer}
|
||||||
disabled={server.status === ServiceStatus.Stopped || isPending || disabled}
|
disabled={server.status === ServiceStatus.Stopped || isPending || disabled}
|
||||||
className="rounded bg-red-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
Stop
|
Stop
|
||||||
</button>
|
</GhButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Server } from '@/lib/schemas/server';
|
import { Server, ServiceStatus } from '@/lib/schemas/server';
|
||||||
import { User, hasPermission } from '@/lib/schemas/user';
|
import { User, hasPermission } from '@/lib/schemas/user';
|
||||||
import { ServerCard } from './ServerCard';
|
import { ServerCard } from './ServerCard';
|
||||||
|
import { ServerRow } from './ServerRow';
|
||||||
import { CreateServerModal } from './CreateServerModal';
|
import { CreateServerModal } from './CreateServerModal';
|
||||||
import RefreshButton from '@/components/ui/RefreshButton';
|
import RefreshButton from '@/components/ui/RefreshButton';
|
||||||
import { useSteamCMD } from '@/lib/context/SteamCMDContext';
|
import { useSteamCMD } from '@/lib/context/SteamCMDContext';
|
||||||
|
import { GhButton } from '@/components/ui/GhButton';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
|
||||||
interface ServerListWithActionsProps {
|
interface ServerListWithActionsProps {
|
||||||
servers: Server[];
|
servers: Server[];
|
||||||
@@ -20,30 +23,59 @@ export function ServerListWithActions({ servers, user }: ServerListWithActionsPr
|
|||||||
const handleOnClose = useCallback(() => setIsCreateModalOpen(false), []);
|
const handleOnClose = useCallback(() => setIsCreateModalOpen(false), []);
|
||||||
const canCreateServer = hasPermission(user, 'server.create');
|
const canCreateServer = hasPermission(user, 'server.create');
|
||||||
|
|
||||||
|
const runningCount = servers.filter((s) => s.status === ServiceStatus.Running).length;
|
||||||
|
const offlineCount = servers.filter((s) => s.status === ServiceStatus.Stopped).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
{/* Topbar */}
|
||||||
<h2 className="text-xl font-semibold">Your Servers</h2>
|
<header className="flex h-12 items-center justify-between border-b border-gh-border-muted px-5">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-semibold text-gh-primary">Servers</span>
|
||||||
|
<span className="text-xs text-gh-muted">{servers.length} total</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RefreshButton />
|
||||||
{canCreateServer && (
|
{canCreateServer && (
|
||||||
<button
|
<GhButton
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
disabled={isSteamCMDRunning}
|
disabled={isSteamCMDRunning}
|
||||||
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
title={isSteamCMDRunning ? 'Disabled while SteamCMD is running' : undefined}
|
||||||
title={isSteamCMDRunning ? 'Server creation disabled while SteamCMD is running' : ''}
|
|
||||||
>
|
>
|
||||||
Create Server
|
+ New Server
|
||||||
</button>
|
</GhButton>
|
||||||
)}
|
)}
|
||||||
<RefreshButton />
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Status summary bar */}
|
||||||
|
<div className="flex items-center px-5 py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{runningCount > 0 && <Badge variant="green">● {runningCount} running</Badge>}
|
||||||
|
{offlineCount > 0 && <Badge variant="gray">{offlineCount} offline</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
{/* Responsive server list */}
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
{/* Card grid — desktop only */}
|
||||||
|
<div
|
||||||
|
className="hidden gap-3 md:grid"
|
||||||
|
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))' }}
|
||||||
|
>
|
||||||
{servers.map((server) => (
|
{servers.map((server) => (
|
||||||
<ServerCard key={server.id} server={server} />
|
<ServerCard key={server.id} server={server} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Row list — mobile only */}
|
||||||
|
<div className="flex flex-col gap-2 md:hidden">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<ServerRow key={server.id} server={server} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CreateServerModal isOpen={isCreateModalOpen} onClose={handleOnClose} />
|
<CreateServerModal isOpen={isCreateModalOpen} onClose={handleOnClose} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
86
src/components/server/ServerRow.tsx
Normal file
86
src/components/server/ServerRow.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Server, ServiceStatus } from '@/lib/schemas';
|
||||||
|
import {
|
||||||
|
startServerEventAction,
|
||||||
|
restartServerEventAction,
|
||||||
|
stopServerEventAction
|
||||||
|
} from '@/lib/actions/servers';
|
||||||
|
import { StatusDot } from '@/components/ui/StatusDot';
|
||||||
|
import { GhButton } from '@/components/ui/GhButton';
|
||||||
|
|
||||||
|
interface ServerRowProps {
|
||||||
|
server: Server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerRow({ server }: ServerRowProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const startServer = () =>
|
||||||
|
startTransition(async () => {
|
||||||
|
await startServerEventAction(server.id);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
const restartServer = () =>
|
||||||
|
startTransition(async () => {
|
||||||
|
await restartServerEventAction(server.id);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
const stopServer = () =>
|
||||||
|
startTransition(async () => {
|
||||||
|
await stopServerEventAction(server.id);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
const transitioning = [
|
||||||
|
ServiceStatus.Restarting,
|
||||||
|
ServiceStatus.Starting,
|
||||||
|
ServiceStatus.Stopping,
|
||||||
|
ServiceStatus.Unknown,
|
||||||
|
].includes(server.status);
|
||||||
|
const disabled = transitioning || isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-gh-border bg-gh-canvas px-3 py-2.5 transition-colors hover:border-gh-border/60">
|
||||||
|
<Link href={`/dashboard/server/${server.id}`} className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<StatusDot status={server.status} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-gh-primary">{server.name}</div>
|
||||||
|
<div className="truncate text-xs text-gh-muted">
|
||||||
|
{server.state?.track ?? 'No track'} · {server.state?.playerCount ?? 0} players
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="flex shrink-0 gap-1.5">
|
||||||
|
{server.status === ServiceStatus.Stopped ? (
|
||||||
|
<GhButton variant="primary" size="sm" onClick={startServer} disabled={disabled}>
|
||||||
|
Start
|
||||||
|
</GhButton>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GhButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={restartServer}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</GhButton>
|
||||||
|
<GhButton
|
||||||
|
variant="danger-outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={stopServer}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</GhButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,11 +12,11 @@ export function SteamCMDNotification() {
|
|||||||
const serverCount = runningSteamServers.size;
|
const serverCount = runningSteamServers.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-yellow-600 border-l-4 border-yellow-400 p-4">
|
<div className="border-l-4 border-gh-yellow bg-gh-yellow-bg px-4 py-3">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-yellow-50"
|
className="h-5 w-5 text-gh-yellow"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
@@ -28,7 +28,7 @@ export function SteamCMDNotification() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<p className="text-sm text-yellow-50">
|
<p className="text-sm text-gh-yellow">
|
||||||
<strong>SteamCMD is currently running</strong> for {serverCount} server{serverCount !== 1 ? 's' : ''}.
|
<strong>SteamCMD is currently running</strong> for {serverCount} server{serverCount !== 1 ? 's' : ''}.
|
||||||
Server actions are temporarily disabled to prevent conflicts.
|
Server actions are temporarily disabled to prevent conflicts.
|
||||||
This will automatically resolve when the download completes.
|
This will automatically resolve when the download completes.
|
||||||
@@ -36,8 +36,8 @@ export function SteamCMDNotification() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex-shrink-0">
|
<div className="ml-auto flex-shrink-0">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="animate-spin h-4 w-4 border-2 border-yellow-50 border-t-transparent rounded-full mr-2"></div>
|
<div className="animate-spin h-4 w-4 border-2 border-gh-yellow border-t-transparent rounded-full mr-2"></div>
|
||||||
<span className="text-xs text-yellow-50">Downloading...</span>
|
<span className="text-xs text-gh-yellow">Downloading...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user