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 { 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 } 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;
|
||||||
@@ -56,10 +61,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 +121,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 +133,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 +145,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 +157,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 +169,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 +198,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 +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 ${
|
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 +222,39 @@ 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-red text-sm">{saveError}</p>}
|
||||||
<p className="text-sm text-red">{saveError}</p>
|
{saveSuccess && <p className="text-green text-sm">All configurations saved.</p>}
|
||||||
)}
|
|
||||||
{saveSuccess && (
|
|
||||||
<p className="text-sm text-green">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>
|
||||||
|
<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
|
<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 +263,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,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: loginData.error.issues[0]?.message ?? 'Validation failed'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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 {
|
import {
|
||||||
assistRulesSchema,
|
assistRulesSchema,
|
||||||
ConfigFile,
|
ConfigFile,
|
||||||
@@ -19,6 +19,21 @@ import type {
|
|||||||
EventRules,
|
EventRules,
|
||||||
ServerSettings
|
ServerSettings
|
||||||
} from '@/lib/schemas/config';
|
} 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';
|
import { boolToInt } from '@/lib/utils';
|
||||||
|
|
||||||
export async function updateConfigurationAction(serverId: string, formData: FormData) {
|
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);
|
const config = assistRulesSchema.safeParse(rawConfig);
|
||||||
if (!config.success) {
|
if (!config.success) {
|
||||||
return { success: false, message: config.error.message };
|
return { success: false, message: config.error.issues[0]?.message ?? 'Validation failed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
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: config.error.issues[0]?.message ?? 'Validation failed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
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: config.error.issues[0]?.message ?? 'Validation failed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
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: config.error.issues[0]?.message ?? 'Validation failed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
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: validated.error.issues[0]?.message ?? 'Validation failed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await bulkUpdateServerConfigurations(session.token!, serverId, validated.data, restart);
|
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 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: validated.error.issues[0]?.message ?? 'Validation failed' };
|
||||||
}
|
}
|
||||||
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}`);
|
||||||
|
|||||||
@@ -15,7 +15,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: data.error.issues[0]?.message ?? 'Validation failed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await createUser(session.token!, data.data);
|
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) {
|
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')) {
|
||||||
|
|||||||
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