14 Commits

30 changed files with 903 additions and 330 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "acc-server-manager-web", "name": "acc-server-manager-web",
"version": "0.24.0", "version": "0.24.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",

View File

@@ -35,6 +35,7 @@ export default async function ServerPage({ params }: ServerPageProps) {
configurations={configurations} configurations={configurations}
statistics={statistics} statistics={statistics}
leaderboard={leaderboard} leaderboard={leaderboard}
user={session.user!}
/> />
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getActivityLogAction, getGlobalActivityLogAction } from '@/lib/actions/activityLog'; import { getActivityLogAction } from '@/lib/actions/activityLog';
import type { ActivityLog, ActivityLogFilter, ActionType } from '@/lib/schemas/activityLog'; import type { ActivityLog, ActivityLogFilter, ActionType } from '@/lib/schemas/activityLog';
import { ACTION_TYPE_LABELS, actionTypeSchema } from '@/lib/schemas/activityLog'; import { ACTION_TYPE_LABELS, actionTypeSchema } from '@/lib/schemas/activityLog';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -39,6 +39,9 @@ function parseDetails(raw: string): string {
if (obj.driver_count !== undefined && obj.race_count !== undefined) { if (obj.driver_count !== undefined && obj.race_count !== undefined) {
return `${obj.driver_count} drivers, ${obj.race_count} races`; return `${obj.driver_count} drivers, ${obj.race_count} races`;
} }
if (obj.preset_id) {
return `Preset: ${obj.preset_name} - Sections applied: ${obj.sections_applied}`;
}
return raw; return raw;
} catch { } catch {
return raw; return raw;
@@ -50,7 +53,9 @@ const ACTION_BADGE: Record<ActionType, string> = {
server_start: 'bg-green-bg text-green', server_start: 'bg-green-bg text-green',
server_stop: 'bg-red-bg text-red', server_stop: 'bg-red-bg text-red',
server_restart: 'bg-yellow-bg text-yellow', server_restart: 'bg-yellow-bg text-yellow',
leaderboard_update: 'bg-overlay text-muted' leaderboard_update: 'bg-overlay text-muted',
preset_create: 'bg-green-bg text-green',
preset_apply: 'bg-blue-bg text-blue'
}; };
export function ActivityLogTable({ serverId, servers }: ActivityLogTableProps) { export function ActivityLogTable({ serverId, servers }: ActivityLogTableProps) {

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import type { AssistRules } from '@/lib/schemas/config'; import type { AssistRules } from '@/lib/schemas/config';
import { FIELD_LABELS } from '@/lib/schemas/config';
interface AssistRulesEditorProps { interface AssistRulesEditorProps {
formData: AssistRules; formData: AssistRules;
@@ -8,52 +9,16 @@ interface AssistRulesEditorProps {
onFormDataChange: (data: AssistRules) => void; onFormDataChange: (data: AssistRules) => void;
} }
const assistFields = [ const assistFields: { key: keyof AssistRules; type: 'number' | 'select' }[] = [
{ { key: 'stabilityControlLevelMax', type: 'number' },
key: 'stabilityControlLevelMax' as keyof AssistRules, { key: 'disableAutosteer', type: 'select' },
label: 'Stability Control Level Max', { key: 'disableAutoLights', type: 'select' },
type: 'number' { key: 'disableAutoWiper', type: 'select' },
}, { key: 'disableAutoEngineStart', type: 'select' },
{ { key: 'disableAutoPitLimiter', type: 'select' },
key: 'disableAutosteer' as keyof AssistRules, { key: 'disableAutoGear', type: 'select' },
label: 'Disable Autosteer', { key: 'disableAutoClutch', type: 'select' },
type: 'select' { key: 'disableIdealLine', type: 'select' }
},
{
key: 'disableAutoLights' as keyof AssistRules,
label: 'Disable Auto Lights',
type: 'select'
},
{
key: 'disableAutoWiper' as keyof AssistRules,
label: 'Disable Auto Wiper',
type: 'select'
},
{
key: 'disableAutoEngineStart' as keyof AssistRules,
label: 'Disable Auto Engine Start',
type: 'select'
},
{
key: 'disableAutoPitLimiter' as keyof AssistRules,
label: 'Disable Auto Pit Limiter',
type: 'select'
},
{
key: 'disableAutoGear' as keyof AssistRules,
label: 'Disable Auto Gear',
type: 'select'
},
{
key: 'disableAutoClutch' as keyof AssistRules,
label: 'Disable Auto Clutch',
type: 'select'
},
{
key: 'disableIdealLine' as keyof AssistRules,
label: 'Disable Ideal Line',
type: 'select'
}
]; ];
export function AssistRulesEditor({ formData, disabled, onFormDataChange }: AssistRulesEditorProps) { export function AssistRulesEditor({ formData, disabled, onFormDataChange }: AssistRulesEditorProps) {
@@ -67,9 +32,9 @@ export function AssistRulesEditor({ formData, disabled, onFormDataChange }: Assi
return ( return (
<div className="max-w-3xl space-y-6"> <div className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{assistFields.map(({ key, label, type }) => ( {assistFields.map(({ key, type }) => (
<div key={key}> <div key={key}>
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS[key]}</label>
{type === 'number' ? ( {type === 'number' ? (
<input <input
type="number" type="number"

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import type { Configuration } from '@/lib/schemas/config'; import type { Configuration } from '@/lib/schemas/config';
import { FIELD_LABELS } from '@/lib/schemas/config';
interface ConfigurationEditorProps { interface ConfigurationEditorProps {
formData: Configuration; formData: Configuration;
@@ -20,7 +21,7 @@ export function ConfigurationEditor({ formData, disabled, onFormDataChange }: Co
<div className="max-w-3xl space-y-6"> <div className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">UDP Port</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.udpPort}</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -31,7 +32,7 @@ export function ConfigurationEditor({ formData, disabled, onFormDataChange }: Co
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">TCP Port</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.tcpPort}</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -42,7 +43,7 @@ export function ConfigurationEditor({ formData, disabled, onFormDataChange }: Co
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Max Connections</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.maxConnections}</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -53,7 +54,7 @@ export function ConfigurationEditor({ formData, disabled, onFormDataChange }: Co
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">LAN Discovery</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.lanDiscovery}</label>
<select <select
disabled={disabled} disabled={disabled}
value={formData.lanDiscovery} value={formData.lanDiscovery}
@@ -66,7 +67,7 @@ export function ConfigurationEditor({ formData, disabled, onFormDataChange }: Co
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Register To Lobby</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.registerToLobby}</label>
<select <select
disabled={disabled} disabled={disabled}
value={formData.registerToLobby} value={formData.registerToLobby}

View File

@@ -1,6 +1,8 @@
'use client'; 'use client';
import type { EventConfig, Session } from '@/lib/schemas/config'; import type { EventConfig, Session } from '@/lib/schemas/config';
import { FIELD_LABELS } from '@/lib/schemas/config';
import { TRACKS } from '@/lib/constants/tracks';
interface EventConfigEditorProps { interface EventConfigEditorProps {
formData: EventConfig; formData: EventConfig;
@@ -62,59 +64,35 @@ export function EventConfigEditor({ formData, disabled, onFormDataChange }: Even
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Track</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.track}</label>
<select <select
disabled={disabled} disabled={disabled}
value={formData.track} value={formData.track}
onChange={(e) => handleInputChange('track', e.target.value)} onChange={(e) => handleInputChange('track', e.target.value)}
className="form-select w-full" className="form-select w-full"
> >
<option value="barcelona">Barcelona</option> {Object.entries(TRACKS).map(([key, name]) => (
<option value="brands_hatch">Brands Hatch</option> <option key={key} value={key}>
<option value="donington">Donington</option> {name}
<option value="cota">COTA</option> </option>
<option value="hungaroring">Hungaroring</option> ))}
<option value="imola">Imola</option>
<option value="indianapolis">Indianapolis</option>
<option value="kyalami">Kyalami</option>
<option value="laguna_seca">Laguna Seca</option>
<option value="misano">Misano</option>
<option value="monza">Monza</option>
<option value="mount_panorama">Mount Panorama</option>
<option value="nurburgring">Nurburgring</option>
<option value="nurburgring_24h">Nurburgring 24h</option>
<option value="oulton_park">Oulton Park</option>
<option value="paul_ricard">Paul Ricard</option>
<option value="red_bull_ring">Red Bull Ring</option>
<option value="silverstone">Silverstone</option>
<option value="snetterton">Snetterton</option>
<option value="spa">Spa-Francorchamps</option>
<option value="suzuka">Suzuka</option>
<option value="valencia">Valencia</option>
<option value="zandvoort">Zandvoort</option>
<option value="zolder">Zolder</option>
<option value="watkins_glen">Watkins Glen</option>
</select> </select>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary"> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.preRaceWaitingTimeSeconds}</label>
Pre-Race Waiting Time (seconds)
</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
value={formData.preRaceWaitingTimeSeconds} value={formData.preRaceWaitingTimeSeconds}
onChange={(e) => handleInputChange('preRaceWaitingTimeSeconds', e.target.value)} onChange={(e) => handleInputChange('preRaceWaitingTimeSeconds', e.target.value)}
className="form-input w-full" className="form-input w-full"
min="0" min="30"
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary"> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.sessionOverTimeSeconds}</label>
Session Over Time (seconds)
</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -126,9 +104,7 @@ export function EventConfigEditor({ formData, disabled, onFormDataChange }: Even
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary"> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.postQualySeconds}</label>
Post Qualify Seconds
</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -140,9 +116,7 @@ export function EventConfigEditor({ formData, disabled, onFormDataChange }: Even
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary"> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.postRaceSeconds}</label>
Post Race Seconds
</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -161,9 +135,7 @@ export function EventConfigEditor({ formData, disabled, onFormDataChange }: Even
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary"> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.ambientTemp}</label>
Ambient Temperature (°C)
</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -176,9 +148,7 @@ export function EventConfigEditor({ formData, disabled, onFormDataChange }: Even
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary"> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.cloudLevel}</label>
Cloud Level (0.0-1.0)
</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -192,7 +162,7 @@ export function EventConfigEditor({ formData, disabled, onFormDataChange }: Even
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Rain (0.0-1.0)</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.rain}</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -206,9 +176,7 @@ export function EventConfigEditor({ formData, disabled, onFormDataChange }: Even
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary"> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.weatherRandomness}</label>
Weather Randomness
</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -221,9 +189,7 @@ export function EventConfigEditor({ formData, disabled, onFormDataChange }: Even
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary"> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.simracerWeatherConditions}</label>
Simracer Weather Conditions
</label>
<select <select
disabled={disabled} disabled={disabled}
value={formData.simracerWeatherConditions} value={formData.simracerWeatherConditions}
@@ -236,9 +202,7 @@ export function EventConfigEditor({ formData, disabled, onFormDataChange }: Even
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary"> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.isFixedConditionQualification}</label>
Fixed Condition Qualification
</label>
<select <select
disabled={disabled} disabled={disabled}
value={formData.isFixedConditionQualification} value={formData.isFixedConditionQualification}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import type { EventRules } from '@/lib/schemas/config'; import type { EventRules } from '@/lib/schemas/config';
import { FIELD_LABELS } from '@/lib/schemas/config';
interface EventRulesEditorProps { interface EventRulesEditorProps {
formData: EventRules; formData: EventRules;
@@ -8,63 +9,21 @@ interface EventRulesEditorProps {
onFormDataChange: (data: EventRules) => void; onFormDataChange: (data: EventRules) => void;
} }
const numberFields = [ const numberFields: { key: keyof EventRules; min: number; max?: number }[] = [
{ { key: 'pitWindowLengthSec', min: -1 },
key: 'qualifyStandingType' as keyof EventRules, { key: 'driverStintTimeSec', min: -1 },
label: 'Qualify Standing Type', { key: 'mandatoryPitstopCount', min: 0, max: 5 },
min: -1, { key: 'maxTotalDrivingTime', min: -1 },
max: 1 { key: 'maxDriversCount', min: 1, max: 10 },
}, { key: 'tyreSetCount', min: 0, max: 50 }
{
key: 'pitWindowLengthSec' as keyof EventRules,
label: 'Pit Window Length (seconds)',
min: -1
},
{
key: 'driverStintTimeSec' as keyof EventRules,
label: 'Driver Stint Time (seconds)',
min: -1
},
{
key: 'mandatoryPitstopCount' as keyof EventRules,
label: 'Mandatory Pitstop Count',
min: -1,
max: 5
},
{
key: 'maxTotalDrivingTime' as keyof EventRules,
label: 'Max Total Driving Time (seconds)',
min: -1
},
{
key: 'tyreSetCount' as keyof EventRules,
label: 'Tyre Set Count',
min: 0,
max: 50
}
]; ];
const booleanFields = [ const booleanFields: { key: keyof EventRules }[] = [
{ { key: 'isRefuellingAllowedInRace' },
key: 'isRefuellingAllowedInRace' as keyof EventRules, { key: 'isRefuellingTimeFixed' },
label: 'Refuelling Allowed in Race' { key: 'isMandatoryPitstopRefuellingRequired' },
}, { key: 'isMandatoryPitstopTyreChangeRequired' },
{ { key: 'isMandatoryPitstopSwapDriverRequired' }
key: 'isRefuellingTimeFixed' as keyof EventRules,
label: 'Refuelling Time Fixed'
},
{
key: 'isMandatoryPitstopRefuellingRequired' as keyof EventRules,
label: 'Mandatory Pitstop Refuelling Required'
},
{
key: 'isMandatoryPitstopTyreChangeRequired' as keyof EventRules,
label: 'Mandatory Pitstop Tyre Change Required'
},
{
key: 'isMandatoryPitstopSwapDriverRequired' as keyof EventRules,
label: 'Mandatory Pitstop Swap Driver Required'
}
]; ];
export function EventRulesEditor({ formData, disabled, onFormDataChange }: EventRulesEditorProps) { export function EventRulesEditor({ formData, disabled, onFormDataChange }: EventRulesEditorProps) {
@@ -77,9 +36,21 @@ export function EventRulesEditor({ formData, disabled, onFormDataChange }: Event
<div className="space-y-6"> <div className="space-y-6">
<h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">Race Rules</h3> <h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">Race Rules</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{numberFields.map(({ key, label, min, max }) => ( <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.qualifyStandingType}</label>
<select
disabled={disabled}
value={formData.qualifyStandingType}
onChange={(e) => handleInputChange('qualifyStandingType', parseInt(e.target.value))}
className="form-select w-full"
>
<option value={1}>Fastest Lap</option>
<option value={2}>Average Lap</option>
</select>
</div>
{numberFields.map(({ key, min, max }) => (
<div key={key}> <div key={key}>
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS[key]}</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -99,9 +70,9 @@ export function EventRulesEditor({ formData, disabled, onFormDataChange }: Event
Pitstop & Refuelling Rules Pitstop & Refuelling Rules
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{booleanFields.map(({ key, label }) => ( {booleanFields.map(({ key }) => (
<div key={key}> <div key={key}>
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS[key]}</label>
<select <select
disabled={disabled} disabled={disabled}
value={formData[key] ? 'true' : 'false'} value={formData[key] ? 'true' : 'false'}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import type { ServerSettings } from '@/lib/schemas/config'; import type { ServerSettings } from '@/lib/schemas/config';
import { FIELD_LABELS } from '@/lib/schemas/config';
interface ServerSettingsEditorProps { interface ServerSettingsEditorProps {
formData: ServerSettings; formData: ServerSettings;
@@ -8,82 +9,31 @@ interface ServerSettingsEditorProps {
onFormDataChange: (data: ServerSettings) => void; onFormDataChange: (data: ServerSettings) => void;
} }
const textFields = [ const textFields: { key: keyof ServerSettings; type: 'text' | 'password' }[] = [
{ { key: 'serverName', type: 'text' },
key: 'serverName' as keyof ServerSettings, { key: 'adminPassword', type: 'password' },
label: 'Server Name', { key: 'password', type: 'password' },
type: 'text' { key: 'spectatorPassword', type: 'password' },
}, { key: 'centralEntryListPath', type: 'text' }
{
key: 'adminPassword' as keyof ServerSettings,
label: 'Admin Password',
type: 'password'
},
{
key: 'password' as keyof ServerSettings,
label: 'Password',
type: 'password'
},
{
key: 'spectatorPassword' as keyof ServerSettings,
label: 'Spectator Password',
type: 'password'
},
{
key: 'centralEntryListPath' as keyof ServerSettings,
label: 'Central Entry List Path',
type: 'text'
}
]; ];
const carGroups = ['FreeForAll', 'GT3', 'GT4', 'GT2', 'GTC', 'TCX']; const carGroups = ['FreeForAll', 'GT3', 'GT4', 'GT2', 'GTC', 'TCX'];
const numberFields = [ const numberFields: { key: keyof ServerSettings; min: number; max: number }[] = [
{ { key: 'trackMedalsRequirement', min: 0, max: 3 },
key: 'trackMedalsRequirement' as keyof ServerSettings, { key: 'safetyRatingRequirement', min: -1, max: 99 },
label: 'Track Medals Requirement', { key: 'racecraftRatingRequirement', min: -1, max: 99 },
min: -1, { key: 'maxCarSlots', min: 1, max: 30 }
max: 3
},
{
key: 'safetyRatingRequirement' as keyof ServerSettings,
label: 'Safety Rating Requirement',
min: -1,
max: 99
},
{
key: 'racecraftRatingRequirement' as keyof ServerSettings,
label: 'Racecraft Rating Requirement',
min: -1,
max: 99
},
{
key: 'maxCarSlots' as keyof ServerSettings,
label: 'Max Car Slots',
min: 1,
max: 30
}
]; ];
const selectFields = [ const selectFields: { key: keyof ServerSettings }[] = [
{ { key: 'dumpLeaderboards' },
key: 'dumpLeaderboards' as keyof ServerSettings, { key: 'isRaceLocked' },
label: 'Dump Leaderboards' { key: 'randomizeTrackWhenEmpty' },
}, { key: 'allowAutoDQ' },
{ key: 'isRaceLocked' as keyof ServerSettings, label: 'Race Locked' }, { key: 'shortFormationLap' },
{ { key: 'dumpEntryList' },
key: 'randomizeTrackWhenEmpty' as keyof ServerSettings, { key: 'ignorePrematureDisconnects' }
label: 'Randomize Track When Empty'
},
{ key: 'allowAutoDQ' as keyof ServerSettings, label: 'Allow Auto DQ' },
{
key: 'shortFormationLap' as keyof ServerSettings,
label: 'Short Formation Lap'
},
{
key: 'ignorePrematureDisconnects' as keyof ServerSettings,
label: 'Ignore Premature Disconnects'
}
]; ];
export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: ServerSettingsEditorProps) { export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: ServerSettingsEditorProps) {
@@ -98,9 +48,9 @@ export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: S
Basic Settings Basic Settings
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{textFields.map(({ key, label, type }) => ( {textFields.map(({ key, type }) => (
<div key={key}> <div key={key}>
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS[key]}</label>
<input <input
type={type} type={type}
disabled={disabled} disabled={disabled}
@@ -112,7 +62,7 @@ export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: S
))} ))}
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Car Group</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.carGroup}</label>
<select <select
disabled={disabled} disabled={disabled}
value={formData.carGroup} value={formData.carGroup}
@@ -134,9 +84,9 @@ export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: S
Requirements & Limits Requirements & Limits
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{numberFields.map(({ key, label, min, max }) => ( {numberFields.map(({ key, min, max }) => (
<div key={key}> <div key={key}>
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS[key]}</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -156,9 +106,9 @@ export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: S
Race Options Race Options
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{selectFields.map(({ key, label }) => ( {selectFields.map(({ key }) => (
<div key={key}> <div key={key}>
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS[key]}</label>
<select <select
disabled={disabled} disabled={disabled}
value={formData[key] as number} value={formData[key] as number}
@@ -172,9 +122,7 @@ export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: S
))} ))}
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary"> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.formationLapType}</label>
Formation Lap Type
</label>
<select <select
disabled={disabled} disabled={disabled}
value={formData.formationLapType} value={formData.formationLapType}

View File

@@ -0,0 +1,187 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { listPresetsAction, applyPresetAction } from '@/lib/actions/presets';
import type { ConfigPreset } from '@/lib/schemas/preset';
import { PRESET_SECTION_LABELS, getPresetSections } from '@/lib/schemas/preset';
interface PresetPickerModalProps {
isOpen: boolean;
onClose: () => void;
serverId: string;
onApplied: () => void;
}
export function PresetPickerModal({ isOpen, onClose, serverId, onApplied }: PresetPickerModalProps) {
const [presets, setPresets] = useState<ConfigPreset[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [restart, setRestart] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const loadPresets = useCallback(async () => {
setIsLoading(true);
setLoadError(null);
const result = await listPresetsAction();
setIsLoading(false);
if (!result.success) {
setLoadError(result.message);
return;
}
setPresets(result.data);
}, []);
useEffect(() => {
if (isOpen) {
setSelected(null);
setApplyError(null);
setSearch('');
loadPresets();
}
}, [isOpen, loadPresets]);
const filtered = presets.filter((p) =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase())
);
const handleApply = async () => {
if (!selected) return;
setIsApplying(true);
setApplyError(null);
const result = await applyPresetAction(serverId, selected, restart);
setIsApplying(false);
if (!result.success) {
setApplyError(result.message);
return;
}
onApplied();
onClose();
};
const selectedPreset = presets.find((p) => p.id === selected);
return (
<Modal isOpen={isOpen} onClose={onClose} title="Load Config Preset" className="max-w-lg">
<div className="flex flex-col gap-4">
<input
type="text"
placeholder="Search presets..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="form-input text-sm"
/>
{loadError && <p className="text-sm text-red">{loadError}</p>}
<div className="max-h-64 overflow-y-auto rounded-md border border-border">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner size="sm" />
</div>
) : filtered.length === 0 ? (
<div className="py-8 text-center text-sm text-muted">
{presets.length === 0 ? 'No presets saved yet.' : 'No presets match your search.'}
</div>
) : (
<ul>
{filtered.map((preset) => {
const sections = getPresetSections(preset);
const isSelected = selected === preset.id;
return (
<li
key={preset.id}
onClick={() => setSelected(preset.id)}
className={`cursor-pointer border-b border-border-muted px-4 py-3 transition-colors last:border-0 ${
isSelected
? 'bg-orange/10'
: 'hover:bg-hover'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className={`truncate text-sm font-medium ${isSelected ? 'text-orange' : 'text-primary'}`}>
{preset.name}
</p>
{preset.description && (
<p className="mt-0.5 truncate text-xs text-muted">{preset.description}</p>
)}
</div>
<div className="shrink-0 text-right">
{sections.length > 0 ? (
<p className="text-xs text-muted">{sections.length} section{sections.length !== 1 ? 's' : ''}</p>
) : (
<p className="text-xs text-muted/50">Empty</p>
)}
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
{selectedPreset && (
<div className="rounded-md border border-border-muted bg-overlay px-4 py-3">
<p className="mb-2 text-xs font-medium text-secondary">Sections in this preset:</p>
<div className="flex flex-wrap gap-1.5">
{getPresetSections(selectedPreset).map((key) => (
<span
key={key}
className="rounded-full bg-blue-bg px-2 py-0.5 text-xs text-blue"
>
{PRESET_SECTION_LABELS[key] ?? key}
</span>
))}
{getPresetSections(selectedPreset).length === 0 && (
<span className="text-xs text-muted">No sections — nothing will be applied.</span>
)}
</div>
</div>
)}
{applyError && <p className="text-sm text-red">{applyError}</p>}
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart after applying
</label>
<div className="flex gap-2">
<Button variant="ghost" size="md" onClick={onClose} disabled={isApplying}>
Cancel
</Button>
<Button
variant="primary"
size="md"
onClick={handleApply}
disabled={!selected || isApplying}
>
{isApplying ? (
<>
<LoadingSpinner size="sm" />
Applying…
</>
) : (
'Apply Preset'
)}
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { createPresetAction } from '@/lib/actions/presets';
import type { Configurations } from '@/lib/schemas/config';
import { PRESET_SECTION_LABELS } from '@/lib/schemas/preset';
type SectionKey = keyof Configurations;
const ALL_SECTIONS: SectionKey[] = ['configuration', 'assistRules', 'event', 'eventRules', 'settings'];
interface SavePresetModalProps {
isOpen: boolean;
onClose: () => void;
configurations: Configurations;
}
export function SavePresetModal({ isOpen, onClose, configurations }: SavePresetModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [selectedSections, setSelectedSections] = useState<Set<SectionKey>>(
new Set(ALL_SECTIONS)
);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const toggleSection = (key: SectionKey) =>
setSelectedSections((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
const handleClose = () => {
if (isSaving) return;
setName('');
setDescription('');
setSelectedSections(new Set(ALL_SECTIONS));
setError(null);
setSaved(false);
onClose();
};
const handleSave = async () => {
if (!name.trim() || selectedSections.size === 0) return;
setIsSaving(true);
setError(null);
const result = await createPresetAction({
name: name.trim(),
description: description.trim() || undefined,
configuration: selectedSections.has('configuration') ? (configurations.configuration as Record<string, unknown>) : null,
assistRules: selectedSections.has('assistRules') ? (configurations.assistRules as Record<string, unknown>) : null,
event: selectedSections.has('event') ? (configurations.event as Record<string, unknown>) : null,
eventRules: selectedSections.has('eventRules') ? (configurations.eventRules as Record<string, unknown>) : null,
settings: selectedSections.has('settings') ? (configurations.settings as Record<string, unknown>) : null
});
setIsSaving(false);
if (!result.success) {
setError(result.message);
return;
}
setSaved(true);
setTimeout(() => handleClose(), 1200);
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Save as Preset">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-secondary">Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Sprint Race — Spa"
className="form-input text-sm"
disabled={isSaving || saved}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-secondary">Description</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description..."
className="form-input text-sm"
disabled={isSaving || saved}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-secondary">Include sections</label>
<div className="flex flex-col gap-1.5">
{ALL_SECTIONS.map((key) => (
<label key={key} className="flex cursor-pointer items-center gap-2.5 text-sm text-secondary">
<input
type="checkbox"
checked={selectedSections.has(key)}
onChange={() => toggleSection(key)}
disabled={isSaving || saved}
className="h-4 w-4 rounded border-border bg-overlay accent-orange"
/>
{PRESET_SECTION_LABELS[key]}
</label>
))}
</div>
</div>
{error && <p className="text-sm text-red">{error}</p>}
{saved && <p className="text-sm text-green">Preset saved!</p>}
<div className="flex justify-end gap-2">
<Button variant="ghost" size="md" onClick={handleClose} disabled={isSaving}>
Cancel
</Button>
<Button
variant="primary"
size="md"
onClick={handleSave}
disabled={!name.trim() || selectedSections.size === 0 || isSaving || saved}
>
{isSaving ? (
<>
<LoadingSpinner size="sm" />
Saving…
</>
) : (
'Save Preset'
)}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -3,6 +3,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { useTransition } from 'react'; import { useTransition } from 'react';
import { Server, ServiceStatus, serviceStatusToString } from '@/lib/schemas'; import { Server, ServiceStatus, serviceStatusToString } from '@/lib/schemas';
import { getTrackDisplayName } from '@/lib/constants/tracks';
import { import {
startServerEventAction, startServerEventAction,
restartServerEventAction, restartServerEventAction,
@@ -55,8 +56,8 @@ export function ServerCard({ server }: ServerCardProps) {
const isRunning = server.status === ServiceStatus.Running; const isRunning = server.status === ServiceStatus.Running;
return ( return (
<div className="overflow-hidden rounded-lg border border-border bg-canvas transition-colors hover:border-blue/40"> <div className="flex flex-col overflow-hidden rounded-lg border border-border bg-canvas transition-colors hover:border-blue/40">
<Link href={`/dashboard/server/${server.id}`} className="block p-4"> <Link href={`/dashboard/server/${server.id}`} className="flex flex-1 flex-col p-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<span className="text-sm font-semibold text-primary">{server.name}</span> <span className="text-sm font-semibold text-primary">{server.name}</span>
<StatusDot status={server.status} /> <StatusDot status={server.status} />
@@ -72,12 +73,12 @@ export function ServerCard({ server }: ServerCardProps) {
<div> <div>
<div className="text-xs text-subtle">Track</div> <div className="text-xs text-subtle">Track</div>
<div className="truncate text-sm font-semibold text-primary"> <div className="truncate text-sm font-semibold text-primary">
{server.state?.track || 'N/A'} {server.state?.track ? getTrackDisplayName(server.state.track) : 'N/A'}
</div> </div>
</div> </div>
</div> </div>
<div className="mt-3 flex items-center justify-between gap-2"> <div className="mt-auto pt-3 flex items-center justify-between gap-2">
<Badge variant={statusBadgeVariant(server.status)}> <Badge variant={statusBadgeVariant(server.status)}>
{serviceStatusToString(server.status)} {serviceStatusToString(server.status)}
</Badge> </Badge>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Configurations, ServerTab } from '@/lib/schemas/config'; import { type Configurations, ServerTab } from '@/lib/schemas/config';
import { ConfigurationEditor } from '@/components/configuration/ConfigurationEditor'; import { ConfigurationEditor } from '@/components/configuration/ConfigurationEditor';
import { AssistRulesEditor } from '@/components/configuration/AssistRulesEditor'; import { AssistRulesEditor } from '@/components/configuration/AssistRulesEditor';
import { EventConfigEditor } from '@/components/configuration/EventConfigEditor'; import { EventConfigEditor } from '@/components/configuration/EventConfigEditor';
@@ -10,16 +10,22 @@ import { StatisticsDashboard } from '@/components/statistics/StatisticsDashboard
import { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager'; import { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager';
import { ActivityLogTable } from '@/components/activityLog/ActivityLogTable'; import { ActivityLogTable } from '@/components/activityLog/ActivityLogTable';
import { ServerLogViewer } from '@/components/server/ServerLogViewer'; import { ServerLogViewer } from '@/components/server/ServerLogViewer';
import { PresetPickerModal } from '@/components/server/PresetPickerModal';
import { SavePresetModal } from '@/components/server/SavePresetModal';
import { useState } from 'react'; import { useState } from 'react';
import { StateHistoryStats } from '@/lib/schemas'; import { StateHistoryStats, hasPermission, type User } from '@/lib/schemas';
import { Leaderboard } from '@/lib/schemas/leaderboard'; import { Leaderboard } from '@/lib/schemas/leaderboard';
import { bulkUpdateConfigurationsAction } from '@/lib/actions/configuration'; import {
bulkUpdateConfigurationsAction,
getConfigurationsAction
} from '@/lib/actions/configuration';
interface ServerConfigurationTabsProps { interface ServerConfigurationTabsProps {
serverId: string; serverId: string;
configurations: Configurations; configurations: Configurations;
statistics: StateHistoryStats; statistics: StateHistoryStats;
leaderboard: Leaderboard; leaderboard: Leaderboard;
user: User;
} }
const tabs = [ const tabs = [
{ id: ServerTab.statistics, name: 'Statistics', icon: '📊' }, { id: ServerTab.statistics, name: 'Statistics', icon: '📊' },
@@ -45,8 +51,11 @@ export function ServerConfigurationTabs({
serverId, serverId,
configurations, configurations,
statistics, statistics,
leaderboard leaderboard,
user
}: ServerConfigurationTabsProps) { }: ServerConfigurationTabsProps) {
const canViewPresets = hasPermission(user, 'preset.view');
const canCreatePresets = hasPermission(user, 'preset.create');
const [currentTab, setCurrentTab] = useState(ServerTab.statistics); const [currentTab, setCurrentTab] = useState(ServerTab.statistics);
const [configData, setConfigData] = useState(configurations.configuration); const [configData, setConfigData] = useState(configurations.configuration);
@@ -54,30 +63,47 @@ export function ServerConfigurationTabs({
const [eventData, setEventData] = useState(configurations.event); const [eventData, setEventData] = useState(configurations.event);
const [eventRulesData, setEventRulesData] = useState(configurations.eventRules); const [eventRulesData, setEventRulesData] = useState(configurations.eventRules);
const [settingsData, setSettingsData] = useState(configurations.settings); const [settingsData, setSettingsData] = useState(configurations.settings);
const [dirtyConfigs, setDirtyConfigs] = useState<Set<keyof Configurations>>(new Set());
const [restart, setRestart] = useState(true); const [restart, setRestart] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
const [isPresetModalOpen, setIsPresetModalOpen] = useState(false);
const [isSavePresetModalOpen, setIsSavePresetModalOpen] = useState(false);
const reloadConfigs = async () => {
const result = await getConfigurationsAction(serverId);
if (!result.success) return;
setConfigData(result.data.configuration);
setAssistData(result.data.assistRules);
setEventData(result.data.event);
setEventRulesData(result.data.eventRules);
setSettingsData(result.data.settings);
setDirtyConfigs(new Set());
};
const markDirty = (key: keyof Configurations) =>
setDirtyConfigs((prev) => new Set(prev).add(key));
const handleSaveAll = async () => { const handleSaveAll = async () => {
if (dirtyConfigs.size === 0) return;
setIsSubmitting(true); setIsSubmitting(true);
setSaveError(null); setSaveError(null);
setSaveSuccess(false); setSaveSuccess(false);
const result = await bulkUpdateConfigurationsAction( const payload: Partial<Configurations> = {};
serverId, if (dirtyConfigs.has('configuration')) payload.configuration = configData;
{ if (dirtyConfigs.has('assistRules')) payload.assistRules = assistData;
configuration: configData, if (dirtyConfigs.has('event')) payload.event = eventData;
assistRules: assistData, if (dirtyConfigs.has('eventRules')) payload.eventRules = eventRulesData;
event: eventData, if (dirtyConfigs.has('settings')) payload.settings = settingsData;
eventRules: eventRulesData,
settings: settingsData const result = await bulkUpdateConfigurationsAction(serverId, payload, restart);
},
restart
);
if (result.success) { if (result.success) {
setDirtyConfigs(new Set());
setSaveSuccess(true); setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000); setTimeout(() => setSaveSuccess(false), 3000);
} else { } else {
@@ -99,7 +125,10 @@ export function ServerConfigurationTabs({
<ConfigurationEditor <ConfigurationEditor
formData={configData} formData={configData}
disabled={isSubmitting} disabled={isSubmitting}
onFormDataChange={setConfigData} onFormDataChange={(data) => {
setConfigData(data);
markDirty('configuration');
}}
/> />
); );
@@ -108,7 +137,10 @@ export function ServerConfigurationTabs({
<AssistRulesEditor <AssistRulesEditor
formData={assistData} formData={assistData}
disabled={isSubmitting} disabled={isSubmitting}
onFormDataChange={setAssistData} onFormDataChange={(data) => {
setAssistData(data);
markDirty('assistRules');
}}
/> />
); );
@@ -117,7 +149,10 @@ export function ServerConfigurationTabs({
<EventConfigEditor <EventConfigEditor
formData={eventData} formData={eventData}
disabled={isSubmitting} disabled={isSubmitting}
onFormDataChange={setEventData} onFormDataChange={(data) => {
setEventData(data);
markDirty('event');
}}
/> />
); );
@@ -126,7 +161,10 @@ export function ServerConfigurationTabs({
<EventRulesEditor <EventRulesEditor
formData={eventRulesData} formData={eventRulesData}
disabled={isSubmitting} disabled={isSubmitting}
onFormDataChange={setEventRulesData} onFormDataChange={(data) => {
setEventRulesData(data);
markDirty('eventRules');
}}
/> />
); );
@@ -135,7 +173,10 @@ export function ServerConfigurationTabs({
<ServerSettingsEditor <ServerSettingsEditor
formData={settingsData} formData={settingsData}
disabled={isSubmitting} disabled={isSubmitting}
onFormDataChange={setSettingsData} onFormDataChange={(data) => {
setSettingsData(data);
markDirty('settings');
}}
/> />
); );
@@ -161,7 +202,10 @@ export function ServerConfigurationTabs({
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<nav className="flex overflow-x-auto border-b border-border-muted px-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" aria-label="Tabs"> <nav
className="border-border-muted flex overflow-x-auto border-b px-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
aria-label="Tabs"
>
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = currentTab === tab.id; const isActive = currentTab === tab.id;
return ( return (
@@ -171,7 +215,7 @@ export function ServerConfigurationTabs({
className={`flex shrink-0 items-center gap-1.5 border-b-2 px-3 py-3 text-sm font-medium whitespace-nowrap transition-colors ${ className={`flex shrink-0 items-center gap-1.5 border-b-2 px-3 py-3 text-sm font-medium whitespace-nowrap transition-colors ${
isActive isActive
? 'border-orange text-primary' ? 'border-orange text-primary'
: 'border-transparent text-muted hover:text-primary' : 'text-muted hover:text-primary border-transparent'
}`} }`}
> >
<span className="text-sm">{tab.icon}</span> <span className="text-sm">{tab.icon}</span>
@@ -182,29 +226,49 @@ export function ServerConfigurationTabs({
</nav> </nav>
{isConfigTab && ( {isConfigTab && (
<div className="flex items-center justify-between border-b border-border-muted bg-overlay px-5 py-3"> <div className="border-border-muted bg-overlay flex items-center justify-between border-b px-5 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{saveError && ( {saveError && (
<p className="text-sm text-red">{saveError}</p> <ul className="flex flex-col gap-0.5">
)} {saveError.split('\n').map((line, i) => (
{saveSuccess && ( <li key={i} className="text-red text-sm">• {line}</li>
<p className="text-sm text-green">All configurations saved.</p> ))}
)} </ul>
)}
{saveSuccess && <p className="text-green text-sm">All configurations saved.</p>}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-muted"> <label className="text-muted flex items-center gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"
checked={restart} checked={restart}
onChange={(e) => setRestart(e.target.checked)} onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue" className="border-border bg-overlay accent-green focus:ring-blue h-4 w-4 rounded focus:ring-1"
/> />
Restart after saving Restart after saving
</label> </label>
{canCreatePresets && (
<button
onClick={() => setIsSavePresetModalOpen(true)}
disabled={isSubmitting}
className="border-border text-secondary hover:bg-hover rounded-md border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40"
>
Save as Preset
</button>
)}
{canViewPresets && (
<button
onClick={() => setIsPresetModalOpen(true)}
disabled={isSubmitting}
className="border-border text-secondary hover:bg-hover rounded-md border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40"
>
Load Preset
</button>
)}
<button <button
onClick={handleSaveAll} onClick={handleSaveAll}
disabled={isSubmitting} disabled={isSubmitting || dirtyConfigs.size === 0}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40" className="bg-btn-green border-btn-green hover:bg-btn-green-hover rounded-md border px-5 py-2 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-40"
> >
{isSubmitting ? 'Saving…' : 'Save All'} {isSubmitting ? 'Saving…' : 'Save All'}
</button> </button>
@@ -213,6 +277,29 @@ export function ServerConfigurationTabs({
)} )}
<div className="flex-1 overflow-y-auto p-5">{renderTabContent()}</div> <div className="flex-1 overflow-y-auto p-5">{renderTabContent()}</div>
<SavePresetModal
isOpen={isSavePresetModalOpen}
onClose={() => setIsSavePresetModalOpen(false)}
configurations={{
configuration: configData,
assistRules: assistData,
event: eventData,
eventRules: eventRulesData,
settings: settingsData
}}
/>
<PresetPickerModal
isOpen={isPresetModalOpen}
onClose={() => setIsPresetModalOpen(false)}
serverId={serverId}
onApplied={async () => {
await reloadConfigs();
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
}}
/>
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { Server, serviceStatusToString, ServiceStatus } from '@/lib/schemas/server'; import { Server, serviceStatusToString, ServiceStatus } from '@/lib/schemas/server';
import { getTrackDisplayName } from '@/lib/constants/tracks';
import { import {
startServerEventAction, startServerEventAction,
restartServerEventAction, restartServerEventAction,
@@ -48,18 +49,18 @@ export function ServerHeader({ server, user }: ServerHeaderProps) {
return ( return (
<> <>
<header className="flex h-12 items-center justify-between border-b border-border-muted px-5"> <header className="flex flex-col gap-2 border-b border-border-muted px-5 py-2 sm:h-12 sm:flex-row sm:items-center sm:justify-between sm:gap-0 sm:py-0">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Link href="/dashboard" className="text-blue hover:underline">Servers</Link> <Link href="/dashboard" className="text-blue hover:underline">Servers</Link>
<span className="text-subtle">/</span> <span className="text-subtle">/</span>
<span className="font-semibold text-primary">{server.name}</span> <span className="font-semibold text-primary">{server.name}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant={ <Badge variant={
server.status === ServiceStatus.Running ? 'green' : server.status === ServiceStatus.Running ? 'green' :
server.status === ServiceStatus.Stopped ? 'gray' : 'yellow' server.status === ServiceStatus.Stopped ? 'gray' : 'yellow'
}> }>
● {serviceStatusToString(server.status)} ● <span className="hidden sm:inline">{serviceStatusToString(server.status)}</span>
</Badge> </Badge>
<Button variant="ghost" size="sm" onClick={startServer} disabled={server.status === ServiceStatus.Running || disabled || isPending}>Start</Button> <Button variant="ghost" size="sm" onClick={startServer} disabled={server.status === ServiceStatus.Running || disabled || isPending}>Start</Button>
<Button variant="ghost" size="sm" onClick={restartServer} disabled={server.status === ServiceStatus.Stopped || disabled || isPending}>Restart</Button> <Button variant="ghost" size="sm" onClick={restartServer} disabled={server.status === ServiceStatus.Stopped || disabled || isPending}>Restart</Button>
@@ -72,7 +73,7 @@ export function ServerHeader({ server, user }: ServerHeaderProps) {
<div className="flex flex-wrap gap-6 border-b border-border-muted px-5 py-3"> <div className="flex flex-wrap gap-6 border-b border-border-muted px-5 py-3">
{[ {[
{ label: 'Track', value: server.state?.track ?? 'N/A' }, { label: 'Track', value: server.state?.track ? getTrackDisplayName(server.state.track) : 'N/A' },
{ label: 'Players', value: `${server.state?.playerCount ?? 0} / ${server.state?.maxConnections ?? 0}` }, { label: 'Players', value: `${server.state?.playerCount ?? 0} / ${server.state?.maxConnections ?? 0}` },
{ label: 'Session', value: server.state?.session ?? 'N/A' }, { label: 'Session', value: server.state?.session ?? 'N/A' },
].map(({ label, value }) => ( ].map(({ label, value }) => (

View File

@@ -60,7 +60,7 @@ export function ServerListWithActions({ servers, user }: ServerListWithActionsPr
{/* Card grid — desktop only */} {/* Card grid — desktop only */}
<div <div
className="hidden gap-3 md:grid" className="hidden gap-3 md:grid"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))' }} style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(420px, 1fr))' }}
> >
{servers.map((server) => ( {servers.map((server) => (
<ServerCard key={server.id} server={server} /> <ServerCard key={server.id} server={server} />

View File

@@ -4,6 +4,7 @@ import { useTransition } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Server, ServiceStatus } from '@/lib/schemas'; import { Server, ServiceStatus } from '@/lib/schemas';
import { getTrackDisplayName } from '@/lib/constants/tracks';
import { import {
startServerEventAction, startServerEventAction,
restartServerEventAction, restartServerEventAction,
@@ -51,7 +52,7 @@ export function ServerRow({ server }: ServerRowProps) {
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-primary">{server.name}</div> <div className="truncate text-sm font-semibold text-primary">{server.name}</div>
<div className="truncate text-xs text-muted"> <div className="truncate text-xs text-muted">
{server.state?.track ?? 'No track'} · {server.state?.playerCount ?? 0} players {server.state?.track ? getTrackDisplayName(server.state.track) : 'No track'} · {server.state?.playerCount ?? 0} players
</div> </div>
</div> </div>
</Link> </Link>

View File

@@ -45,7 +45,8 @@ export function PlayerCountChart({ data }: PlayerCountChartProps) {
})), })),
borderColor: '#58a6ff', borderColor: '#58a6ff',
backgroundColor: '#58a6ff1a', backgroundColor: '#58a6ff1a',
tension: 0.4 tension: 0.4,
cubicInterpolationMode: 'monotone' as const
} }
] ]
}; };

View File

@@ -1,3 +1,5 @@
import { getTrackDisplayName } from '@/lib/constants/tracks';
interface RecentSession { interface RecentSession {
id: string; id: string;
date: string; date: string;
@@ -70,7 +72,7 @@ export function RecentSessions({ sessions }: RecentSessionsProps) {
{session.type === 'R' ? 'Race' : session.type === 'Q' ? 'Qualify' : 'Practice'} {session.type === 'R' ? 'Race' : session.type === 'Q' ? 'Qualify' : 'Practice'}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-sm text-secondary">{session.track}</td> <td className="px-4 py-3 text-sm text-secondary">{getTrackDisplayName(session.track)}</td>
<td className="px-4 py-3 text-sm text-muted"> <td className="px-4 py-3 text-sm text-muted">
{formatDuration(session.duration)} {formatDuration(session.duration)}
</td> </td>

View File

@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { loginUser, getOpenToken } from '@/lib/api/server/auth'; import { loginUser, getOpenToken } from '@/lib/api/server/auth';
import { login, logout } from '@/lib/auth/server'; import { login, logout } from '@/lib/auth/server';
import { formatZodIssues } from '@/lib/utils';
import { loginSchema, loginResponseSchema } from '../schemas'; import { loginSchema, loginResponseSchema } from '../schemas';
export type LoginResult = { export type LoginResult = {
@@ -19,7 +20,7 @@ export async function loginAction(prevState: LoginResult, formData: FormData) {
if (!loginData.success) { if (!loginData.success) {
return { return {
success: false, success: false,
message: loginData.error.message message: formatZodIssues(loginData.error.issues)
}; };
} }

View File

@@ -2,7 +2,8 @@
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server'; import { requireAuth } from '@/lib/auth/server';
import { updateServerConfiguration, bulkUpdateServerConfigurations } from '@/lib/api/server/configuration'; import { getServerConfigurations, updateServerConfiguration, bulkUpdateServerConfigurations } from '@/lib/api/server/configuration';
import { boolToInt, formatZodIssues } from '@/lib/utils';
import { import {
assistRulesSchema, assistRulesSchema,
ConfigFile, ConfigFile,
@@ -19,7 +20,21 @@ import type {
EventRules, EventRules,
ServerSettings ServerSettings
} from '@/lib/schemas/config'; } from '@/lib/schemas/config';
import { boolToInt } from '@/lib/utils';
export async function getConfigurationsAction(
serverId: string
): Promise<{ success: true; data: Configurations } | { success: false; message: string }> {
try {
const session = await requireAuth();
const data = await getServerConfigurations(session.token!, serverId);
return { success: true, data };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to fetch configurations'
};
}
}
export async function updateConfigurationAction(serverId: string, formData: FormData) { export async function updateConfigurationAction(serverId: string, formData: FormData) {
try { try {
@@ -72,7 +87,7 @@ export async function updateAssistRulesAction(serverId: string, formData: FormDa
const config = assistRulesSchema.safeParse(rawConfig); const config = assistRulesSchema.safeParse(rawConfig);
if (!config.success) { if (!config.success) {
return { success: false, message: config.error.message }; return { success: false, message: formatZodIssues(config.error.issues) };
} }
await updateServerConfiguration( await updateServerConfiguration(
@@ -114,12 +129,13 @@ export async function updateServerSettingsAction(serverId: string, formData: For
centralEntryListPath: formData.get('centralEntryListPath') as string, centralEntryListPath: formData.get('centralEntryListPath') as string,
allowAutoDQ: parseInt(formData.get('allowAutoDQ') as string), allowAutoDQ: parseInt(formData.get('allowAutoDQ') as string),
shortFormationLap: parseInt(formData.get('shortFormationLap') as string), shortFormationLap: parseInt(formData.get('shortFormationLap') as string),
dumpEntryList: parseInt(formData.get('dumpEntryList') as string),
formationLapType: parseInt(formData.get('formationLapType') as string), formationLapType: parseInt(formData.get('formationLapType') as string),
ignorePrematureDisconnects: parseInt(formData.get('ignorePrematureDisconnects') as string) ignorePrematureDisconnects: parseInt(formData.get('ignorePrematureDisconnects') as string)
}; };
const config = serverSettingsSchema.safeParse(rawConfig); const config = serverSettingsSchema.safeParse(rawConfig);
if (!config.success) { if (!config.success) {
return { success: false, message: config.error.message }; return { success: false, message: formatZodIssues(config.error.issues) };
} }
await updateServerConfiguration( await updateServerConfiguration(
@@ -166,7 +182,7 @@ export async function updateEventConfigAction(serverId: string, formData: FormDa
}; };
const config = eventConfigSchema.safeParse(rawConfig); const config = eventConfigSchema.safeParse(rawConfig);
if (!config.success) { if (!config.success) {
return { success: false, message: config.error.message }; return { success: false, message: formatZodIssues(config.error.issues) };
} }
await updateServerConfiguration( await updateServerConfiguration(
@@ -201,6 +217,7 @@ export async function updateEventRulesAction(serverId: string, formData: FormDat
driverStintTimeSec: parseInt(formData.get('driverStintTimeSec') as string), driverStintTimeSec: parseInt(formData.get('driverStintTimeSec') as string),
mandatoryPitstopCount: parseInt(formData.get('mandatoryPitstopCount') as string), mandatoryPitstopCount: parseInt(formData.get('mandatoryPitstopCount') as string),
maxTotalDrivingTime: parseInt(formData.get('maxTotalDrivingTime') as string), maxTotalDrivingTime: parseInt(formData.get('maxTotalDrivingTime') as string),
maxDriversCount: parseInt(formData.get('maxDriversCount') as string),
isRefuellingAllowedInRace: boolToInt(formData.get('isRefuellingAllowedInRace') === 'true'), isRefuellingAllowedInRace: boolToInt(formData.get('isRefuellingAllowedInRace') === 'true'),
isRefuellingTimeFixed: boolToInt(formData.get('isRefuellingTimeFixed') === 'true'), isRefuellingTimeFixed: boolToInt(formData.get('isRefuellingTimeFixed') === 'true'),
isMandatoryPitstopRefuellingRequired: isMandatoryPitstopRefuellingRequired:
@@ -214,7 +231,7 @@ export async function updateEventRulesAction(serverId: string, formData: FormDat
const config = eventRulesSchema.safeParse(rawConfig); const config = eventRulesSchema.safeParse(rawConfig);
if (!config.success) { if (!config.success) {
return { success: false, message: config.error.message }; return { success: false, message: formatZodIssues(config.error.issues) };
} }
await updateServerConfiguration( await updateServerConfiguration(
@@ -237,15 +254,15 @@ export async function updateEventRulesAction(serverId: string, formData: FormDat
export async function bulkUpdateConfigurationsAction( export async function bulkUpdateConfigurationsAction(
serverId: string, serverId: string,
configs: Configurations, configs: Partial<Configurations>,
restart: boolean restart: boolean
) { ) {
try { try {
const session = await requireAuth(); const session = await requireAuth();
const validated = configurationsSchema.safeParse(configs); const validated = configurationsSchema.partial().safeParse(configs);
if (!validated.success) { if (!validated.success) {
return { success: false, message: validated.error.message }; return { success: false, message: formatZodIssues(validated.error.issues) };
} }
await bulkUpdateServerConfigurations(session.token!, serverId, validated.data, restart); await bulkUpdateServerConfigurations(session.token!, serverId, validated.data, restart);

View File

@@ -3,6 +3,7 @@
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server'; import { requireAuth } from '@/lib/auth/server';
import { fetchServerAPI } from '@/lib/api/server/base'; import { fetchServerAPI } from '@/lib/api/server/base';
import { formatZodIssues } from '@/lib/utils';
import { leaderboardSchema, type Leaderboard } from '@/lib/schemas/leaderboard'; import { leaderboardSchema, type Leaderboard } from '@/lib/schemas/leaderboard';
export async function saveLeaderboardAction(serverId: string, data: Leaderboard) { export async function saveLeaderboardAction(serverId: string, data: Leaderboard) {
@@ -10,7 +11,7 @@ export async function saveLeaderboardAction(serverId: string, data: Leaderboard)
const session = await requireAuth(); const session = await requireAuth();
const validated = leaderboardSchema.safeParse(data); const validated = leaderboardSchema.safeParse(data);
if (!validated.success) { if (!validated.success) {
return { success: false, message: validated.error.message }; return { success: false, message: formatZodIssues(validated.error.issues) };
} }
await fetchServerAPI(`/server/${serverId}/leaderboard`, session.token!, 'PUT', validated.data); await fetchServerAPI(`/server/${serverId}/leaderboard`, session.token!, 'PUT', validated.data);
revalidatePath(`/dashboard/server/${serverId}`); revalidatePath(`/dashboard/server/${serverId}`);

View File

@@ -3,6 +3,7 @@
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server'; import { requireAuth } from '@/lib/auth/server';
import { createUser, deleteUser } from '@/lib/api/server/membership'; import { createUser, deleteUser } from '@/lib/api/server/membership';
import { formatZodIssues } from '@/lib/utils';
import { userCreateSchema } from '../schemas'; import { userCreateSchema } from '../schemas';
export async function createUserAction(formData: FormData) { export async function createUserAction(formData: FormData) {
@@ -15,7 +16,7 @@ export async function createUserAction(formData: FormData) {
const rawData = { username, password, role }; const rawData = { username, password, role };
const data = userCreateSchema.safeParse(rawData); const data = userCreateSchema.safeParse(rawData);
if (!data.success) { if (!data.success) {
return { success: false, message: data.error.message }; return { success: false, message: formatZodIssues(data.error.issues) };
} }
await createUser(session.token!, data.data); await createUser(session.token!, data.data);

View File

@@ -0,0 +1,54 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server';
import { listPresets, createPreset, applyPreset, type ConfigPresetCreateRequest } from '@/lib/api/server/presets';
import type { ConfigPreset } from '@/lib/schemas/preset';
export async function listPresetsAction(
name?: string
): Promise<{ success: true; data: ConfigPreset[] } | { success: false; message: string }> {
try {
const session = await requireAuth();
const data = await listPresets(session.token!, name);
return { success: true, data };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to fetch presets'
};
}
}
export async function createPresetAction(
data: ConfigPresetCreateRequest
): Promise<{ success: true; data: ConfigPreset } | { success: false; message: string }> {
try {
const session = await requireAuth();
const preset = await createPreset(session.token!, data);
return { success: true, data: preset };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to save preset'
};
}
}
export async function applyPresetAction(
serverId: string,
presetId: string,
restart = false
): Promise<{ success: true; message: string } | { success: false; message: string }> {
try {
const session = await requireAuth();
await applyPreset(session.token!, serverId, presetId, restart);
revalidatePath(`/dashboard/server/${serverId}`);
return { success: true, message: 'Preset applied successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to apply preset'
};
}
}

View File

@@ -30,7 +30,14 @@ export async function fetchServerAPI<T>(
if (response.status == 401) { if (response.status == 401) {
redirect('/login?expired=true'); redirect('/login?expired=true');
} }
throw new Error(`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint}`); let message = response.statusText;
try {
const body = await response.json();
message = body?.error ?? body?.message ?? message;
} catch {
// non-JSON error body, keep statusText
}
throw new Error(message);
} }
if (response.headers.get('Content-Type')?.includes('application/json')) { if (response.headers.get('Content-Type')?.includes('application/json')) {

View File

@@ -67,7 +67,7 @@ export async function updateServerConfiguration(
export async function bulkUpdateServerConfigurations( export async function bulkUpdateServerConfigurations(
token: string, token: string,
serverId: string, serverId: string,
configs: Configurations, configs: Partial<Configurations>,
restart = false restart = false
): Promise<void> { ): Promise<void> {
await fetchServerAPI<ConfigAuditRecord[]>( await fetchServerAPI<ConfigAuditRecord[]>(

View File

@@ -0,0 +1,43 @@
import { fetchServerAPI } from './base';
import { configPresetSchema, type ConfigPreset } from '@/lib/schemas/preset';
import type { ConfigAuditRecord } from './configuration';
const route = '/presets';
export type ConfigPresetCreateRequest = {
name: string;
description?: string;
configuration?: Record<string, unknown> | null;
assistRules?: Record<string, unknown> | null;
event?: Record<string, unknown> | null;
eventRules?: Record<string, unknown> | null;
settings?: Record<string, unknown> | null;
};
export async function createPreset(
token: string,
data: ConfigPresetCreateRequest
): Promise<ConfigPreset> {
const response = await fetchServerAPI<ConfigPreset>(route, token, 'POST', data);
return configPresetSchema.parse(response.data);
}
export async function listPresets(token: string, name?: string): Promise<ConfigPreset[]> {
const query = name ? `?name=${encodeURIComponent(name)}` : '';
const response = await fetchServerAPI<ConfigPreset[]>(`${route}${query}`, token);
return configPresetSchema.array().parse(response.data);
}
export async function applyPreset(
token: string,
serverId: string,
presetId: string,
restart = false
): Promise<ConfigAuditRecord[]> {
const response = await fetchServerAPI<ConfigAuditRecord[]>(
`/server/${serverId}/config/presets/${presetId}/apply?restart=${restart}`,
token,
'POST'
);
return response.data ?? [];
}

View File

@@ -0,0 +1,31 @@
export const TRACKS: Record<string, string> = {
barcelona: 'Barcelona',
brands_hatch: 'Brands Hatch',
cota: 'COTA',
donington: 'Donington',
hungaroring: 'Hungaroring',
imola: 'Imola',
indianapolis: 'Indianapolis',
kyalami: 'Kyalami',
laguna_seca: 'Laguna Seca',
misano: 'Misano',
monza: 'Monza',
mount_panorama: 'Mount Panorama',
nurburgring: 'Nürburgring',
nurburgring_24h: 'Nürburgring 24h',
oulton_park: 'Oulton Park',
paul_ricard: 'Paul Ricard',
red_bull_ring: 'Red Bull Ring',
silverstone: 'Silverstone',
snetterton: 'Snetterton',
spa: 'Spa-Francorchamps',
suzuka: 'Suzuka',
valencia: 'Valencia',
watkins_glen: 'Watkins Glen',
zandvoort: 'Zandvoort',
zolder: 'Zolder'
};
export function getTrackDisplayName(key: string): string {
return TRACKS[key] ?? key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@@ -5,22 +5,26 @@ export const actionTypeSchema = z.enum([
'server_start', 'server_start',
'server_stop', 'server_stop',
'server_restart', 'server_restart',
'leaderboard_update' 'leaderboard_update',
'preset_create',
'preset_apply'
]); ]);
export type ActionType = z.infer<typeof actionTypeSchema>; export type ActionType = z.infer<typeof actionTypeSchema>;
// Minimal server shape embedded in activity log responses (avoids circular import with server.ts) // Minimal server shape embedded in activity log responses (avoids circular import with server.ts)
const embeddedServerSchema = z.object({ const embeddedServerSchema = z
id: z.string(), .object({
name: z.string() id: z.string(),
}).passthrough(); name: z.string()
})
.passthrough();
export type EmbeddedServer = z.infer<typeof embeddedServerSchema>; export type EmbeddedServer = z.infer<typeof embeddedServerSchema>;
export const activityLogSchema = z.object({ export const activityLogSchema = z.object({
id: z.string(), id: z.string(),
serverId: z.string(), serverId: z.string().nullable(),
server: embeddedServerSchema.optional(), server: embeddedServerSchema.optional(),
userId: z.string(), userId: z.string(),
username: z.string(), username: z.string(),
@@ -58,5 +62,7 @@ export const ACTION_TYPE_LABELS: Record<ActionType, string> = {
server_start: 'Server Start', server_start: 'Server Start',
server_stop: 'Server Stop', server_stop: 'Server Stop',
server_restart: 'Server Restart', server_restart: 'Server Restart',
leaderboard_update: 'Leaderboard Update' leaderboard_update: 'Leaderboard Update',
preset_create: 'Preset Create',
preset_apply: 'Preset Apply'
}; };

View File

@@ -63,16 +63,17 @@ export const serverSettingsSchema = z.object({
centralEntryListPath: z.string().max(255).optional().or(z.literal('')), centralEntryListPath: z.string().max(255).optional().or(z.literal('')),
allowAutoDQ: z.number().min(0).max(1).default(0), allowAutoDQ: z.number().min(0).max(1).default(0),
shortFormationLap: z.number().min(0).max(1).default(0), shortFormationLap: z.number().min(0).max(1).default(0),
formationLapType: z.number().min(0).max(3).default(0), dumpEntryList: z.number().min(0).max(1).default(0),
ignorePrematureDisconnects: z.number().min(0).max(1).default(0) formationLapType: z.number().min(0).max(3).default(3),
ignorePrematureDisconnects: z.number().min(0).max(1).default(1)
}); });
export type ServerSettings = z.infer<typeof serverSettingsSchema>; export type ServerSettings = z.infer<typeof serverSettingsSchema>;
export const sessionSchema = z.object({ export const sessionSchema = z.object({
hourOfDay: z.number().min(1).max(24).default(14), hourOfDay: z.number().min(0).max(23).default(14),
dayOfWeekend: z.number().min(1).max(3).default(1), dayOfWeekend: z.number().min(1).max(3).default(1),
timeMultiplier: z.number().min(1).max(120).default(1), timeMultiplier: z.number().min(0).max(24).default(1),
sessionType: z.string().min(1).max(20), sessionType: z.string().min(1).max(20),
sessionDurationMinutes: z.number().min(1).max(180).default(20) sessionDurationMinutes: z.number().min(1).max(180).default(20)
}); });
@@ -81,7 +82,7 @@ export type Session = z.infer<typeof sessionSchema>;
export const eventConfigSchema = z.object({ export const eventConfigSchema = z.object({
track: z.string().min(1).max(100), track: z.string().min(1).max(100),
preRaceWaitingTimeSeconds: z.number().min(0).max(600).default(30), preRaceWaitingTimeSeconds: z.number().min(30).max(600).default(30),
sessionOverTimeSeconds: z.number().min(0).max(300).default(30), sessionOverTimeSeconds: z.number().min(0).max(300).default(30),
ambientTemp: z.number().min(0).max(50).default(24), ambientTemp: z.number().min(0).max(50).default(24),
cloudLevel: z.number().min(0).max(1).default(0), cloudLevel: z.number().min(0).max(1).default(0),
@@ -97,17 +98,23 @@ export const eventConfigSchema = z.object({
export type EventConfig = z.infer<typeof eventConfigSchema>; export type EventConfig = z.infer<typeof eventConfigSchema>;
export const eventRulesSchema = z.object({ export const eventRulesSchema = z.object({
qualifyStandingType: z.number().min(-1).max(1).default(0), qualifyStandingType: z.preprocess((val) => {
pitWindowLengthSec: z.number().min(-1).max(3600).default(30), if (val == '') {
driverStintTimeSec: z.number().min(-1).max(7200).default(30), return 1;
mandatoryPitstopCount: z.number().min(-1).max(5).default(0), }
maxTotalDrivingTime: z.number().min(-1).max(14400).default(60), return val;
isRefuellingAllowedInRace: z.number().min(0).max(1).default(0), }, z.number().min(1).max(2).default(1)),
pitWindowLengthSec: z.number().min(-1).max(3600).default(-1),
driverStintTimeSec: z.number().min(-1).max(7200).default(-1),
mandatoryPitstopCount: z.number().min(0).max(5).default(0),
maxTotalDrivingTime: z.number().min(-1).max(14400).default(-1),
maxDriversCount: z.number().min(1).max(10).default(1),
isRefuellingAllowedInRace: z.number().min(0).max(1).default(1),
isRefuellingTimeFixed: z.number().min(0).max(1).default(0), isRefuellingTimeFixed: z.number().min(0).max(1).default(0),
isMandatoryPitstopRefuellingRequired: z.number().min(0).max(1).default(0), isMandatoryPitstopRefuellingRequired: z.number().min(0).max(1).default(0),
isMandatoryPitstopTyreChangeRequired: z.number().min(0).max(1).default(0), isMandatoryPitstopTyreChangeRequired: z.number().min(0).max(1).default(0),
isMandatoryPitstopSwapDriverRequired: z.number().min(0).max(1).default(0), isMandatoryPitstopSwapDriverRequired: z.number().min(0).max(1).default(0),
tyreSetCount: z.number().min(0).max(50).default(0) tyreSetCount: z.number().min(0).max(50).default(50)
}); });
export type EventRules = z.infer<typeof eventRulesSchema>; export type EventRules = z.infer<typeof eventRulesSchema>;
@@ -131,3 +138,76 @@ export const configSchemaMap = {
}; };
export type Config = Configuration | AssistRules | EventConfig | EventRules | ServerSettings; export type Config = Configuration | AssistRules | EventConfig | EventRules | ServerSettings;
export const SECTION_LABELS: Record<string, string> = {
configuration: 'Configuration',
assistRules: 'Assist Rules',
event: 'Event Config',
eventRules: 'Event Rules',
settings: 'Server Settings'
};
export const FIELD_LABELS: Record<string, string> = {
// configuration.json
udpPort: 'UDP Port',
tcpPort: 'TCP Port',
maxConnections: 'Max Connections',
lanDiscovery: 'LAN Discovery',
registerToLobby: 'Register To Lobby',
configVersion: 'Config Version',
// assistRules.json
stabilityControlLevelMax: 'Stability Control Level Max',
disableAutosteer: 'Disable Autosteer',
disableAutoLights: 'Disable Auto Lights',
disableAutoWiper: 'Disable Auto Wiper',
disableAutoEngineStart: 'Disable Auto Engine Start',
disableAutoPitLimiter: 'Disable Auto Pit Limiter',
disableAutoGear: 'Disable Auto Gear',
disableAutoClutch: 'Disable Auto Clutch',
disableIdealLine: 'Disable Ideal Line',
// event.json
track: 'Track',
preRaceWaitingTimeSeconds: 'Pre-Race Waiting Time (seconds)',
sessionOverTimeSeconds: 'Session Over Time (seconds)',
ambientTemp: 'Ambient Temperature (°C)',
cloudLevel: 'Cloud Level (0.0-1.0)',
rain: 'Rain (0.0-1.0)',
weatherRandomness: 'Weather Randomness',
postQualySeconds: 'Post Qualify Seconds',
postRaceSeconds: 'Post Race Seconds',
simracerWeatherConditions: 'Simracer Weather Conditions',
isFixedConditionQualification: 'Fixed Condition Qualification',
sessions: 'Sessions',
// eventRules.json
qualifyStandingType: 'Qualify Standing Type',
pitWindowLengthSec: 'Pit Window Length (seconds)',
driverStintTimeSec: 'Driver Stint Time (seconds)',
mandatoryPitstopCount: 'Mandatory Pitstop Count',
maxTotalDrivingTime: 'Max Total Driving Time (seconds)',
maxDriversCount: 'Max Drivers Count',
isRefuellingAllowedInRace: 'Refuelling Allowed in Race',
isRefuellingTimeFixed: 'Refuelling Time Fixed',
isMandatoryPitstopRefuellingRequired: 'Mandatory Pitstop Refuelling Required',
isMandatoryPitstopTyreChangeRequired: 'Mandatory Pitstop Tyre Change Required',
isMandatoryPitstopSwapDriverRequired: 'Mandatory Pitstop Swap Driver Required',
tyreSetCount: 'Tyre Set Count',
// settings.json
serverName: 'Server Name',
adminPassword: 'Admin Password',
carGroup: 'Car Group',
trackMedalsRequirement: 'Track Medals Requirement',
safetyRatingRequirement: 'Safety Rating Requirement',
racecraftRatingRequirement: 'Racecraft Rating Requirement',
password: 'Password',
spectatorPassword: 'Spectator Password',
maxCarSlots: 'Max Car Slots',
dumpLeaderboards: 'Dump Leaderboards',
isRaceLocked: 'Race Locked',
randomizeTrackWhenEmpty: 'Randomize Track When Empty',
centralEntryListPath: 'Central Entry List Path',
allowAutoDQ: 'Allow Auto DQ',
shortFormationLap: 'Short Formation Lap',
dumpEntryList: 'Dump Entry List',
formationLapType: 'Formation Lap Type',
ignorePrematureDisconnects: 'Ignore Premature Disconnects'
};

34
src/lib/schemas/preset.ts Normal file
View File

@@ -0,0 +1,34 @@
import * as z from 'zod';
export const configPresetSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
configuration: z.string().nullable(),
assistRules: z.string().nullable(),
event: z.string().nullable(),
eventRules: z.string().nullable(),
settings: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string()
});
export type ConfigPreset = z.infer<typeof configPresetSchema>;
export const PRESET_SECTION_LABELS: Record<string, string> = {
configuration: 'Configuration',
assistRules: 'Assist Rules',
event: 'Event Config',
eventRules: 'Event Rules',
settings: 'Server Settings'
};
export function getPresetSections(preset: ConfigPreset): string[] {
const sections: string[] = [];
if (preset.configuration) sections.push('configuration');
if (preset.assistRules) sections.push('assistRules');
if (preset.event) sections.push('event');
if (preset.eventRules) sections.push('eventRules');
if (preset.settings) sections.push('settings');
return sections;
}

View File

@@ -1,14 +1,28 @@
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { FIELD_LABELS, SECTION_LABELS } from '@/lib/schemas/config';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function boolToInt(val: boolean) { export function boolToInt(val: boolean) {
return val ? 1 : 0; return val ? 1 : 0;
} }
export function intToBool(val: number) { export function intToBool(val: number) {
return !!val return !!val;
}
export function formatZodIssues(issues: Array<{ path: PropertyKey[]; message: string }>): string {
return issues
.map((issue) => {
const path = issue.path.map(String);
const sectionKey = path.length > 1 ? path[0] : null;
const fieldKey = path.at(-1) ?? '';
const section = sectionKey && SECTION_LABELS[sectionKey] ? `[${SECTION_LABELS[sectionKey]}] ` : '';
const label = FIELD_LABELS[fieldKey] ?? fieldKey;
return `${section}${label}: ${issue.message}`;
})
.join('\n');
} }