6 Commits

Author SHA1 Message Date
syntaxbullet
1e978dff58 refactor(panel): extract page sub-components from mega-files
Some checks failed
Deploy to Production / test (push) Failing after 33s
Split ItemStudio (1863->388), Settings (1445->355), and Users
(1062->164) into focused sub-components under pages/components/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:39 +02:00
syntaxbullet
3c256ba0b2 refactor: centralize custom interaction IDs into constants
Replace all hardcoded custom ID strings with module-level constants.
Each module now has *_CUSTOM_IDS in its types file, using functions
for dynamic IDs and PREFIX for startsWith matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:35 +02:00
syntaxbullet
70d59a091a docs: fix drift in docs/main.md
Fix web/ -> api/, add missing panel/modules/graphics sections,
expand module and utility listings to match actual codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:26 +02:00
syntaxbullet
9569972cd6 docs: document interaction routing flow in CLAUDE.md
Add routing table mapping custom ID prefixes to handler files and
describe the ComponentInteractionHandler dispatch mechanism.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:22 +02:00
syntaxbullet
5bd390b4ee docs: add JSDoc to service public methods
One-line JSDoc on 82 methods across 11 service files for quick
scanning without reading full implementations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:18 +02:00
syntaxbullet
5f8819bb46 docs: add subdirectory CLAUDE.md files for key domain modules
Provide non-obvious business rules and constraints for economy,
inventory, quest, moderation, trade, and trivia modules to reduce
context-gathering overhead for AI tools.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:12 +02:00
65 changed files with 4312 additions and 3706 deletions

View File

@@ -87,6 +87,30 @@ import { localHelper } from "./helper"; // relative
- `*.types.ts` — Module-specific TypeScript types
- `*.test.ts` — Tests (co-located with source)
### Interaction Routing
Component interactions (buttons, select menus, modals) flow through a centralized routing system:
```
Discord event → interactionCreate → ComponentInteractionHandler → interaction.routes.ts → *.interaction.ts
```
`ComponentInteractionHandler` (`bot/lib/handlers/ComponentInteractionHandler.ts`) iterates over the route table in `bot/lib/interaction.routes.ts`. Each route has a `predicate` that matches on `customId`, a lazy `handler` import, and a `method` name to call. The handler also provides centralized `UserError` / system error handling.
**Route table (custom ID prefix → handler):**
| Custom ID prefix | Handler file | Method |
| ------------------ | ----------------------------------------------- | ------------------------------ |
| `trade_`, `amount` | `bot/modules/trade/trade.interaction.ts` | `handleTradeInteraction` |
| `shop_buy_` | `bot/modules/economy/shop.interaction.ts` | `handleShopInteraction` |
| `lootdrop_` | `bot/modules/economy/lootdrop.interaction.ts` | `handleLootdropInteraction` |
| `trivia_` | `bot/modules/trivia/trivia.interaction.ts` | `handleTriviaInteraction` |
| `createitem_` | `bot/modules/admin/item_wizard.ts` | `handleItemWizardInteraction` |
| `enrollment` | `bot/modules/user/enrollment.interaction.ts` | `handleEnrollmentInteraction` |
| `feedback_` | `bot/modules/feedback/feedback.interaction.ts` | `handleFeedbackInteraction` |
Routes are evaluated in order — the first matching predicate wins. Some modules (e.g., inventory with `inv_` prefix) handle interactions locally via message component collectors instead of the global route table.
### Command Definition
```typescript

View File

@@ -11,6 +11,7 @@ import {
getCancelledEmbed
} from "@/modules/moderation/prune.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
import { PRUNE_CUSTOM_IDS } from "@modules/moderation/prune.types";
export const prune = createCommand({
data: new SlashCommandBuilder()
@@ -83,7 +84,7 @@ export const prune = createCommand({
time: 30000
});
if (confirmation.customId === "cancel_prune") {
if (confirmation.customId === PRUNE_CUSTOM_IDS.CANCEL) {
await confirmation.update({
embeds: [getCancelledEmbed()],
components: []

View File

@@ -2,11 +2,12 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { questService } from "@shared/modules/quest/quest.service";
import { createSuccessEmbed } from "@lib/embeds";
import {
getQuestListComponents,
getAvailableQuestsComponents,
getQuestActionRows
import {
getQuestListComponents,
getAvailableQuestsComponents,
getQuestActionRows
} from "@/modules/quest/quest.view";
import { QUEST_CUSTOM_IDS } from "@modules/quest/quest.types";
export const quests = createCommand({
data: new SlashCommandBuilder()
@@ -56,19 +57,19 @@ export const quests = createCommand({
if (i.user.id !== interaction.user.id) return;
try {
if (i.customId === "quest_view_active") {
if (i.customId === QUEST_CUSTOM_IDS.VIEW_ACTIVE) {
await i.deferUpdate();
await updateView('active', 0);
} else if (i.customId === "quest_view_available") {
} else if (i.customId === QUEST_CUSTOM_IDS.VIEW_AVAILABLE) {
await i.deferUpdate();
await updateView('available', 0);
} else if (i.customId === "quest_page_prev") {
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_PREV) {
await i.deferUpdate();
await updateView(currentView, Math.max(0, currentPage - 1));
} else if (i.customId === "quest_page_next") {
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_NEXT) {
await i.deferUpdate();
await updateView(currentView, currentPage + 1);
} else if (i.customId.startsWith("quest_accept:")) {
} else if (i.customId.startsWith(QUEST_CUSTOM_IDS.ACCEPT_PREFIX)) {
const questIdStr = i.customId.split(":")[1];
if (!questIdStr) return;
const questId = parseInt(questIdStr);

View File

@@ -1,11 +1,14 @@
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
import { TRADE_CUSTOM_IDS } from "@modules/trade/trade.types";
import { SHOP_CUSTOM_IDS, LOOTDROP_CUSTOM_IDS } from "@modules/economy/economy.types";
import { ITEM_WIZARD_CUSTOM_IDS } from "@modules/admin/item_wizard.types";
import { TRIVIA_CUSTOM_IDS } from "@modules/trivia/trivia.types";
import { ENROLLMENT_CUSTOM_IDS } from "@modules/user/user.types";
import { FEEDBACK_CUSTOM_IDS } from "@modules/feedback/feedback.types";
// Union type for all component interactions
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
// Type for the handler function that modules export
type InteractionHandler = (interaction: ComponentInteraction) => Promise<void>;
// Type for the dynamically imported module containing the handler
interface InteractionModule {
[key: string]: (...args: any[]) => Promise<void> | any;
@@ -21,45 +24,45 @@ interface InteractionRoute {
export const interactionRoutes: InteractionRoute[] = [
// --- TRADE MODULE ---
{
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
predicate: (i) => i.customId.startsWith(TRADE_CUSTOM_IDS.PREFIX) || i.customId === TRADE_CUSTOM_IDS.MONEY_AMOUNT_FIELD,
handler: () => import("@/modules/trade/trade.interaction"),
method: 'handleTradeInteraction'
},
// --- ECONOMY MODULE ---
{
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
predicate: (i) => i.isButton() && i.customId.startsWith(SHOP_CUSTOM_IDS.BUY_PREFIX),
handler: () => import("@/modules/economy/shop.interaction"),
method: 'handleShopInteraction'
},
{
predicate: (i) => i.isButton() && i.customId.startsWith("lootdrop_"),
predicate: (i) => i.isButton() && i.customId.startsWith(LOOTDROP_CUSTOM_IDS.PREFIX),
handler: () => import("@/modules/economy/lootdrop.interaction"),
method: 'handleLootdropInteraction'
},
{
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
predicate: (i) => i.isButton() && i.customId.startsWith(TRIVIA_CUSTOM_IDS.PREFIX),
handler: () => import("@/modules/trivia/trivia.interaction"),
method: 'handleTriviaInteraction'
},
// --- ADMIN MODULE ---
{
predicate: (i) => i.customId.startsWith("createitem_"),
predicate: (i) => i.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX),
handler: () => import("@/modules/admin/item_wizard"),
method: 'handleItemWizardInteraction'
},
// --- USER MODULE ---
{
predicate: (i) => i.isButton() && i.customId === "enrollment",
predicate: (i) => i.isButton() && i.customId === ENROLLMENT_CUSTOM_IDS.ENROLL,
handler: () => import("@/modules/user/enrollment.interaction"),
method: 'handleEnrollmentInteraction'
},
// --- FEEDBACK MODULE ---
{
predicate: (i) => i.customId.startsWith("feedback_"),
predicate: (i) => i.customId.startsWith(FEEDBACK_CUSTOM_IDS.PREFIX),
handler: () => import("@/modules/feedback/feedback.interaction"),
method: 'handleFeedbackInteraction'
}

View File

@@ -3,7 +3,7 @@ import { items } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
import type { DraftItem } from "./item_wizard.types";
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
import { ItemType, EffectType } from "@shared/lib/constants";
// --- Types ---
@@ -41,13 +41,13 @@ export const renderWizard = (userId: string, isDraft = true) => {
export const handleItemWizardInteraction = async (interaction: Interaction) => {
// Only handle createitem interactions
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
if (!interaction.customId.startsWith("createitem_")) return;
if (!interaction.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX)) return;
const userId = interaction.user.id;
let draft = draftSession.get(userId);
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
if (interaction.customId === "createitem_cancel") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.CANCEL) {
draftSession.delete(userId);
if (interaction.isMessageComponent()) {
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
@@ -59,7 +59,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
if (!draft) {
if (interaction.isMessageComponent()) {
// Create one implicitly to prevent crashes, or warn user
if (interaction.customId === "createitem_start") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.START) {
// Allow start
} else {
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
@@ -81,7 +81,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
// --- Routing ---
// 1. Details Modal
if (interaction.customId === "createitem_details") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.DETAILS) {
if (!interaction.isButton()) return;
const modal = getDetailsModal(draft);
await interaction.showModal(modal);
@@ -89,7 +89,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// 2. Economy Modal
if (interaction.customId === "createitem_economy") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ECONOMY) {
if (!interaction.isButton()) return;
const modal = getEconomyModal(draft);
await interaction.showModal(modal);
@@ -97,7 +97,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// 3. Visuals Modal
if (interaction.customId === "createitem_visuals") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.VISUALS) {
if (!interaction.isButton()) return;
const modal = getVisualsModal(draft);
await interaction.showModal(modal);
@@ -105,14 +105,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// 4. Type Toggle (Start Select Menu)
if (interaction.customId === "createitem_type_toggle") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE) {
if (!interaction.isButton()) return;
const { components } = getItemTypeSelection();
await interaction.update({ components }); // Temporary view
return;
}
if (interaction.customId === "createitem_select_type") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE) {
if (!interaction.isStringSelectMenu()) return;
const selected = interaction.values[0];
if (selected) {
@@ -125,14 +125,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// 5. Add Effect Flow
if (interaction.customId === "createitem_addeffect_start") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START) {
if (!interaction.isButton()) return;
const { components } = getEffectTypeSelection();
await interaction.update({ components });
return;
}
if (interaction.customId === "createitem_select_effect_type") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE) {
if (!interaction.isStringSelectMenu()) return;
const effectType = interaction.values[0];
if (!effectType) return;
@@ -149,7 +149,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// Toggle Consume
if (interaction.customId === "createitem_toggle_consume") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME) {
if (!interaction.isButton()) return;
draft.usageData.consume = !draft.usageData.consume;
const payload = renderWizard(userId);
@@ -159,43 +159,43 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
// 6. Handle Modal Submits
if (interaction.isModalSubmit()) {
if (interaction.customId === "createitem_modal_details") {
draft.name = interaction.fields.getTextInputValue("name");
draft.description = interaction.fields.getTextInputValue("desc");
draft.rarity = interaction.fields.getTextInputValue("rarity");
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS) {
draft.name = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME);
draft.description = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC);
draft.rarity = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY);
}
else if (interaction.customId === "createitem_modal_economy") {
const price = parseInt(interaction.fields.getTextInputValue("price"));
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY) {
const price = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE));
draft.price = isNaN(price) || price === 0 ? null : price;
}
else if (interaction.customId === "createitem_modal_visuals") {
draft.iconUrl = interaction.fields.getTextInputValue("icon");
draft.imageUrl = interaction.fields.getTextInputValue("image");
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS) {
draft.iconUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON);
draft.imageUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE);
}
else if (interaction.customId === "createitem_modal_effect") {
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT) {
const type = draft.pendingEffectType;
if (type) {
let effect: ItemEffect | null = null;
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
const amount = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT));
if (!isNaN(amount)) effect = { type: type as any, amount };
}
else if (type === EffectType.REPLY_MESSAGE) {
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE) };
}
else if (type === EffectType.XP_BOOST) {
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
const multiplier = parseFloat(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER));
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
}
else if (type === EffectType.TEMP_ROLE) {
const roleId = interaction.fields.getTextInputValue("role_id");
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
}
else if (type === EffectType.COLOR_ROLE) {
const roleId = interaction.fields.getTextInputValue("role_id");
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
}
@@ -214,7 +214,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// 7. Save
if (interaction.customId === "createitem_save") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SAVE) {
if (!interaction.isButton()) return;
await interaction.deferUpdate(); // Prepare to save

View File

@@ -1,5 +1,36 @@
import type { ItemUsageData } from "@shared/lib/types";
export const ITEM_WIZARD_CUSTOM_IDS = {
PREFIX: "createitem_",
START: "createitem_start",
DETAILS: "createitem_details",
ECONOMY: "createitem_economy",
VISUALS: "createitem_visuals",
TYPE_TOGGLE: "createitem_type_toggle",
SELECT_TYPE: "createitem_select_type",
ADD_EFFECT_START: "createitem_addeffect_start",
SELECT_EFFECT_TYPE: "createitem_select_effect_type",
TOGGLE_CONSUME: "createitem_toggle_consume",
SAVE: "createitem_save",
CANCEL: "createitem_cancel",
MODAL_DETAILS: "createitem_modal_details",
MODAL_ECONOMY: "createitem_modal_economy",
MODAL_VISUALS: "createitem_modal_visuals",
MODAL_EFFECT: "createitem_modal_effect",
// Modal field IDs
FIELD_NAME: "name",
FIELD_DESC: "desc",
FIELD_RARITY: "rarity",
FIELD_PRICE: "price",
FIELD_ICON: "icon",
FIELD_IMAGE: "image",
FIELD_AMOUNT: "amount",
FIELD_MESSAGE: "message",
FIELD_MULTIPLIER: "multiplier",
FIELD_DURATION: "duration",
FIELD_ROLE_ID: "role_id",
} as const;
export interface DraftItem {
name: string;
description: string;

View File

@@ -9,7 +9,7 @@ import {
type MessageActionRowComponentBuilder
} from "discord.js";
import { createBaseEmbed } from "@lib/embeds";
import type { DraftItem } from "./item_wizard.types";
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
import { ItemType } from "@shared/lib/constants";
const getItemTypeOptions = () => [
@@ -51,18 +51,18 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
// Components
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
.addComponents(
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.DETAILS).setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ECONOMY).setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.VISUALS).setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE).setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
);
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
.addComponents(
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START).setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME).setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SAVE).setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.CANCEL).setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
);
return { embeds: [embed], components: [row1, row2] };
@@ -70,65 +70,65 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
export const getItemTypeSelection = () => {
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE).setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
);
return { components: [row] };
};
export const getEffectTypeSelection = () => {
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE).setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
);
return { components: [row] };
};
export const getDetailsModal = (current: DraftItem) => {
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS).setTitle("Edit Details");
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME).setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC).setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY).setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
);
return modal;
};
export const getEconomyModal = (current: DraftItem) => {
const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy");
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY).setTitle("Edit Economy");
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE).setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
);
return modal;
};
export const getVisualsModal = (current: DraftItem) => {
const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals");
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS).setTitle("Edit Visuals");
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON).setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE).setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
);
return modal;
};
export const getEffectConfigModal = (effectType: string) => {
let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`);
let modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT).setTitle(`Config ${effectType}`);
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT).setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
} else if (effectType === "REPLY_MESSAGE") {
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE).setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
} else if (effectType === "XP_BOOST") {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER).setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
);
} else if (effectType === "TEMP_ROLE") {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
);
} else if (effectType === "COLOR_ROLE") {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
);
}
return modal;

View File

@@ -0,0 +1,10 @@
export const LOOTDROP_CUSTOM_IDS = {
PREFIX: "lootdrop_",
CLAIM: "lootdrop_claim",
CLAIM_DISABLED: "lootdrop_claim_disabled",
} as const;
export const SHOP_CUSTOM_IDS = {
BUY_PREFIX: "shop_buy_",
BUY: (itemId: number) => `shop_buy_${itemId}`,
} as const;

View File

