Some checks failed
Deploy to Production / test (push) Failing after 31s
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>
1412 lines
41 KiB
TypeScript
1412 lines
41 KiB
TypeScript
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>
|
||
);
|
||
}
|