6 Commits

24 changed files with 806 additions and 274 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "acc-server-manager-web", "name": "acc-server-manager-web",
"version": "0.24.3", "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

@@ -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,7 @@
'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'; import { TRACKS } from '@/lib/constants/tracks';
interface EventConfigEditorProps { interface EventConfigEditorProps {
@@ -63,7 +64,7 @@ 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}
@@ -79,9 +80,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.preRaceWaitingTimeSeconds}</label>
Pre-Race Waiting Time (seconds)
</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -93,9 +92,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.sessionOverTimeSeconds}</label>
Session Over Time (seconds)
</label>
<input <input
type="number" type="number"
disabled={disabled} disabled={disabled}
@@ -107,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}
@@ -121,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}
@@ -142,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}
@@ -157,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}
@@ -173,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}
@@ -187,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}
@@ -202,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}
@@ -217,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: 'pitWindowLengthSec' as keyof EventRules, { key: 'driverStintTimeSec', min: -1 },
label: 'Pit Window Length (seconds)', { key: 'mandatoryPitstopCount', min: 0, max: 5 },
min: -1 { key: 'maxTotalDrivingTime', min: -1 },
}, { key: 'maxDriversCount', min: 1, max: 10 },
{ { key: 'tyreSetCount', min: 0, max: 50 }
key: 'driverStintTimeSec' as keyof EventRules,
label: 'Driver Stint Time (seconds)',
min: -1
},
{
key: 'mandatoryPitstopCount' as keyof EventRules,
label: 'Mandatory Pitstop Count',
min: 0,
max: 5
},
{
key: 'maxTotalDrivingTime' as keyof EventRules,
label: 'Max Total Driving Time (seconds)',
min: -1
},
{
key: 'maxDriversCount' as keyof EventRules,
label: 'Max Drivers Count',
min: 1,
max: 10
},
{
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) {
@@ -78,7 +37,7 @@ export function EventRulesEditor({ formData, disabled, onFormDataChange }: Event
<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">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Qualify Standing Type</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{FIELD_LABELS.qualifyStandingType}</label>
<select <select
disabled={disabled} disabled={disabled}
value={formData.qualifyStandingType} value={formData.qualifyStandingType}
@@ -89,9 +48,9 @@ export function EventRulesEditor({ formData, disabled, onFormDataChange }: Event
<option value={2}>Average Lap</option> <option value={2}>Average Lap</option>
</select> </select>
</div> </div>
{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}
@@ -111,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,86 +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: 0, { 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: 'dumpEntryList' as keyof ServerSettings,
label: 'Dump Entry List'
},
{
key: 'ignorePrematureDisconnects' as keyof ServerSettings,
label: 'Ignore Premature Disconnects'
}
]; ];
export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: ServerSettingsEditorProps) { export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: ServerSettingsEditorProps) {
@@ -102,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}
@@ -116,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}
@@ -138,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}
@@ -160,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}
@@ -176,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