@@ -3,9 +3,10 @@ import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { UserError } from "@shared/lib/errors";
import { getLootdropClaimedMessage } from "./lootdrop.view";
import { terminalService } from "@modules/system/terminal.service";
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
if (interaction.customId === "lootdrop_claim") {
if (interaction.customId === LOOTDROP_CUSTOM_IDS.CLAIM) {
await interaction.deferReply({ ephemeral: true });
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);

View File

@@ -1,12 +1,13 @@
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
export async function getLootdropMessage(reward: number, currency: string) {
const cardBuffer = await generateLootdropCard(reward, currency);
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
const claimButton = new ButtonBuilder()
.setCustomId("lootdrop_claim")
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM)
.setLabel("CLAIM REWARD")
.setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
.setEmoji("🌠");
@@ -28,7 +29,7 @@ export async function getLootdropClaimedMessage(userId: string, username: string
const newRow = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setCustomId("lootdrop_claim_disabled")
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM_DISABLED)
.setLabel("CLAIMED")
.setStyle(ButtonStyle.Secondary)
.setEmoji("✅")

View File

@@ -2,13 +2,14 @@ import { ButtonInteraction, MessageFlags } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { UserError } from "@shared/lib/errors";
import { SHOP_CUSTOM_IDS } from "./economy.types";
export async function handleShopInteraction(interaction: ButtonInteraction) {
if (!interaction.customId.startsWith("shop_buy_")) return;
if (!interaction.customId.startsWith(SHOP_CUSTOM_IDS.BUY_PREFIX)) return;
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
const itemId = parseInt(interaction.customId.replace(SHOP_CUSTOM_IDS.BUY_PREFIX, ""));
if (isNaN(itemId)) {
throw new UserError("Invalid Item ID.");
}

View File

@@ -19,6 +19,7 @@ import { existsSync } from "fs";
import { LootType, EffectType } from "@shared/lib/constants";
import type { LootTableItem } from "@shared/lib/types";
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
import { SHOP_CUSTOM_IDS } from "./economy.types";
export function getShopListingMessage(
item: {
@@ -100,7 +101,7 @@ export function getShopListingMessage(
// Create buy button (used in either main or loot container)
const buyButton = new ButtonBuilder()
.setCustomId(`shop_buy_${item.id}`)
.setCustomId(SHOP_CUSTOM_IDS.BUY(item.id))
.setLabel(`Purchase for ${item.price} 🪙`)
.setStyle(ButtonStyle.Success)
.setEmoji("🛒");

View File

@@ -8,7 +8,7 @@ import { UserError } from "@shared/lib/errors";
export const handleFeedbackInteraction = async (interaction: Interaction) => {
// Handle select menu for choosing feedback type
if (interaction.isStringSelectMenu() && interaction.customId === "feedback_select_type") {
if (interaction.isStringSelectMenu() && interaction.customId === FEEDBACK_CUSTOM_IDS.SELECT_TYPE) {
const feedbackType = interaction.values[0] as FeedbackType;
if (!feedbackType) {

View File

@@ -16,8 +16,10 @@ export const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
};
export const FEEDBACK_CUSTOM_IDS = {
PREFIX: "feedback_",
SELECT_TYPE: "feedback_select_type",
MODAL: "feedback_modal",
TYPE_FIELD: "feedback_type",
TITLE_FIELD: "feedback_title",
DESCRIPTION_FIELD: "feedback_description"
DESCRIPTION_FIELD: "feedback_description",
} as const;

View File

@@ -14,7 +14,7 @@ import { FEEDBACK_TYPE_LABELS, FEEDBACK_CUSTOM_IDS, type FeedbackData, type Feed
export function getFeedbackTypeMenu() {
const select = new StringSelectMenuBuilder()
.setCustomId("feedback_select_type")
.setCustomId(FEEDBACK_CUSTOM_IDS.SELECT_TYPE)
.setPlaceholder("Choose feedback type")
.addOptions([
{

View File

@@ -3,6 +3,7 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { getLootboxResultMessage } from "./inventory.view";
import type { ItemUsageData } from "@shared/lib/types";
import { getGuildConfig } from "@shared/lib/config";
import { INVENTORY_CUSTOM_IDS } from "./inventory.types";
export interface InventoryState {
ownerId: string;
@@ -25,7 +26,7 @@ export function parseInventoryCustomId(customId: string): { action: string; view
* Checks if a custom ID belongs to the inventory system.
*/
export function isInventoryInteraction(customId: string): boolean {
return customId.startsWith("inv_");
return customId.startsWith(INVENTORY_CUSTOM_IDS.PREFIX);
}
/**

View File

@@ -0,0 +1,13 @@
export const INVENTORY_CUSTOM_IDS = {
PREFIX: "inv_",
SELECT: (viewerId: string) => `inv_select_${viewerId}`,
PREV: (viewerId: string) => `inv_prev_${viewerId}`,
PAGE: (viewerId: string) => `inv_page_${viewerId}`,
NEXT: (viewerId: string) => `inv_next_${viewerId}`,
BACK: (viewerId: string) => `inv_back_${viewerId}`,
USE: (viewerId: string) => `inv_use_${viewerId}`,
DISCARD: (viewerId: string) => `inv_discard_${viewerId}`,
DISCARD_CONFIRM: (viewerId: string) => `inv_discard_confirm_${viewerId}`,
DISCARD_CANCEL: (viewerId: string) => `inv_discard_cancel_${viewerId}`,
USE_BACK: (viewerId: string) => `inv_use_back_${viewerId}`,
} as const;

View File

@@ -22,6 +22,7 @@ import { ItemType } from "@shared/lib/constants";
import type { ItemUsageData } from "@shared/lib/types";
import { join } from "path";
import { existsSync } from "fs";
import { INVENTORY_CUSTOM_IDS } from "./inventory.types";
export const ITEMS_PER_PAGE = 5;
@@ -101,7 +102,7 @@ export function getInventoryListMessage(
// Select menu with current page items
const selectMenu = new StringSelectMenuBuilder()
.setCustomId(`inv_select_${viewerId}`)
.setCustomId(INVENTORY_CUSTOM_IDS.SELECT(viewerId))
.setPlaceholder("Select an item for details");
for (const entry of pageItems) {
@@ -121,17 +122,17 @@ export function getInventoryListMessage(
// Pagination buttons
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`inv_prev_${viewerId}`)
.setCustomId(INVENTORY_CUSTOM_IDS.PREV(viewerId))
.setLabel("◀ Previous")
.setStyle(ButtonStyle.Secondary)
.setDisabled(safePage <= 0),
new ButtonBuilder()
.setCustomId(`inv_page_${viewerId}`)
.setCustomId(INVENTORY_CUSTOM_IDS.PAGE(viewerId))
.setLabel(`Page ${safePage + 1}/${totalPages}`)
.setStyle(ButtonStyle.Secondary)
.setDisabled(true),
new ButtonBuilder()
.setCustomId(`inv_next_${viewerId}`)
.setCustomId(INVENTORY_CUSTOM_IDS.NEXT(viewerId))
.setLabel("Next ▶")
.setStyle(ButtonStyle.Secondary)
.setDisabled(safePage >= totalPages - 1),
@@ -225,7 +226,7 @@ export function getItemDetailMessage(
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`inv_back_${viewerId}`)
.setCustomId(INVENTORY_CUSTOM_IDS.BACK(viewerId))
.setLabel("◀ Back")
.setStyle(ButtonStyle.Primary)
);
@@ -233,7 +234,7 @@ export function getItemDetailMessage(
if (isUsable) {
actionRow.addComponents(
new ButtonBuilder()
.setCustomId(`inv_use_${viewerId}`)
.setCustomId(INVENTORY_CUSTOM_IDS.USE(viewerId))
.setLabel("🧪 Use")
.setStyle(ButtonStyle.Success)
);
@@ -242,7 +243,7 @@ export function getItemDetailMessage(
if (isOwner) {
actionRow.addComponents(
new ButtonBuilder()
.setCustomId(`inv_discard_${viewerId}`)
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD(viewerId))
.setLabel("🗑 Discard")
.setStyle(ButtonStyle.Danger)
);
@@ -271,11 +272,11 @@ export function getDiscardConfirmMessage(entry: InventoryEntry, viewerId: string
.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`inv_discard_confirm_${viewerId}`)
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD_CONFIRM(viewerId))
.setLabel("Confirm")
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId(`inv_discard_cancel_${viewerId}`)
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD_CANCEL(viewerId))
.setLabel("Cancel")
.setStyle(ButtonStyle.Secondary)
)
@@ -296,7 +297,7 @@ export function getDiscardConfirmMessage(entry: InventoryEntry, viewerId: string
export function appendUseBackButton(message: any, viewerId: string): any {
const backRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`inv_use_back_${viewerId}`)
.setCustomId(INVENTORY_CUSTOM_IDS.USE_BACK(viewerId))
.setLabel("◀ Back to Inventory")
.setStyle(ButtonStyle.Primary)
);

View File

@@ -1,3 +1,8 @@
export const PRUNE_CUSTOM_IDS = {
CONFIRM: "confirm_prune",
CANCEL: "cancel_prune",
} as const;
export interface PruneOptions {
amount?: number;
userId?: string;

View File

@@ -1,5 +1,5 @@
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from "discord.js";
import type { PruneResult, PruneProgress } from "./prune.types";
import { PRUNE_CUSTOM_IDS, type PruneResult, type PruneProgress } from "./prune.types";
/**
* Creates a confirmation message for prune operations
@@ -25,12 +25,12 @@ export function getConfirmationMessage(
.setTimestamp();
const confirmButton = new ButtonBuilder()
.setCustomId("confirm_prune")
.setCustomId(PRUNE_CUSTOM_IDS.CONFIRM)
.setLabel("Confirm")
.setStyle(ButtonStyle.Danger);
const cancelButton = new ButtonBuilder()
.setCustomId("cancel_prune")
.setCustomId(PRUNE_CUSTOM_IDS.CANCEL)
.setLabel("Cancel")
.setStyle(ButtonStyle.Secondary);

View File

@@ -0,0 +1,8 @@
export const QUEST_CUSTOM_IDS = {
ACCEPT_PREFIX: "quest_accept:",
ACCEPT: (questId: number) => `quest_accept:${questId}`,
PAGE_PREV: "quest_page_prev",
PAGE_NEXT: "quest_page_next",
VIEW_ACTIVE: "quest_view_active",
VIEW_AVAILABLE: "quest_view_available",
} as const;

View File

@@ -8,6 +8,7 @@ import {
SeparatorSpacingSize,
MessageFlags
} from "discord.js";
import { QUEST_CUSTOM_IDS } from "./quest.types";
/**
* Quest entry with quest details and progress
@@ -169,7 +170,7 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[],
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`quest_accept:${quest.id}`)
.setCustomId(QUEST_CUSTOM_IDS.ACCEPT(quest.id))
.setLabel("Accept Quest")
.setStyle(ButtonStyle.Success)
.setEmoji("✅")
@@ -191,12 +192,12 @@ export function getQuestActionRows(viewType: 'active' | 'available', totalItems:
if (totalPages > 1) {
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("quest_page_prev")
.setCustomId(QUEST_CUSTOM_IDS.PAGE_PREV)
.setLabel("◀ Prev")
.setStyle(ButtonStyle.Secondary)
.setDisabled(page <= 0),
new ButtonBuilder()
.setCustomId("quest_page_next")
.setCustomId(QUEST_CUSTOM_IDS.PAGE_NEXT)
.setLabel("Next ▶")
.setStyle(ButtonStyle.Secondary)
.setDisabled(page >= totalPages - 1)
@@ -206,12 +207,12 @@ export function getQuestActionRows(viewType: 'active' | 'available', totalItems:
// Tab navigation row
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("quest_view_active")
.setCustomId(QUEST_CUSTOM_IDS.VIEW_ACTIVE)
.setLabel("📜 Active")
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
.setDisabled(viewType === 'active'),
new ButtonBuilder()
.setCustomId("quest_view_available")
.setCustomId(QUEST_CUSTOM_IDS.VIEW_AVAILABLE)
.setLabel("🗺️ Available")
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
.setDisabled(viewType === 'available')

View File

@@ -12,6 +12,7 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
import { TRADE_CUSTOM_IDS } from "./trade.types";
@@ -23,25 +24,25 @@ export async function handleTradeInteraction(interaction: Interaction) {
if (!threadId) return;
if (customId === 'trade_cancel') {
if (customId === TRADE_CUSTOM_IDS.CANCEL) {
await handleCancel(interaction, threadId);
} else if (customId === 'trade_lock') {
} else if (customId === TRADE_CUSTOM_IDS.LOCK) {
await handleLock(interaction, threadId);
} else if (customId === 'trade_confirm') {
} else if (customId === TRADE_CUSTOM_IDS.CONFIRM) {
// Confirm logic is handled implicitly by both locking or explicitly if needed.
// For now, locking both triggers execution, so no separate confirm handler is actively used
// unless we re-introduce a specific button. keeping basic handler stub if needed.
} else if (customId === 'trade_add_money') {
} else if (customId === TRADE_CUSTOM_IDS.ADD_MONEY) {
await handleAddMoneyClick(interaction);
} else if (customId === 'trade_money_modal') {
} else if (customId === TRADE_CUSTOM_IDS.MONEY_MODAL) {
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
} else if (customId === 'trade_add_item') {
} else if (customId === TRADE_CUSTOM_IDS.ADD_ITEM) {
await handleAddItemClick(interaction as ButtonInteraction, threadId);
} else if (customId === 'trade_select_item') {
} else if (customId === TRADE_CUSTOM_IDS.SELECT_ITEM) {
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
} else if (customId === 'trade_remove_item') {
} else if (customId === TRADE_CUSTOM_IDS.REMOVE_ITEM) {
await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
} else if (customId === 'trade_remove_item_select') {
} else if (customId === TRADE_CUSTOM_IDS.REMOVE_ITEM_SELECT) {
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
}
}
@@ -82,7 +83,7 @@ async function handleAddMoneyClick(interaction: Interaction) {
}
async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId: string) {
const amountStr = interaction.fields.getTextInputValue('amount');
const amountStr = interaction.fields.getTextInputValue(TRADE_CUSTOM_IDS.MONEY_AMOUNT_FIELD);
const amount = BigInt(amountStr);
if (amount < 0n) throw new UserError("Amount must be positive");
@@ -107,7 +108,7 @@ async function handleAddItemClick(interaction: ButtonInteraction, threadId: stri
description: `Rarity: ${entry.item.rarity} `
}));
const { components } = getItemSelectMenu(options, 'trade_select_item', 'Select an item to add');
const { components } = getItemSelectMenu(options, TRADE_CUSTOM_IDS.SELECT_ITEM, 'Select an item to add');
await interaction.reply({ content: "Select an item to add:", components, ephemeral: true });
}
@@ -142,7 +143,7 @@ async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: s
value: i.id.toString(),
}));
const { components } = getItemSelectMenu(options, 'trade_remove_item_select', 'Select an item to remove');
const { components } = getItemSelectMenu(options, TRADE_CUSTOM_IDS.REMOVE_ITEM_SELECT, 'Select an item to remove');
await interaction.reply({ content: "Select an item to remove:", components, ephemeral: true });
}

View File

@@ -1,3 +1,16 @@
export const TRADE_CUSTOM_IDS = {
PREFIX: "trade_",
ADD_ITEM: "trade_add_item",
ADD_MONEY: "trade_add_money",
REMOVE_ITEM: "trade_remove_item",
LOCK: "trade_lock",
CANCEL: "trade_cancel",
CONFIRM: "trade_confirm",
MONEY_MODAL: "trade_money_modal",
MONEY_AMOUNT_FIELD: "amount",
SELECT_ITEM: "trade_select_item",
REMOVE_ITEM_SELECT: "trade_remove_item_select",
} as const;
export interface TradeItem {
id: number;

View File

@@ -1,6 +1,6 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
import { createBaseEmbed } from "@lib/embeds";
import type { TradeSession, TradeParticipant } from "./trade.types";
import { TRADE_CUSTOM_IDS, type TradeSession, type TradeParticipant } from "./trade.types";
const EMBED_COLOR = 0xFFD700; // Gold
@@ -34,11 +34,11 @@ export function getTradeDashboard(session: TradeSession) {
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.ADD_ITEM).setLabel('Add Item').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.ADD_MONEY).setLabel('Add Money').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.REMOVE_ITEM).setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.LOCK).setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.CANCEL).setLabel('Cancel').setStyle(ButtonStyle.Danger),
);
return { embeds: [embed], components: [row] };
@@ -57,11 +57,11 @@ export function getTradeCompletedEmbed(session: TradeSession) {
export function getTradeMoneyModal() {
const modal = new ModalBuilder()
.setCustomId('trade_money_modal')
.setCustomId(TRADE_CUSTOM_IDS.MONEY_MODAL)
.setTitle('Add Money');
const input = new TextInputBuilder()
.setCustomId('amount')
.setCustomId(TRADE_CUSTOM_IDS.MONEY_AMOUNT_FIELD)
.setLabel("Amount to trade")
.setStyle(TextInputStyle.Short)
.setPlaceholder("100")

View File

@@ -0,0 +1,7 @@
export const TRIVIA_CUSTOM_IDS = {
PREFIX: "trivia_",
ANSWER: (sessionId: string, index: number) => `trivia_answer_${sessionId}_${index}`,
GIVE_UP: (sessionId: string) => `trivia_giveup_${sessionId}`,
RESULT: (index: number) => `trivia_result_${index}`,
TIMEOUT: (index: number) => `trivia_timeout_${index}`,
} as const;

View File

@@ -1,5 +1,6 @@
import { MessageFlags } from "discord.js";
import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service";
import { TRIVIA_CUSTOM_IDS } from "./trivia.types";
/**
* Get color based on difficulty level
@@ -97,14 +98,14 @@ export function getTriviaQuestionView(session: TriviaSession, username: string):
components: [
{
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${trueIndex}`,
custom_id: TRIVIA_CUSTOM_IDS.ANSWER(sessionId, trueIndex),
label: 'True',
style: 3, // Success
emoji: { name: '✅' }
},
{
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${falseIndex}`,
custom_id: TRIVIA_CUSTOM_IDS.ANSWER(sessionId, falseIndex),
label: 'False',
style: 4, // Danger
emoji: { name: '❌' }
@@ -129,7 +130,7 @@ export function getTriviaQuestionView(session: TriviaSession, username: string):
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${i}`,
custom_id: TRIVIA_CUSTOM_IDS.ANSWER(sessionId, i),
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: 2, // Secondary
emoji: { name: emoji }
@@ -145,7 +146,7 @@ export function getTriviaQuestionView(session: TriviaSession, username: string):
components: [
{
type: 2, // Button
custom_id: `trivia_giveup_${sessionId}`,
custom_id: TRIVIA_CUSTOM_IDS.GIVE_UP(sessionId),
label: 'Give Up',
style: 4, // Danger
emoji: { name: '🏳️' }
@@ -245,7 +246,7 @@ export function getTriviaResultView(
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_result_${i}`,
custom_id: TRIVIA_CUSTOM_IDS.RESULT(i),
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary
emoji: { name: isCorrect ? '✅' : wasUserAnswer ? '❌' : emoji },
@@ -318,7 +319,7 @@ export function getTriviaTimeoutView(
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_timeout_${i}`,
custom_id: TRIVIA_CUSTOM_IDS.TIMEOUT(i),
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: isCorrect ? 3 : 2, // Success : Secondary
emoji: { name: isCorrect ? '✅' : emoji },

View File

@@ -0,0 +1,3 @@
export const ENROLLMENT_CUSTOM_IDS = {
ENROLL: "enrollment",
} as const;

View File

@@ -14,14 +14,17 @@ aurora-bot-discord/
│ ├── commands/ # Slash command implementations
│ ├── events/ # Discord event handlers
│ ├── lib/ # Bot core logic (BotClient, utilities)
│ ├── modules/ # Feature modules (views, interactions per domain)
│ ├── graphics/ # Canvas-based image generation
│ └── index.ts # Bot entry point
├── web/ # REST API server
├── api/ # REST API server
│ └── src/routes/ # API route handlers
├── shared/ # Shared code between bot and web
├── shared/ # Shared code between bot and API
│ ├── db/ # Database schema and Drizzle ORM
│ ├── lib/ # Utilities, config, logger, events
── modules/ # Domain services (economy, admin, quest)
│ └── config/ # Configuration files
│ ├── lib/ # Utilities, config, errors, logger, events
── modules/ # Domain services (economy, admin, inventory, quest, etc.)
├── panel/ # React admin dashboard (Vite + Tailwind)
├── scripts/ # Helper scripts
├── docker-compose.yml # Docker services (app, db)
└── package.json # Root package manifest
```
@@ -38,17 +41,20 @@ The bot is built with Discord.js v14 and handles all Discord-related functionali
- **Commands** (`bot/commands/`): Slash command implementations organized by category:
- `admin/`: Server management commands (config, prune, warnings, notes)
- `economy/`: Economy commands (balance, daily, pay, trade, trivia)
- `feedback/`: Feedback commands
- `inventory/`: Item management commands
- `leveling/`: XP and level tracking
- `quest/`: Quest commands
- `user/`: User profile commands
- **Modules** (`bot/modules/`): Feature modules with views and interaction handlers per domain (admin, economy, inventory, moderation, trade, trivia, etc.)
- **Graphics** (`bot/graphics/`): Canvas-based image generation (lootdrops, student IDs)
- **Events** (`bot/events/`): Discord event handlers:
- `interactionCreate.ts`: Command interactions
- `messageCreate.ts`: Message processing
- `ready.ts`: Bot ready events
- `guildMemberAdd.ts`: New member handling
### 2. REST API (`web/`)
### 2. REST API (`api/`)
A headless REST API built with Bun's native HTTP server for bot administration and data access.
@@ -56,10 +62,15 @@ A headless REST API built with Bun's native HTTP server for bot administration a
- **Stats** (`/api/stats`): Real-time bot metrics and statistics
- **Settings** (`/api/settings`): Configuration management endpoints
- **Guild Settings** (`/api/guild-settings`): Per-guild configuration
- **Users** (`/api/users`): User data and profiles
- **Items** (`/api/items`): Item catalog and management
- **Quests** (`/api/quests`): Quest data and progress
- **Economy** (`/api/transactions`): Economy and transaction data
- **Moderation** (`/api/moderation`): Moderation case data
- **Classes** (`/api/classes`): RPG class data
- **Lootdrops** (`/api/lootdrops`): Lootdrop data
- **Health** (`/api/health`): Health check endpoint
**API Features:**
@@ -69,9 +80,13 @@ A headless REST API built with Bun's native HTTP server for bot administration a
- Real-time event streaming via WebSocket
- Zod validation for all requests
### 3. Shared Core (`shared/`)
### 3. Admin Panel (`panel/`)
Shared code accessible by both bot and web applications.
A React-based admin dashboard built with Vite and Tailwind CSS for managing the bot through a web interface.
### 4. Shared Core (`shared/`)
Shared code accessible by both the bot and API.
**Database Layer (`shared/db/`):**
@@ -86,19 +101,36 @@ Shared code accessible by both bot and web applications.
**Modules (`shared/modules/`):**
- **economy/**: Economy service, lootdrops, daily rewards, trading
- **economy/**: Economy service, lootdrops, daily rewards
- **admin/**: Administrative actions (maintenance mode, cache clearing)
- **inventory/**: Inventory management
- **items/**: Item catalog and management
- **trade/**: Trading system
- **trivia/**: Trivia game logic
- **quest/**: Quest creation and tracking
- **dashboard/**: Dashboard statistics and real-time event bus
- **class/**: RPG class system
- **leveling/**: XP and leveling logic
- **moderation/**: Moderation case management
- **user/**: User profile management
- **dashboard/**: Dashboard statistics and real-time event bus
- **guild-settings/**: Per-guild configuration
- **game-settings/**: Game-wide settings
- **feature-flags/**: Feature flag management
- **system/**: System-level utilities
**Utilities (`shared/lib/`):**
- `config.ts`: Application configuration management
- `logger.ts`: Structured logging system
- `env.ts`: Environment variable handling
- `errors.ts`: Error classes (UserError, SystemError)
- `events.ts`: Event bus for inter-module communication
- `eventWiring.ts`: Event bus wiring
- `constants.ts`: Application-wide constants
- `types.ts`: Shared TypeScript types
- `utils.ts`: General utility functions
- `rarity.ts`: Item rarity definitions
- `assets.ts`: Asset path utilities
## Main Use-Cases
@@ -145,6 +177,7 @@ Shared code accessible by both bot and web applications.
| Web Framework | Bun HTTP Server (REST API) |
| Database | PostgreSQL 17 |
| ORM | Drizzle ORM |
| Admin Panel | React + Vite + Tailwind CSS |
| UI | Discord embeds and components |
| Validation | Zod |
| Containerization | Docker |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,908 +1,10 @@
import { useState, useEffect } from "react";
import {
Loader2,
AlertTriangle,
Save,
Check,
UserCircle2,
Search,
X,
ChevronLeft,
ChevronRight,
Package,
Plus,
Trash2,
} from "lucide-react";
import { cn } from "../lib/utils";
import { useUsers, type User, type InventoryEntry } from "../lib/useUsers";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatNumber(num: number | string): string {
const n = typeof num === "string" ? parseInt(num) : num;
return n.toLocaleString();
}
function formatBigInt(value: string): string {
try {
const num = BigInt(value);
return num.toLocaleString();
} catch {
return value;
}
}
// ---------------------------------------------------------------------------
// Reusable field components (from Settings.tsx)
// ---------------------------------------------------------------------------
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 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>
);
}
function SectionCard({
title,
icon: Icon,
children,
}: {
title: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center gap-2 mb-4">
<Icon className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// SearchFilterBar Component
// ---------------------------------------------------------------------------
function SearchFilterBar({
search,
onSearchChange,
classId,
onClassChange,
isActive,
onActiveChange,
sortBy,
onSortByChange,
sortOrder,
onSortOrderChange,
onClear,
classes,
}: {
search: string;
onSearchChange: (v: string) => void;
classId: string | null;
onClassChange: (v: string | null) => void;
isActive: boolean | null;
onActiveChange: (v: boolean | null) => void;
sortBy: string;
onSortByChange: (v: string) => void;
sortOrder: string;
onSortOrderChange: (v: string) => void;
onClear: () => void;
classes: { id: string; name: string }[];
}) {
return (
<div className="flex flex-wrap gap-3 items-center">
{/* Search input */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search by username..."
className={cn(
"w-full bg-input border border-border rounded-md pl-10 pr-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
/>
</div>
{/* Class filter */}
<select
value={classId ?? ""}
onChange={(e) => onClassChange(e.target.value || null)}
className={cn(
"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="">All Classes</option>
{Array.isArray(classes) && classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{/* Active status filter */}
<select
value={isActive === null ? "" : String(isActive)}
onChange={(e) =>
onActiveChange(e.target.value === "" ? null : e.target.value === "true")
}
className={cn(
"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="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
{/* Sort by */}
<select
value={sortBy}
onChange={(e) => onSortByChange(e.target.value)}
className={cn(
"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="balance">Sort by Balance</option>
<option value="level">Sort by Level</option>
<option value="xp">Sort by XP</option>
<option value="username">Sort by Username</option>
</select>
{/* Sort order */}
<button
onClick={() => onSortOrderChange(sortOrder === "asc" ? "desc" : "asc")}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"hover:bg-raised transition-colors"
)}
>
{sortOrder === "asc" ? "↑ Asc" : "↓ Desc"}
</button>
{/* Clear filters */}
<button
onClick={onClear}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-text-secondary",
"hover:bg-destructive hover:text-white hover:border-destructive transition-colors"
)}
>
Clear
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// UserTable Component
// ---------------------------------------------------------------------------
function UserTable({
users,
loading,
onSelectUser,
}: {
users: User[];
loading: boolean;
onSelectUser: (user: User) => void;
}) {
if (loading) {
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Username
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Level
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Balance
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Class
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Status
</th>
</tr>
</thead>
<tbody>
{[...Array(5)].map((_, i) => (
<tr key={i} className="border-b border-border">
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-32"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-12"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-24"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-20"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-16"></div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
if (users.length === 0) {
return (
<div className="bg-card border border-border rounded-lg p-12 text-center">
<UserCircle2 className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
<p className="text-lg font-semibold text-text-secondary mb-2">
No users found
</p>
<p className="text-sm text-text-tertiary">
Try adjusting your search or filter criteria
</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Username
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Level
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Balance
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
XP
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Class
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Status
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
onClick={() => onSelectUser(user)}
className="border-b border-border hover:bg-raised cursor-pointer transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<UserCircle2 className="w-5 h-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-foreground">
{user.username}
</p>
<p className="text-xs text-text-tertiary font-mono">
{user.id}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-foreground">
{user.level}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-foreground">
{formatBigInt(user.balance)}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-text-secondary">
{formatBigInt(user.xp)}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-foreground">
{user.class?.name || "—"}
</span>
</td>
<td className="px-4 py-3">
<span
className={cn(
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
user.isActive
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
)}
>
{user.isActive ? "Active" : "Inactive"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Pagination Component
// ---------------------------------------------------------------------------
function Pagination({
currentPage,
totalPages,
limit,
total,
onPageChange,
onLimitChange,
}: {
currentPage: number;
totalPages: number;
limit: number;
total: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
}) {
const startItem = (currentPage - 1) * limit + 1;
const endItem = Math.min(currentPage * limit, total);
// Calculate page numbers to show
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const showPages = 5;
const halfShow = Math.floor(showPages / 2);
let start = Math.max(1, currentPage - halfShow);
let end = Math.min(totalPages, start + showPages - 1);
if (end - start < showPages - 1) {
start = Math.max(1, end - showPages + 1);
}
if (start > 1) {
pages.push(1);
if (start > 2) pages.push("...");
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (end < totalPages) {
if (end < totalPages - 1) pages.push("...");
pages.push(totalPages);
}
return pages;
};
return (
<div className="mt-4 flex flex-wrap gap-4 items-center justify-between">
{/* Items info */}
<p className="text-sm text-text-secondary">
Showing {startItem}{endItem} of {formatNumber(total)} users
</p>
{/* Page controls */}
<div className="flex items-center gap-2">
{/* Previous button */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === 1
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronLeft className="w-4 h-4" />
</button>
{/* Page numbers */}
{getPageNumbers().map((page, i) =>
typeof page === "number" ? (
<button
key={i}
onClick={() => onPageChange(page)}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors min-w-[40px]",
page === currentPage
? "bg-primary text-white"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
{page}
</button>
) : (
<span key={i} className="px-2 text-text-tertiary">
{page}
</span>
)
)}
{/* Next button */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === totalPages
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronRight className="w-4 h-4" />
</button>
{/* Items per page */}
<select
value={limit}
onChange={(e) => onLimitChange(Number(e.target.value))}
className={cn(
"ml-2 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="10">10 / page</option>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// InventoryAddForm Component
// ---------------------------------------------------------------------------
function InventoryAddForm({
items,
onAdd,
}: {
items: { id: number; name: string }[];
onAdd: (itemId: number, quantity: string) => void;
}) {
const [selectedItemId, setSelectedItemId] = useState<string>("");
const [quantity, setQuantity] = useState<string>("1");
const handleAdd = () => {
if (!selectedItemId) return;
onAdd(parseInt(selectedItemId), quantity);
setSelectedItemId("");
setQuantity("1");
};
return (
<div className="space-y-3">
<p className="text-xs font-semibold text-text-secondary">Add Item</p>
<select
value={selectedItemId}
onChange={(e) => setSelectedItemId(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="">Select item...</option>
{Array.isArray(items) && items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<div className="flex gap-2">
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
min="1"
placeholder="Qty"
className={cn(
"w-20 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"
)}
/>
<button
onClick={handleAdd}
disabled={!selectedItemId}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
"bg-primary text-white hover:bg-primary/90",
"disabled:opacity-50 disabled:cursor-not-allowed",
"flex items-center justify-center gap-1.5"
)}
>
<Plus className="w-4 h-4" />
Add
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// DetailPanel Component
// ---------------------------------------------------------------------------
function DetailPanel({
user,
userDraft,
onClose,
onUpdateDraft,
onSave,
onDiscard,
isDirty,
saving,
saveSuccess,
classes,
inventoryDraft,
items,
onAddItem,
onRemoveItem,
}: {
user: User;
userDraft: Partial<User> | null;
onClose: () => void;
onUpdateDraft: (field: keyof User, value: unknown) => void;
onSave: () => void;
onDiscard: () => void;
isDirty: boolean;
saving: boolean;
saveSuccess: boolean;
classes: { id: string; name: string }[];
inventoryDraft: InventoryEntry[];
items: { id: number; name: string }[];
onAddItem: (itemId: number, quantity: string) => void;
onRemoveItem: (itemId: number) => void;
}) {
if (!userDraft) return null;
const classOptions = [
{ value: "", label: "No Class" },
...(Array.isArray(classes) ? classes.map((c) => ({ value: c.id, label: c.name })) : []),
];
return (
<div className="fixed inset-0 md:relative md:w-96 border-l border-border bg-card overflow-auto z-50 md:z-auto">
<div className="p-6 space-y-6 pb-24">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex-1">
<h2 className="text-lg font-semibold text-foreground mb-1">
{user.username}
</h2>
<p className="text-xs font-mono text-text-tertiary">{user.id}</p>
<p className="text-xs text-text-tertiary mt-1">
Joined {new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={onClose}
className="text-text-tertiary hover:text-foreground transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* User Info (Editable) */}
<div className="space-y-4">
<Field label="Balance" hint="User's currency balance">
<StringInput
value={String(userDraft.balance || "0")}
onChange={(v) => onUpdateDraft("balance", v)}
placeholder="0"
/>
</Field>
<Field label="XP" hint="User's experience points">
<StringInput
value={String(userDraft.xp || "0")}
onChange={(v) => onUpdateDraft("xp", v)}
placeholder="0"
/>
</Field>
<Field label="Level" hint="User's current level">
<NumberInput
value={userDraft.level || 1}
onChange={(v) => onUpdateDraft("level", v)}
min={1}
max={100}
/>
</Field>
<Field label="Daily Streak" hint="Consecutive days of daily command usage">
<NumberInput
value={userDraft.dailyStreak || 0}
onChange={(v) => onUpdateDraft("dailyStreak", v)}
min={0}
/>
</Field>
<Field label="Class" hint="User's selected class">
<SelectInput
value={String(userDraft.classId || "")}
onChange={(v) => onUpdateDraft("classId", v || null)}
options={classOptions}
/>
</Field>
<Field label="Active Status" hint="Whether the user is active in the system">
<div className="flex items-center gap-3">
<Toggle
checked={userDraft.isActive ?? true}
onChange={(v) => onUpdateDraft("isActive", v)}
/>
<span
className={cn(
"text-sm font-medium",
userDraft.isActive ? "text-green-400" : "text-gray-400"
)}
>
{userDraft.isActive ? "Active" : "Inactive"}
</span>
</div>
</Field>
</div>
{/* Inventory Section */}
<SectionCard title="Inventory" icon={Package}>
{inventoryDraft.length === 0 ? (
<p className="text-sm text-text-tertiary">No items in inventory</p>
) : (
<div className="space-y-2">
{inventoryDraft.map((entry) => (
<div
key={entry.itemId}
className="flex items-center justify-between gap-3 p-2 bg-raised rounded-md"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{entry.item?.name || `Item #${entry.itemId}`}
</p>
<p className="text-xs text-text-tertiary">
Quantity: {formatBigInt(entry.quantity)}
</p>
</div>
<button
onClick={() => onRemoveItem(entry.itemId)}
className="p-1.5 text-text-tertiary hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
title="Remove item"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{/* Add Item Form */}
<div className="mt-4 pt-4 border-t border-border">
<InventoryAddForm items={items} onAdd={onAddItem} />
</div>
</SectionCard>
</div>
{/* Sticky footer for save/discard (only shown when dirty) */}
{isDirty && (
<div className="sticky bottom-0 left-0 right-0 border-t border-border bg-card p-4 space-y-3">
<div className="flex items-center gap-2 text-amber-400">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">You have unsaved changes</span>
</div>
<div className="flex gap-2">
<button
onClick={onDiscard}
disabled={saving}
className={cn(
"flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors",
"bg-input border border-border text-foreground hover:bg-raised",
saving && "opacity-50 cursor-not-allowed"
)}
>
Discard
</button>
<button
onClick={onSave}
disabled={saving}
className={cn(
"flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors",
"bg-primary text-white hover:bg-primary/90",
"flex items-center justify-center gap-2",
saving && "opacity-50 cursor-not-allowed"
)}
>
{saving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : saveSuccess ? (
<>
<Check className="w-4 h-4" />
Saved!
</>
) : (
<>
<Save className="w-4 h-4" />
Save Changes
</>
)}
</button>
</div>
</div>
)}
</div>
);
}
import { AlertTriangle } from "lucide-react";
import { useUsers } from "../lib/useUsers";
import { SearchFilterBar } from "./components/SearchFilterBar";
import { UserTable } from "./components/UserTable";
import { UserPagination } from "./components/UserPagination";
import { UserDetailPanel } from "./components/UserDetailPanel";
// ---------------------------------------------------------------------------
// Main Users Component
@@ -1025,7 +127,7 @@ export default function Users() {
{/* Main content */}
<div className="flex-1 overflow-auto p-6">
<UserTable users={users} loading={loading} onSelectUser={selectUser} />
<Pagination
<UserPagination
currentPage={currentPage}
totalPages={Math.ceil(total / limit)}
limit={limit}
@@ -1037,7 +139,7 @@ export default function Users() {
{/* Detail panel */}
{selectedUser && userDraft && (
<DetailPanel
<UserDetailPanel
user={selectedUser}
userDraft={userDraft}
onClose={closeDetail}

View File

@@ -0,0 +1,174 @@
import { Trash2 } from "lucide-react";
import { cn } from "../../lib/utils";
import {
type EffectDraft,
type EffectKind,
EFFECT_META,
inputCls,
} from "./ItemStudioTypes";
import { Field } from "./ItemStudioShared";
import { LootboxEditor } from "./LootboxEditor";
// ===== Effect Editor =====
export function EffectEditor({
effect,
onChange,
onRemove,
}: {
effect: EffectDraft;
onChange: (updated: EffectDraft) => void;
onRemove: () => void;
}) {
const update = (fields: Partial<EffectDraft>) =>
onChange({ ...effect, ...fields });
const resetFields = {
amount: "",
multiplier: "",
durationSeconds: "",
roleId: "",
message: "",
pool: [],
};
const EffectIcon = EFFECT_META[effect.kind].icon;
const isLootbox = effect.kind === "LOOTBOX";
return (
<div
className={cn(
"border rounded-lg p-4 space-y-3",
isLootbox
? "bg-amber-500/5 border-amber-500/25"
: "bg-raised/20 border-border"
)}
>
<div className="flex items-center gap-2">
<EffectIcon
className={cn(
"w-4 h-4 shrink-0",
isLootbox ? "text-amber-400" : "text-primary"
)}
/>
<select
value={effect.kind}
onChange={(e) =>
update({ kind: e.target.value as EffectKind, ...resetFields })
}
className={cn(
"flex-1 bg-input border border-border rounded-md px-3 py-1.5 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30 transition-colors"
)}
>
{(Object.keys(EFFECT_META) as EffectKind[]).map((kind) => (
<option key={kind} value={kind}>
{EFFECT_META[kind].label}
</option>
))}
</select>
<button
onClick={onRemove}
className="p-1.5 rounded-md text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Remove effect"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{(effect.kind === "ADD_XP" || effect.kind === "ADD_BALANCE") && (
<Field label="Amount">
<input
type="number"
min="1"
value={effect.amount}
onChange={(e) => update({ amount: e.target.value })}
placeholder="e.g. 100"
className={inputCls}
/>
</Field>
)}
{effect.kind === "REPLY_MESSAGE" && (
<Field label="Message">
<textarea
value={effect.message}
onChange={(e) => update({ message: e.target.value })}
placeholder="The message the bot will reply with..."
rows={2}
className={cn(inputCls, "resize-none")}
/>
</Field>
)}
{effect.kind === "XP_BOOST" && (
<div className="grid grid-cols-2 gap-3">
<Field label="Multiplier (x)">
<input
type="number"
min="1"
step="0.1"
value={effect.multiplier}
onChange={(e) => update({ multiplier: e.target.value })}
placeholder="e.g. 2"
className={inputCls}
/>
</Field>
<Field label="Duration (sec, 0 = permanent)">
<input
type="number"
min="0"
value={effect.durationSeconds}
onChange={(e) => update({ durationSeconds: e.target.value })}
placeholder="e.g. 3600"
className={inputCls}
/>
</Field>
</div>
)}
{effect.kind === "TEMP_ROLE" && (
<div className="grid grid-cols-2 gap-3">
<Field label="Role ID">
<input
type="text"
value={effect.roleId}
onChange={(e) => update({ roleId: e.target.value })}
placeholder="Discord role ID"
className={inputCls}
/>
</Field>
<Field label="Duration (sec, 0 = permanent)">
<input
type="number"
min="0"
value={effect.durationSeconds}
onChange={(e) => update({ durationSeconds: e.target.value })}
placeholder="e.g. 86400"
className={inputCls}
/>
</Field>
</div>
)}
{effect.kind === "COLOR_ROLE" && (
<Field label="Role ID">
<input
type="text"
value={effect.roleId}
onChange={(e) => update({ roleId: e.target.value })}
placeholder="Discord role ID"
className={inputCls}
/>
</Field>
)}
{effect.kind === "LOOTBOX" && (
<LootboxEditor
pool={effect.pool}
onChange={(pool) => update({ pool })}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { ImageIcon, CircleDollarSign, Gift, Zap } from "lucide-react";
import { cn } from "../../lib/utils";
import {
type Draft,
TYPE_META,
RARITY_META,
LOOT_TYPE_META,
} from "./ItemStudioTypes";
// ===== Item Preview Card =====
export function ItemPreviewCard({
draft,
previewImageSrc,
}: {
draft: Draft;
previewImageSrc: string | null;
}) {
const rarity = RARITY_META[draft.rarity];
const type = TYPE_META[draft.type];
const TypeIcon = type.icon;
const lootboxEffect = draft.effects.find((e) => e.kind === "LOOTBOX");
return (
<div
className={cn(
"bg-card border-2 rounded-xl overflow-hidden transition-all duration-300",
rarity.activeBorder
)}
>
{/* Image area */}
<div className="relative aspect-square bg-raised">
{previewImageSrc ? (
<img
src={previewImageSrc}
alt="Preview"
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.opacity = "0.2";
}}
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-text-tertiary gap-2">
<ImageIcon className="w-10 h-10 opacity-25" />
<span className="text-xs opacity-40">No image</span>
</div>
)}
<span
className={cn(
"absolute top-3 right-3 px-2.5 py-1 rounded-full text-xs font-bold backdrop-blur-sm",
rarity.badgeBg,
rarity.text
)}
>
{draft.rarity}
</span>
</div>
{/* Info */}
<div className="p-4 space-y-2">
<h4 className="text-base font-bold text-foreground leading-tight">
{draft.name.trim() ? (
draft.name
) : (
<span className="text-text-tertiary italic font-normal text-sm">
Item name...
</span>
)}
</h4>
<div className="flex items-center gap-1.5">
<TypeIcon className="w-3.5 h-3.5 text-text-tertiary" />
<span className="text-xs text-text-tertiary">{type.label}</span>
</div>
{draft.description.trim() && (
<p className="text-xs text-text-secondary line-clamp-3 leading-relaxed">
{draft.description}
</p>
)}
{draft.price && Number(draft.price) > 0 && (
<div className="flex items-center gap-1.5">
<CircleDollarSign className="w-3.5 h-3.5 text-gold" />
<span className="text-xs font-mono text-gold font-medium">
{parseInt(draft.price).toLocaleString()} coins
</span>
</div>
)}
{/* Lootbox pool mini-preview */}
{lootboxEffect && lootboxEffect.pool.length > 0 && (
<div className="pt-2 border-t border-border space-y-1.5">
<div className="flex items-center gap-1.5">
<Gift className="w-3.5 h-3.5 text-amber-400" />
<span className="text-xs text-amber-400 font-medium">
Lootbox · {lootboxEffect.pool.length} outcome
{lootboxEffect.pool.length !== 1 ? "s" : ""}
</span>
</div>
{/* Stacked bar */}
{(() => {
const total = lootboxEffect.pool.reduce(
(s, e) => s + Number(e.weight || 0),
0
);
return (
<div className="flex h-1.5 w-full rounded-full overflow-hidden gap-px bg-raised">
{lootboxEffect.pool.map((e) => {
const pct =
total > 0
? (Number(e.weight || 0) / total) * 100
: 0;
return (
<div
key={e._id}
style={{ width: `${pct}%` }}
className={cn(
"transition-all",
LOOT_TYPE_META[e.type].barColor
)}
title={`${LOOT_TYPE_META[e.type].label} ${pct.toFixed(1)}%`}
/>
);
})}
</div>
);
})()}
</div>
)}
{(draft.effects.length > 0 || draft.consume) && !lootboxEffect && (
<div className="pt-2 border-t border-border flex flex-wrap gap-1.5">
{draft.consume && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 text-xs">
Consumed on use
</span>
)}
{draft.effects.length > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs">
<Zap className="w-3 h-3" />
{draft.effects.length} effect
{draft.effects.length !== 1 ? "s" : ""}
</span>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { useState, useRef, useEffect } from "react";
import { Search, X, Package, Loader2 } from "lucide-react";
import { cn } from "../../lib/utils";
import { get } from "../../lib/api";
import { RARITY_BADGE, inputCls } from "./ItemStudioTypes";
// ===== Item Search Picker =====
interface ItemResult {
id: number;
name: string;
rarity: string;
iconUrl: string;
}
export function ItemSearchPicker({
value,
onChange,
}: {
value: { id: number; name: string; rarity: string } | null;
onChange: (item: { id: number; name: string; rarity: string } | null) => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<ItemResult[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!query.trim()) {
setResults([]);
setOpen(false);
return;
}
const t = setTimeout(async () => {
setLoading(true);
try {
const data = await get<{ items: ItemResult[] }>(
`/api/items?search=${encodeURIComponent(query)}&limit=8`
);
setResults(data.items);
setOpen(data.items.length > 0);
} catch {
// silently fail -- network error shouldn't break the form
} finally {
setLoading(false);
}
}, 300);
return () => clearTimeout(t);
}, [query]);
useEffect(() => {
function onOutsideClick(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", onOutsideClick);
return () => document.removeEventListener("mousedown", onOutsideClick);
}, []);
if (value) {
return (
<div className="flex items-center gap-2 p-2 bg-raised/50 rounded-md border border-border">
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded font-bold shrink-0",
RARITY_BADGE[value.rarity] ?? "bg-gray-500/20 text-gray-400"
)}
>
{value.rarity}
</span>
<span className="text-sm flex-1 text-foreground truncate">
{value.name}
</span>
<span className="text-xs text-text-tertiary font-mono shrink-0">
#{value.id}
</span>
<button
onClick={() => onChange(null)}
className="p-0.5 text-text-tertiary hover:text-destructive transition-colors shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
return (
<div className="relative" ref={containerRef}>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary pointer-events-none" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search items by name..."
className={cn(inputCls, "pl-9 pr-9")}
/>
{loading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary animate-spin pointer-events-none" />
)}
</div>
{open && results.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-card border border-border rounded-lg shadow-xl overflow-hidden">
{results.map((item) => (
<button
key={item.id}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange({ id: item.id, name: item.name, rarity: item.rarity });
setQuery("");
setOpen(false);
}}
className="w-full flex items-center gap-2.5 px-3 py-2 hover:bg-raised text-left transition-colors"
>
{item.iconUrl ? (
<img
src={item.iconUrl}
alt=""
className="w-6 h-6 rounded object-cover bg-raised shrink-0"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
) : (
<Package className="w-6 h-6 text-text-tertiary shrink-0" />
)}
<span className="text-sm text-foreground flex-1 truncate">
{item.name}
</span>
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded font-bold shrink-0",
RARITY_BADGE[item.rarity] ?? "bg-gray-500/20 text-gray-400"
)}
>
{item.rarity}
</span>
<span className="text-xs text-text-tertiary font-mono shrink-0">
#{item.id}
</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,379 @@
import { Upload, X, Plus, CircleDollarSign } from "lucide-react";
import { cn } from "../../lib/utils";
import {
type Draft,
type EffectDraft,
type ItemType,
type ItemRarity,
TYPE_META,
RARITY_META,
inputCls,
} from "./ItemStudioTypes";
import { SectionCard, Field } from "./ItemStudioShared";
import { EffectEditor } from "./EffectEditor";
// ===== Item Studio Form Sections =====
export function IdentitySection({
draft,
update,
validationErrors,
}: {
draft: Draft;
update: (fields: Partial<Draft>) => void;
validationErrors: Record<string, string>;
}) {
return (
<SectionCard title="Identity">
<Field label="Item Name *" error={validationErrors.name}>
<input
type="text"
value={draft.name}
onChange={(e) => update({ name: e.target.value })}
placeholder="e.g. Treasure Chest"
maxLength={255}
className={cn(
inputCls,
validationErrors.name &&
"border-destructive focus:border-destructive focus:ring-destructive/30"
)}
/>
</Field>
<Field label="Description">
<textarea
value={draft.description}
onChange={(e) => update({ description: e.target.value })}
placeholder="A brief description of this item..."
rows={3}
className={cn(inputCls, "resize-none")}
/>
</Field>
</SectionCard>
);
}
export function ClassificationSection({
draft,
update,
}: {
draft: Draft;
update: (fields: Partial<Draft>) => void;
}) {
return (
<SectionCard title="Classification">
<Field label="Type">
<div className="grid grid-cols-4 gap-2">
{(Object.keys(TYPE_META) as ItemType[]).map((t) => {
const meta = TYPE_META[t];
const Icon = meta.icon;
const active = draft.type === t;
return (
<button
key={t}
onClick={() => update({ type: t })}
className={cn(
"flex flex-col items-center gap-1.5 py-3 px-2 rounded-lg border text-xs font-medium transition-all",
active
? "bg-primary/15 border-primary text-primary"
: "bg-input border-border text-text-tertiary hover:border-primary/40 hover:text-foreground"
)}
>
<Icon className="w-4 h-4" />
{meta.label}
</button>
);
})}
</div>
</Field>
<Field label="Rarity">
<div className="grid grid-cols-4 gap-2">
{(Object.keys(RARITY_META) as ItemRarity[]).map((r) => {
const meta = RARITY_META[r];
const active = draft.rarity === r;
return (
<button
key={r}
onClick={() => update({ rarity: r })}
className={cn(
"py-3 px-2 rounded-lg border text-center transition-all",
active
? cn(meta.bg, meta.text, meta.activeBorder)
: "bg-input border-border text-text-tertiary hover:border-primary/40"
)}
>
<div className="text-sm font-bold">{r}</div>
<div
className={cn(
"text-xs mt-0.5 opacity-80",
active ? meta.text : "text-text-disabled"
)}
>
{meta.label}
</div>
</button>
);
})}
</div>
</Field>
</SectionCard>
);
}
export function EconomySection({
draft,
update,
}: {
draft: Draft;
update: (fields: Partial<Draft>) => void;
}) {
return (
<SectionCard title="Economy">
<Field label="Price (0 or empty = not for sale)">
<div className="relative">
<CircleDollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary pointer-events-none" />
<input
type="number"
min="0"
step="1"
value={draft.price}
onChange={(e) => update({ price: e.target.value })}
placeholder="0"
className={cn(inputCls, "pl-10")}
/>
</div>
</Field>
</SectionCard>
);
}
export function ArtworkSection({
imageMode,
setImageMode,
imageFile,
imagePreview,
iconUrlInput,
setIconUrlInput,
imageUrlInput,
setImageUrlInput,
dragOver,
setDragOver,
handleDrop,
clearImage,
handleImageFile,
fileInputRef,
validationErrors,
}: {
imageMode: "upload" | "url";
setImageMode: (mode: "upload" | "url") => void;
imageFile: File | null;
imagePreview: string | null;
iconUrlInput: string;
setIconUrlInput: (v: string) => void;
imageUrlInput: string;
setImageUrlInput: (v: string) => void;
dragOver: boolean;
setDragOver: (v: boolean) => void;
handleDrop: (e: React.DragEvent) => void;
clearImage: () => void;
handleImageFile: (file: File) => void;
fileInputRef: React.RefObject<HTMLInputElement | null>;
validationErrors: Record<string, string>;
}) {
return (
<SectionCard title="Artwork">
<div className="flex gap-2">
{(["upload", "url"] as const).map((mode) => (
<button
key={mode}
onClick={() => setImageMode(mode)}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-medium transition-colors border",
imageMode === mode
? "bg-primary/15 border-primary text-primary"
: "bg-input border-border text-text-tertiary hover:text-foreground"
)}
>
{mode === "upload" ? "Upload File" : "Enter URL"}
</button>
))}
</div>
{imageMode === "upload" ? (
<div className="space-y-2">
{imageFile ? (
<div className="relative rounded-lg overflow-hidden border border-border">
<img
src={imagePreview!}
alt="Selected"
className="w-full max-h-56 object-contain bg-raised"
/>
<button
onClick={clearImage}
className="absolute top-2 right-2 p-1.5 bg-background/80 rounded-full hover:bg-destructive hover:text-white text-text-secondary transition-colors backdrop-blur-sm"
>
<X className="w-4 h-4" />
</button>
<div className="px-3 py-2 bg-card border-t border-border">
<p className="text-xs text-text-tertiary truncate">
{imageFile.name} ({(imageFile.size / 1024).toFixed(1)}{" "}
KB)
</p>
</div>
</div>
) : (
<div
onDrop={handleDrop}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onClick={() => fileInputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-lg p-10 flex flex-col items-center gap-3 cursor-pointer transition-all select-none",
dragOver
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-primary/3"
)}
>
<Upload
className={cn(
"w-8 h-8 transition-colors",
dragOver ? "text-primary" : "text-text-tertiary"
)}
/>
<div className="text-center">
<p className="text-sm font-medium text-text-secondary">
{dragOver ? "Drop to upload" : "Drop an image here"}
</p>
<p className="text-xs text-text-tertiary mt-1">
or click to browse · PNG, JPEG, WebP, GIF · max 15 MB
</p>
</div>
</div>
)}
{validationErrors.image && (
<p className="text-xs text-destructive">
{validationErrors.image}
</p>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImageFile(file);
}}
className="hidden"
/>
</div>
) : (
<div className="space-y-3">
<Field
label="Icon URL (small, used in lists)"
error={validationErrors.iconUrl}
>
<input
type="url"
value={iconUrlInput}
onChange={(e) => setIconUrlInput(e.target.value)}
placeholder="https://example.com/icon.png"
className={inputCls}
/>
</Field>
<Field
label="Image URL (full size)"
error={validationErrors.imageUrl}
>
<input
type="url"
value={imageUrlInput}
onChange={(e) => setImageUrlInput(e.target.value)}
placeholder="https://example.com/image.png"
className={inputCls}
/>
</Field>
{iconUrlInput && (
<div className="flex items-center gap-3 p-3 bg-raised/40 rounded-lg border border-border">
<img
src={iconUrlInput}
alt="Icon preview"
className="w-10 h-10 rounded object-cover bg-raised"
onError={(e) => {
e.currentTarget.style.opacity = "0.2";
}}
/>
<span className="text-xs text-text-tertiary">
Icon preview
</span>
</div>
)}
</div>
)}
</SectionCard>
);
}
export function EffectsSection({
draft,
update,
updateEffect,
removeEffect,
addEffect,
}: {
draft: Draft;
update: (fields: Partial<Draft>) => void;
updateEffect: (i: number, updated: EffectDraft) => void;
removeEffect: (i: number) => void;
addEffect: () => void;
}) {
return (
<SectionCard title="Effects">
<label className="flex items-center gap-3 cursor-pointer group select-none">
<button
role="switch"
aria-checked={draft.consume}
onClick={() => update({ consume: !draft.consume })}
className={cn(
"relative w-10 h-5 rounded-full border transition-all shrink-0",
draft.consume
? "bg-primary border-primary"
: "bg-input border-border"
)}
>
<span
className={cn(
"absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-all duration-200",
draft.consume ? "left-5" : "left-0.5"
)}
/>
</button>
<span className="text-sm text-text-secondary group-hover:text-foreground transition-colors">
Consume item on use
</span>
</label>
{draft.effects.map((eff, i) => (
<EffectEditor
key={eff._id}
effect={eff}
onChange={(updated) => updateEffect(i, updated)}
onRemove={() => removeEffect(i)}
/>
))}
<button
onClick={addEffect}
className={cn(
"w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed text-sm",
"border-border text-text-tertiary hover:border-primary/50 hover:text-primary transition-colors"
)}
>
<Plus className="w-4 h-4" />
Add Effect
</button>
</SectionCard>
);
}

View File

@@ -0,0 +1,38 @@
// ===== Shared UI atoms for ItemStudio =====
export function SectionCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
{title}
</h3>
{children}
</div>
);
}
export function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<label className="block text-xs font-medium text-text-secondary">
{label}
</label>
{children}
{error && <p className="text-xs text-destructive mt-1">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,185 @@
import type { Draft, EffectDraft } from "./ItemStudioTypes";
// ===== Build effects payload from draft =====
export function buildEffectsPayload(effects: EffectDraft[]) {
return effects
.map((eff) => {
switch (eff.kind) {
case "ADD_XP":
return { type: "ADD_XP", amount: Number(eff.amount || 0) };
case "ADD_BALANCE":
return { type: "ADD_BALANCE", amount: Number(eff.amount || 0) };
case "REPLY_MESSAGE":
return { type: "REPLY_MESSAGE", message: eff.message };
case "XP_BOOST":
return {
type: "XP_BOOST",
multiplier: Number(eff.multiplier || 1),
...(eff.durationSeconds
? { durationSeconds: Number(eff.durationSeconds) }
: {}),
};
case "TEMP_ROLE":
return {
type: "TEMP_ROLE",
roleId: eff.roleId,
...(eff.durationSeconds
? { durationSeconds: Number(eff.durationSeconds) }
: {}),
};
case "COLOR_ROLE":
return { type: "COLOR_ROLE", roleId: eff.roleId };
case "LOOTBOX":
return {
type: "LOOTBOX",
pool: eff.pool.map((entry) => {
const loot: Record<string, unknown> = {
type: entry.type,
weight: Number(entry.weight || 1),
};
if (entry.type !== "NOTHING") {
if (entry.amountMode === "range") {
loot.minAmount = Number(entry.minAmount || 0);
loot.maxAmount = Number(entry.maxAmount || 0);
} else {
loot.amount = Number(entry.amount || 0);
}
}
if (entry.type === "ITEM" && entry.itemId) {
loot.itemId = Number(entry.itemId);
}
if (entry.message.trim()) {
loot.message = entry.message.trim();
}
return loot;
}),
};
default:
return null;
}
})
.filter(Boolean);
}
// ===== Build base payload from draft =====
export function buildBasePayload(draft: Draft) {
const effects = buildEffectsPayload(draft.effects);
return {
name: draft.name.trim(),
description: draft.description.trim() || null,
type: draft.type,
rarity: draft.rarity,
price:
draft.price && Number(draft.price) > 0 ? Number(draft.price) : null,
usageData:
draft.effects.length > 0
? { consume: draft.consume, effects }
: null,
} as Record<string, unknown>;
}
// ===== Submit: Edit existing item =====
export async function submitEditItem({
editItemId,
basePayload,
imageMode,
iconUrlInput,
imageUrlInput,
imageFile,
}: {
editItemId: number;
basePayload: Record<string, unknown>;
imageMode: "upload" | "url";
iconUrlInput: string;
imageUrlInput: string;
imageFile: File | null;
}) {
const editPayload = { ...basePayload };
if (imageMode === "url") {
editPayload.iconUrl = iconUrlInput.trim();
editPayload.imageUrl = imageUrlInput.trim();
}
const putRes = await fetch(`/api/items/${editItemId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editPayload),
credentials: "same-origin",
});
if (!putRes.ok) {
const body = (await putRes
.json()
.catch(() => ({ error: putRes.statusText }))) as { error?: string };
throw new Error(body.error || "Failed to save item");
}
if (imageMode === "upload" && imageFile) {
const form = new FormData();
form.append("image", imageFile);
const iconRes = await fetch(`/api/items/${editItemId}/icon`, {
method: "POST",
body: form,
credentials: "same-origin",
});
if (!iconRes.ok) {
const body = (await iconRes
.json()
.catch(() => ({ error: iconRes.statusText }))) as {
error?: string;
};
throw new Error(body.error || "Failed to upload image");
}
}
}
// ===== Submit: Create new item =====
export async function submitCreateItem({
basePayload,
imageMode,
iconUrlInput,
imageUrlInput,
imageFile,
}: {
basePayload: Record<string, unknown>;
imageMode: "upload" | "url";
iconUrlInput: string;
imageUrlInput: string;
imageFile: File | null;
}) {
const payload = {
...basePayload,
iconUrl: imageMode === "url" ? iconUrlInput.trim() : "",
imageUrl: imageMode === "url" ? imageUrlInput.trim() : "",
};
let res: Response;
if (imageMode === "upload" && imageFile) {
const form = new FormData();
form.append("data", JSON.stringify(payload));
form.append("image", imageFile);
res = await fetch("/api/items", {
method: "POST",
body: form,
credentials: "same-origin",
});
} else {
res = await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "same-origin",
});
}
if (!res.ok) {
const body = (await res
.json()
.catch(() => ({ error: res.statusText }))) as { error?: string };
throw new Error(body.error || "Failed to create item");
}
}

View File

@@ -0,0 +1,304 @@
import {
Package,
Zap,
Shield,
MessageSquare,
TrendingUp,
Palette,
CircleDollarSign,
Gift,
} from "lucide-react";
import { cn } from "../../lib/utils";
// ===== Types =====
export type ItemType = "MATERIAL" | "CONSUMABLE" | "EQUIPMENT" | "QUEST";
export type ItemRarity = "C" | "R" | "SR" | "SSR";
export type EffectKind =
| "ADD_XP"
| "ADD_BALANCE"
| "REPLY_MESSAGE"
| "XP_BOOST"
| "TEMP_ROLE"
| "COLOR_ROLE"
| "LOOTBOX";
export type LootEntryType = "NOTHING" | "CURRENCY" | "XP" | "ITEM";
export interface LootPoolEntry {
_id: string;
type: LootEntryType;
weight: string;
amountMode: "fixed" | "range";
amount: string;
minAmount: string;
maxAmount: string;
itemId: string;
selectedItemName: string;
selectedItemRarity: string;
message: string;
}
export interface EffectDraft {
_id: string;
kind: EffectKind;
amount: string;
multiplier: string;
durationSeconds: string;
roleId: string;
message: string;
pool: LootPoolEntry[];
}
export interface Draft {
name: string;
description: string;
type: ItemType;
rarity: ItemRarity;
price: string;
consume: boolean;
effects: EffectDraft[];
}
// ===== Full item shape returned by GET /api/items/:id =====
export interface LootPayloadEntry {
type: LootEntryType;
weight: number;
amount?: number;
minAmount?: number;
maxAmount?: number;
itemId?: number;
message?: string;
}
export type StoredEffect =
| { type: "ADD_XP"; amount: number }
| { type: "ADD_BALANCE"; amount: number }
| { type: "REPLY_MESSAGE"; message: string }
| { type: "XP_BOOST"; multiplier: number; durationSeconds?: number }
| { type: "TEMP_ROLE"; roleId: string; durationSeconds?: number }
| { type: "COLOR_ROLE"; roleId: string }
| { type: "LOOTBOX"; pool: LootPayloadEntry[] };
export interface FullItem {
id: number;
name: string;
description: string | null;
type: ItemType;
rarity: ItemRarity;
price: string | null;
iconUrl: string;
imageUrl: string;
usageData: { consume: boolean; effects: StoredEffect[] } | null;
}
// ===== Constants =====
export const TYPE_META: Record<
ItemType,
{ label: string; icon: React.ComponentType<{ className?: string }> }
> = {
MATERIAL: { label: "Material", icon: Package },
CONSUMABLE: { label: "Consumable", icon: Zap },
EQUIPMENT: { label: "Equipment", icon: Shield },
QUEST: { label: "Quest", icon: MessageSquare },
};
export const RARITY_META: Record<
ItemRarity,
{
label: string;
bg: string;
text: string;
activeBorder: string;
badgeBg: string;
}
> = {
C: {
label: "Common",
bg: "bg-gray-500/15",
text: "text-gray-300",
activeBorder: "border-gray-500",
badgeBg: "bg-gray-500/20",
},
R: {
label: "Rare",
bg: "bg-blue-500/15",
text: "text-blue-300",
activeBorder: "border-blue-500",
badgeBg: "bg-blue-500/20",
},
SR: {
label: "Super Rare",
bg: "bg-purple-500/15",
text: "text-purple-300",
activeBorder: "border-purple-500",
badgeBg: "bg-purple-500/20",
},
SSR: {
label: "SSR",
bg: "bg-amber-500/15",
text: "text-amber-300",
activeBorder: "border-amber-500",
badgeBg: "bg-amber-500/20",
},
};
export const RARITY_BADGE: Record<string, string> = {
C: "bg-gray-500/20 text-gray-400",
R: "bg-blue-500/20 text-blue-400",
SR: "bg-purple-500/20 text-purple-400",
SSR: "bg-amber-500/20 text-amber-400",
};
export const EFFECT_META: Record<
EffectKind,
{ label: string; icon: React.ComponentType<{ className?: string }> }
> = {
ADD_XP: { label: "Add XP", icon: Zap },
ADD_BALANCE: { label: "Add Balance", icon: CircleDollarSign },
REPLY_MESSAGE: { label: "Reply Message", icon: MessageSquare },
XP_BOOST: { label: "XP Boost", icon: TrendingUp },
TEMP_ROLE: { label: "Temporary Role", icon: Shield },
COLOR_ROLE: { label: "Color Role", icon: Palette },
LOOTBOX: { label: "Lootbox", icon: Gift },
};
export const LOOT_TYPE_META: Record<
LootEntryType,
{ label: string; barColor: string; textColor: string }
> = {
NOTHING: {
label: "Nothing",
barColor: "bg-text-disabled",
textColor: "text-text-tertiary",
},
CURRENCY: {
label: "Currency",
barColor: "bg-gold",
textColor: "text-gold",
},
XP: {
label: "XP",
barColor: "bg-blue-400",
textColor: "text-blue-400",
},
ITEM: {
label: "Item",
barColor: "bg-purple-400",
textColor: "text-purple-400",
},
};
// ===== Helpers =====
export function uid() {
return Math.random().toString(36).slice(2);
}
export function makeLootEntry(): LootPoolEntry {
return {
_id: uid(),
type: "CURRENCY",
weight: "1",
amountMode: "fixed",
amount: "",
minAmount: "",
maxAmount: "",
itemId: "",
selectedItemName: "",
selectedItemRarity: "",
message: "",
};
}
export function makeEffect(kind: EffectKind): EffectDraft {
return {
_id: uid(),
kind,
amount: "",
multiplier: "",
durationSeconds: "",
roleId: "",
message: "",
pool: [],
};
}
export function defaultDraft(): Draft {
return {
name: "",
description: "",
type: "MATERIAL",
rarity: "C",
price: "",
consume: false,
effects: [],
};
}
export function draftFromItem(item: FullItem): Draft {
const effects: EffectDraft[] = (item.usageData?.effects ?? []).map((eff) => {
const base = makeEffect(eff.type as EffectKind);
switch (eff.type) {
case "ADD_XP":
case "ADD_BALANCE":
return { ...base, amount: String(eff.amount) };
case "REPLY_MESSAGE":
return { ...base, message: eff.message };
case "XP_BOOST":
return {
...base,
multiplier: String(eff.multiplier),
durationSeconds: String(eff.durationSeconds ?? ""),
};
case "TEMP_ROLE":
return {
...base,
roleId: eff.roleId,
durationSeconds: String(eff.durationSeconds ?? ""),
};
case "COLOR_ROLE":
return { ...base, roleId: eff.roleId };
case "LOOTBOX":
return {
...base,
pool: eff.pool.map((entry) => ({
_id: uid(),
type: entry.type,
weight: String(entry.weight),
amountMode:
entry.minAmount !== undefined || entry.maxAmount !== undefined
? ("range" as const)
: ("fixed" as const),
amount: String(entry.amount ?? ""),
minAmount: String(entry.minAmount ?? ""),
maxAmount: String(entry.maxAmount ?? ""),
itemId: String(entry.itemId ?? ""),
selectedItemName: "",
selectedItemRarity: "",
message: entry.message ?? "",
})),
};
default:
return base;
}
});
return {
name: item.name,
description: item.description ?? "",
type: item.type,
rarity: item.rarity,
price: item.price ? String(parseInt(item.price)) : "",
consume: item.usageData?.consume ?? false,
effects,
};
}
export const inputCls = 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",
"placeholder:text-text-tertiary transition-colors"
);

View File

@@ -0,0 +1,222 @@
import { Trash2 } from "lucide-react";
import { cn } from "../../lib/utils";
import {
type LootPoolEntry,
type LootEntryType,
LOOT_TYPE_META,
inputCls,
} from "./ItemStudioTypes";
import { Field } from "./ItemStudioShared";
import { ItemSearchPicker } from "./ItemSearchPicker";
// ===== Loot Pool Entry Editor =====
export function LootPoolEntryEditor({
entry,
totalWeight,
onChange,
onRemove,
}: {
entry: LootPoolEntry;
totalWeight: number;
onChange: (updated: LootPoolEntry) => void;
onRemove: () => void;
}) {
const upd = (fields: Partial<LootPoolEntry>) =>
onChange({ ...entry, ...fields });
const weight = Number(entry.weight || 0);
const pct =
totalWeight > 0 ? ((weight / totalWeight) * 100).toFixed(1) : "0.0";
const meta = LOOT_TYPE_META[entry.type];
const resetAmounts = {
amount: "",
minAmount: "",
maxAmount: "",
itemId: "",
selectedItemName: "",
selectedItemRarity: "",
};
return (
<div className="bg-raised/30 border border-border rounded-lg p-3 space-y-3">
{/* Header row: type tabs + weight input + percentage + remove */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex gap-1 flex-1 flex-wrap">
{(Object.keys(LOOT_TYPE_META) as LootEntryType[]).map((t) => (
<button
key={t}
onClick={() => upd({ type: t, ...resetAmounts })}
className={cn(
"px-2 py-1 rounded text-xs font-medium transition-colors border",
entry.type === t
? "bg-primary/15 border-primary text-primary"
: "bg-input border-border text-text-tertiary hover:text-foreground"
)}
>
{LOOT_TYPE_META[t].label}
</button>
))}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<label className="text-xs text-text-tertiary">Weight</label>
<input
type="number"
min="1"
value={entry.weight}
onChange={(e) => upd({ weight: e.target.value })}
className={cn(
"w-14 bg-input border border-border rounded-md px-2 py-1 text-xs text-foreground text-center",
"focus:outline-none focus:border-primary transition-colors"
)}
/>
<span className={cn("text-xs font-semibold w-12 text-right", meta.textColor)}>
{pct}%
</span>
</div>
<button
onClick={onRemove}
className="p-1 rounded text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0"
title="Remove entry"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{/* NOTHING */}
{entry.type === "NOTHING" && (
<Field label="Message (optional)">
<input
type="text"
value={entry.message}
onChange={(e) => upd({ message: e.target.value })}
placeholder="You found nothing inside..."
className={inputCls}
/>
</Field>
)}
{/* CURRENCY / XP */}
{(entry.type === "CURRENCY" || entry.type === "XP") && (
<div className="space-y-3">
<div className="flex gap-1.5">
{(["fixed", "range"] as const).map((mode) => (
<button
key={mode}
onClick={() => upd({ amountMode: mode })}
className={cn(
"px-2.5 py-1 rounded text-xs font-medium border transition-colors",
entry.amountMode === mode
? "bg-primary/15 border-primary text-primary"
: "bg-input border-border text-text-tertiary hover:text-foreground"
)}
>
{mode === "fixed" ? "Fixed" : "Random Range"}
</button>
))}
</div>
{entry.amountMode === "fixed" ? (
<Field
label={
entry.type === "CURRENCY" ? "Coins Amount" : "XP Amount"
}
>
<input
type="number"
min="1"
value={entry.amount}
onChange={(e) => upd({ amount: e.target.value })}
placeholder="e.g. 100"
className={inputCls}
/>
</Field>
) : (
<div className="grid grid-cols-2 gap-3">
<Field label="Min">
<input
type="number"
min="0"
value={entry.minAmount}
onChange={(e) => upd({ minAmount: e.target.value })}
placeholder="e.g. 50"
className={inputCls}
/>
</Field>
<Field label="Max">
<input
type="number"
min="0"
value={entry.maxAmount}
onChange={(e) => upd({ maxAmount: e.target.value })}
placeholder="e.g. 200"
className={inputCls}
/>
</Field>
</div>
)}
<Field label="Message (optional)">
<input
type="text"
value={entry.message}
onChange={(e) => upd({ message: e.target.value })}
placeholder={
entry.type === "CURRENCY"
? "You received {amount} coins!"
: "You gained {amount} XP!"
}
className={inputCls}
/>
</Field>
</div>
)}
{/* ITEM */}
{entry.type === "ITEM" && (
<div className="space-y-3">
<Field label="Item to Award">
<ItemSearchPicker
value={
entry.itemId && entry.selectedItemName
? {
id: Number(entry.itemId),
name: entry.selectedItemName,
rarity: entry.selectedItemRarity,
}
: null
}
onChange={(item) =>
upd({
itemId: item ? String(item.id) : "",
selectedItemName: item?.name ?? "",
selectedItemRarity: item?.rarity ?? "",
})
}
/>
</Field>
<Field label="Quantity (optional, defaults to 1)">
<input
type="number"
min="1"
value={entry.amount}
onChange={(e) => upd({ amount: e.target.value })}
placeholder="1"
className={inputCls}
/>
</Field>
<Field label="Message (optional)">
<input
type="text"
value={entry.message}
onChange={(e) => upd({ message: e.target.value })}
placeholder="You found a rare item!"
className={inputCls}
/>
</Field>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { Plus } from "lucide-react";
import { cn } from "../../lib/utils";
import {
type LootPoolEntry,
type LootEntryType,
LOOT_TYPE_META,
makeLootEntry,
} from "./ItemStudioTypes";
import { LootPoolEntryEditor } from "./LootPoolEntryEditor";
// ===== Lootbox Pool Builder =====
export function LootboxEditor({
pool,
onChange,
}: {
pool: LootPoolEntry[];
onChange: (pool: LootPoolEntry[]) => void;
}) {
const totalWeight = pool.reduce((sum, e) => sum + Number(e.weight || 0), 0);
const addEntry = () => onChange([...pool, makeLootEntry()]);
const updateEntry = (i: number, updated: LootPoolEntry) => {
const next = [...pool];
next[i] = updated;
onChange(next);
};
const removeEntry = (i: number) =>
onChange(pool.filter((_, idx) => idx !== i));
return (
<div className="space-y-3 pt-1">
{/* Summary bar */}
{pool.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary">
{pool.length} entr{pool.length === 1 ? "y" : "ies"} · total
weight {totalWeight}
</span>
<div className="flex gap-2">
{(Object.keys(LOOT_TYPE_META) as LootEntryType[]).map((t) => {
const count = pool.filter((e) => e.type === t).length;
if (!count) return null;
return (
<span
key={t}
className={cn(
"text-xs",
LOOT_TYPE_META[t].textColor
)}
>
{LOOT_TYPE_META[t].label} ×{count}
</span>
);
})}
</div>
</div>
{/* Stacked probability bar */}
<div className="flex h-2 w-full rounded-full overflow-hidden gap-px bg-raised">
{pool.map((e) => {
const w = Number(e.weight || 0);
const pct = totalWeight > 0 ? (w / totalWeight) * 100 : 0;
return (
<div
key={e._id}
style={{ width: `${pct}%` }}
className={cn(
"transition-all duration-200",
LOOT_TYPE_META[e.type].barColor
)}
title={`${LOOT_TYPE_META[e.type].label}: ${pct.toFixed(1)}%`}
/>
);
})}
</div>
</div>
)}
{/* Pool entries */}
{pool.map((entry, i) => (
<LootPoolEntryEditor
key={entry._id}
entry={entry}
totalWeight={totalWeight}
onChange={(updated) => updateEntry(i, updated)}
onRemove={() => removeEntry(i)}
/>
))}
{/* Empty hint */}
{pool.length === 0 && (
<p className="text-xs text-text-tertiary text-center py-2">
Add pool entries each has a type, weight, and reward amount.
</p>
)}
{/* Add entry */}
<button
onClick={addEntry}
className={cn(
"w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-dashed text-xs",
"border-border text-text-tertiary hover:border-amber-500/50 hover:text-amber-400 transition-colors"
)}
>
<Plus className="w-3.5 h-3.5" />
Add Pool Entry
</button>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { Search } from "lucide-react";
import { cn } from "../../lib/utils";
// ---------------------------------------------------------------------------
// SearchFilterBar Component
// ---------------------------------------------------------------------------
export function SearchFilterBar({
search,
onSearchChange,
classId,
onClassChange,
isActive,
onActiveChange,
sortBy,
onSortByChange,
sortOrder,
onSortOrderChange,
onClear,
classes,
}: {
search: string;
onSearchChange: (v: string) => void;
classId: string | null;
onClassChange: (v: string | null) => void;
isActive: boolean | null;
onActiveChange: (v: boolean | null) => void;
sortBy: string;
onSortByChange: (v: string) => void;
sortOrder: string;
onSortOrderChange: (v: string) => void;
onClear: () => void;
classes: { id: string; name: string }[];
}) {
return (
<div className="flex flex-wrap gap-3 items-center">
{/* Search input */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search by username..."
className={cn(
"w-full bg-input border border-border rounded-md pl-10 pr-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
/>
</div>
{/* Class filter */}
<select
value={classId ?? ""}
onChange={(e) => onClassChange(e.target.value || null)}
className={cn(
"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="">All Classes</option>
{Array.isArray(classes) && classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{/* Active status filter */}
<select
value={isActive === null ? "" : String(isActive)}
onChange={(e) =>
onActiveChange(e.target.value === "" ? null : e.target.value === "true")
}
className={cn(
"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="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
{/* Sort by */}
<select
value={sortBy}
onChange={(e) => onSortByChange(e.target.value)}
className={cn(
"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="balance">Sort by Balance</option>
<option value="level">Sort by Level</option>
<option value="xp">Sort by XP</option>
<option value="username">Sort by Username</option>
</select>
{/* Sort order */}
<button
onClick={() => onSortOrderChange(sortOrder === "asc" ? "desc" : "asc")}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"hover:bg-raised transition-colors"
)}
>
{sortOrder === "asc" ? "\u2191 Asc" : "\u2193 Desc"}
</button>
{/* Clear filters */}
<button
onClick={onClear}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-text-secondary",
"hover:bg-destructive hover:text-white hover:border-destructive transition-colors"
)}
>
Clear
</button>
</div>
);
}

View File

@@ -0,0 +1,486 @@
import { useState } from "react";
import { X } from "lucide-react";
import { cn } from "../../lib/utils";
import type { SettingsMeta } from "../../lib/useSettings";
// ---------------------------------------------------------------------------
// Reusable field components
// ---------------------------------------------------------------------------
export 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>
);
}
export 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
)}
/>
);
}
export 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
)}
/>
);
}
export 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"
)}
/>
);
}
export 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>
);
}
export 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 */
export 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) */
export 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 */
export 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" },
];
export 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 */
export 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>
);
}
export 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>
);
}

View File

@@ -0,0 +1,649 @@
import {
TrendingUp,
Coins,
Package,
Gift,
Brain,
Shield,
Terminal,
Server,
Scroll,
} from "lucide-react";
import { cn } from "../../lib/utils";
import type { GameSettings, GuildSettings, SettingsMeta } from "../../lib/useSettings";
import {
Field,
NumberInput,
StringInput,
TextArea,
Toggle,
SelectInput,
RolePicker,
ChannelPicker,
MultiRolePicker,
CategoryPicker,
FeatureOverridesEditor,
SectionCard,
} from "./SettingsFormFields";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
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`;
}
// ---------------------------------------------------------------------------
// Section editors
// ---------------------------------------------------------------------------
export 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>
);
}
export 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>
);
}
export 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>
);
}
export 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>
);
}
export 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>
);
}
export 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>
);
}
export 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>
);
}
export function QuestSection({
data,
onChange,
}: {
data: GameSettings["quest"];
onChange: (d: GameSettings["quest"]) => void;
}) {
return (
<SectionCard title="Quests" icon={Scroll}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Field
label="Max Active Quests"
hint="How many incomplete quests a player can have at once"
>
<NumberInput
value={data.maxActiveQuests}
onChange={(v) => onChange({ ...data, maxActiveQuests: v })}
min={1}
/>
</Field>
</div>
</SectionCard>
);
}
export 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>
);
}

View File

@@ -0,0 +1,309 @@
import { useState } from "react";
import {
Loader2,
AlertTriangle,
Save,
Check,
X,
Package,
Plus,
Trash2,
} from "lucide-react";
import { cn } from "../../lib/utils";
import type { User, InventoryEntry } from "../../lib/useUsers";
import {
Field,
NumberInput,
StringInput,
Toggle,
SelectInput,
SectionCard,
formatBigInt,
} from "./UserFormFields";
// ---------------------------------------------------------------------------
// InventoryAddForm Component
// ---------------------------------------------------------------------------
function InventoryAddForm({
items,
onAdd,
}: {
items: { id: number; name: string }[];
onAdd: (itemId: number, quantity: string) => void;
}) {
const [selectedItemId, setSelectedItemId] = useState<string>("");
const [quantity, setQuantity] = useState<string>("1");
const handleAdd = () => {
if (!selectedItemId) return;
onAdd(parseInt(selectedItemId), quantity);
setSelectedItemId("");
setQuantity("1");
};
return (
<div className="space-y-3">
<p className="text-xs font-semibold text-text-secondary">Add Item</p>
<select
value={selectedItemId}
onChange={(e) => setSelectedItemId(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="">Select item...</option>
{Array.isArray(items) && items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<div className="flex gap-2">
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
min="1"
placeholder="Qty"
className={cn(
"w-20 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"
)}
/>
<button
onClick={handleAdd}
disabled={!selectedItemId}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
"bg-primary text-white hover:bg-primary/90",
"disabled:opacity-50 disabled:cursor-not-allowed",
"flex items-center justify-center gap-1.5"
)}
>
<Plus className="w-4 h-4" />
Add
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// DetailPanel Component
// ---------------------------------------------------------------------------
export function UserDetailPanel({
user,
userDraft,
onClose,
onUpdateDraft,
onSave,
onDiscard,
isDirty,
saving,
saveSuccess,
classes,
inventoryDraft,
items,
onAddItem,
onRemoveItem,
}: {
user: User;
userDraft: Partial<User> | null;
onClose: () => void;
onUpdateDraft: (field: keyof User, value: unknown) => void;
onSave: () => void;
onDiscard: () => void;
isDirty: boolean;
saving: boolean;
saveSuccess: boolean;
classes: { id: string; name: string }[];
inventoryDraft: InventoryEntry[];
items: { id: number; name: string }[];
onAddItem: (itemId: number, quantity: string) => void;
onRemoveItem: (itemId: number) => void;
}) {
if (!userDraft) return null;
const classOptions = [
{ value: "", label: "No Class" },
...(Array.isArray(classes) ? classes.map((c) => ({ value: c.id, label: c.name })) : []),
];
return (
<div className="fixed inset-0 md:relative md:w-96 border-l border-border bg-card overflow-auto z-50 md:z-auto">
<div className="p-6 space-y-6 pb-24">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex-1">
<h2 className="text-lg font-semibold text-foreground mb-1">
{user.username}
</h2>
<p className="text-xs font-mono text-text-tertiary">{user.id}</p>
<p className="text-xs text-text-tertiary mt-1">
Joined {new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={onClose}
className="text-text-tertiary hover:text-foreground transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* User Info (Editable) */}
<div className="space-y-4">
<Field label="Balance" hint="User's currency balance">
<StringInput
value={String(userDraft.balance || "0")}
onChange={(v) => onUpdateDraft("balance", v)}
placeholder="0"
/>
</Field>
<Field label="XP" hint="User's experience points">
<StringInput
value={String(userDraft.xp || "0")}
onChange={(v) => onUpdateDraft("xp", v)}
placeholder="0"
/>
</Field>
<Field label="Level" hint="User's current level">
<NumberInput
value={userDraft.level || 1}
onChange={(v) => onUpdateDraft("level", v)}
min={1}
max={100}
/>
</Field>
<Field label="Daily Streak" hint="Consecutive days of daily command usage">
<NumberInput
value={userDraft.dailyStreak || 0}
onChange={(v) => onUpdateDraft("dailyStreak", v)}
min={0}
/>
</Field>
<Field label="Class" hint="User's selected class">
<SelectInput
value={String(userDraft.classId || "")}
onChange={(v) => onUpdateDraft("classId", v || null)}
options={classOptions}
/>
</Field>
<Field label="Active Status" hint="Whether the user is active in the system">
<div className="flex items-center gap-3">
<Toggle
checked={userDraft.isActive ?? true}
onChange={(v) => onUpdateDraft("isActive", v)}
/>
<span
className={cn(
"text-sm font-medium",
userDraft.isActive ? "text-green-400" : "text-gray-400"
)}
>
{userDraft.isActive ? "Active" : "Inactive"}
</span>
</div>
</Field>
</div>
{/* Inventory Section */}
<SectionCard title="Inventory" icon={Package}>
{inventoryDraft.length === 0 ? (
<p className="text-sm text-text-tertiary">No items in inventory</p>
) : (
<div className="space-y-2">
{inventoryDraft.map((entry) => (
<div
key={entry.itemId}
className="flex items-center justify-between gap-3 p-2 bg-raised rounded-md"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{entry.item?.name || `Item #${entry.itemId}`}
</p>
<p className="text-xs text-text-tertiary">
Quantity: {formatBigInt(entry.quantity)}
</p>
</div>
<button
onClick={() => onRemoveItem(entry.itemId)}
className="p-1.5 text-text-tertiary hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
title="Remove item"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{/* Add Item Form */}
<div className="mt-4 pt-4 border-t border-border">
<InventoryAddForm items={items} onAdd={onAddItem} />
</div>
</SectionCard>
</div>
{/* Sticky footer for save/discard (only shown when dirty) */}
{isDirty && (
<div className="sticky bottom-0 left-0 right-0 border-t border-border bg-card p-4 space-y-3">
<div className="flex items-center gap-2 text-amber-400">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">You have unsaved changes</span>
</div>
<div className="flex gap-2">
<button
onClick={onDiscard}
disabled={saving}
className={cn(
"flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors",
"bg-input border border-border text-foreground hover:bg-raised",
saving && "opacity-50 cursor-not-allowed"
)}
>
Discard
</button>
<button
onClick={onSave}
disabled={saving}
className={cn(
"flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors",
"bg-primary text-white hover:bg-primary/90",
"flex items-center justify-center gap-2",
saving && "opacity-50 cursor-not-allowed"
)}
>
{saving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : saveSuccess ? (
<>
<Check className="w-4 h-4" />
Saved!
</>
) : (
<>
<Save className="w-4 h-4" />
Save Changes
</>
)}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { cn } from "../../lib/utils";
// ---------------------------------------------------------------------------
// Reusable field components for Users page
// ---------------------------------------------------------------------------
export 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>
);
}
export 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
)}
/>
);
}
export 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
)}
/>
);
}
export 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>
);
}
export 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>
);
}
export function SectionCard({
title,
icon: Icon,
children,
}: {
title: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center gap-2 mb-4">
<Icon className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function formatNumber(num: number | string): string {
const n = typeof num === "string" ? parseInt(num) : num;
return n.toLocaleString();
}
export function formatBigInt(value: string): string {
try {
const num = BigInt(value);
return num.toLocaleString();
} catch {
return value;
}
}

View File

@@ -0,0 +1,134 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "../../lib/utils";
import { formatNumber } from "./UserFormFields";
// ---------------------------------------------------------------------------
// Pagination Component
// ---------------------------------------------------------------------------
export function UserPagination({
currentPage,
totalPages,
limit,
total,
onPageChange,
onLimitChange,
}: {
currentPage: number;
totalPages: number;
limit: number;
total: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
}) {
const startItem = (currentPage - 1) * limit + 1;
const endItem = Math.min(currentPage * limit, total);
// Calculate page numbers to show
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const showPages = 5;
const halfShow = Math.floor(showPages / 2);
let start = Math.max(1, currentPage - halfShow);
let end = Math.min(totalPages, start + showPages - 1);
if (end - start < showPages - 1) {
start = Math.max(1, end - showPages + 1);
}
if (start > 1) {
pages.push(1);
if (start > 2) pages.push("...");
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (end < totalPages) {
if (end < totalPages - 1) pages.push("...");
pages.push(totalPages);
}
return pages;
};
return (
<div className="mt-4 flex flex-wrap gap-4 items-center justify-between">
{/* Items info */}
<p className="text-sm text-text-secondary">
Showing {startItem}\u2013{endItem} of {formatNumber(total)} users
</p>
{/* Page controls */}
<div className="flex items-center gap-2">
{/* Previous button */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === 1
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronLeft className="w-4 h-4" />
</button>
{/* Page numbers */}
{getPageNumbers().map((page, i) =>
typeof page === "number" ? (
<button
key={i}
onClick={() => onPageChange(page)}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors min-w-[40px]",
page === currentPage
? "bg-primary text-white"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
{page}
</button>
) : (
<span key={i} className="px-2 text-text-tertiary">
{page}
</span>
)
)}
{/* Next button */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === totalPages
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronRight className="w-4 h-4" />
</button>
{/* Items per page */}
<select
value={limit}
onChange={(e) => onLimitChange(Number(e.target.value))}
className={cn(
"ml-2 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="10">10 / page</option>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { UserCircle2 } from "lucide-react";
import { cn } from "../../lib/utils";
import type { User } from "../../lib/useUsers";
import { formatBigInt } from "./UserFormFields";
// ---------------------------------------------------------------------------
// UserTable Component
// ---------------------------------------------------------------------------
export function UserTable({
users,
loading,
onSelectUser,
}: {
users: User[];
loading: boolean;
onSelectUser: (user: User) => void;
}) {
if (loading) {
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Username
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Level
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Balance
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Class
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Status
</th>
</tr>
</thead>
<tbody>
{[...Array(5)].map((_, i) => (
<tr key={i} className="border-b border-border">
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-32"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-12"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-24"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-20"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-16"></div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
if (users.length === 0) {
return (
<div className="bg-card border border-border rounded-lg p-12 text-center">
<UserCircle2 className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
<p className="text-lg font-semibold text-text-secondary mb-2">
No users found
</p>
<p className="text-sm text-text-tertiary">
Try adjusting your search or filter criteria
</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Username
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Level
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Balance
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
XP
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Class
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Status
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
onClick={() => onSelectUser(user)}
className="border-b border-border hover:bg-raised cursor-pointer transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<UserCircle2 className="w-5 h-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-foreground">
{user.username}
</p>
<p className="text-xs text-text-tertiary font-mono">
{user.id}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-foreground">
{user.level}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-foreground">
{formatBigInt(user.balance)}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-text-secondary">
{formatBigInt(user.xp)}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-foreground">
{user.class?.name || "\u2014"}
</span>
</td>
<td className="px-4 py-3">
<span
className={cn(
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
user.isActive
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
)}
>
{user.isActive ? "Active" : "Inactive"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -6,10 +6,12 @@ import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
export const classService = {
/** Retrieve all available classes. */
getAllClasses: async () => {
return await DrizzleClient.query.classes.findMany();
},
/** Assign a class to a user; throws if the class does not exist. */
assignClass: async (userId: string, classId: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const cls = await txFn.query.classes.findFirst({
@@ -26,12 +28,14 @@ export const classService = {
return user;
}, tx);
},
/** Get the current balance for a class, returning 0 if not found. */
getClassBalance: async (classId: bigint) => {
const cls = await DrizzleClient.query.classes.findFirst({
where: eq(classes.id, classId),
});
return cls?.balance || 0n;
},
/** Adjust a class balance by the given amount; throws if funds are insufficient for a deduction. */
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const cls = await txFn.query.classes.findFirst({
@@ -55,6 +59,7 @@ export const classService = {
}, tx);
},
/** Update a class record with partial data. */
updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const [updatedClass] = await txFn.update(classes)
@@ -65,6 +70,7 @@ export const classService = {
}, tx);
},
/** Create a new class record. */
createClass: async (data: typeof classes.$inferInsert, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const [newClass] = await txFn.insert(classes)
@@ -74,6 +80,7 @@ export const classService = {
}, tx);
},
/** Delete a class by ID. */
deleteClass: async (id: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
await txFn.delete(classes).where(eq(classes.id, id));

View File

@@ -0,0 +1,11 @@
# Economy Module
- All currency values are `bigint`. Never use `Number()` for arithmetic on balances -- use BigInt literals (e.g., `0n`, `500n`) and `sql` template expressions for DB updates.
- `modifyUserBalance` is the canonical way to change a user's balance. It checks for insufficient funds on negative amounts, logs a transaction record, and emits `BALANCE_CHANGED` for quest progression. Bypass it only if you have a very good reason.
- Daily rewards reset at **UTC midnight**, not 24h from last claim. The cooldown `expiresAt` is set to the next UTC 00:00:00. Streak breaks if the user misses an entire 24h window after the cooldown expired.
- Daily reward is capped at `MAX_DAILY_REWARD = 500n` regardless of streak/weekly bonus.
- The streak has a grace period: if a user's timer record is missing (e.g., DB migration), the code allows one "free" increment to avoid unfair resets.
- Weekly bonus triggers every 7th consecutive day (streak % 7 === 0).
- **Exam system**: a weekly check-in that rewards users based on XP gained since their last exam. The reward uses scaled BigInt arithmetic (`* 10000 / 10000n`) to avoid floating-point precision loss. Exams are locked to a specific day of the week set at registration time. Missing your exam day means zero reward -- there is no retroactive claim.
- Lootdrops use **in-memory state** (`Map`s for channel activity and cooldowns). This state is lost on restart. The DB stores only spawned/claimed drops. Claiming uses an atomic `UPDATE ... WHERE claimedBy IS NULL` to prevent race conditions.
- Lootdrops expire after 10 minutes and are cleaned up by a 60-second interval.

View File

@@ -8,6 +8,7 @@ import { UserError } from "@shared/lib/errors";
import { TimerKey, TimerType, TransactionType } from "@shared/lib/constants";
export const economyService = {
/** Transfer currency between two users; validates sufficient balance and creates transaction records for both sides. */
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
if (amount <= 0n) {
throw new UserError("Amount must be positive");
@@ -69,6 +70,7 @@ export const economyService = {
}, tx);
},
/** Claim the daily reward, applying streak bonuses and weekly bonuses; enforces a UTC-midnight cooldown. */
claimDaily: async (userId: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const now = new Date();
@@ -164,6 +166,7 @@ export const economyService = {
}, tx);
},
/** Adjust a user's balance by the given amount and log a transaction; throws if deduction exceeds current balance. */
modifyUserBalance: async (id: string, amount: bigint, type: string, description: string, relatedUserId?: string | null, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
if (amount < 0n) {

View File

@@ -213,12 +213,20 @@ async function clearCaches() {
}
export const lootdropService = {
/** Delete expired lootdrops from the database; optionally includes already-claimed ones. */
cleanupExpiredLootdrops,
/** Record a message in a channel and return whether a lootdrop should spawn based on activity and RNG. */
trackActivity,
/** Calculate a random lootdrop reward amount and currency, with optional overrides. */
calculateReward,
/** Save a spawned lootdrop to the database with a 10-minute expiration. */
persistLootdrop,
/** Remove a lootdrop by message ID and return its channel ID for Discord cleanup. */
removeLootdrop,
/** Atomically claim a lootdrop for a user; credits reward to their balance. */
tryClaim,
/** Get current lootdrop system state including the most active channel and spawn config. */
getLootdropState,
/** Clear all in-memory activity tracking and cooldown caches. */
clearCaches,
};

View File

@@ -10,6 +10,7 @@ export interface FeatureFlagContext {
}
export const featureFlagsService = {
/** Check whether a feature flag is enabled globally. */
isFlagEnabled: async (flagName: string): Promise<boolean> => {
const flag = await DrizzleClient.query.featureFlags.findFirst({
where: eq(featureFlags.name, flagName),
@@ -17,6 +18,7 @@ export const featureFlagsService = {
return flag?.enabled ?? false;
},
/** Check if a guild/user/role has access to a feature flag; returns false if flag is disabled. */
hasAccess: async (
flagName: string,
context: FeatureFlagContext
@@ -51,6 +53,7 @@ export const featureFlagsService = {
return false;
},
/** Create a new feature flag, disabled by default. */
createFlag: async (name: string, description?: string) => {
const [flag] = await DrizzleClient.insert(featureFlags).values({
name,
@@ -60,6 +63,7 @@ export const featureFlagsService = {
return flag;
},
/** Enable or disable a feature flag by name; throws if the flag does not exist. */
setFlagEnabled: async (name: string, enabled: boolean) => {
const [flag] = await DrizzleClient.update(featureFlags)
.set({ enabled, updatedAt: new Date() })
@@ -72,6 +76,7 @@ export const featureFlagsService = {
return flag;
},
/** Grant a guild, user, or role access to a feature flag. */
grantAccess: async (
flagName: string,
access: { guildId?: string; userId?: string; roleId?: string }
@@ -90,6 +95,7 @@ export const featureFlagsService = {
return accessRecord;
},
/** Revoke a specific access record by ID; throws if not found. */
revokeAccess: async (accessId: number) => {
const [access] = await DrizzleClient.delete(featureFlagAccess)
.where(eq(featureFlagAccess.id, accessId))
@@ -101,18 +107,21 @@ export const featureFlagsService = {
return access;
},
/** Retrieve a single feature flag by name. */
getFlag: async (name: string) => {
return await DrizzleClient.query.featureFlags.findFirst({
where: eq(featureFlags.name, name),
});
},
/** List all feature flags, ordered by name. */
listFlags: async () => {
return await DrizzleClient.query.featureFlags.findMany({
orderBy: (flags, { asc }) => [asc(flags.name)],
});
},
/** List all access records for a given feature flag. */
listAccess: async (flagName: string) => {
const flag = await DrizzleClient.query.featureFlags.findFirst({
where: eq(featureFlags.name, flagName),
@@ -125,6 +134,7 @@ export const featureFlagsService = {
});
},
/** Delete a feature flag and its associated access records; throws if not found. */
deleteFlag: async (name: string) => {
const [flag] = await DrizzleClient.delete(featureFlags)
.where(eq(featureFlags.name, name))

View File

@@ -29,6 +29,7 @@ let cacheTimestamp = 0;
const CACHE_TTL_MS = 30000;
export const gameSettingsService = {
/** Retrieve game settings, using a 30-second TTL cache by default. */
getSettings: async (useCache = true): Promise<GameSettingsData | null> => {
if (useCache && cachedSettings && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
return cachedSettings;
@@ -56,6 +57,7 @@ export const gameSettingsService = {
return cachedSettings;
},
/** Create or update game settings, merging with existing values and invalidating cache. */
upsertSettings: async (data: Partial<GameSettingsData>) => {
const existing = await gameSettingsService.getSettings(false);
@@ -86,6 +88,7 @@ export const gameSettingsService = {
return result;
},
/** Update a single configuration section (e.g., "leveling", "economy") and invalidate cache. */
updateSection: async <K extends keyof GameSettingsData>(
section: K,
value: GameSettingsData[K]
@@ -102,6 +105,7 @@ export const gameSettingsService = {
gameSettingsService.invalidateCache();
},
/** Enable or disable a specific command in the game settings. */
toggleCommand: async (commandName: string, enabled: boolean) => {
const settings = await gameSettingsService.getSettings(false);
@@ -117,11 +121,13 @@ export const gameSettingsService = {
await gameSettingsService.updateSection("commands", commands);
},
/** Invalidate the in-memory settings cache, forcing a fresh DB read on next access. */
invalidateCache: () => {
cachedSettings = null;
cacheTimestamp = 0;
},
/** Return default leveling configuration values. */
getDefaultLeveling: (): LevelingConfig => ({
base: 100,
exponent: 1.5,
@@ -132,6 +138,7 @@ export const gameSettingsService = {
},
}),
/** Return default economy configuration values. */
getDefaultEconomy: (): EconomyConfig => ({
daily: {
amount: "100",
@@ -149,11 +156,13 @@ export const gameSettingsService = {
},
}),
/** Return default inventory configuration values. */
getDefaultInventory: (): InventoryConfig => ({
maxStackSize: "99",
maxSlots: 20,
}),
/** Return default lootdrop configuration values. */
getDefaultLootdrop: (): LootdropConfig => ({
activityWindowMs: 300000,
minMessages: 5,
@@ -166,6 +175,7 @@ export const gameSettingsService = {
},
}),
/** Return default trivia configuration values. */
getDefaultTrivia: (): TriviaConfig => ({
entryFee: "50",
rewardMultiplier: 1.8,
@@ -175,6 +185,7 @@ export const gameSettingsService = {
difficulty: "random",
}),
/** Return default moderation configuration values. */
getDefaultModeration: (): ModerationConfig => ({
prune: {
maxAmount: 100,
@@ -184,10 +195,12 @@ export const gameSettingsService = {
},
}),
/** Return default quest configuration values. */
getDefaultQuest: (): QuestConfig => ({
maxActiveQuests: 3,
}),
/** Return the complete set of default game settings across all sections. */
getDefaults: (): GameSettingsData => ({
leveling: gameSettingsService.getDefaultLeveling(),
economy: gameSettingsService.getDefaultEconomy(),

View File

@@ -20,6 +20,7 @@ export interface GuildSettingsData {
}
export const guildSettingsService = {
/** Retrieve guild settings by guild ID, or null if none exist. */
getSettings: async (guildId: string): Promise<GuildSettingsData | null> => {
const settings = await DrizzleClient.query.guildSettings.findFirst({
where: eq(guildSettings.guildId, BigInt(guildId)),
@@ -44,6 +45,7 @@ export const guildSettingsService = {
};
},
/** Create or fully replace guild settings via upsert. */
upsertSettings: async (data: Partial<GuildSettingsData> & { guildId: string }) => {
const values: typeof guildSettings.$inferInsert = {
guildId: BigInt(data.guildId),
@@ -73,6 +75,7 @@ export const guildSettingsService = {
return result;
},
/** Update a single guild setting by key name; throws if the key is unknown or settings do not exist. */
updateSetting: async (
guildId: string,
key: string,
@@ -128,6 +131,7 @@ export const guildSettingsService = {
return result;
},
/** Delete all settings for a guild. */
deleteSettings: async (guildId: string) => {
const [result] = await DrizzleClient.delete(guildSettings)
.where(eq(guildSettings.guildId, BigInt(guildId)))
@@ -136,6 +140,7 @@ export const guildSettingsService = {
return result;
},
/** Add a color role to the guild's allowed list; no-ops if already present. */
addColorRole: async (guildId: string, roleId: string) => {
const settings = await guildSettingsService.getSettings(guildId);
const colorRoleIds = settings?.colorRoleIds ?? [];
@@ -148,6 +153,7 @@ export const guildSettingsService = {
return await guildSettingsService.upsertSettings({ guildId, colorRoleIds });
},
/** Remove a color role from the guild's allowed list. */
removeColorRole: async (guildId: string, roleId: string) => {
const settings = await guildSettingsService.getSettings(guildId);
if (!settings) return null;

View File

@@ -0,0 +1,11 @@
# Inventory Module
- Inventory has two hard limits from config: **max slots** (distinct item types) and **max stack size** (quantity per item). Both are enforced in `addItem` and will throw `UserError` if exceeded.
- When quantity reaches 0, the inventory row is **deleted** (not kept with quantity 0). This means slot count = row count.
- `buyItem` delegates balance deduction to `economyService.modifyUserBalance` within the same transaction to ensure atomicity. Never deduct balance directly when purchasing.
- Item usage is driven by a JSON `usageData` field on the item record. Items without `usageData.effects` cannot be used. The `consume` flag in `usageData` controls whether the item is removed after use.
- **Effect system**: effects are validated at runtime via Zod (`EffectPayloadSchema`) before execution. The registry maps effect type strings to handler functions. Adding a new effect type requires: (1) add to `EffectType` enum in constants, (2) add Zod schema variant in `effect.types.ts`, (3) add handler in `effect.handlers.ts`, (4) register in `effect.registry.ts`.
- `XP_BOOST` and `TEMP_ROLE` effects use `userTimers` with upsert -- activating while already active **replaces** the timer (does not stack or extend).
- `TEMP_ROLE` only records the timer in DB; actual Discord role assignment must happen in the bot command layer.
- `LOOTBOX` effect uses weighted random selection. Weights are relative, not percentages. A `NOTHING` loot type is valid and intentional.
- The `getAutocompleteItems` method filters to only show items that have usable effects, so non-usable items won't appear in the `/use` autocomplete.

View File

@@ -13,6 +13,7 @@ import { TransactionType } from "@shared/lib/constants";
export const inventoryService = {
/** Add items to a user's inventory; enforces max stack size and max slot limits. */
addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// Check if item exists in inventory
@@ -74,6 +75,7 @@ export const inventoryService = {
}, tx);
},
/** Remove items from a user's inventory; deletes the entry if quantity reaches zero. */
removeItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const existing = await txFn.query.inventory.findFirst({
@@ -110,6 +112,7 @@ export const inventoryService = {
}, tx);
},
/** Retrieve all inventory entries for a user, including item details. */
getInventory: async (userId: string) => {
return await DrizzleClient.query.inventory.findMany({
where: eq(inventory.userId, BigInt(userId)),
@@ -119,6 +122,7 @@ export const inventoryService = {
});
},
/** Purchase an item from the shop; deducts balance and adds to inventory atomically. */
buyItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const item = await txFn.query.items.findFirst({
@@ -139,12 +143,14 @@ export const inventoryService = {
}, tx);
},
/** Fetch a single item definition by ID. */
getItem: async (itemId: number) => {
return await DrizzleClient.query.items.findFirst({
where: eq(items.id, itemId),
});
},
/** Use a consumable item, applying its effects and consuming it if configured. */
useItem: async (userId: string, itemId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// 1. Check Ownership & Quantity
@@ -189,6 +195,7 @@ export const inventoryService = {
}, tx);
},
/** Search a user's usable inventory items by name for autocomplete suggestions. */
getAutocompleteItems: async (userId: string, query: string) => {
const entries = await DrizzleClient.select({
quantity: inventory.quantity,

View File

@@ -8,11 +8,7 @@ import { TimerKey, TimerType } from "@shared/lib/constants";
import { UserError } from "@shared/lib/errors";
export const levelingService = {
// Calculate total XP required to REACH a specific level (Cumulative)
// Level 1 = 0 XP
// Level 2 = Base * (1^Exp)
// Level 3 = Level 2 + Base * (2^Exp)
// ...
/** Calculate the cumulative XP required to reach a specific level. */
getXpToReachLevel: (level: number) => {
let total = 0;
for (let l = 1; l < level; l++) {
@@ -21,7 +17,7 @@ export const levelingService = {
return total;
},
// Calculate level from Total XP
/** Derive a user's level from their total accumulated XP. */
getLevelFromXp: (totalXp: bigint) => {
let level = 1;
let xp = Number(totalXp);
@@ -37,13 +33,12 @@ export const levelingService = {
}
},
// Get XP needed to complete the current level (for calculating next level threshold in isolation)
// Used internally or for display
/** Get the XP needed to advance from the given level to the next. */
getXpForNextLevel: (currentLevel: number) => {
return Math.floor(config.leveling.base * Math.pow(currentLevel, config.leveling.exponent));
},
// Cumulative XP addition
/** Add XP to a user, recalculating their level and emitting a quest event. */
addXp: async (id: string, amount: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// Get current state
@@ -77,7 +72,7 @@ export const levelingService = {
}, tx);
},
// Handle chat XP with cooldowns
/** Award random chat XP if the user is not on cooldown; applies active XP boost multipliers. */
processChatXp: async (id: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// check if an xp cooldown is in place

View File

@@ -0,0 +1,10 @@
# Moderation Module
- Case IDs are sequential strings formatted as `CASE-XXXX` (zero-padded to 4 digits). Generated by querying the latest case and incrementing. Not a DB sequence -- concurrent inserts could theoretically collide, but in practice moderation actions are low-frequency.
- Only `WARN` type cases are created with `active: true`. All other case types (TIMEOUT, BAN, KICK, NOTE) default to `active: false`. The `active` flag is specifically for tracking unresolved warnings.
- `issueWarning` has two side effects beyond creating the case:
- **DM notification**: sends the user a warning embed via DM. Fails silently if the user has DMs disabled. Controlled by `config.dmOnWarn` (defaults to true if unset).
- **Auto-timeout**: if active warning count >= `autoTimeoutThreshold`, the user is automatically timed out for 24 hours and a separate `TIMEOUT` case is created with `moderatorId: "0"` (system). The timeout target (Discord GuildMember) is passed in from the command layer.
- `clearCase` sets `active: false` and records who cleared it and why. It works on any case type, not just warnings.
- The service does **not** perform Discord actions (kick, ban, timeout) directly -- it only manages database records. The bot command layer is responsible for calling Discord APIs and then recording cases here. The one exception is auto-timeout in `issueWarning`, where the Discord member object is passed in.
- `searchCases` supports pagination via `limit`/`offset` (default limit 50).

View File

@@ -0,0 +1,10 @@
# Quest Module
- Quests are **event-driven**. The `handleEvent` method is called by system event listeners (not by commands directly). It matches events by exact name or prefix (e.g., trigger `ITEM_COLLECT` matches event `ITEM_COLLECT:101`), enabling both generic and specific quest triggers.
- Max active quests is controlled by `gameSettingsService`, not hardcoded. Default is 3.
- `assignQuest` uses `onConflictDoNothing` -- re-assigning an already-assigned quest silently no-ops. This is intentional to avoid duplicate quest entries.
- Quest progress is a simple integer counter. The `weight` parameter in `handleEvent` allows a single event to advance progress by more than 1 (useful for bulk actions).
- Quest completion is **automatic**: when progress >= target during `handleEvent`, `completeQuest` is called within the same transaction. There is no manual "turn in" step.
- Rewards (xp and balance) are distributed via `economyService` and `levelingService` inside the completion transaction. The `QUEST.COMPLETED` event is emitted with `systemEvents.emit` (fire-and-forget, not async) for bot-layer notifications.
- `requirements` and `rewards` are stored as JSON columns. Always expect `{ target: number }` for requirements and `{ xp?: number, balance?: number }` for rewards.
- Completed quests are never deleted -- they stay in `userQuests` with a `completedAt` timestamp. `getAvailableQuests` excludes any quest the user has ever been assigned (completed or not).

View File

@@ -11,6 +11,7 @@ import { TransactionType } from "@shared/lib/constants";
import { systemEvents, EVENTS } from "@shared/lib/events";
export const questService = {
/** Assign a quest to a user; enforces the maximum active quest limit. */
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// Check active quest limit
@@ -40,6 +41,7 @@ export const questService = {
}, tx);
},
/** Set the progress value for a user's active quest. */
updateProgress: async (userId: string, questId: number, progress: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
return await txFn.update(userQuests)
@@ -52,6 +54,7 @@ export const questService = {
}, tx);
},
/** Process a domain event against active quests, incrementing progress and auto-completing if target is met. */
handleEvent: async (userId: string, eventName: string, weight: number = 1, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// 1. Fetch active user quests for this event
@@ -86,6 +89,7 @@ export const questService = {
}, tx);
},
/** Mark a quest as completed and distribute its XP and balance rewards. */
completeQuest: async (userId: string, questId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const userQuest = await txFn.query.userQuests.findFirst({
@@ -137,6 +141,7 @@ export const questService = {
}, tx);
},
/** Get all quests assigned to a user, including quest details. */
getUserQuests: async (userId: string) => {
return await DrizzleClient.query.userQuests.findMany({
where: eq(userQuests.userId, BigInt(userId)),
@@ -146,6 +151,7 @@ export const questService = {
});
},
/** Get quests not yet assigned to a user. */
async getAvailableQuests(userId: string) {
const userQuestIds = (await DrizzleClient.query.userQuests.findMany({
where: eq(userQuests.userId, BigInt(userId)),
@@ -161,6 +167,7 @@ export const questService = {
});
},
/** Create a new quest definition with trigger event, requirements, and rewards. */
async createQuest(data: {
name: string;
description: string;
@@ -181,12 +188,14 @@ export const questService = {
}, tx);
},
/** List all quest definitions, ordered by ID. */
async getAllQuests() {
return await DrizzleClient.query.quests.findMany({
orderBy: (quests, { asc }) => [asc(quests.id)],
});
},
/** Delete a quest definition by ID. */
async deleteQuest(id: number, tx?: Transaction) {
return await withTransaction(async (txFn) => {
return await txFn.delete(quests)
@@ -195,6 +204,7 @@ export const questService = {
}, tx);
},
/** Update a quest definition with partial data. */
async updateQuest(id: number, data: {
name?: string;
description?: string;

View File

@@ -0,0 +1,9 @@
# Trade Module
- Trade sessions are stored **in-memory only** (a `Map` keyed by thread ID). Sessions are lost on restart. There is no persistence or recovery mechanism.
- The trade uses a **two-phase lock** pattern: both users must `toggleLock` (accept) before `executeTrade` can proceed. Any offer modification (add/remove item, change money) automatically **unlocks both users**, forcing re-confirmation. This prevents bait-and-switch.
- `executeTrade` wraps both directions of transfer in a single DB transaction. If any part fails (e.g., insufficient funds, inventory full), the entire trade rolls back.
- Money and item transfers go through `economyService.modifyUserBalance` and `inventoryService.addItem`/`removeItem`, which means all their validation (balance checks, stack limits, slot limits) and side effects (transaction logging, quest events) apply.
- Item transactions are logged separately in the `itemTransactions` table (distinct from currency `transactions`), with `TRADE_IN`/`TRADE_OUT` types.
- The trade types are defined in `bot/modules/trade/trade.types.ts` but the service lives in `shared/modules/trade/`. The import uses `@/modules/trade/trade.types` (bot alias). This cross-boundary import works because both run in the same process.
- `_sessions` is exposed on the service object for testing purposes only.

View File

@@ -101,10 +101,12 @@ export const tradeService = {
return session;
},
/** Get an active trade session by thread ID. */
getSession: (threadId: string): TradeSession | undefined => {
return sessions.get(threadId);
},
/** Remove a trade session from memory. */
endSession: (threadId: string) => {
sessions.delete(threadId);
},
@@ -126,6 +128,7 @@ export const tradeService = {
session.lastInteraction = Date.now();
},
/** Add an item to a participant's trade offer; unlocks both sides when the offer changes. */
addItem: (threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) => {
const session = tradeService.getSession(threadId);
if (!session) throw new UserError("Session not found");
@@ -145,6 +148,7 @@ export const tradeService = {
session.lastInteraction = Date.now();
},
/** Remove an item from a participant's trade offer; unlocks both sides. */
removeItem: (threadId: string, userId: string, itemId: number) => {
const session = tradeService.getSession(threadId);
if (!session) throw new UserError("Session not found");
@@ -158,6 +162,7 @@ export const tradeService = {
session.lastInteraction = Date.now();
},
/** Toggle a participant's lock status on their offer; returns the new lock state. */
toggleLock: (threadId: string, userId: string): boolean => {
const session = tradeService.getSession(threadId);
if (!session) throw new UserError("Session not found");
@@ -199,6 +204,7 @@ export const tradeService = {
tradeService.endSession(threadId);
},
/** Clear all active trade sessions from memory. */
clearSessions: () => {
sessions.clear();
console.log("[TradeService] All active trade sessions cleared.");

View File

@@ -0,0 +1,11 @@
# Trivia Module
- Trivia is an **economic sink**: the entry fee is deducted immediately when starting, before the question is fetched. If the API call fails after payment, the user loses the fee. This is by design (prevents free retries).
- Questions come from the **OpenTDB API** with base64 encoding to avoid HTML entity issues. The service decodes all fields from base64 before returning.
- Sessions are in-memory (`Map` keyed by `userId_timestamp`). Lost on restart. Expired sessions are cleaned up every 30 seconds.
- The cooldown is set **at session start**, not on answer submission. This means a user is on cooldown even if they never answer.
- Answer correctness (`isCorrect`) is determined by the **caller** (interaction handler), not the service. The `submitAnswer` method trusts the `isCorrect` boolean. The session stores `correctIndex` for the UI layer to compare.
- Reward calculation: `potentialReward = entryFee * rewardMultiplier`. The multiplier comes from config. Wrong answers get 0 (the entry fee is already gone).
- Unlike most services, `TriviaService` is a **class instance** (not a plain object). This is because it needs constructor logic for the cleanup interval. The singleton is exported as `triviaService`.
- The reward payment in `submitAnswer` reads the current balance and sets it directly (not using `sql` addition). This is a potential race condition under extreme concurrency but acceptable given the per-user cooldown.
- Session is deleted before processing the reward to prevent double-submit, even if the reward transaction fails.

View File

@@ -5,6 +5,7 @@ import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
export const userService = {
/** Fetch a user by Discord ID, including their class relation. */
getUserById: async (id: string) => {
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(id)),
@@ -12,10 +13,12 @@ export const userService = {
});
return user;
},
/** Fetch a user by their username. */
getUserByUsername: async (username: string) => {
const user = await DrizzleClient.query.users.findFirst({ where: eq(users.username, username) });
return user;
},
/** Fetch a user by ID, creating a new record if one does not exist. */
getOrCreateUser: async (id: string, username: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
let user = await txFn.query.users.findFirst({
@@ -38,6 +41,7 @@ export const userService = {
return user;
}, tx);
},
/** Get the class assigned to a user, or undefined if none. */
getUserClass: async (id: string) => {
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(id)),
@@ -45,6 +49,7 @@ export const userService = {
});
return user?.class;
},
/** Create a new user with an optional initial class assignment. */
createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const [user] = await txFn.insert(users).values({
@@ -55,6 +60,7 @@ export const userService = {
return user;
}, tx);
},
/** Update a user record with partial data. */
updateUser: async (id: string, data: Partial<typeof users.$inferInsert>, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const [user] = await txFn.update(users)
@@ -64,6 +70,7 @@ export const userService = {
return user;
}, tx);
},
/** Delete a user by ID. */
deleteUser: async (id: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
await txFn.delete(users).where(eq(users.id, BigInt(id)));