- {servers.map((server) => (
-
- ))}
+
+ {/* Card grid — desktop only */}
+
+ {servers.map((server) => (
+
+ ))}
+
+ {/* Row list — mobile only */}
+
+ {servers.map((server) => (
+
+ ))}
+
diff --git a/src/components/server/ServerRow.tsx b/src/components/server/ServerRow.tsx
new file mode 100644
index 0000000..5ecd8e5
--- /dev/null
+++ b/src/components/server/ServerRow.tsx
@@ -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 { Button } from '@/components/ui/Button';
+
+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 (
+
+
+
+
+
{server.name}
+
+ {server.state?.track ?? 'No track'} · {server.state?.playerCount ?? 0} players
+
+
+
+
+ {server.status === ServiceStatus.Stopped ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/statistics/DailyActivityChart.tsx b/src/components/statistics/DailyActivityChart.tsx
index f6af6af..b3f300e 100755
--- a/src/components/statistics/DailyActivityChart.tsx
+++ b/src/components/statistics/DailyActivityChart.tsx
@@ -29,8 +29,8 @@ export function DailyActivityChart({ data }: DailyActivityChartProps) {
{
label: 'Sessions',
data: data.map((item) => item.sessionsCount),
- backgroundColor: 'rgba(59, 130, 246, 0.8)',
- borderColor: 'rgb(59, 130, 246)',
+ backgroundColor: '#3fb950',
+ borderColor: '#3fb950',
borderWidth: 1
}
]
@@ -43,27 +43,27 @@ export function DailyActivityChart({ data }: DailyActivityChartProps) {
legend: {
position: 'top' as const,
labels: {
- color: '#e5e7eb'
+ color: '#e6edf3'
}
}
},
scales: {
x: {
ticks: {
- color: '#9ca3af'
+ color: '#8b949e'
},
grid: {
- color: '#374151'
+ color: '#21262d'
}
},
y: {
beginAtZero: true,
ticks: {
- color: '#9ca3af',
+ color: '#8b949e',
stepSize: 1
},
grid: {
- color: '#374151'
+ color: '#21262d'
}
}
}
diff --git a/src/components/statistics/PlayerCountChart.tsx b/src/components/statistics/PlayerCountChart.tsx
index 49a4b85..996fb73 100755
--- a/src/components/statistics/PlayerCountChart.tsx
+++ b/src/components/statistics/PlayerCountChart.tsx
@@ -43,8 +43,8 @@ export function PlayerCountChart({ data }: PlayerCountChartProps) {
x: new Date(point.timestamp),
y: point.count
})),
- borderColor: 'rgb(59, 130, 246)',
- backgroundColor: 'rgba(59, 130, 246, 0.1)',
+ borderColor: '#58a6ff',
+ backgroundColor: '#58a6ff1a',
tension: 0.4
}
]
@@ -57,7 +57,7 @@ export function PlayerCountChart({ data }: PlayerCountChartProps) {
legend: {
position: 'top' as const,
labels: {
- color: '#e5e7eb'
+ color: '#e6edf3'
}
}
},
@@ -68,20 +68,20 @@ export function PlayerCountChart({ data }: PlayerCountChartProps) {
unit: 'hour' as const
},
ticks: {
- color: '#9ca3af'
+ color: '#8b949e'
},
grid: {
- color: '#374151'
+ color: '#21262d'
}
},
y: {
beginAtZero: true,
ticks: {
- color: '#9ca3af',
+ color: '#8b949e',
stepSize: 1
},
grid: {
- color: '#374151'
+ color: '#21262d'
}
}
}
diff --git a/src/components/statistics/RecentSessions.tsx b/src/components/statistics/RecentSessions.tsx
index a6cd72c..8beadb3 100755
--- a/src/components/statistics/RecentSessions.tsx
+++ b/src/components/statistics/RecentSessions.tsx
@@ -29,51 +29,52 @@ export function RecentSessions({ sessions }: RecentSessionsProps) {
if (sessions.length === 0) {
return (
-
-
📊
-
No recent sessions found
-
+
No recent sessions found
);
}
return (
-
+
-
- |
+ |
+ |
Date
|
-
+ |
Type
|
-
+ |
Track
|
-
+ |
Duration
|
-
+ |
Players
|
-
+
{sessions.map((session) => (
-
- |
+ |
+ |
{formatDate(session.date)}
|
-
-
- {session.type}
+ |
+
+ {session.type === 'R' ? 'Race' : session.type === 'Q' ? 'Qualify' : 'Practice'}
|
- {session.track} |
-
+ | {session.track} |
+
{formatDuration(session.duration)}
|
-
+ |
{session.players}
|
diff --git a/src/components/statistics/SessionTypesChart.tsx b/src/components/statistics/SessionTypesChart.tsx
index 3ba5e23..d597ca3 100755
--- a/src/components/statistics/SessionTypesChart.tsx
+++ b/src/components/statistics/SessionTypesChart.tsx
@@ -15,12 +15,10 @@ interface SessionTypesChartProps {
}
const colors = [
- '#3b82f6', // blue
- '#10b981', // emerald
- '#f59e0b', // amber
- '#ef4444', // red
- '#8b5cf6', // violet
- '#06b6d4' // cyan
+ '#3fb950', // green
+ '#58a6ff', // blue
+ '#f78166', // red
+ '#d29922' // yellow
];
export function SessionTypesChart({ data }: SessionTypesChartProps) {
@@ -43,7 +41,7 @@ export function SessionTypesChart({ data }: SessionTypesChartProps) {
legend: {
position: 'bottom' as const,
labels: {
- color: '#e5e7eb',
+ color: '#e6edf3',
padding: 20
}
},
diff --git a/src/components/statistics/StatCard.tsx b/src/components/statistics/StatCard.tsx
index 6c2b32c..fc66ca2 100755
--- a/src/components/statistics/StatCard.tsx
+++ b/src/components/statistics/StatCard.tsx
@@ -6,16 +6,12 @@ interface StatCardProps {
export function StatCard({ title, value, icon }: StatCardProps) {
return (
-
-
-
- {icon}
-
-
+
+
+ {icon}
+ {title}
+
{value}
);
}
diff --git a/src/components/statistics/StatisticsDashboard.tsx b/src/components/statistics/StatisticsDashboard.tsx
index 498f781..d6bc6c7 100755
--- a/src/components/statistics/StatisticsDashboard.tsx
+++ b/src/components/statistics/StatisticsDashboard.tsx
@@ -14,21 +14,21 @@ interface StatisticsDashboardProps {
export function StatisticsDashboard({ stats }: StatisticsDashboardProps) {
if (!stats) {
return (
-
-
📊
-
No Statistics Available
-
No data found for the selected date range.
+
+
📊
+
No Statistics Available
+
No data found for the selected date range.
);
}
return (
-
-
+
+
- {/* Charts */}
-
-
-
Player Count Over Time
+
+
-
-
-
Session Types
+
+
Session Types
-
-
Daily Activity
+
+
Daily Activity
- {/* Recent Sessions */}
-
diff --git a/src/components/ui/AppShell.tsx b/src/components/ui/AppShell.tsx
new file mode 100644
index 0000000..d87359f
--- /dev/null
+++ b/src/components/ui/AppShell.tsx
@@ -0,0 +1,19 @@
+// NOTE: intentionally no 'use client' — must remain a Server Component
+import { Sidebar } from './Sidebar';
+
+interface AppShellProps {
+ children: React.ReactNode;
+ showUsers: boolean;
+}
+
+export function AppShell({ children, showUsers }: AppShellProps) {
+ return (
+
+
+ {/* Offset for sidebar (desktop: left margin, mobile: bottom padding) */}
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..4add63b
--- /dev/null
+++ b/src/components/ui/Badge.tsx
@@ -0,0 +1,31 @@
+import { cn } from '@/lib/utils';
+
+type BadgeVariant = 'green' | 'yellow' | 'red' | 'blue' | 'gray';
+
+interface BadgeProps {
+ variant: BadgeVariant;
+ children: React.ReactNode;
+ className?: string;
+}
+
+const variantClasses: Record
= {
+ green: 'bg-green-bg text-green border border-green/20',
+ yellow: 'bg-yellow-bg text-yellow border border-yellow/20',
+ red: 'bg-red-bg text-red border border-red/20',
+ blue: 'bg-blue-bg text-blue border border-blue/20',
+ gray: 'bg-overlay text-muted border border-border',
+};
+
+export function Badge({ variant, children, className }: BadgeProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
old mode 100755
new mode 100644
index 0691188..3621381
--- a/src/components/ui/Button.tsx
+++ b/src/components/ui/Button.tsx
@@ -1,46 +1,49 @@
-import { forwardRef, ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
-import { LoadingSpinner } from './LoadingSpinner';
+import { ButtonHTMLAttributes } from 'react';
+
+type ButtonVariant = 'primary' | 'ghost' | 'danger-outline' | 'danger-solid';
+type ButtonSize = 'sm' | 'md' | 'logout';
interface ButtonProps extends ButtonHTMLAttributes {
- variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
- size?: 'sm' | 'md' | 'lg';
- isLoading?: boolean;
+ variant?: ButtonVariant;
+ size?: ButtonSize;
}
-const baseClasses =
- 'inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
-
-const variants = {
- primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
- secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500',
- danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
- ghost: 'text-gray-300 hover:text-white hover:bg-gray-700 focus:ring-gray-500'
+const variantClasses: Record = {
+ primary: 'bg-btn-green border border-btn-green-hover text-white hover:bg-btn-green-hover',
+ ghost: 'bg-transparent border border-border text-secondary hover:bg-overlay hover:text-primary',
+ 'danger-outline':
+ 'bg-transparent border border-border text-red hover:border-red/40 hover:bg-red-bg',
+ 'danger-solid': 'bg-overlay border border-red/20 text-red hover:bg-red-bg'
};
-const sizes = {
- sm: 'px-3 py-1 text-sm',
+const sizeClasses: Record = {
+ sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
- lg: 'px-6 py-3 text-base'
+ logout: 'px-2 py-2 cursor-pointer hover:bg-red'
};
-export const Button = forwardRef(
- (
- { className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props },
- ref
- ) => {
- return (
-
- );
- }
-);
-
-Button.displayName = 'Button';
+export function Button({
+ variant = 'ghost',
+ size = 'md',
+ className,
+ children,
+ disabled,
+ ...props
+}: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/LogoutButton.tsx b/src/components/ui/LogoutButton.tsx
index 3a3b526..0c2c963 100755
--- a/src/components/ui/LogoutButton.tsx
+++ b/src/components/ui/LogoutButton.tsx
@@ -2,15 +2,16 @@
import { logoutAction } from '@/lib/actions/auth';
import { useActionState } from 'react';
+import { Button } from '@/components/ui/Button';
export default function LogoutButton() {
const [, formAction] = useActionState(logoutAction, null);
return (
);
}
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
index 7db1a5d..6d42916 100755
--- a/src/components/ui/Modal.tsx
+++ b/src/components/ui/Modal.tsx
@@ -33,17 +33,20 @@ export function Modal({ isOpen, onClose, title, children, className }: ModalProp
if (!isOpen) return null;
return (
-
-
+
+
-
{title}
-
diff --git a/src/components/ui/RefreshButton.tsx b/src/components/ui/RefreshButton.tsx
index 7fb329c..0e0852b 100755
--- a/src/components/ui/RefreshButton.tsx
+++ b/src/components/ui/RefreshButton.tsx
@@ -1,15 +1,13 @@
'use client';
import { useRouter } from 'next/navigation';
+import { Button } from '@/components/ui/Button';
export default function RefreshButton() {
const router = useRouter();
return (
-
router.refresh()}
- className="rounded-md bg-gray-700 px-3 py-1 text-sm hover:bg-gray-600"
- >
+ router.refresh()}>
Refresh
-
+
);
}
diff --git a/src/components/ui/Sidebar.tsx b/src/components/ui/Sidebar.tsx
new file mode 100644
index 0000000..d651e68
--- /dev/null
+++ b/src/components/ui/Sidebar.tsx
@@ -0,0 +1,122 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { cn } from '@/lib/utils';
+import LogoutButton from './LogoutButton';
+
+interface SidebarProps {
+ showUsers: boolean; // true if user has membership.view permission
+}
+
+function ServerIcon({ active }: { active: boolean }) {
+ return (
+
+ );
+}
+
+function UsersIcon({ active }: { active: boolean }) {
+ return (
+
+ );
+}
+
+export function Sidebar({ showUsers }: SidebarProps) {
+ const pathname = usePathname();
+ const isServers = pathname === '/dashboard' || pathname.startsWith('/dashboard/server');
+ const isUsers = pathname.startsWith('/dashboard/membership');
+
+ const navItems = [
+ {
+ href: '/dashboard',
+ label: 'Servers',
+ active: isServers,
+ icon:
+ },
+ ...(showUsers
+ ? [
+ {
+ href: '/dashboard/membership',
+ label: 'Users',
+ active: isUsers,
+ icon:
+ }
+ ]
+ : [])
+ ];
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/components/ui/StatusDot.tsx b/src/components/ui/StatusDot.tsx
new file mode 100644
index 0000000..ddcd629
--- /dev/null
+++ b/src/components/ui/StatusDot.tsx
@@ -0,0 +1,28 @@
+import { ServiceStatus } from '@/lib/schemas/server';
+import { cn } from '@/lib/utils';
+
+interface StatusDotProps {
+ status: ServiceStatus;
+ className?: string;
+}
+
+export function StatusDot({ status, className }: StatusDotProps) {
+ const dotClass = {
+ [ServiceStatus.Running]: 'bg-green shadow-[0_0_0_3px_#3fb95022]',
+ [ServiceStatus.Starting]: 'bg-yellow animate-pulse',
+ [ServiceStatus.Stopping]: 'bg-yellow animate-pulse',
+ [ServiceStatus.Restarting]: 'bg-yellow animate-pulse',
+ [ServiceStatus.Stopped]: 'bg-subtle',
+ [ServiceStatus.Unknown]: 'bg-subtle',
+ }[status] ?? 'bg-subtle';
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/SteamCMDNotification.tsx b/src/components/ui/SteamCMDNotification.tsx
index 1dfddc4..94c4e94 100755
--- a/src/components/ui/SteamCMDNotification.tsx
+++ b/src/components/ui/SteamCMDNotification.tsx
@@ -12,11 +12,11 @@ export function SteamCMDNotification() {
const serverCount = runningSteamServers.size;
return (
-
+
-
+
SteamCMD is currently running for {serverCount} server{serverCount !== 1 ? 's' : ''}.
Server actions are temporarily disabled to prevent conflicts.
This will automatically resolve when the download completes.
@@ -36,8 +36,8 @@ export function SteamCMDNotification() {
-
-
Downloading...
+
+
Downloading...
diff --git a/src/lib/actions/configuration.ts b/src/lib/actions/configuration.ts
index 3fd55a7..2ce6929 100755
--- a/src/lib/actions/configuration.ts
+++ b/src/lib/actions/configuration.ts
@@ -2,10 +2,11 @@
import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server';
-import { updateServerConfiguration } from '@/lib/api/server/configuration';
+import { updateServerConfiguration, bulkUpdateServerConfigurations } from '@/lib/api/server/configuration';
import {
assistRulesSchema,
ConfigFile,
+ configurationsSchema,
eventConfigSchema,
eventRulesSchema,
serverSettingsSchema
@@ -13,6 +14,7 @@ import {
import type {
Configuration,
AssistRules,
+ Configurations,
EventConfig,
EventRules,
ServerSettings
@@ -232,3 +234,28 @@ export async function updateEventRulesAction(serverId: string, formData: FormDat
};
}
}
+
+export async function bulkUpdateConfigurationsAction(
+ serverId: string,
+ configs: Configurations,
+ restart: boolean
+) {
+ try {
+ const session = await requireAuth();
+
+ const validated = configurationsSchema.safeParse(configs);
+ if (!validated.success) {
+ return { success: false, message: validated.error.message };
+ }
+
+ await bulkUpdateServerConfigurations(session.token!, serverId, validated.data, restart);
+ revalidatePath(`/dashboard/server/${serverId}`);
+
+ return { success: true, message: 'All configurations updated successfully' };
+ } catch (error) {
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Failed to update configurations'
+ };
+ }
+}
diff --git a/src/lib/api/server/base.ts b/src/lib/api/server/base.ts
index 19bb40e..2baa181 100755
--- a/src/lib/api/server/base.ts
+++ b/src/lib/api/server/base.ts
@@ -30,9 +30,7 @@ export async function fetchServerAPI
(
if (response.status == 401) {
redirect('/login?expired=true');
}
- throw new Error(
- `API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint} - ${token}`
- );
+ throw new Error(`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint}`);
}
if (response.headers.get('Content-Type')?.includes('application/json')) {
diff --git a/src/lib/api/server/configuration.ts b/src/lib/api/server/configuration.ts
index c21b396..d9a763e 100755
--- a/src/lib/api/server/configuration.ts
+++ b/src/lib/api/server/configuration.ts
@@ -8,14 +8,23 @@ import {
} from '@/lib/schemas/config';
import * as z from 'zod';
+export type ConfigAuditRecord = {
+ id: string;
+ serverId: string;
+ configFile: string;
+ oldConfig: string;
+ newConfig: string;
+ changedAt: string;
+};
+
const serverRoute = '/server';
export async function getServerConfigurations(
token: string,
serverId: string
): Promise {
- const response = await fetchServerAPI(`${serverRoute}/${serverId}/config`, token);
- return configurationsSchema.parse(response.data);
+ const response = await fetchServerAPI(`${serverRoute}/${serverId}/config`, token);
+ return configurationsSchema.parse(response.data);
}
export function validateConfig(
@@ -45,8 +54,26 @@ export async function updateServerConfiguration(
config: Config,
restart = false
): Promise {
- await fetchServerAPI(`${serverRoute}/${serverId}/config/${configType}?override=true`, token, 'PUT', {
- ...validateConfig(configType, config),
- restart
- });
+ await fetchServerAPI(
+ `${serverRoute}/${serverId}/config/${configType}?override=true&restart=${restart}`,
+ token,
+ 'PUT',
+ {
+ ...validateConfig(configType, config)
+ }
+ );
+}
+
+export async function bulkUpdateServerConfigurations(
+ token: string,
+ serverId: string,
+ configs: Configurations,
+ restart = false
+): Promise {
+ await fetchServerAPI(
+ `${serverRoute}/${serverId}/config?override=true&restart=${restart}`,
+ token,
+ 'PATCH',
+ configs
+ );
}
diff --git a/src/lib/api/server/health.ts b/src/lib/api/server/health.ts
new file mode 100644
index 0000000..7210b19
--- /dev/null
+++ b/src/lib/api/server/health.ts
@@ -0,0 +1,17 @@
+const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
+
+export type HealthStatus = { healthy: true; version: string } | { healthy: false; version: null };
+
+export async function checkHealth(): Promise {
+ try {
+ const response = await fetch(`${BASE_URL}/system/health`, {
+ method: 'GET',
+ next: { revalidate: 0 }
+ });
+ if (!response.ok) return { healthy: false, version: null };
+ const version = await response.text();
+ return { healthy: true, version: version.trim() || 'unknown' };
+ } catch {
+ return { healthy: false, version: null };
+ }
+}
diff --git a/src/lib/schemas/user.ts b/src/lib/schemas/user.ts
index cf39e04..3ccedb0 100755
--- a/src/lib/schemas/user.ts
+++ b/src/lib/schemas/user.ts
@@ -10,7 +10,7 @@ export type Permission = z.infer;
export const roleSchema = z.object({
id: z.uuid(),
name: z.string(),
- permissions: z.array(permissionSchema)
+ permissions: z.array(permissionSchema).nullable()
});
export type Role = z.infer;