@@ -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);
@@ -56,10 +65,23 @@ export function ServerConfigurationTabs({
const [settingsData, setSettingsData] = useState(configurations.settings); const [settingsData, setSettingsData] = useState(configurations.settings);
const [dirtyConfigs, setDirtyConfigs] = useState<Set<keyof Configurations>>(new Set()); 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) => const markDirty = (key: keyof Configurations) =>
setDirtyConfigs((prev) => new Set(prev).add(key)); setDirtyConfigs((prev) => new Set(prev).add(key));
@@ -103,7 +125,10 @@ export function ServerConfigurationTabs({
<ConfigurationEditor <ConfigurationEditor
formData={configData} formData={configData}
disabled={isSubmitting} disabled={isSubmitting}
onFormDataChange={(data) => { setConfigData(data); markDirty('configuration'); }} onFormDataChange={(data) => {
setConfigData(data);
markDirty('configuration');
}}
/> />
); );
@@ -112,7 +137,10 @@ export function ServerConfigurationTabs({
<AssistRulesEditor <AssistRulesEditor
formData={assistData} formData={assistData}
disabled={isSubmitting} disabled={isSubmitting}
onFormDataChange={(data) => { setAssistData(data); markDirty('assistRules'); }} onFormDataChange={(data) => {
setAssistData(data);
markDirty('assistRules');
}}
/> />
); );
@@ -121,7 +149,10 @@ export function ServerConfigurationTabs({
<EventConfigEditor <EventConfigEditor
formData={eventData} formData={eventData}
disabled={isSubmitting} disabled={isSubmitting}
onFormDataChange={(data) => { setEventData(data); markDirty('event'); }} onFormDataChange={(data) => {
setEventData(data);
markDirty('event');
}}
/> />
); );
@@ -130,7 +161,10 @@ export function ServerConfigurationTabs({
<EventRulesEditor <EventRulesEditor
formData={eventRulesData} formData={eventRulesData}
disabled={isSubmitting} disabled={isSubmitting}
onFormDataChange={(data) => { setEventRulesData(data); markDirty('eventRules'); }} onFormDataChange={(data) => {
setEventRulesData(data);
markDirty('eventRules');
}}
/> />
); );
@@ -139,7 +173,10 @@ export function ServerConfigurationTabs({
<ServerSettingsEditor <ServerSettingsEditor
formData={settingsData} formData={settingsData}
disabled={isSubmitting} disabled={isSubmitting}
onFormDataChange={(data) => { setSettingsData(data); markDirty('settings'); }} onFormDataChange={(data) => {
setSettingsData(data);
markDirty('settings');
}}
/> />
); );
@@ -165,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 (
@@ -175,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>
@@ -186,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 || dirtyConfigs.size === 0} 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>
@@ -217,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

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

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

@@ -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(
@@ -120,7 +135,7 @@ export async function updateServerSettingsAction(serverId: string, formData: For
}; };
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(
@@ -167,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(
@@ -216,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(
@@ -247,7 +262,7 @@ export async function bulkUpdateConfigurationsAction(
const validated = configurationsSchema.partial().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

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

@@ -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
.object({
id: z.string(), id: z.string(),
name: z.string() name: z.string()
}).passthrough(); })
.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

@@ -51,7 +51,7 @@ export const serverSettingsSchema = z.object({
serverName: z.string().min(3).max(150), serverName: z.string().min(3).max(150),
adminPassword: z.string().min(6).max(50).or(z.literal('').nullable()), adminPassword: z.string().min(6).max(50).or(z.literal('').nullable()),
carGroup: z.string().min(1).max(50), carGroup: z.string().min(1).max(50),
trackMedalsRequirement: z.number().min(0).max(3), trackMedalsRequirement: z.number().min(-1).max(3),
safetyRatingRequirement: z.number().min(-1).max(99), safetyRatingRequirement: z.number().min(-1).max(99),
racecraftRatingRequirement: z.number().min(-1).max(99), racecraftRatingRequirement: z.number().min(-1).max(99),
password: z.string().max(50).optional().or(z.literal('')), password: z.string().max(50).optional().or(z.literal('')),
@@ -98,7 +98,12 @@ 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(2).default(1), qualifyStandingType: z.preprocess((val) => {
if (val == '') {
return 1;
}
return val;
}, z.number().min(1).max(2).default(1)),
pitWindowLengthSec: z.number().min(-1).max(3600).default(-1), pitWindowLengthSec: z.number().min(-1).max(3600).default(-1),
driverStintTimeSec: z.number().min(-1).max(7200).default(-1), driverStintTimeSec: z.number().min(-1).max(7200).default(-1),
mandatoryPitstopCount: z.number().min(0).max(5).default(0), mandatoryPitstopCount: z.number().min(0).max(5).default(0),
@@ -133,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,5 +1,6 @@
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));
@@ -10,5 +11,18 @@ export function boolToInt(val: boolean) {
} }
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');
} }