diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index f75f6a0..abc05b1 100755 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -3,19 +3,28 @@ import { SteamCMDProvider } from '@/lib/context/SteamCMDContext'; import { ServerCreationPopupProvider } from '@/lib/context/ServerCreationPopupContext'; import { ServerCreationPopupContainer } from '@/components/server/ServerCreationPopupContainer'; import { requireAuth } from '@/lib/auth/server'; +import { AppShell } from '@/components/ui/AppShell'; +import { hasPermission } from '@/lib/schemas'; +import { checkHealth } from '@/lib/api/server/health'; +import { redirect } from 'next/navigation'; -export default async function DashboardLayout({ - children -}: Readonly<{ - children: React.ReactNode; -}>) { +export default async function DashboardLayout({ children }: Readonly<{ children: React.ReactNode }>) { const session = await requireAuth(); + + const health = await checkHealth(); + if (!health.healthy) { + redirect('/login'); + } + + const showUsers = hasPermission(session.user!, 'membership.view'); return ( - {children} - + + {children} + + diff --git a/src/app/dashboard/membership/page.tsx b/src/app/dashboard/membership/page.tsx index 42d7ac6..dfaf8c8 100755 --- a/src/app/dashboard/membership/page.tsx +++ b/src/app/dashboard/membership/page.tsx @@ -4,24 +4,26 @@ import type { UserListParams } from '@/lib/api/server/membership'; import { UserManagementTable } from '@/components/membership/UserManagementTable'; interface MembershipPageProps { - searchParams: { + searchParams: Promise<{ username?: string; role_name?: string; sort_by?: string; sort_desc?: string; page?: string; - }; + }>; } export default async function MembershipPage({ searchParams }: MembershipPageProps) { const session = await requireAuth(); + const resolvedParams = await searchParams; + const params: UserListParams = { - username: searchParams.username, - role_name: searchParams.role_name, - sort_by: searchParams.sort_by || 'username', - sort_desc: searchParams.sort_desc === 'true', - page: parseInt(searchParams.page || '1'), + username: resolvedParams.username, + role_name: resolvedParams.role_name, + sort_by: resolvedParams.sort_by || 'username', + sort_desc: resolvedParams.sort_desc === 'true', + page: parseInt(resolvedParams.page || '1'), limit: 20 }; @@ -31,8 +33,6 @@ export default async function MembershipPage({ searchParams }: MembershipPagePro ]); return ( -
- -
+ ); } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9ccfd1c..18acf6d 100755 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,53 +1,16 @@ import { requireAuth } from '@/lib/auth/server'; import { getServers } from '@/lib/api/server/servers'; -import { hasPermission } from '@/lib/schemas'; -import Link from 'next/link'; import { ServerListWithActions } from '@/components/server/ServerListWithActions'; import { SteamCMDNotification } from '@/components/ui/SteamCMDNotification'; -import LogoutButton from '@/components/ui/LogoutButton'; export default async function DashboardPage() { const session = await requireAuth(); const servers = await getServers(session.token!); return ( -
-
-
-

ACC Server Manager

-
- {hasPermission(session.user!, 'membership.view') && ( - - - - - Users - - )} - -
-
-
- +
- -
- -
-
+ + ); } diff --git a/src/app/dashboard/server/[id]/page.tsx b/src/app/dashboard/server/[id]/page.tsx index f93150d..ef08731 100755 --- a/src/app/dashboard/server/[id]/page.tsx +++ b/src/app/dashboard/server/[id]/page.tsx @@ -28,19 +28,14 @@ export default async function ServerPage({ params }: ServerPageProps) { ]); return ( -
-
- - -
- -
-
+
+ +
); } diff --git a/src/app/favicon.ico b/src/app/favicon.ico old mode 100755 new mode 100644 index 02cccb7..ad320a0 Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css index de36292..638aa11 100755 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,11 +1,35 @@ @import "tailwindcss"; +@theme { + --color-base: #0d1117; + --color-canvas: #161b22; + --color-overlay: #21262d; + --color-sidebar: #010409; + --color-border: #30363d; + --color-border-muted: #21262d; + --color-hover: #1c2128; + --color-primary: #e6edf3; + --color-secondary: #c9d1d9; + --color-muted: #8b949e; + --color-subtle: #6e7681; + --color-blue: #58a6ff; + --color-blue-bg: #1f6feb33; + --color-green: #3fb950; + --color-green-bg: #3fb95022; + --color-yellow: #d29922; + --color-yellow-bg: #d2992220; + --color-red: #f85149; + --color-red-bg: #f8514920; + --color-orange: #f78166; + --color-btn-green: #238636; + --color-btn-green-hover: #2ea043; +} + @layer components { .form-input { - @apply mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500; + @apply block w-full rounded-md border border-border bg-overlay px-3 py-2 text-sm text-primary placeholder-subtle focus:border-blue focus:outline-none focus:ring-1 focus:ring-blue; } - .form-select { - @apply mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500; + @apply block w-full rounded-md border border-border bg-overlay px-3 py-2 text-sm text-primary focus:border-blue focus:outline-none focus:ring-1 focus:ring-blue; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2f59ab4..6618931 100755 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,7 +13,7 @@ export default function RootLayout({ }>) { return ( - {children} + {children} ); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 2a1d25a..07a8751 100755 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,15 +1,18 @@ import { Suspense } from 'react'; import LoginForm from '@/components/login/LoginForm'; +import { checkHealth } from '@/lib/api/server/health'; export const dynamic = 'force-dynamic'; -export default function LoginPage({ +export default async function LoginPage({ searchParams }: { searchParams: Promise<{ expired: boolean | undefined }>; }) { + const health = await checkHealth(); + return ( Loading...
}> - + ); } diff --git a/src/components/configuration/AssistRulesEditor.tsx b/src/components/configuration/AssistRulesEditor.tsx index 190bc19..325d6fb 100755 --- a/src/components/configuration/AssistRulesEditor.tsx +++ b/src/components/configuration/AssistRulesEditor.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useState } from 'react'; import type { AssistRules } from '@/lib/schemas/config'; -import { updateAssistRulesAction } from '@/lib/actions/configuration'; interface AssistRulesEditorProps { - serverId: string; - config: AssistRules; + formData: AssistRules; + disabled?: boolean; + onFormDataChange: (data: AssistRules) => void; } const assistFields = [ @@ -57,50 +56,24 @@ const assistFields = [ } ]; -export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps) { - const [formData, setFormData] = useState(config); - const [restart, setRestart] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - - const formDataObj = new FormData(); - Object.entries(formData).forEach(([key, value]) => { - formDataObj.append(key, value.toString()); - }); - if (restart) { - formDataObj.append('restart', 'on'); - } - - try { - const result = await updateAssistRulesAction(serverId, formDataObj); - if (!result.success) { - console.error('Failed to update assist rules:', result.message); - } - } finally { - setIsSubmitting(false); - } - }; - +export function AssistRulesEditor({ formData, disabled, onFormDataChange }: AssistRulesEditorProps) { const handleInputChange = (key: keyof AssistRules, value: string | number) => { - setFormData((prev) => ({ - ...prev, + onFormDataChange({ + ...formData, [key]: typeof value === 'string' ? parseInt(value) : value - })); + }); }; return ( -
+
{assistFields.map(({ key, label, type }) => (
- + {type === 'number' ? ( handleInputChange(key, e.target.value)} className="form-input w-full" @@ -109,10 +82,10 @@ export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps) /> ) : ( setRestart(e.target.checked)} - className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500" - /> - Restart server after saving - -
- -
- -
- +
); } diff --git a/src/components/configuration/ConfigurationEditor.tsx b/src/components/configuration/ConfigurationEditor.tsx index 95766e7..27b7cf5 100755 --- a/src/components/configuration/ConfigurationEditor.tsx +++ b/src/components/configuration/ConfigurationEditor.tsx @@ -1,56 +1,29 @@ 'use client'; -import { useState } from 'react'; import type { Configuration } from '@/lib/schemas/config'; -import { updateConfigurationAction } from '@/lib/actions/configuration'; interface ConfigurationEditorProps { - serverId: string; - config: Configuration; + formData: Configuration; + disabled?: boolean; + onFormDataChange: (data: Configuration) => void; } -export function ConfigurationEditor({ serverId, config }: ConfigurationEditorProps) { - const [formData, setFormData] = useState(config); - const [restart, setRestart] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - - const formDataObj = new FormData(); - Object.entries(formData).forEach(([key, value]) => { - formDataObj.append(key, value.toString()); - }); - if (restart) { - formDataObj.append('restart', 'on'); - } - - try { - const result = await updateConfigurationAction(serverId, formDataObj); - if (!result.success) { - console.error('Failed to update configuration:', result.message); - } - } finally { - setIsSubmitting(false); - } - }; - +export function ConfigurationEditor({ formData, disabled, onFormDataChange }: ConfigurationEditorProps) { const handleInputChange = (key: keyof Configuration, value: string | number) => { - setFormData((prev) => ({ - ...prev, + onFormDataChange({ + ...formData, [key]: typeof value === 'string' ? parseInt(value) : value - })); + }); }; return ( -
+
- + handleInputChange('udpPort', e.target.value)} className="form-input w-full" @@ -58,10 +31,10 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
- + handleInputChange('tcpPort', e.target.value)} className="form-input w-full" @@ -69,10 +42,10 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
- + handleInputChange('maxConnections', e.target.value)} className="form-input w-full" @@ -80,12 +53,12 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
- + handleInputChange('registerToLobby', e.target.value)} - className="form-input w-full" + className="form-select w-full" >
- -
- -
- -
- -
- +
); } diff --git a/src/components/configuration/EventConfigEditor.tsx b/src/components/configuration/EventConfigEditor.tsx index 8d2d8ae..5d9d28c 100755 --- a/src/components/configuration/EventConfigEditor.tsx +++ b/src/components/configuration/EventConfigEditor.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useState } from 'react'; import type { EventConfig, Session } from '@/lib/schemas/config'; -import { updateEventConfigAction } from '@/lib/actions/configuration'; interface EventConfigEditorProps { - serverId: string; - config: EventConfig; + formData: EventConfig; + disabled?: boolean; + onFormDataChange: (data: EventConfig) => void; } const sessionTypes = [ @@ -15,49 +14,19 @@ const sessionTypes = [ { value: 'R', label: 'Race' } ]; -export function EventConfigEditor({ serverId, config }: EventConfigEditorProps) { - const [formData, setFormData] = useState(config); - const [restart, setRestart] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - - const formDataObj = new FormData(); - Object.entries(formData).forEach(([key, value]) => { - if (key === 'sessions') { - formDataObj.append(key, JSON.stringify(value)); - } else { - formDataObj.append(key, value.toString()); - } - }); - if (restart) { - formDataObj.append('restart', 'on'); - } - - try { - const result = await updateEventConfigAction(serverId, formDataObj); - if (!result.success) { - console.error('Failed to update event config:', result.message); - } - } finally { - setIsSubmitting(false); - } - }; - +export function EventConfigEditor({ formData, disabled, onFormDataChange }: EventConfigEditorProps) { const handleInputChange = (key: keyof EventConfig, value: string | number) => { if (key === 'sessions') return; - setFormData((prev) => ({ - ...prev, + onFormDataChange({ + ...formData, [key]: typeof formData[key] === 'number' ? typeof value === 'string' ? parseFloat(value) || 0 : value : value - })); + }); }; const handleSessionChange = (index: number, field: keyof Session, value: string | number) => { @@ -67,11 +36,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps) [field]: field === 'sessionType' ? value : typeof value === 'string' ? parseFloat(value) || 0 : value }; - - setFormData((prev) => ({ - ...prev, - sessions: newSessions - })); + onFormDataChange({ ...formData, sessions: newSessions }); }; const addSession = () => { @@ -82,34 +47,27 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps) sessionType: 'P', sessionDurationMinutes: 20 }; - - setFormData((prev) => ({ - ...prev, - sessions: [...prev.sessions, newSession] - })); + onFormDataChange({ ...formData, sessions: [...formData.sessions, newSession] }); }; const removeSession = (index: number) => { - setFormData((prev) => ({ - ...prev, - sessions: prev.sessions.filter((_, i) => i !== index) - })); + onFormDataChange({ ...formData, sessions: formData.sessions.filter((_, i) => i !== index) }); }; return ( -
+
-

+

Basic Event Settings

- + handleInputChange('preRaceWaitingTimeSeconds', e.target.value)} className="form-input w-full" @@ -154,12 +112,12 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
-
-
-
-

