Files
aurorabot/panel/src/pages/Settings.tsx
syntaxbullet f0bfaecb0b
Some checks failed
Deploy to Production / test (push) Failing after 31s
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>
2026-02-14 12:45:23 +01:00

1412 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from "react";
import {
Loader2,
AlertTriangle,
Save,
Check,
TrendingUp,
Coins,
Package,
Gift,
Brain,
Shield,
Terminal,
RotateCcw,
Server,
X,
} from "lucide-react";
import { cn } from "../lib/utils";
import {
useSettings,
type GameSettings,
type GuildSettings,
type SettingsMeta,
} from "../lib/useSettings";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
type SettingsSection =
| "guild"
| "leveling"
| "economy"
| "inventory"
| "lootdrop"
| "trivia"
| "moderation"
| "commands";
const sections: {
key: SettingsSection;
label: string;
icon: React.ComponentType<{ className?: string }>;
}[] = [
{ key: "guild", label: "Guild", icon: Server },
{ key: "leveling", label: "Leveling", icon: TrendingUp },
{ key: "economy", label: "Economy", icon: Coins },
{ key: "inventory", label: "Inventory", icon: Package },
{ key: "lootdrop", label: "Lootdrops", icon: Gift },
{ key: "trivia", label: "Trivia", icon: Brain },
{ key: "moderation", label: "Moderation", icon: Shield },
{ key: "commands", label: "Commands", icon: Terminal },
];
function formatMs(ms: number): string {
if (ms >= 86_400_000) return `${ms / 86_400_000}h`;
if (ms >= 60_000) return `${ms / 60_000} min`;
return `${ms / 1_000}s`;
}
// ---------------------------------------------------------------------------
// Reusable field components
// ---------------------------------------------------------------------------
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-text-secondary">
{label}
</label>
{children}
{hint && <p className="text-[11px] text-text-tertiary">{hint}</p>}
</div>
);
}
function NumberInput({
value,
onChange,
min,
max,
step,
className,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
className?: string;
}) {
return (
<input
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
max={max}
step={step}
className={cn(
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors",
className
)}
/>
);
}
function StringInput({
value,
onChange,
placeholder,
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
className?: string;
}) {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cn(
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors",
className
)}
/>
);
}
function TextArea({
value,
onChange,
placeholder,
rows = 3,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
rows?: number;
}) {
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className={cn(
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground resize-y",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
/>
);
}
function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-raised"
)}
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
checked ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
);
}
function SelectInput({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
/** Role picker dropdown — selects a single role ID */
function RolePicker({
value,
onChange,
roles,
placeholder = "Select a role…",
}: {
value: string | undefined;
onChange: (v: string | undefined) => void;
roles: SettingsMeta["roles"];
placeholder?: string;
}) {
return (
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value || undefined)}
className={cn(
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="">{placeholder}</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
);
}
/** Channel picker dropdown — selects a single channel ID (text channels only) */
function ChannelPicker({
value,
onChange,
channels,
placeholder = "Select a channel…",
}: {
value: string | undefined;
onChange: (v: string | undefined) => void;
channels: SettingsMeta["channels"];
placeholder?: string;
}) {
// type 0 = text channel
const textChannels = channels.filter((c) => c.type === 0);
return (
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value || undefined)}
className={cn(
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="">{placeholder}</option>
{textChannels.map((c) => (
<option key={c.id} value={c.id}>
#{c.name}
</option>
))}
</select>
);
}
/** Multi-select role picker for color roles */
function MultiRolePicker({
selected,
onChange,
roles,
}: {
selected: string[];
onChange: (v: string[]) => void;
roles: SettingsMeta["roles"];
}) {
const available = roles.filter((r) => !selected.includes(r.id));
const selectedRoles = selected
.map((id) => roles.find((r) => r.id === id))
.filter(Boolean) as SettingsMeta["roles"];
return (
<div className="space-y-2">
{selectedRoles.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedRoles.map((r) => (
<span
key={r.id}
className="inline-flex items-center gap-1.5 bg-primary/15 border border-primary/30 rounded-full px-3 py-1 text-xs font-medium"
>
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: r.color !== "#000000" ? r.color : "#6B7280" }}
/>
{r.name}
<button
type="button"
onClick={() => onChange(selected.filter((id) => id !== r.id))}
className="ml-0.5 text-text-tertiary hover:text-destructive transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{available.length > 0 && (
<select
value=""
onChange={(e) => {
if (e.target.value) onChange([...selected, e.target.value]);
}}
className={cn(
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="">Add a color role</option>
{available.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
)}
</div>
);
}
/** OpenTDB category IDs */
const TRIVIA_CATEGORIES: { id: number; name: string }[] = [
{ id: 9, name: "General Knowledge" },
{ id: 10, name: "Books" },
{ id: 11, name: "Film" },
{ id: 12, name: "Music" },
{ id: 13, name: "Musicals & Theatre" },
{ id: 14, name: "Television" },
{ id: 15, name: "Video Games" },
{ id: 16, name: "Board Games" },
{ id: 17, name: "Science & Nature" },
{ id: 18, name: "Computers" },
{ id: 19, name: "Mathematics" },
{ id: 20, name: "Mythology" },
{ id: 21, name: "Sports" },
{ id: 22, name: "Geography" },
{ id: 23, name: "History" },
{ id: 24, name: "Politics" },
{ id: 25, name: "Art" },
{ id: 26, name: "Celebrities" },
{ id: 27, name: "Animals" },
{ id: 28, name: "Vehicles" },
{ id: 29, name: "Comics" },
{ id: 30, name: "Gadgets" },
{ id: 31, name: "Anime & Manga" },
{ id: 32, name: "Cartoons & Animations" },
];
function CategoryPicker({
selected,
onChange,
}: {
selected: number[];
onChange: (v: number[]) => void;
}) {
const available = TRIVIA_CATEGORIES.filter((c) => !selected.includes(c.id));
const selectedCats = selected
.map((id) => TRIVIA_CATEGORIES.find((c) => c.id === id))
.filter(Boolean) as typeof TRIVIA_CATEGORIES;
return (
<div className="space-y-2">
{selectedCats.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedCats.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 bg-info/15 border border-info/30 rounded-full px-3 py-1 text-xs font-medium"
>
{c.name}
<button
type="button"
onClick={() => onChange(selected.filter((id) => id !== c.id))}
className="ml-0.5 text-text-tertiary hover:text-destructive transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{available.length > 0 && (
<select
value=""
onChange={(e) => {
const id = Number(e.target.value);
if (id) onChange([...selected, id]);
}}
className={cn(
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="">Add a category</option>
{available.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
</div>
);
}
/** Key-value editor for feature override flags */
function FeatureOverridesEditor({
value,
onChange,
}: {
value: Record<string, boolean>;
onChange: (v: Record<string, boolean>) => void;
}) {
const [newKey, setNewKey] = useState("");
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
const addFlag = () => {
const key = newKey.trim().toLowerCase().replace(/\s+/g, "_");
if (!key || key in value) return;
onChange({ ...value, [key]: true });
setNewKey("");
};
return (
<div className="space-y-3">
{entries.length > 0 && (
<div className="space-y-2">
{entries.map(([key, enabled]) => (
<div
key={key}
className="flex items-center justify-between py-1.5"
>
<span
className={cn(
"text-sm font-mono",
enabled ? "text-foreground" : "text-text-disabled"
)}
>
{key}
</span>
<div className="flex items-center gap-3">
<Toggle
checked={enabled}
onChange={(v) => onChange({ ...value, [key]: v })}
/>
<button
type="button"
onClick={() => {
const { [key]: _, ...rest } = value;
onChange(rest);
}}
className="text-text-tertiary hover:text-destructive transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
<div className="flex gap-2">
<StringInput
value={newKey}
onChange={setNewKey}
placeholder="feature_name"
className="flex-1"
/>
<button
type="button"
onClick={addFlag}
disabled={!newKey.trim()}
className={cn(
"rounded-md px-4 py-2 text-sm font-medium transition-colors",
newKey.trim()
? "bg-primary/15 text-primary hover:bg-primary/25 border border-primary/30"
: "bg-raised text-text-disabled cursor-not-allowed"
)}
>
Add
</button>
</div>
</div>
);
}
function SectionCard({
title,
icon: Icon,
children,
}: {
title: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<div className="bg-card rounded-lg border border-border">
<div className="flex items-center gap-2.5 px-6 py-4 border-b border-border">
<Icon className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
<div className="px-6 py-5 space-y-5">{children}</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Section editors
// ---------------------------------------------------------------------------
function GuildSection({
data,
onChange,
meta,
}: {
data: GuildSettings;
onChange: (d: GuildSettings) => void;
meta: SettingsMeta | null;
}) {
const roles = meta?.roles ?? [];
const channels = meta?.channels ?? [];
return (
<div className="space-y-6">
<SectionCard title="Roles" icon={Server}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Student Role" hint="Assigned to verified students">
<RolePicker
value={data.studentRoleId}
onChange={(v) => onChange({ ...data, studentRoleId: v })}
roles={roles}
/>
</Field>
<Field label="Visitor Role" hint="Assigned to unverified members">
<RolePicker
value={data.visitorRoleId}
onChange={(v) => onChange({ ...data, visitorRoleId: v })}
roles={roles}
/>
</Field>
</div>
<Field label="Color Roles" hint="Roles users can self-assign for name color">
<MultiRolePicker
selected={data.colorRoleIds ?? []}
onChange={(v) => onChange({ ...data, colorRoleIds: v })}
roles={roles}
/>
</Field>
</SectionCard>
<SectionCard title="Channels" icon={Server}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Welcome Channel">
<ChannelPicker
value={data.welcomeChannelId}
onChange={(v) => onChange({ ...data, welcomeChannelId: v })}
channels={channels}
/>
</Field>
<Field label="Feedback Channel">
<ChannelPicker
value={data.feedbackChannelId}
onChange={(v) => onChange({ ...data, feedbackChannelId: v })}
channels={channels}
/>
</Field>
<Field label="Terminal Channel">
<ChannelPicker
value={data.terminalChannelId}
onChange={(v) => onChange({ ...data, terminalChannelId: v })}
channels={channels}
/>
</Field>
<Field label="Moderation Log Channel">
<ChannelPicker
value={data.moderationLogChannelId}
onChange={(v) => onChange({ ...data, moderationLogChannelId: v })}
channels={channels}
/>
</Field>
</div>
</SectionCard>
<SectionCard title="Welcome Message" icon={Server}>
<Field label="Message" hint="Sent when a new member joins. Leave empty to disable.">
<TextArea
value={data.welcomeMessage ?? ""}
onChange={(v) => onChange({ ...data, welcomeMessage: v || undefined })}
placeholder="Welcome to the academy, {user}!"
rows={3}
/>
</Field>
</SectionCard>
<SectionCard title="Moderation" icon={Shield}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="DM on Warn" hint="Send a DM to users when they receive a warning">
<Toggle
checked={data.moderationDmOnWarn ?? true}
onChange={(v) => onChange({ ...data, moderationDmOnWarn: v })}
/>
</Field>
<Field label="Auto-Timeout Threshold" hint="Warnings before auto-timeout (empty = disabled)">
<NumberInput
value={data.moderationAutoTimeoutThreshold ?? 0}
onChange={(v) =>
onChange({
...data,
moderationAutoTimeoutThreshold: v || undefined,
})
}
min={0}
/>
</Field>
</div>
</SectionCard>
<SectionCard title="Feature Overrides" icon={Server}>
<p className="text-xs text-text-tertiary">
Toggle feature flags for this guild. Add new flags by name.
</p>
<FeatureOverridesEditor
value={data.featureOverrides ?? {}}
onChange={(v) => onChange({ ...data, featureOverrides: v })}
/>
</SectionCard>
</div>
);
}
function LevelingSection({
data,
onChange,
}: {
data: GameSettings["leveling"];
onChange: (d: GameSettings["leveling"]) => void;
}) {
return (
<SectionCard title="Leveling" icon={TrendingUp}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Base XP" hint="Base XP required for level 2">
<NumberInput
value={data.base}
onChange={(v) => onChange({ ...data, base: v })}
min={1}
/>
</Field>
<Field label="Exponent" hint="XP growth curve exponent">
<NumberInput
value={data.exponent}
onChange={(v) => onChange({ ...data, exponent: v })}
min={1}
max={5}
step={0.1}
/>
</Field>
</div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider pt-2">
Chat XP
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<Field label="Cooldown (ms)" hint={formatMs(data.chat.cooldownMs)}>
<NumberInput
value={data.chat.cooldownMs}
onChange={(v) =>
onChange({ ...data, chat: { ...data.chat, cooldownMs: v } })
}
min={0}
step={1000}
/>
</Field>
<Field label="Min XP">
<NumberInput
value={data.chat.minXp}
onChange={(v) =>
onChange({ ...data, chat: { ...data.chat, minXp: v } })
}
min={0}
/>
</Field>
<Field label="Max XP">
<NumberInput
value={data.chat.maxXp}
onChange={(v) =>
onChange({ ...data, chat: { ...data.chat, maxXp: v } })
}
min={0}
/>
</Field>
</div>
</SectionCard>
);
}
function EconomySection({
data,
onChange,
}: {
data: GameSettings["economy"];
onChange: (d: GameSettings["economy"]) => void;
}) {
const setDaily = (patch: Partial<GameSettings["economy"]["daily"]>) =>
onChange({ ...data, daily: { ...data.daily, ...patch } });
const setTransfers = (
patch: Partial<GameSettings["economy"]["transfers"]>
) => onChange({ ...data, transfers: { ...data.transfers, ...patch } });
const setExam = (patch: Partial<GameSettings["economy"]["exam"]>) =>
onChange({ ...data, exam: { ...data.exam, ...patch } });
return (
<SectionCard title="Economy" icon={Coins}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Daily Rewards
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Field label="Amount (AU)">
<StringInput
value={data.daily.amount}
onChange={(v) => setDaily({ amount: v })}
/>
</Field>
<Field label="Streak Bonus">
<StringInput
value={data.daily.streakBonus}
onChange={(v) => setDaily({ streakBonus: v })}
/>
</Field>
<Field label="Weekly Bonus">
<StringInput
value={data.daily.weeklyBonus}
onChange={(v) => setDaily({ weeklyBonus: v })}
/>
</Field>
<Field label="Cooldown (ms)" hint={formatMs(data.daily.cooldownMs)}>
<NumberInput
value={data.daily.cooldownMs}
onChange={(v) => setDaily({ cooldownMs: v })}
min={0}
step={1000}
/>
</Field>
</div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider pt-2">
Transfers
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Min Amount">
<StringInput
value={data.transfers.minAmount}
onChange={(v) => setTransfers({ minAmount: v })}
/>
</Field>
<Field label="Allow Self-Transfer">
<Toggle
checked={data.transfers.allowSelfTransfer}
onChange={(v) => setTransfers({ allowSelfTransfer: v })}
/>
</Field>
</div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider pt-2">
Exam
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Min Multiplier">
<NumberInput
value={data.exam.multMin}
onChange={(v) => setExam({ multMin: v })}
min={0}
step={0.1}
/>
</Field>
<Field label="Max Multiplier">
<NumberInput
value={data.exam.multMax}
onChange={(v) => setExam({ multMax: v })}
min={0}
step={0.1}
/>
</Field>
</div>
</SectionCard>
);
}
function InventorySection({
data,
onChange,
}: {
data: GameSettings["inventory"];
onChange: (d: GameSettings["inventory"]) => void;
}) {
return (
<SectionCard title="Inventory" icon={Package}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Max Stack Size">
<StringInput
value={data.maxStackSize}
onChange={(v) => onChange({ ...data, maxStackSize: v })}
/>
</Field>
<Field label="Max Slots">
<NumberInput
value={data.maxSlots}
onChange={(v) => onChange({ ...data, maxSlots: v })}
min={1}
/>
</Field>
</div>
</SectionCard>
);
}
function LootdropSection({
data,
onChange,
}: {
data: GameSettings["lootdrop"];
onChange: (d: GameSettings["lootdrop"]) => void;
}) {
const setReward = (patch: Partial<GameSettings["lootdrop"]["reward"]>) =>
onChange({ ...data, reward: { ...data.reward, ...patch } });
return (
<SectionCard title="Lootdrops" icon={Gift}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Field
label="Activity Window (ms)"
hint={formatMs(data.activityWindowMs)}
>
<NumberInput
value={data.activityWindowMs}
onChange={(v) => onChange({ ...data, activityWindowMs: v })}
min={0}
step={1000}
/>
</Field>
<Field label="Min Messages">
<NumberInput
value={data.minMessages}
onChange={(v) => onChange({ ...data, minMessages: v })}
min={1}
/>
</Field>
<Field label="Spawn Chance" hint="0.0 1.0">
<NumberInput
value={data.spawnChance}
onChange={(v) => onChange({ ...data, spawnChance: v })}
min={0}
max={1}
step={0.01}
/>
</Field>
<Field label="Cooldown (ms)" hint={formatMs(data.cooldownMs)}>
<NumberInput
value={data.cooldownMs}
onChange={(v) => onChange({ ...data, cooldownMs: v })}
min={0}
step={1000}
/>
</Field>
</div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider pt-2">
Reward
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<Field label="Min Reward">
<NumberInput
value={data.reward.min}
onChange={(v) => setReward({ min: v })}
min={0}
/>
</Field>
<Field label="Max Reward">
<NumberInput
value={data.reward.max}
onChange={(v) => setReward({ max: v })}
min={0}
/>
</Field>
<Field label="Currency">
<SelectInput
value={data.reward.currency}
onChange={(v) => setReward({ currency: v })}
options={[
{ value: "AU", label: "AU (Astral Units)" },
{ value: "CU", label: "CU (Constellation Units)" },
]}
/>
</Field>
</div>
</SectionCard>
);
}
function TriviaSection({
data,
onChange,
}: {
data: GameSettings["trivia"];
onChange: (d: GameSettings["trivia"]) => void;
}) {
return (
<SectionCard title="Trivia" icon={Brain}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Field label="Entry Fee (AU)">
<StringInput
value={data.entryFee}
onChange={(v) => onChange({ ...data, entryFee: v })}
/>
</Field>
<Field label="Reward Multiplier">
<NumberInput
value={data.rewardMultiplier}
onChange={(v) => onChange({ ...data, rewardMultiplier: v })}
min={0}
step={0.1}
/>
</Field>
<Field label="Timeout (seconds)">
<NumberInput
value={data.timeoutSeconds}
onChange={(v) => onChange({ ...data, timeoutSeconds: v })}
min={5}
/>
</Field>
<Field label="Cooldown (ms)" hint={formatMs(data.cooldownMs)}>
<NumberInput
value={data.cooldownMs}
onChange={(v) => onChange({ ...data, cooldownMs: v })}
min={0}
step={1000}
/>
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Difficulty">
<SelectInput
value={data.difficulty}
onChange={(v) => onChange({ ...data, difficulty: v })}
options={[
{ value: "random", label: "Random" },
{ value: "easy", label: "Easy" },
{ value: "medium", label: "Medium" },
{ value: "hard", label: "Hard" },
]}
/>
</Field>
</div>
<Field
label="Categories"
hint="Select which OpenTDB categories to pull from. Leave empty for all categories."
>
<CategoryPicker
selected={data.categories ?? []}
onChange={(v) => onChange({ ...data, categories: v })}
/>
</Field>
</SectionCard>
);
}
function ModerationSection({
data,
onChange,
}: {
data: GameSettings["moderation"];
onChange: (d: GameSettings["moderation"]) => void;
}) {
const setPrune = (patch: Partial<GameSettings["moderation"]["prune"]>) =>
onChange({ ...data, prune: { ...data.prune, ...patch } });
return (
<SectionCard title="Moderation" icon={Shield}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Prune
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Field label="Max Amount">
<NumberInput
value={data.prune.maxAmount}
onChange={(v) => setPrune({ maxAmount: v })}
min={1}
/>
</Field>
<Field
label="Confirm Threshold"
hint="Require confirmation above this"
>
<NumberInput
value={data.prune.confirmThreshold}
onChange={(v) => setPrune({ confirmThreshold: v })}
min={1}
/>
</Field>
<Field label="Batch Size">
<NumberInput
value={data.prune.batchSize}
onChange={(v) => setPrune({ batchSize: v })}
min={1}
/>
</Field>
<Field
label="Batch Delay (ms)"
hint={formatMs(data.prune.batchDelayMs)}
>
<NumberInput
value={data.prune.batchDelayMs}
onChange={(v) => setPrune({ batchDelayMs: v })}
min={0}
step={100}
/>
</Field>
</div>
</SectionCard>
);
}
function CommandsSection({
commands,
onChange,
meta,
}: {
commands: GameSettings["commands"];
meta: SettingsMeta | null;
onChange: (d: GameSettings["commands"]) => void;
}) {
const grouped = (meta?.commands ?? []).reduce<
Record<string, Array<{ name: string; category: string }>>
>((acc, cmd) => {
(acc[cmd.category] ??= []).push(cmd);
return acc;
}, {});
const categories = Object.keys(grouped).sort();
if (categories.length === 0) {
return (
<SectionCard title="Commands" icon={Terminal}>
<p className="text-sm text-text-tertiary">
No registered commands found. Make sure the bot is online.
</p>
</SectionCard>
);
}
return (
<SectionCard title="Commands" icon={Terminal}>
<p className="text-xs text-text-tertiary">
Toggle commands on or off. Disabled commands cannot be used by anyone.
</p>
<div className="space-y-6">
{categories.map((cat) => (
<div key={cat}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-3">
{cat}
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-3">
{grouped[cat]!.map((cmd) => {
const enabled = commands[cmd.name] !== false;
return (
<div
key={cmd.name}
className="flex items-center justify-between py-1.5"
>
<span
className={cn(
"text-sm font-mono",
enabled ? "text-foreground" : "text-text-disabled"
)}
>
/{cmd.name}
</span>
<Toggle
checked={enabled}
onChange={(v) =>
onChange({ ...commands, [cmd.name]: v })
}
/>
</div>
);
})}
</div>
</div>
))}
</div>
</SectionCard>
);
}
// ---------------------------------------------------------------------------
// Default guild settings (used when guild has no config yet)
// ---------------------------------------------------------------------------
const defaultGuildSettings: GuildSettings = {
guildId: "",
configured: false,
colorRoleIds: [],
moderationDmOnWarn: true,
featureOverrides: {},
};
// ---------------------------------------------------------------------------
// Main Settings page
// ---------------------------------------------------------------------------
export default function Settings() {
const {
settings,
guildSettings,
meta,
loading,
saving,
error,
saveSettings,
saveGuildSettings,
} = useSettings();
const [gameDraft, setGameDraft] = useState<GameSettings | null>(null);
const [guildDraft, setGuildDraft] = useState<GuildSettings | null>(null);
const [activeSection, setActiveSection] =
useState<SettingsSection>("guild");
const [saveSuccess, setSaveSuccess] = useState(false);
// Sync drafts when data loads
useEffect(() => {
if (settings && !gameDraft) {
setGameDraft(structuredClone(settings));
}
}, [settings, gameDraft]);
useEffect(() => {
if (!guildDraft) {
setGuildDraft(
guildSettings
? structuredClone(guildSettings)
: { ...defaultGuildSettings, guildId: meta?.guildId ?? "" }
);
}
}, [guildSettings, guildDraft, meta?.guildId]);
const isGuildTab = activeSection === "guild";
const gameDirty =
gameDraft && settings
? JSON.stringify(gameDraft) !== JSON.stringify(settings)
: false;
const guildDirty =
guildDraft && guildSettings
? JSON.stringify(guildDraft) !== JSON.stringify(guildSettings)
: guildDraft
? JSON.stringify(guildDraft) !==
JSON.stringify({ ...defaultGuildSettings, guildId: meta?.guildId ?? "" })
: false;
const dirty = isGuildTab ? guildDirty : gameDirty;
const handleSave = useCallback(async () => {
let ok: boolean;
if (isGuildTab && guildDraft) {
ok = await saveGuildSettings(guildDraft);
} else if (gameDraft) {
ok = await saveSettings(
gameDraft as unknown as Record<string, unknown>
);
} else {
return;
}
if (ok) {
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
// Reset drafts so they re-sync from server
if (isGuildTab) setGuildDraft(null);
else setGameDraft(null);
}
}, [isGuildTab, gameDraft, guildDraft, saveSettings, saveGuildSettings]);
const handleReset = useCallback(() => {
if (isGuildTab) {
setGuildDraft(
guildSettings
? structuredClone(guildSettings)
: { ...defaultGuildSettings, guildId: meta?.guildId ?? "" }
);
} else if (settings) {
setGameDraft(structuredClone(settings));
}
}, [isGuildTab, settings, guildSettings, meta?.guildId]);
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
);
}
if (error && !settings) {
return (
<div className="flex items-center justify-center py-32">
<div className="text-center">
<AlertTriangle className="w-8 h-8 text-warning mx-auto mb-3" />
<p className="text-sm text-text-tertiary">{error}</p>
</div>
</div>
);
}
if (!gameDraft || !guildDraft) return null;
const updateGameSection = <K extends keyof GameSettings>(
key: K,
value: GameSettings[K]
) => setGameDraft((prev) => (prev ? { ...prev, [key]: value } : prev));
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-bold tracking-tight">
Settings
</h1>
<p className="text-sm text-text-tertiary mt-1">
Configure guild, bot systems, economy, and command access
</p>
</div>
<div className="flex items-center gap-3">
{dirty && (
<button
onClick={handleReset}
className="inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-text-secondary hover:bg-primary/10 transition-colors"
>
<RotateCcw className="w-4 h-4" />
Discard
</button>
)}
<button
onClick={handleSave}
disabled={!dirty || saving}
className={cn(
"inline-flex items-center gap-2 rounded-md px-5 py-2 text-sm font-medium transition-colors",
dirty
? "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm shadow-primary/30"
: "bg-primary/30 text-primary-foreground/50 cursor-not-allowed"
)}
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saveSuccess ? (
<Check className="w-4 h-4" />
) : (
<Save className="w-4 h-4" />
)}
{saving ? "Saving…" : saveSuccess ? "Saved" : "Save Changes"}
</button>
</div>
</div>
{/* Error banner */}
{error && (
<div className="flex items-center gap-3 bg-destructive/10 border border-destructive/30 rounded-lg px-5 py-3">
<AlertTriangle className="w-4 h-4 text-destructive shrink-0" />
<span className="text-sm text-destructive">{error}</span>
</div>
)}
{/* Section tabs */}
<div className="flex gap-1 overflow-x-auto border-b border-border pb-px">
{sections.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveSection(key)}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeSection === key
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
{/* Active section content */}
<div>
{activeSection === "guild" && (
<GuildSection
data={guildDraft}
onChange={setGuildDraft}
meta={meta}
/>
)}
{activeSection === "leveling" && (
<LevelingSection
data={gameDraft.leveling}
onChange={(v) => updateGameSection("leveling", v)}
/>
)}
{activeSection === "economy" && (
<EconomySection
data={gameDraft.economy}
onChange={(v) => updateGameSection("economy", v)}
/>
)}
{activeSection === "inventory" && (
<InventorySection
data={gameDraft.inventory}
onChange={(v) => updateGameSection("inventory", v)}
/>
)}
{activeSection === "lootdrop" && (
<LootdropSection
data={gameDraft.lootdrop}
onChange={(v) => updateGameSection("lootdrop", v)}
/>
)}
{activeSection === "trivia" && (
<TriviaSection
data={gameDraft.trivia}
onChange={(v) => updateGameSection("trivia", v)}
/>
)}
{activeSection === "moderation" && (
<ModerationSection
data={gameDraft.moderation}
onChange={(v) => updateGameSection("moderation", v)}
/>
)}
{activeSection === "commands" && (
<CommandsSection
commands={gameDraft.commands}
meta={meta}
onChange={(v) => updateGameSection("commands", v)}
/>
)}
</div>
{/* Dirty indicator footer */}
{dirty && (
<div className="sticky bottom-0 -mx-6 px-6 py-3 bg-background/80 backdrop-blur border-t border-border flex items-center justify-between">
<span className="text-sm text-warning font-medium">
You have unsaved changes
</span>
<div className="flex items-center gap-3">
<button
onClick={handleReset}
className="text-sm text-text-tertiary hover:text-foreground transition-colors"
>
Discard
</button>
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 rounded-md bg-primary text-primary-foreground px-4 py-1.5 text-sm font-medium hover:bg-primary/90 transition-colors"
>
{saving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</button>
</div>
</div>
)}
</div>
);
}