feat: add settings page with guild config, game settings, and command toggles

Implements the full admin settings page covering all game settings
(leveling, economy, inventory, lootdrops, trivia, moderation, commands)
and guild settings (roles, channels, welcome message, moderation,
feature overrides). Includes role/channel pickers, trivia category
multi-select, and a feature override flag editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-02-14 12:45:23 +01:00
parent 9471b6fdab
commit f0bfaecb0b
4 changed files with 1607 additions and 1 deletions

View File

@@ -139,7 +139,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
return jsonResponse({ roles, channels, commands }); return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands });
}, "fetch settings meta"); }, "fetch settings meta");
} }

View File

@@ -3,6 +3,7 @@ import { useAuth } from "./lib/useAuth";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import Layout, { type Page } from "./components/Layout"; import Layout, { type Page } from "./components/Layout";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import Settings from "./pages/Settings";
import PlaceholderPage from "./pages/PlaceholderPage"; import PlaceholderPage from "./pages/PlaceholderPage";
const placeholders: Record<string, { title: string; description: string }> = { const placeholders: Record<string, { title: string; description: string }> = {
@@ -74,6 +75,8 @@ export default function App() {
<Layout user={user} logout={logout} currentPage={page} onNavigate={setPage}> <Layout user={user} logout={logout} currentPage={page} onNavigate={setPage}>
{page === "dashboard" ? ( {page === "dashboard" ? (
<Dashboard /> <Dashboard />
) : page === "settings" ? (
<Settings />
) : ( ) : (
<PlaceholderPage {...placeholders[page]!} /> <PlaceholderPage {...placeholders[page]!} />
)} )}

View File

@@ -0,0 +1,192 @@
import { useCallback, useEffect, useState } from "react";
import { get, post, put } from "./api";
export interface LevelingConfig {
base: number;
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
};
}
export interface EconomyConfig {
daily: {
amount: string;
streakBonus: string;
weeklyBonus: string;
cooldownMs: number;
};
transfers: {
allowSelfTransfer: boolean;
minAmount: string;
};
exam: {
multMin: number;
multMax: number;
};
}
export interface InventoryConfig {
maxStackSize: string;
maxSlots: number;
}
export interface LootdropConfig {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
};
}
export interface TriviaConfig {
entryFee: string;
rewardMultiplier: number;
timeoutSeconds: number;
cooldownMs: number;
categories: number[];
difficulty: string;
}
export interface ModerationConfig {
prune: {
maxAmount: number;
confirmThreshold: number;
batchSize: number;
batchDelayMs: number;
};
}
export interface GameSettings {
leveling: LevelingConfig;
economy: EconomyConfig;
inventory: InventoryConfig;
lootdrop: LootdropConfig;
trivia: TriviaConfig;
moderation: ModerationConfig;
commands: Record<string, boolean>;
system: Record<string, unknown>;
}
export interface GuildSettings {
guildId: string;
configured: boolean;
studentRoleId?: string;
visitorRoleId?: string;
colorRoleIds?: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminalChannelId?: string;
terminalMessageId?: string;
moderationLogChannelId?: string;
moderationDmOnWarn?: boolean;
moderationAutoTimeoutThreshold?: number;
featureOverrides?: Record<string, boolean>;
}
export interface SettingsMeta {
guildId?: string;
roles: Array<{ id: string; name: string; color: string }>;
channels: Array<{ id: string; name: string; type: number }>;
commands: Array<{ name: string; category: string }>;
}
export function useSettings() {
const [settings, setSettings] = useState<GameSettings | null>(null);
const [guildSettings, setGuildSettings] = useState<GuildSettings | null>(null);
const [meta, setMeta] = useState<SettingsMeta | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchSettings = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [settingsData, metaData] = await Promise.all([
get<GameSettings>("/api/settings"),
get<SettingsMeta>("/api/settings/meta"),
]);
setSettings(settingsData);
setMeta(metaData);
// Fetch guild settings if we have a guild ID
if (metaData.guildId) {
const gs = await get<GuildSettings>(
`/api/guilds/${metaData.guildId}/settings`
);
setGuildSettings(gs);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load settings");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const saveSettings = useCallback(
async (partial: Record<string, unknown>) => {
try {
setSaving(true);
setError(null);
await post("/api/settings", partial);
const updated = await get<GameSettings>("/api/settings");
setSettings(updated);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save settings");
return false;
} finally {
setSaving(false);
}
},
[]
);
const saveGuildSettings = useCallback(
async (data: Partial<GuildSettings>) => {
if (!meta?.guildId) return false;
try {
setSaving(true);
setError(null);
await put(`/api/guilds/${meta.guildId}/settings`, data);
const updated = await get<GuildSettings>(
`/api/guilds/${meta.guildId}/settings`
);
setGuildSettings(updated);
return true;
} catch (e) {
setError(
e instanceof Error ? e.message : "Failed to save guild settings"
);
return false;
} finally {
setSaving(false);
}
},
[meta?.guildId]
);
return {
settings,
guildSettings,
meta,
loading,
saving,
error,
saveSettings,
saveGuildSettings,
refetch: fetchSettings,
};
}

1411
panel/src/pages/Settings.tsx Normal file

File diff suppressed because it is too large Load Diff