+

Weather Settings

-
-
- + handleInputChange('rain', e.target.value)} className="form-input w-full" @@ -248,12 +206,12 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
-
-
-
-

Sessions

+
+

Sessions

{formData.sessions.map((session, index) => ( -
+
-

Session {index + 1}

+

Session {index + 1}

@@ -324,14 +282,14 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
-
-
-
-
- -
- -
- -
- -
- +
); } diff --git a/src/components/configuration/EventRulesEditor.tsx b/src/components/configuration/EventRulesEditor.tsx index 1b9e466..c00f41b 100755 --- a/src/components/configuration/EventRulesEditor.tsx +++ b/src/components/configuration/EventRulesEditor.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useState } from 'react'; import type { EventRules } from '@/lib/schemas/config'; -import { updateEventRulesAction } from '@/lib/actions/configuration'; interface EventRulesEditorProps { - serverId: string; - config: EventRules; + formData: EventRules; + disabled?: boolean; + onFormDataChange: (data: EventRules) => void; } const numberFields = [ @@ -68,51 +67,22 @@ const booleanFields = [ } ]; -export function EventRulesEditor({ serverId, config }: EventRulesEditorProps) { - const [formData, setFormData] = useState(config); - const [restart, setRestart] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - - const formDataObj = new FormData(); - Object.entries(formData).forEach(([key, value]) => { - formDataObj.append(key, value.toString()); - }); - if (restart) { - formDataObj.append('restart', 'on'); - } - - try { - const result = await updateEventRulesAction(serverId, formDataObj); - if (!result.success) { - console.error('Failed to update event rules:', result.message); - } - } finally { - setIsSubmitting(false); - } - }; - - const handleInputChange = (key: keyof EventRules, value: string | number | boolean) => { - setFormData((prev) => ({ - ...prev, - [key]: value - })); +export function EventRulesEditor({ formData, disabled, onFormDataChange }: EventRulesEditorProps) { + const handleInputChange = (key: keyof EventRules, value: number) => { + onFormDataChange({ ...formData, [key]: value }); }; return ( -
+
-

Race Rules

+

Race Rules

{numberFields.map(({ key, label, min, max }) => (
- + handleInputChange(key, parseInt(e.target.value) || 0)} className="form-input w-full" @@ -125,18 +95,18 @@ export function EventRulesEditor({ serverId, config }: EventRulesEditorProps) {
-

+

Pitstop & Refuelling Rules

{booleanFields.map(({ key, label }) => (
- + setRestart(e.target.checked)} - className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500" - /> - Restart server after saving - -
- -
- -
- +
); } diff --git a/src/components/configuration/ServerSettingsEditor.tsx b/src/components/configuration/ServerSettingsEditor.tsx index 671f5cb..e2a74d2 100755 --- a/src/components/configuration/ServerSettingsEditor.tsx +++ b/src/components/configuration/ServerSettingsEditor.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useState } from 'react'; import type { ServerSettings } from '@/lib/schemas/config'; -import { updateServerSettingsAction } from '@/lib/actions/configuration'; interface ServerSettingsEditorProps { - serverId: string; - config: ServerSettings; + formData: ServerSettings; + disabled?: boolean; + onFormDataChange: (data: ServerSettings) => void; } const textFields = [ @@ -87,53 +86,24 @@ const selectFields = [ } ]; -export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorProps) { - const [formData, setFormData] = useState(config); - const [restart, setRestart] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - - const formDataObj = new FormData(); - Object.entries(formData).forEach(([key, value]) => { - formDataObj.append(key, value?.toString() ?? ''); - }); - if (restart) { - formDataObj.append('restart', 'on'); - } - - try { - const result = await updateServerSettingsAction(serverId, formDataObj); - if (!result.success) { - console.error('Failed to update server settings:', result.message); - } - } finally { - setIsSubmitting(false); - } - }; - +export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: ServerSettingsEditorProps) { const handleInputChange = (key: keyof ServerSettings, value: string | number) => { - setFormData((prev) => ({ - ...prev, - [key]: value - })); + onFormDataChange({ ...formData, [key]: value }); }; return ( -
+
-

+

Basic Settings

{textFields.map(({ key, label, type }) => (
- + handleInputChange(key, e.target.value)} className="form-input w-full" @@ -142,12 +112,12 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP ))}
- + handleInputChange(key, parseInt(e.target.value) || 0)} className="form-input w-full" @@ -182,18 +152,18 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
-

+

Race Options

{selectFields.map(({ key, label }) => (
- + handleInputChange('formationLapType', parseInt(e.target.value))} - className="form-input w-full" + className="form-select w-full" >
- -
- -
- -
- -
- +
); } diff --git a/src/components/leaderboard/LeaderboardManager.tsx b/src/components/leaderboard/LeaderboardManager.tsx index fa028f7..774b00b 100755 --- a/src/components/leaderboard/LeaderboardManager.tsx +++ b/src/components/leaderboard/LeaderboardManager.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { Button } from '@/components/ui/Button'; import { Leaderboard, LeaderboardDriver, FlPoints } from '@/lib/schemas/leaderboard'; import { saveLeaderboardAction } from '@/lib/actions/leaderboard'; import { LeaderboardStandings } from './LeaderboardStandings'; @@ -135,19 +136,19 @@ export function LeaderboardManager({ serverId, initialData }: LeaderboardManager return (
{dirty && ( -
+
Unsaved changes
@@ -156,7 +157,9 @@ export function LeaderboardManager({ serverId, initialData }: LeaderboardManager )} {error && ( -
{error}
+
+ {error} +
)}
@@ -164,10 +167,10 @@ export function LeaderboardManager({ serverId, initialData }: LeaderboardManager +
)}
diff --git a/src/components/login/LoginForm.tsx b/src/components/login/LoginForm.tsx index cb0e6fe..0742835 100755 --- a/src/components/login/LoginForm.tsx +++ b/src/components/login/LoginForm.tsx @@ -2,6 +2,8 @@ import { clearExpiredSessionAction, loginAction, LoginResult } from '@/lib/actions/auth'; import { use, useActionState, useEffect } from 'react'; +import type { HealthStatus } from '@/lib/api/server/health'; +import { Badge } from '@/components/ui/Badge'; const initialState: LoginResult = { message: '', @@ -9,9 +11,11 @@ const initialState: LoginResult = { }; export default function LoginForm({ - searchParams + searchParams, + health }: { searchParams: Promise<{ expired: boolean | undefined }>; + health: HealthStatus; }) { const params = use(searchParams); const expired = params.expired; @@ -23,26 +27,33 @@ export default function LoginForm({ }, [expired]); const [state, formAction] = useActionState(loginAction, initialState); return ( -
-
+
+
-

ACC Server Manager

-

Sign in to manage your servers

+

ACC Server Manager

+

Sign in to manage your servers

+
+ {health.healthy ? ( + API v{health.version} + ) : ( + API unreachable + )} +
{expired && ( -
+
Your session has expired. Please sign in again.
)} {state?.success ? null : ( -
+
{state?.message}
)} -
+
-
-
diff --git a/src/components/membership/CreateUserModal.tsx b/src/components/membership/CreateUserModal.tsx index 7222004..54eee95 100755 --- a/src/components/membership/CreateUserModal.tsx +++ b/src/components/membership/CreateUserModal.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import type { Role } from '@/lib/schemas'; import { createUserAction } from '@/lib/actions/membership'; +import { Button } from '@/components/ui/Button'; interface CreateUserModalProps { roles: Role[]; @@ -44,17 +45,22 @@ export function CreateUserModal({ roles, onClose }: CreateUserModalProps) { }; return ( -
-
-

Create New User

+
+
+

Create New User

{error && ( -
{error}
+
+ {error} +
)}
-
-
-
-
- - + +
diff --git a/src/components/membership/DeleteUserModal.tsx b/src/components/membership/DeleteUserModal.tsx index 06d0442..8a7e1ee 100755 --- a/src/components/membership/DeleteUserModal.tsx +++ b/src/components/membership/DeleteUserModal.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import type { User } from '@/lib/schemas'; import { deleteUserAction } from '@/lib/actions/membership'; +import { Button } from '@/components/ui/Button'; interface DeleteUserModalProps { user: User; @@ -37,36 +38,29 @@ export function DeleteUserModal({ user, onClose }: DeleteUserModalProps) { }; return ( -
-
-

Delete User

+
+
+

Delete User

{error && ( -
{error}
+
+ {error} +
)} -

+

Are you sure you want to delete the user "{user.username}"? This action cannot be undone.

-
- - + +
diff --git a/src/components/membership/UserManagementTable.tsx b/src/components/membership/UserManagementTable.tsx index 9500bb1..e637b8f 100755 --- a/src/components/membership/UserManagementTable.tsx +++ b/src/components/membership/UserManagementTable.tsx @@ -1,12 +1,12 @@ 'use client'; import { useState } from 'react'; -import Link from 'next/link'; import { useRouter } from 'next/navigation'; import type { Role, User } from '@/lib/schemas'; import { hasPermission } from '@/lib/schemas/user'; import { CreateUserModal } from './CreateUserModal'; import { DeleteUserModal } from './DeleteUserModal'; +import { Button } from '@/components/ui/Button'; interface UserManagementTableProps { initialData: User[]; @@ -55,7 +55,7 @@ export function UserManagementTable({ initialData, roles, currentUser }: UserMan }; const getSortIcon = (field: string) => { - if (sortBy !== field) return '↕️'; + if (sortBy !== field) return '↕'; return sortDesc ? '↓' : '↑'; }; @@ -79,163 +79,134 @@ export function UserManagementTable({ initialData, roles, currentUser }: UserMan return ( <> -
-
-
- - - - - -

User Management

-
- {hasPermission(currentUser, 'membership.create') && ( - - )} +
+
+ User Management
+ {hasPermission(currentUser, 'membership.create') && ( + + )}
-
- {/* Filters */} -
-

Filters

-
-
- - setUsernameFilter(e.target.value)} - placeholder="Search by username..." - className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" - /> -
-
- - setRoleNameFilter(e.target.value)} - placeholder="Filter by role..." - className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" - /> -
+
+
Filters
+
+
+ + setUsernameFilter(e.target.value)} + placeholder="Search by username..." + className="form-input" + />
-
- - +
+ + setRoleNameFilter(e.target.value)} + placeholder="Filter by role..." + className="form-input" + />
+
+ + +
+
- {/* Results Summary */} -
Showing {initialData.length} users
+
Showing {initialData.length} users
- {/* Users Table */} -
- - - - + + + {initialData.length > 0 ? ( + initialData.map((user) => ( + + + + {hasPermission(currentUser, 'membership.edit') && ( + + )} + + )) + ) : ( + + + + )} + +
- +
+ + + + + + {hasPermission(currentUser, 'membership.edit') && ( + - - {hasPermission(currentUser, 'membership.edit') && ( - - )} - - - - {initialData.length > 0 ? ( - initialData.map((user) => ( - - - - {hasPermission(currentUser, 'membership.edit') && ( - - )} - - )) - ) : ( - - - )} - -
+ + + + + Actions - - - Actions -
- {user.username} - - - {user.role.name} - - -
- - {hasPermission(currentUser, 'membership.delete') && ( - - )} -
-
- No users found -
-
- +
+ {user.username} + + + {user.role.name} + + +
+ + {hasPermission(currentUser, 'membership.delete') && ( + + )} +
+
+ No users found +
+
- {/* Modals */} {showCreateModal && } {showDeleteModal && selectedUser && ( diff --git a/src/components/server/CreateServerModal.tsx b/src/components/server/CreateServerModal.tsx index 520c0f2..8ee4b54 100755 --- a/src/components/server/CreateServerModal.tsx +++ b/src/components/server/CreateServerModal.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useActionState, useTransition } from 'react'; import { Modal } from '@/components/ui/Modal'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { Button } from '@/components/ui/Button'; import { createServerAction, type ServerActionResult } from '@/lib/actions/server-management'; import { useServerCreationPopup } from '@/lib/context/ServerCreationPopupContext'; @@ -58,12 +59,12 @@ export function CreateServerModal({ isOpen, onClose }: CreateServerModalProps) { return ( {!state.success && state.message && ( -
{state.message}
+
{state.message}
)}
-
- - +
diff --git a/src/components/server/DeleteServerModal.tsx b/src/components/server/DeleteServerModal.tsx index 3b6d0b5..509d159 100755 --- a/src/components/server/DeleteServerModal.tsx +++ b/src/components/server/DeleteServerModal.tsx @@ -3,6 +3,7 @@ import { useState, useTransition } from 'react'; import { Modal } from '@/components/ui/Modal'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { Button } from '@/components/ui/Button'; import { deleteServerAction } from '@/lib/actions/server-management'; import { Server } from '@/lib/schemas/server'; @@ -42,37 +43,39 @@ export function DeleteServerModal({ isOpen, onClose, server }: DeleteServerModal return ( - {error &&
{error}
} + {error &&
{error}
}
-

+

Are you sure you want to delete the server "{server.name}"?

-

This action cannot be undone.

+

This action cannot be undone.

- - +
); diff --git a/src/components/server/ServerCard.tsx b/src/components/server/ServerCard.tsx index 8a68653..66ccea5 100755 --- a/src/components/server/ServerCard.tsx +++ b/src/components/server/ServerCard.tsx @@ -1,17 +1,31 @@ +'use client'; + import Link from 'next/link'; import { useTransition } from 'react'; -import { Server, ServiceStatus, getStatusColor, serviceStatusToString } from '@/lib/schemas'; +import { Server, ServiceStatus, serviceStatusToString } from '@/lib/schemas'; import { startServerEventAction, restartServerEventAction, stopServerEventAction } from '@/lib/actions/servers'; import { useRouter } from 'next/navigation'; +import { StatusDot } from '@/components/ui/StatusDot'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; interface ServerCardProps { 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) { const [isPending, startTransition] = useTransition(); const router = useRouter(); @@ -36,82 +50,66 @@ export function ServerCard({ server }: ServerCardProps) { ServiceStatus.Restarting, ServiceStatus.Starting, ServiceStatus.Stopping, - ServiceStatus.Unknown + ServiceStatus.Unknown, ].includes(server.status); + const isRunning = server.status === ServiceStatus.Running; return ( - <> -
- -
-
-
-

{server.name}

-
- - - {serviceStatusToString(server.status)} - -
-
-
-
- - - -
-
-
+
+ +
+ {server.name} + +
-
-
- Track: - {server.state?.track || 'N/A'} -
-
- Players: - {server.state?.playerCount || 0} -
+
+
+
Players
+
+ {server.state?.playerCount ?? 0} +
+
+
+
Track
+
+ {server.state?.track || 'N/A'}
- - -
- - - - -
+ +
+ + {serviceStatusToString(server.status)} + +
+ + +
+ + +
- +
); } diff --git a/src/components/server/ServerConfigurationTabs.tsx b/src/components/server/ServerConfigurationTabs.tsx index 499c825..338a8dc 100755 --- a/src/components/server/ServerConfigurationTabs.tsx +++ b/src/components/server/ServerConfigurationTabs.tsx @@ -11,6 +11,7 @@ import { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager' import { useState } from 'react'; import { StateHistoryStats } from '@/lib/schemas'; import { Leaderboard } from '@/lib/schemas/leaderboard'; +import { bulkUpdateConfigurationsAction } from '@/lib/actions/configuration'; interface ServerConfigurationTabsProps { serverId: string; @@ -28,6 +29,14 @@ const tabs = [ { id: ServerTab.leaderboard, name: 'Leaderboard', icon: '🏆' } ]; +const configTabs = new Set([ + ServerTab.configuration, + ServerTab.assistRules, + ServerTab.event, + ServerTab.eventRules, + ServerTab.settings +]); + export function ServerConfigurationTabs({ serverId, configurations, @@ -36,25 +45,95 @@ export function ServerConfigurationTabs({ }: ServerConfigurationTabsProps) { const [currentTab, setCurrentTab] = useState(ServerTab.statistics); + const [configData, setConfigData] = useState(configurations.configuration); + const [assistData, setAssistData] = useState(configurations.assistRules); + const [eventData, setEventData] = useState(configurations.event); + const [eventRulesData, setEventRulesData] = useState(configurations.eventRules); + const [settingsData, setSettingsData] = useState(configurations.settings); + + const [restart, setRestart] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [saveError, setSaveError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(false); + + const handleSaveAll = async () => { + setIsSubmitting(true); + setSaveError(null); + setSaveSuccess(false); + + const result = await bulkUpdateConfigurationsAction( + serverId, + { + configuration: configData, + assistRules: assistData, + event: eventData, + eventRules: eventRulesData, + settings: settingsData + }, + restart + ); + + if (result.success) { + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 3000); + } else { + setSaveError(result.message); + } + + setIsSubmitting(false); + }; + + const isConfigTab = configTabs.has(currentTab); + const renderTabContent = () => { switch (currentTab) { case ServerTab.statistics: return ; case ServerTab.configuration: - return ; + return ( + + ); case ServerTab.assistRules: - return ; + return ( + + ); case ServerTab.event: - return ; + return ( + + ); case ServerTab.eventRules: - return ; + return ( + + ); case ServerTab.settings: - return ; + return ( + + ); case ServerTab.leaderboard: return ; @@ -71,30 +150,59 @@ export function ServerConfigurationTabs({ }; return ( -
-
- -
+
+ -
{renderTabContent()}
+ {isConfigTab && ( +
+
+ {saveError && ( +

{saveError}

+ )} + {saveSuccess && ( +

All configurations saved.

+ )} +
+
+ + +
+
+ )} + +
{renderTabContent()}
); } diff --git a/src/components/server/ServerCreationPopup.tsx b/src/components/server/ServerCreationPopup.tsx index 7f1dbfa..fcb5d0a 100755 --- a/src/components/server/ServerCreationPopup.tsx +++ b/src/components/server/ServerCreationPopup.tsx @@ -310,7 +310,7 @@ export function ServerCreationPopup({ !isCompleted && Object.values(steps).some((step) => step.status === 'in_progress'); return ( -
+
- )} - - - - - -
+
+ + ● {serviceStatusToString(server.status)} + + + + + {canDeleteServer && ( + + )}
+
-
-
-
Current Track
-
{server.state?.track || 'N/A'}
+
+ {[ + { label: 'Track', value: server.state?.track ?? 'N/A' }, + { label: 'Players', value: `${server.state?.playerCount ?? 0} / ${server.state?.maxConnections ?? 0}` }, + { label: 'Session', value: server.state?.session ?? 'N/A' }, + ].map(({ label, value }) => ( +
+
{label}
+
{value}
-
-
Players
-
- {server.state?.playerCount || 0} / {server.state?.maxConnections || 0} -
-
-
-
Session
-
{server.state?.session || 'N/A'}
-
-
-
Max Connections
-
- {server.state?.maxConnections || 0} -
-
-
+ ))}
setIsDeleteModalOpen(false)} server={server} /> -
+ ); } diff --git a/src/components/server/ServerListWithActions.tsx b/src/components/server/ServerListWithActions.tsx index 08a2e75..289d9da 100755 --- a/src/components/server/ServerListWithActions.tsx +++ b/src/components/server/ServerListWithActions.tsx @@ -1,12 +1,15 @@ 'use client'; 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 { ServerCard } from './ServerCard'; +import { ServerRow } from './ServerRow'; import { CreateServerModal } from './CreateServerModal'; import RefreshButton from '@/components/ui/RefreshButton'; import { useSteamCMD } from '@/lib/context/SteamCMDContext'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; interface ServerListWithActionsProps { servers: Server[]; @@ -20,29 +23,55 @@ export function ServerListWithActions({ servers, user }: ServerListWithActionsPr const handleOnClose = useCallback(() => setIsCreateModalOpen(false), []); const canCreateServer = hasPermission(user, 'server.create'); + const runningCount = servers.filter((s) => s.status === ServiceStatus.Running).length; + const offlineCount = servers.filter((s) => s.status !== ServiceStatus.Running).length; + return ( <> -
-

Your Servers

-
+
+
+ Servers + {servers.length} total +
+
+ {canCreateServer && ( - + + New Server + )} - +
+
+ +
+
+ {runningCount > 0 && ● {runningCount} running} + {offlineCount > 0 && {offlineCount} offline}
-
- {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 (
- +
- - + - - - - - + {sessions.map((session) => ( - - + - - - + - 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} -
-
-
{value}
-
{title}
-
+
+
+ {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

+
+
+

Player Count Over Time

- -
-

Session Types

+
+

Session Types

-
-

Daily Activity

+
+

Daily Activity

- {/* Recent Sessions */} -
-

Recent Sessions

+
+
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 ( - + ); } 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 ( -
+
@@ -28,7 +28,7 @@ export function SteamCMDNotification() {
-

+

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;
+
Date + Type + Track + Duration + Players
+
{formatDate(session.date)} - - {session.type} + + + {session.type === 'R' ? 'Race' : session.type === 'Q' ? 'Qualify' : 'Practice'} {session.track} + {session.track} {formatDuration(session.duration)} + {session.players}