feat: restyle dashboard — card grid (desktop), row list (mobile), status badges

This commit is contained in:
2026-03-21 21:12:50 +01:00
parent a4ff0a9339
commit 025c021896
5 changed files with 210 additions and 129 deletions

View File

@@ -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>
); );
} }

View File

@@ -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>
</>
); );
} }

View File

@@ -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} />
</> </>

View 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>
);
}

View File

@@ -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>