Files
discord-rpg-concept/web/src/hooks/use-settings.ts

188 lines
6.0 KiB
TypeScript

import { useEffect, useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
// Sentinel value for "none" selection
export const NONE_VALUE = "__none__";
// Schema definition matching backend config
const bigIntStringSchema = z.coerce.string()
.refine((val) => /^\d+$/.test(val), { message: "Must be a valid integer" });
export const formSchema = z.object({
leveling: z.object({
base: z.number(),
exponent: z.number(),
chat: z.object({
cooldownMs: z.number(),
minXp: z.number(),
maxXp: z.number(),
})
}),
economy: z.object({
daily: z.object({
amount: bigIntStringSchema,
streakBonus: bigIntStringSchema,
weeklyBonus: bigIntStringSchema,
cooldownMs: z.number(),
}),
transfers: z.object({
allowSelfTransfer: z.boolean(),
minAmount: bigIntStringSchema,
}),
exam: z.object({
multMin: z.number(),
multMax: z.number(),
})
}),
inventory: z.object({
maxStackSize: bigIntStringSchema,
maxSlots: z.number(),
}),
commands: z.record(z.string(), z.boolean()).optional(),
lootdrop: z.object({
activityWindowMs: z.number(),
minMessages: z.number(),
spawnChance: z.number(),
cooldownMs: z.number(),
reward: z.object({
min: z.number(),
max: z.number(),
currency: z.string(),
})
}),
studentRole: z.string().optional(),
visitorRole: z.string().optional(),
colorRoles: z.array(z.string()).default([]),
welcomeChannelId: z.string().optional(),
welcomeMessage: z.string().optional(),
feedbackChannelId: z.string().optional(),
terminal: z.object({
channelId: z.string(),
messageId: z.string()
}).optional(),
moderation: z.object({
prune: z.object({
maxAmount: z.number(),
confirmThreshold: z.number(),
batchSize: z.number(),
batchDelayMs: z.number(),
}),
cases: z.object({
dmOnWarn: z.boolean(),
logChannelId: z.string().optional(),
autoTimeoutThreshold: z.number().optional()
})
}),
trivia: z.object({
entryFee: bigIntStringSchema,
rewardMultiplier: z.number(),
timeoutSeconds: z.number(),
cooldownMs: z.number(),
categories: z.array(z.number()).default([]),
difficulty: z.enum(['easy', 'medium', 'hard', 'random']),
}).optional(),
system: z.record(z.string(), z.any()).optional(),
});
export type FormValues = z.infer<typeof formSchema>;
export interface ConfigMeta {
roles: { id: string, name: string, color: string }[];
channels: { id: string, name: string, type: number }[];
commands: { name: string, category: string }[];
}
export const toSelectValue = (v: string | undefined | null) => v || NONE_VALUE;
export const fromSelectValue = (v: string) => v === NONE_VALUE ? "" : v;
export function useSettings() {
const [meta, setMeta] = useState<ConfigMeta | null>(null);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema) as any,
defaultValues: {
economy: {
daily: { amount: "0", streakBonus: "0", weeklyBonus: "0", cooldownMs: 0 },
transfers: { minAmount: "0", allowSelfTransfer: false },
exam: { multMin: 1, multMax: 1 }
},
leveling: { base: 100, exponent: 1.5, chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 } },
inventory: { maxStackSize: "1", maxSlots: 10 },
moderation: {
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
cases: { dmOnWarn: true }
},
lootdrop: {
spawnChance: 0.05,
minMessages: 10,
cooldownMs: 300000,
activityWindowMs: 600000,
reward: { min: 100, max: 500, currency: "AU" }
}
}
});
const loadSettings = useCallback(async () => {
setLoading(true);
try {
const [config, metaData] = await Promise.all([
fetch("/api/settings").then(res => res.json()),
fetch("/api/settings/meta").then(res => res.json())
]);
form.reset(config as any);
setMeta(metaData);
} catch (err) {
toast.error("Failed to load settings", {
description: "Unable to fetch bot configuration. Please try again."
});
console.error(err);
} finally {
setLoading(false);
}
}, [form]);
useEffect(() => {
loadSettings();
}, [loadSettings]);
const saveSettings = async (data: FormValues) => {
setIsSaving(true);
try {
const response = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error("Failed to save");
toast.success("Settings saved successfully", {
description: "Bot configuration has been updated and reloaded."
});
// Reload settings to ensure we have the latest state
await loadSettings();
} catch (error) {
toast.error("Failed to save settings", {
description: error instanceof Error ? error.message : "Unable to save changes. Please try again."
});
console.error(error);
} finally {
setIsSaving(false);
}
};
return {
form,
meta,
loading,
isSaving,
saveSettings,
loadSettings
};
}