feat: add config preset manager
This commit is contained in:
187
src/components/server/PresetPickerModal.tsx
Normal file
187
src/components/server/PresetPickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
src/components/server/SavePresetModal.tsx
Normal file
149
src/components/server/SavePresetModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,15 @@ import { StatisticsDashboard } from '@/components/statistics/StatisticsDashboard
|
||||
import { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager';
|
||||
import { ActivityLogTable } from '@/components/activityLog/ActivityLogTable';
|
||||
import { ServerLogViewer } from '@/components/server/ServerLogViewer';
|
||||
import { PresetPickerModal } from '@/components/server/PresetPickerModal';
|
||||
import { SavePresetModal } from '@/components/server/SavePresetModal';
|
||||
import { useState } from 'react';
|
||||
import { StateHistoryStats } from '@/lib/schemas';
|
||||
import { Leaderboard } from '@/lib/schemas/leaderboard';
|
||||
import { bulkUpdateConfigurationsAction } from '@/lib/actions/configuration';
|
||||
import {
|
||||
bulkUpdateConfigurationsAction,
|
||||
getConfigurationsAction
|
||||
} from '@/lib/actions/configuration';
|
||||
|
||||
interface ServerConfigurationTabsProps {
|
||||
serverId: string;
|
||||
@@ -56,10 +61,23 @@ export function ServerConfigurationTabs({
|
||||
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 [saveError, setSaveError] = useState<string | null>(null);
|
||||
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));
|
||||
@@ -103,7 +121,10 @@ export function ServerConfigurationTabs({
|
||||
<ConfigurationEditor
|
||||
formData={configData}
|
||||
disabled={isSubmitting}
|
||||
onFormDataChange={(data) => { setConfigData(data); markDirty('configuration'); }}
|
||||
onFormDataChange={(data) => {
|
||||
setConfigData(data);
|
||||
markDirty('configuration');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -112,7 +133,10 @@ export function ServerConfigurationTabs({
|
||||
<AssistRulesEditor
|
||||
formData={assistData}
|
||||
disabled={isSubmitting}
|
||||
onFormDataChange={(data) => { setAssistData(data); markDirty('assistRules'); }}
|
||||
onFormDataChange={(data) => {
|
||||
setAssistData(data);
|
||||
markDirty('assistRules');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -121,7 +145,10 @@ export function ServerConfigurationTabs({
|
||||
<EventConfigEditor
|
||||
formData={eventData}
|
||||
disabled={isSubmitting}
|
||||
onFormDataChange={(data) => { setEventData(data); markDirty('event'); }}
|
||||
onFormDataChange={(data) => {
|
||||
setEventData(data);
|
||||
markDirty('event');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -130,7 +157,10 @@ export function ServerConfigurationTabs({
|
||||
<EventRulesEditor
|
||||
formData={eventRulesData}
|
||||
disabled={isSubmitting}
|
||||
onFormDataChange={(data) => { setEventRulesData(data); markDirty('eventRules'); }}
|
||||
onFormDataChange={(data) => {
|
||||
setEventRulesData(data);
|
||||
markDirty('eventRules');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -139,7 +169,10 @@ export function ServerConfigurationTabs({
|
||||
<ServerSettingsEditor
|
||||
formData={settingsData}
|
||||
disabled={isSubmitting}
|
||||
onFormDataChange={(data) => { setSettingsData(data); markDirty('settings'); }}
|
||||
onFormDataChange={(data) => {
|
||||
setSettingsData(data);
|
||||
markDirty('settings');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -165,7 +198,10 @@ export function ServerConfigurationTabs({
|
||||
|
||||
return (
|
||||
<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) => {
|
||||
const isActive = currentTab === tab.id;
|
||||
return (
|
||||
@@ -175,7 +211,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 ${
|
||||
isActive
|
||||
? '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>
|
||||
@@ -186,29 +222,39 @@ export function ServerConfigurationTabs({
|
||||
</nav>
|
||||
|
||||
{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">
|
||||
{saveError && (
|
||||
<p className="text-sm text-red">{saveError}</p>
|
||||
)}
|
||||
{saveSuccess && (
|
||||
<p className="text-sm text-green">All configurations saved.</p>
|
||||
)}
|
||||
{saveError && <p className="text-red text-sm">{saveError}</p>}
|
||||
{saveSuccess && <p className="text-green text-sm">All configurations saved.</p>}
|
||||
</div>
|
||||
<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
|
||||
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"
|
||||
className="border-border bg-overlay accent-green focus:ring-blue h-4 w-4 rounded focus:ring-1"
|
||||
/>
|
||||
Restart after saving
|
||||
</label>
|
||||
<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>
|
||||
<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
|
||||
onClick={handleSaveAll}
|
||||
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'}
|
||||
</button>
|
||||
@@ -217,6 +263,29 @@ export function ServerConfigurationTabs({
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function loginAction(prevState: LoginResult, formData: FormData) {
|
||||
if (!loginData.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: loginData.error.message
|
||||
message: loginData.error.issues[0]?.message ?? 'Validation failed'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireAuth } from '@/lib/auth/server';
|
||||
import { updateServerConfiguration, bulkUpdateServerConfigurations } from '@/lib/api/server/configuration';
|
||||
import { getServerConfigurations, updateServerConfiguration, bulkUpdateServerConfigurations } from '@/lib/api/server/configuration';
|
||||
import {
|
||||
assistRulesSchema,
|
||||
ConfigFile,
|
||||
@@ -19,6 +19,21 @@ import type {
|
||||
EventRules,
|
||||
ServerSettings
|
||||
} from '@/lib/schemas/config';
|
||||
|
||||
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'
|
||||
};
|
||||
}
|
||||
}
|
||||
import { boolToInt } from '@/lib/utils';
|
||||
|
||||
export async function updateConfigurationAction(serverId: string, formData: FormData) {
|
||||
@@ -72,7 +87,7 @@ export async function updateAssistRulesAction(serverId: string, formData: FormDa
|
||||
|
||||
const config = assistRulesSchema.safeParse(rawConfig);
|
||||
if (!config.success) {
|
||||
return { success: false, message: config.error.message };
|
||||
return { success: false, message: config.error.issues[0]?.message ?? 'Validation failed' };
|
||||
}
|
||||
|
||||
await updateServerConfiguration(
|
||||
@@ -120,7 +135,7 @@ export async function updateServerSettingsAction(serverId: string, formData: For
|
||||
};
|
||||
const config = serverSettingsSchema.safeParse(rawConfig);
|
||||
if (!config.success) {
|
||||
return { success: false, message: config.error.message };
|
||||
return { success: false, message: config.error.issues[0]?.message ?? 'Validation failed' };
|
||||
}
|
||||
|
||||
await updateServerConfiguration(
|
||||
@@ -167,7 +182,7 @@ export async function updateEventConfigAction(serverId: string, formData: FormDa
|
||||
};
|
||||
const config = eventConfigSchema.safeParse(rawConfig);
|
||||
if (!config.success) {
|
||||
return { success: false, message: config.error.message };
|
||||
return { success: false, message: config.error.issues[0]?.message ?? 'Validation failed' };
|
||||
}
|
||||
|
||||
await updateServerConfiguration(
|
||||
@@ -216,7 +231,7 @@ export async function updateEventRulesAction(serverId: string, formData: FormDat
|
||||
|
||||
const config = eventRulesSchema.safeParse(rawConfig);
|
||||
if (!config.success) {
|
||||
return { success: false, message: config.error.message };
|
||||
return { success: false, message: config.error.issues[0]?.message ?? 'Validation failed' };
|
||||
}
|
||||
|
||||
await updateServerConfiguration(
|
||||
@@ -247,7 +262,7 @@ export async function bulkUpdateConfigurationsAction(
|
||||
|
||||
const validated = configurationsSchema.partial().safeParse(configs);
|
||||
if (!validated.success) {
|
||||
return { success: false, message: validated.error.message };
|
||||
return { success: false, message: validated.error.issues[0]?.message ?? 'Validation failed' };
|
||||
}
|
||||
|
||||
await bulkUpdateServerConfigurations(session.token!, serverId, validated.data, restart);
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function saveLeaderboardAction(serverId: string, data: Leaderboard)
|
||||
const session = await requireAuth();
|
||||
const validated = leaderboardSchema.safeParse(data);
|
||||
if (!validated.success) {
|
||||
return { success: false, message: validated.error.message };
|
||||
return { success: false, message: validated.error.issues[0]?.message ?? 'Validation failed' };
|
||||
}
|
||||
await fetchServerAPI(`/server/${serverId}/leaderboard`, session.token!, 'PUT', validated.data);
|
||||
revalidatePath(`/dashboard/server/${serverId}`);
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function createUserAction(formData: FormData) {
|
||||
const rawData = { username, password, role };
|
||||
const data = userCreateSchema.safeParse(rawData);
|
||||
if (!data.success) {
|
||||
return { success: false, message: data.error.message };
|
||||
return { success: false, message: data.error.issues[0]?.message ?? 'Validation failed' };
|
||||
}
|
||||
|
||||
await createUser(session.token!, data.data);
|
||||
|
||||
54
src/lib/actions/presets.ts
Normal file
54
src/lib/actions/presets.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,14 @@ export async function fetchServerAPI<T>(
|
||||
if (response.status == 401) {
|
||||
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')) {
|
||||
|
||||
43
src/lib/api/server/presets.ts
Normal file
43
src/lib/api/server/presets.ts
Normal 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 ?? [];
|
||||
}
|
||||
34
src/lib/schemas/preset.ts
Normal file
34
src/lib/schemas/preset.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user