Compare commits
6 Commits
b8cf136ff7
...
1e978dff58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e978dff58 | ||
|
|
3c256ba0b2 | ||
|
|
70d59a091a | ||
|
|
9569972cd6 | ||
|
|
5bd390b4ee | ||
|
|
5f8819bb46 |
24
CLAUDE.md
24
CLAUDE.md
@@ -87,6 +87,30 @@ import { localHelper } from "./helper"; // relative
|
|||||||
- `*.types.ts` — Module-specific TypeScript types
|
- `*.types.ts` — Module-specific TypeScript types
|
||||||
- `*.test.ts` — Tests (co-located with source)
|
- `*.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
|
### Command Definition
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getCancelledEmbed
|
getCancelledEmbed
|
||||||
} from "@/modules/moderation/prune.view";
|
} from "@/modules/moderation/prune.view";
|
||||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
import { PRUNE_CUSTOM_IDS } from "@modules/moderation/prune.types";
|
||||||
|
|
||||||
export const prune = createCommand({
|
export const prune = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -83,7 +84,7 @@ export const prune = createCommand({
|
|||||||
time: 30000
|
time: 30000
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirmation.customId === "cancel_prune") {
|
if (confirmation.customId === PRUNE_CUSTOM_IDS.CANCEL) {
|
||||||
await confirmation.update({
|
await confirmation.update({
|
||||||
embeds: [getCancelledEmbed()],
|
embeds: [getCancelledEmbed()],
|
||||||
components: []
|
components: []
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getAvailableQuestsComponents,
|
getAvailableQuestsComponents,
|
||||||
getQuestActionRows
|
getQuestActionRows
|
||||||
} from "@/modules/quest/quest.view";
|
} from "@/modules/quest/quest.view";
|
||||||
|
import { QUEST_CUSTOM_IDS } from "@modules/quest/quest.types";
|
||||||
|
|
||||||
export const quests = createCommand({
|
export const quests = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -56,19 +57,19 @@ export const quests = createCommand({
|
|||||||
if (i.user.id !== interaction.user.id) return;
|
if (i.user.id !== interaction.user.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (i.customId === "quest_view_active") {
|
if (i.customId === QUEST_CUSTOM_IDS.VIEW_ACTIVE) {
|
||||||
await i.deferUpdate();
|
await i.deferUpdate();
|
||||||
await updateView('active', 0);
|
await updateView('active', 0);
|
||||||
} else if (i.customId === "quest_view_available") {
|
} else if (i.customId === QUEST_CUSTOM_IDS.VIEW_AVAILABLE) {
|
||||||
await i.deferUpdate();
|
await i.deferUpdate();
|
||||||
await updateView('available', 0);
|
await updateView('available', 0);
|
||||||
} else if (i.customId === "quest_page_prev") {
|
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_PREV) {
|
||||||
await i.deferUpdate();
|
await i.deferUpdate();
|
||||||
await updateView(currentView, Math.max(0, currentPage - 1));
|
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 i.deferUpdate();
|
||||||
await updateView(currentView, currentPage + 1);
|
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];
|
const questIdStr = i.customId.split(":")[1];
|
||||||
if (!questIdStr) return;
|
if (!questIdStr) return;
|
||||||
const questId = parseInt(questIdStr);
|
const questId = parseInt(questIdStr);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
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
|
// Union type for all component interactions
|
||||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
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
|
// Type for the dynamically imported module containing the handler
|
||||||
interface InteractionModule {
|
interface InteractionModule {
|
||||||
[key: string]: (...args: any[]) => Promise<void> | any;
|
[key: string]: (...args: any[]) => Promise<void> | any;
|
||||||
@@ -21,45 +24,45 @@ interface InteractionRoute {
|
|||||||
export const interactionRoutes: InteractionRoute[] = [
|
export const interactionRoutes: InteractionRoute[] = [
|
||||||
// --- TRADE MODULE ---
|
// --- 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"),
|
handler: () => import("@/modules/trade/trade.interaction"),
|
||||||
method: 'handleTradeInteraction'
|
method: 'handleTradeInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- ECONOMY MODULE ---
|
// --- 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"),
|
handler: () => import("@/modules/economy/shop.interaction"),
|
||||||
method: 'handleShopInteraction'
|
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"),
|
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||||
method: 'handleLootdropInteraction'
|
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"),
|
handler: () => import("@/modules/trivia/trivia.interaction"),
|
||||||
method: 'handleTriviaInteraction'
|
method: 'handleTriviaInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- ADMIN MODULE ---
|
// --- ADMIN MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("createitem_"),
|
predicate: (i) => i.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX),
|
||||||
handler: () => import("@/modules/admin/item_wizard"),
|
handler: () => import("@/modules/admin/item_wizard"),
|
||||||
method: 'handleItemWizardInteraction'
|
method: 'handleItemWizardInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- USER MODULE ---
|
// --- 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"),
|
handler: () => import("@/modules/user/enrollment.interaction"),
|
||||||
method: 'handleEnrollmentInteraction'
|
method: 'handleEnrollmentInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- FEEDBACK MODULE ---
|
// --- FEEDBACK MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("feedback_"),
|
predicate: (i) => i.customId.startsWith(FEEDBACK_CUSTOM_IDS.PREFIX),
|
||||||
handler: () => import("@/modules/feedback/feedback.interaction"),
|
handler: () => import("@/modules/feedback/feedback.interaction"),
|
||||||
method: 'handleFeedbackInteraction'
|
method: 'handleFeedbackInteraction'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { items } from "@db/schema";
|
|||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
|
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
|
||||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
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";
|
import { ItemType, EffectType } from "@shared/lib/constants";
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
@@ -41,13 +41,13 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
|||||||
export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||||
// Only handle createitem interactions
|
// Only handle createitem interactions
|
||||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
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;
|
const userId = interaction.user.id;
|
||||||
let draft = draftSession.get(userId);
|
let draft = draftSession.get(userId);
|
||||||
|
|
||||||
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
|
// 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);
|
draftSession.delete(userId);
|
||||||
if (interaction.isMessageComponent()) {
|
if (interaction.isMessageComponent()) {
|
||||||
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
|
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
|
||||||
@@ -59,7 +59,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
if (!draft) {
|
if (!draft) {
|
||||||
if (interaction.isMessageComponent()) {
|
if (interaction.isMessageComponent()) {
|
||||||
// Create one implicitly to prevent crashes, or warn user
|
// Create one implicitly to prevent crashes, or warn user
|
||||||
if (interaction.customId === "createitem_start") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.START) {
|
||||||
// Allow start
|
// Allow start
|
||||||
} else {
|
} else {
|
||||||
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
|
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
|
||||||
@@ -81,7 +81,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
// --- Routing ---
|
// --- Routing ---
|
||||||
|
|
||||||
// 1. Details Modal
|
// 1. Details Modal
|
||||||
if (interaction.customId === "createitem_details") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.DETAILS) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
const modal = getDetailsModal(draft);
|
const modal = getDetailsModal(draft);
|
||||||
await interaction.showModal(modal);
|
await interaction.showModal(modal);
|
||||||
@@ -89,7 +89,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Economy Modal
|
// 2. Economy Modal
|
||||||
if (interaction.customId === "createitem_economy") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ECONOMY) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
const modal = getEconomyModal(draft);
|
const modal = getEconomyModal(draft);
|
||||||
await interaction.showModal(modal);
|
await interaction.showModal(modal);
|
||||||
@@ -97,7 +97,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Visuals Modal
|
// 3. Visuals Modal
|
||||||
if (interaction.customId === "createitem_visuals") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.VISUALS) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
const modal = getVisualsModal(draft);
|
const modal = getVisualsModal(draft);
|
||||||
await interaction.showModal(modal);
|
await interaction.showModal(modal);
|
||||||
@@ -105,14 +105,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Type Toggle (Start Select Menu)
|
// 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;
|
if (!interaction.isButton()) return;
|
||||||
const { components } = getItemTypeSelection();
|
const { components } = getItemTypeSelection();
|
||||||
await interaction.update({ components }); // Temporary view
|
await interaction.update({ components }); // Temporary view
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === "createitem_select_type") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE) {
|
||||||
if (!interaction.isStringSelectMenu()) return;
|
if (!interaction.isStringSelectMenu()) return;
|
||||||
const selected = interaction.values[0];
|
const selected = interaction.values[0];
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -125,14 +125,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Add Effect Flow
|
// 5. Add Effect Flow
|
||||||
if (interaction.customId === "createitem_addeffect_start") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
const { components } = getEffectTypeSelection();
|
const { components } = getEffectTypeSelection();
|
||||||
await interaction.update({ components });
|
await interaction.update({ components });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === "createitem_select_effect_type") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE) {
|
||||||
if (!interaction.isStringSelectMenu()) return;
|
if (!interaction.isStringSelectMenu()) return;
|
||||||
const effectType = interaction.values[0];
|
const effectType = interaction.values[0];
|
||||||
if (!effectType) return;
|
if (!effectType) return;
|
||||||
@@ -149,7 +149,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Toggle Consume
|
// Toggle Consume
|
||||||
if (interaction.customId === "createitem_toggle_consume") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
draft.usageData.consume = !draft.usageData.consume;
|
draft.usageData.consume = !draft.usageData.consume;
|
||||||
const payload = renderWizard(userId);
|
const payload = renderWizard(userId);
|
||||||
@@ -159,43 +159,43 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
|
|
||||||
// 6. Handle Modal Submits
|
// 6. Handle Modal Submits
|
||||||
if (interaction.isModalSubmit()) {
|
if (interaction.isModalSubmit()) {
|
||||||
if (interaction.customId === "createitem_modal_details") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS) {
|
||||||
draft.name = interaction.fields.getTextInputValue("name");
|
draft.name = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME);
|
||||||
draft.description = interaction.fields.getTextInputValue("desc");
|
draft.description = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC);
|
||||||
draft.rarity = interaction.fields.getTextInputValue("rarity");
|
draft.rarity = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY);
|
||||||
}
|
}
|
||||||
else if (interaction.customId === "createitem_modal_economy") {
|
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY) {
|
||||||
const price = parseInt(interaction.fields.getTextInputValue("price"));
|
const price = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE));
|
||||||
draft.price = isNaN(price) || price === 0 ? null : price;
|
draft.price = isNaN(price) || price === 0 ? null : price;
|
||||||
}
|
}
|
||||||
else if (interaction.customId === "createitem_modal_visuals") {
|
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS) {
|
||||||
draft.iconUrl = interaction.fields.getTextInputValue("icon");
|
draft.iconUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON);
|
||||||
draft.imageUrl = interaction.fields.getTextInputValue("image");
|
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;
|
const type = draft.pendingEffectType;
|
||||||
if (type) {
|
if (type) {
|
||||||
let effect: ItemEffect | null = null;
|
let effect: ItemEffect | null = null;
|
||||||
|
|
||||||
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
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 };
|
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||||
}
|
}
|
||||||
else if (type === EffectType.REPLY_MESSAGE) {
|
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) {
|
else if (type === EffectType.XP_BOOST) {
|
||||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
const multiplier = parseFloat(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER));
|
||||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
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 };
|
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
||||||
}
|
}
|
||||||
else if (type === EffectType.TEMP_ROLE) {
|
else if (type === EffectType.TEMP_ROLE) {
|
||||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
|
||||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
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 };
|
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
||||||
}
|
}
|
||||||
else if (type === EffectType.COLOR_ROLE) {
|
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 };
|
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Save
|
// 7. Save
|
||||||
if (interaction.customId === "createitem_save") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SAVE) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
|
|
||||||
await interaction.deferUpdate(); // Prepare to save
|
await interaction.deferUpdate(); // Prepare to save
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
import type { ItemUsageData } from "@shared/lib/types";
|
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 {
|
export interface DraftItem {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type MessageActionRowComponentBuilder
|
type MessageActionRowComponentBuilder
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
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";
|
import { ItemType } from "@shared/lib/constants";
|
||||||
|
|
||||||
const getItemTypeOptions = () => [
|
const getItemTypeOptions = () => [
|
||||||
@@ -51,18 +51,18 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
|
|||||||
// Components
|
// Components
|
||||||
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||||
.addComponents(
|
.addComponents(
|
||||||
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.DETAILS).setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||||
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ECONOMY).setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||||
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.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.TYPE_TOGGLE).setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||||
.addComponents(
|
.addComponents(
|
||||||
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_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(ITEM_WIZARD_CUSTOM_IDS.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(ITEM_WIZARD_CUSTOM_IDS.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.CANCEL).setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||||
);
|
);
|
||||||
|
|
||||||
return { embeds: [embed], components: [row1, row2] };
|
return { embeds: [embed], components: [row1, row2] };
|
||||||
@@ -70,65 +70,65 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
|
|||||||
|
|
||||||
export const getItemTypeSelection = () => {
|
export const getItemTypeSelection = () => {
|
||||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
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] };
|
return { components: [row] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEffectTypeSelection = () => {
|
export const getEffectTypeSelection = () => {
|
||||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
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] };
|
return { components: [row] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDetailsModal = (current: DraftItem) => {
|
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(
|
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(ITEM_WIZARD_CUSTOM_IDS.FIELD_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(ITEM_WIZARD_CUSTOM_IDS.FIELD_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_RARITY).setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
|
||||||
);
|
);
|
||||||
return modal;
|
return modal;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEconomyModal = (current: DraftItem) => {
|
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(
|
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;
|
return modal;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getVisualsModal = (current: DraftItem) => {
|
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(
|
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(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("image").setLabel("Image URL").setValue(current.imageUrl).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;
|
return modal;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEffectConfigModal = (effectType: string) => {
|
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") {
|
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") {
|
} 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") {
|
} else if (effectType === "XP_BOOST") {
|
||||||
modal.addComponents(
|
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(ITEM_WIZARD_CUSTOM_IDS.FIELD_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_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||||
);
|
);
|
||||||
} else if (effectType === "TEMP_ROLE") {
|
} else if (effectType === "TEMP_ROLE") {
|
||||||
modal.addComponents(
|
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)),
|
||||||
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_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||||
);
|
);
|
||||||
} else if (effectType === "COLOR_ROLE") {
|
} else if (effectType === "COLOR_ROLE") {
|
||||||
modal.addComponents(
|
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;
|
return modal;
|
||||||
|
|||||||
10
bot/modules/economy/economy.types.ts
Normal file
10
bot/modules/economy/economy.types.ts
Normal 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;
|
||||||
@@ -3,9 +3,10 @@ import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
|||||||
import { UserError } from "@shared/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||||
import { terminalService } from "@modules/system/terminal.service";
|
import { terminalService } from "@modules/system/terminal.service";
|
||||||
|
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
|
||||||
|
|
||||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||||
if (interaction.customId === "lootdrop_claim") {
|
if (interaction.customId === LOOTDROP_CUSTOM_IDS.CLAIM) {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||||
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
|
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
|
||||||
|
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
|
||||||
|
|
||||||
export async function getLootdropMessage(reward: number, currency: string) {
|
export async function getLootdropMessage(reward: number, currency: string) {
|
||||||
const cardBuffer = await generateLootdropCard(reward, currency);
|
const cardBuffer = await generateLootdropCard(reward, currency);
|
||||||
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
|
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
|
||||||
|
|
||||||
const claimButton = new ButtonBuilder()
|
const claimButton = new ButtonBuilder()
|
||||||
.setCustomId("lootdrop_claim")
|
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM)
|
||||||
.setLabel("CLAIM REWARD")
|
.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
|
.setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
|
||||||
.setEmoji("🌠");
|
.setEmoji("🌠");
|
||||||
@@ -28,7 +29,7 @@ export async function getLootdropClaimedMessage(userId: string, username: string
|
|||||||
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
||||||
.addComponents(
|
.addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId("lootdrop_claim_disabled")
|
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM_DISABLED)
|
||||||
.setLabel("CLAIMED")
|
.setLabel("CLAIMED")
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setEmoji("✅")
|
.setEmoji("✅")
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { ButtonInteraction, MessageFlags } from "discord.js";
|
|||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import { SHOP_CUSTOM_IDS } from "./economy.types";
|
||||||
|
|
||||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
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 });
|
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)) {
|
if (isNaN(itemId)) {
|
||||||
throw new UserError("Invalid Item ID.");
|
throw new UserError("Invalid Item ID.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { existsSync } from "fs";
|
|||||||
import { LootType, EffectType } from "@shared/lib/constants";
|
import { LootType, EffectType } from "@shared/lib/constants";
|
||||||
import type { LootTableItem } from "@shared/lib/types";
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
|
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
|
||||||
|
import { SHOP_CUSTOM_IDS } from "./economy.types";
|
||||||
|
|
||||||
export function getShopListingMessage(
|
export function getShopListingMessage(
|
||||||
item: {
|
item: {
|
||||||
@@ -100,7 +101,7 @@ export function getShopListingMessage(
|
|||||||
|
|
||||||
// Create buy button (used in either main or loot container)
|
// Create buy button (used in either main or loot container)
|
||||||
const buyButton = new ButtonBuilder()
|
const buyButton = new ButtonBuilder()
|
||||||
.setCustomId(`shop_buy_${item.id}`)
|
.setCustomId(SHOP_CUSTOM_IDS.BUY(item.id))
|
||||||
.setLabel(`Purchase for ${item.price} 🪙`)
|
.setLabel(`Purchase for ${item.price} 🪙`)
|
||||||
.setStyle(ButtonStyle.Success)
|
.setStyle(ButtonStyle.Success)
|
||||||
.setEmoji("🛒");
|
.setEmoji("🛒");
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { UserError } from "@shared/lib/errors";
|
|||||||
|
|
||||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||||
// Handle select menu for choosing feedback type
|
// 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;
|
const feedbackType = interaction.values[0] as FeedbackType;
|
||||||
|
|
||||||
if (!feedbackType) {
|
if (!feedbackType) {
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ export const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const FEEDBACK_CUSTOM_IDS = {
|
export const FEEDBACK_CUSTOM_IDS = {
|
||||||
|
PREFIX: "feedback_",
|
||||||
|
SELECT_TYPE: "feedback_select_type",
|
||||||
MODAL: "feedback_modal",
|
MODAL: "feedback_modal",
|
||||||
TYPE_FIELD: "feedback_type",
|
TYPE_FIELD: "feedback_type",
|
||||||
TITLE_FIELD: "feedback_title",
|
TITLE_FIELD: "feedback_title",
|
||||||
DESCRIPTION_FIELD: "feedback_description"
|
DESCRIPTION_FIELD: "feedback_description",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { FEEDBACK_TYPE_LABELS, FEEDBACK_CUSTOM_IDS, type FeedbackData, type Feed
|
|||||||
|
|
||||||
export function getFeedbackTypeMenu() {
|
export function getFeedbackTypeMenu() {
|
||||||
const select = new StringSelectMenuBuilder()
|
const select = new StringSelectMenuBuilder()
|
||||||
.setCustomId("feedback_select_type")
|
.setCustomId(FEEDBACK_CUSTOM_IDS.SELECT_TYPE)
|
||||||
.setPlaceholder("Choose feedback type")
|
.setPlaceholder("Choose feedback type")
|
||||||
.addOptions([
|
.addOptions([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
|||||||
import { getLootboxResultMessage } from "./inventory.view";
|
import { getLootboxResultMessage } from "./inventory.view";
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
import { getGuildConfig } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
import { INVENTORY_CUSTOM_IDS } from "./inventory.types";
|
||||||
|
|
||||||
export interface InventoryState {
|
export interface InventoryState {
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
@@ -25,7 +26,7 @@ export function parseInventoryCustomId(customId: string): { action: string; view
|
|||||||
* Checks if a custom ID belongs to the inventory system.
|
* Checks if a custom ID belongs to the inventory system.
|
||||||
*/
|
*/
|
||||||
export function isInventoryInteraction(customId: string): boolean {
|
export function isInventoryInteraction(customId: string): boolean {
|
||||||
return customId.startsWith("inv_");
|
return customId.startsWith(INVENTORY_CUSTOM_IDS.PREFIX);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
13
bot/modules/inventory/inventory.types.ts
Normal file
13
bot/modules/inventory/inventory.types.ts
Normal 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;
|
||||||
@@ -22,6 +22,7 @@ import { ItemType } from "@shared/lib/constants";
|
|||||||
import type { ItemUsageData } from "@shared/lib/types";
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
|
import { INVENTORY_CUSTOM_IDS } from "./inventory.types";
|
||||||
|
|
||||||
export const ITEMS_PER_PAGE = 5;
|
export const ITEMS_PER_PAGE = 5;
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ export function getInventoryListMessage(
|
|||||||
|
|
||||||
// Select menu with current page items
|
// Select menu with current page items
|
||||||
const selectMenu = new StringSelectMenuBuilder()
|
const selectMenu = new StringSelectMenuBuilder()
|
||||||
.setCustomId(`inv_select_${viewerId}`)
|
.setCustomId(INVENTORY_CUSTOM_IDS.SELECT(viewerId))
|
||||||
.setPlaceholder("Select an item for details");
|
.setPlaceholder("Select an item for details");
|
||||||
|
|
||||||
for (const entry of pageItems) {
|
for (const entry of pageItems) {
|
||||||
@@ -121,17 +122,17 @@ export function getInventoryListMessage(
|
|||||||
// Pagination buttons
|
// Pagination buttons
|
||||||
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(`inv_prev_${viewerId}`)
|
.setCustomId(INVENTORY_CUSTOM_IDS.PREV(viewerId))
|
||||||
.setLabel("◀ Previous")
|
.setLabel("◀ Previous")
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setDisabled(safePage <= 0),
|
.setDisabled(safePage <= 0),
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(`inv_page_${viewerId}`)
|
.setCustomId(INVENTORY_CUSTOM_IDS.PAGE(viewerId))
|
||||||
.setLabel(`Page ${safePage + 1}/${totalPages}`)
|
.setLabel(`Page ${safePage + 1}/${totalPages}`)
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setDisabled(true),
|
.setDisabled(true),
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(`inv_next_${viewerId}`)
|
.setCustomId(INVENTORY_CUSTOM_IDS.NEXT(viewerId))
|
||||||
.setLabel("Next ▶")
|
.setLabel("Next ▶")
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setDisabled(safePage >= totalPages - 1),
|
.setDisabled(safePage >= totalPages - 1),
|
||||||
@@ -225,7 +226,7 @@ export function getItemDetailMessage(
|
|||||||
|
|
||||||
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(`inv_back_${viewerId}`)
|
.setCustomId(INVENTORY_CUSTOM_IDS.BACK(viewerId))
|
||||||
.setLabel("◀ Back")
|
.setLabel("◀ Back")
|
||||||
.setStyle(ButtonStyle.Primary)
|
.setStyle(ButtonStyle.Primary)
|
||||||
);
|
);
|
||||||
@@ -233,7 +234,7 @@ export function getItemDetailMessage(
|
|||||||
if (isUsable) {
|
if (isUsable) {
|
||||||
actionRow.addComponents(
|
actionRow.addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(`inv_use_${viewerId}`)
|
.setCustomId(INVENTORY_CUSTOM_IDS.USE(viewerId))
|
||||||
.setLabel("🧪 Use")
|
.setLabel("🧪 Use")
|
||||||
.setStyle(ButtonStyle.Success)
|
.setStyle(ButtonStyle.Success)
|
||||||
);
|
);
|
||||||
@@ -242,7 +243,7 @@ export function getItemDetailMessage(
|
|||||||
if (isOwner) {
|
if (isOwner) {
|
||||||
actionRow.addComponents(
|
actionRow.addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(`inv_discard_${viewerId}`)
|
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD(viewerId))
|
||||||
.setLabel("🗑 Discard")
|
.setLabel("🗑 Discard")
|
||||||
.setStyle(ButtonStyle.Danger)
|
.setStyle(ButtonStyle.Danger)
|
||||||
);
|
);
|
||||||
@@ -271,11 +272,11 @@ export function getDiscardConfirmMessage(entry: InventoryEntry, viewerId: string
|
|||||||
.addActionRowComponents(
|
.addActionRowComponents(
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(`inv_discard_confirm_${viewerId}`)
|
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD_CONFIRM(viewerId))
|
||||||
.setLabel("Confirm")
|
.setLabel("Confirm")
|
||||||
.setStyle(ButtonStyle.Danger),
|
.setStyle(ButtonStyle.Danger),
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(`inv_discard_cancel_${viewerId}`)
|
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD_CANCEL(viewerId))
|
||||||
.setLabel("Cancel")
|
.setLabel("Cancel")
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
)
|
)
|
||||||
@@ -296,7 +297,7 @@ export function getDiscardConfirmMessage(entry: InventoryEntry, viewerId: string
|
|||||||
export function appendUseBackButton(message: any, viewerId: string): any {
|
export function appendUseBackButton(message: any, viewerId: string): any {
|
||||||
const backRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const backRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(`inv_use_back_${viewerId}`)
|
.setCustomId(INVENTORY_CUSTOM_IDS.USE_BACK(viewerId))
|
||||||
.setLabel("◀ Back to Inventory")
|
.setLabel("◀ Back to Inventory")
|
||||||
.setStyle(ButtonStyle.Primary)
|
.setStyle(ButtonStyle.Primary)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
export const PRUNE_CUSTOM_IDS = {
|
||||||
|
CONFIRM: "confirm_prune",
|
||||||
|
CANCEL: "cancel_prune",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface PruneOptions {
|
export interface PruneOptions {
|
||||||
amount?: number;
|
amount?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from "discord.js";
|
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
|
* Creates a confirmation message for prune operations
|
||||||
@@ -25,12 +25,12 @@ export function getConfirmationMessage(
|
|||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
const confirmButton = new ButtonBuilder()
|
const confirmButton = new ButtonBuilder()
|
||||||
.setCustomId("confirm_prune")
|
.setCustomId(PRUNE_CUSTOM_IDS.CONFIRM)
|
||||||
.setLabel("Confirm")
|
.setLabel("Confirm")
|
||||||
.setStyle(ButtonStyle.Danger);
|
.setStyle(ButtonStyle.Danger);
|
||||||
|
|
||||||
const cancelButton = new ButtonBuilder()
|
const cancelButton = new ButtonBuilder()
|
||||||
.setCustomId("cancel_prune")
|
.setCustomId(PRUNE_CUSTOM_IDS.CANCEL)
|
||||||
.setLabel("Cancel")
|
.setLabel("Cancel")
|
||||||
.setStyle(ButtonStyle.Secondary);
|
.setStyle(ButtonStyle.Secondary);
|
||||||
|
|
||||||
|
|||||||
8
bot/modules/quest/quest.types.ts
Normal file
8
bot/modules/quest/quest.types.ts
Normal 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;
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SeparatorSpacingSize,
|
SeparatorSpacingSize,
|
||||||
MessageFlags
|
MessageFlags
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
|
import { QUEST_CUSTOM_IDS } from "./quest.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quest entry with quest details and progress
|
* Quest entry with quest details and progress
|
||||||
@@ -169,7 +170,7 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[],
|
|||||||
container.addActionRowComponents(
|
container.addActionRowComponents(
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(`quest_accept:${quest.id}`)
|
.setCustomId(QUEST_CUSTOM_IDS.ACCEPT(quest.id))
|
||||||
.setLabel("Accept Quest")
|
.setLabel("Accept Quest")
|
||||||
.setStyle(ButtonStyle.Success)
|
.setStyle(ButtonStyle.Success)
|
||||||
.setEmoji("✅")
|
.setEmoji("✅")
|
||||||
@@ -191,12 +192,12 @@ export function getQuestActionRows(viewType: 'active' | 'available', totalItems:
|
|||||||
if (totalPages > 1) {
|
if (totalPages > 1) {
|
||||||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId("quest_page_prev")
|
.setCustomId(QUEST_CUSTOM_IDS.PAGE_PREV)
|
||||||
.setLabel("◀ Prev")
|
.setLabel("◀ Prev")
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setDisabled(page <= 0),
|
.setDisabled(page <= 0),
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId("quest_page_next")
|
.setCustomId(QUEST_CUSTOM_IDS.PAGE_NEXT)
|
||||||
.setLabel("Next ▶")
|
.setLabel("Next ▶")
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setDisabled(page >= totalPages - 1)
|
.setDisabled(page >= totalPages - 1)
|
||||||
@@ -206,12 +207,12 @@ export function getQuestActionRows(viewType: 'active' | 'available', totalItems:
|
|||||||
// Tab navigation row
|
// Tab navigation row
|
||||||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId("quest_view_active")
|
.setCustomId(QUEST_CUSTOM_IDS.VIEW_ACTIVE)
|
||||||
.setLabel("📜 Active")
|
.setLabel("📜 Active")
|
||||||
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
.setDisabled(viewType === 'active'),
|
.setDisabled(viewType === 'active'),
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId("quest_view_available")
|
.setCustomId(QUEST_CUSTOM_IDS.VIEW_AVAILABLE)
|
||||||
.setLabel("🗺️ Available")
|
.setLabel("🗺️ Available")
|
||||||
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
.setDisabled(viewType === 'available')
|
.setDisabled(viewType === 'available')
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
|||||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
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 (!threadId) return;
|
||||||
|
|
||||||
if (customId === 'trade_cancel') {
|
if (customId === TRADE_CUSTOM_IDS.CANCEL) {
|
||||||
await handleCancel(interaction, threadId);
|
await handleCancel(interaction, threadId);
|
||||||
} else if (customId === 'trade_lock') {
|
} else if (customId === TRADE_CUSTOM_IDS.LOCK) {
|
||||||
await handleLock(interaction, threadId);
|
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.
|
// 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
|
// 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.
|
// 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);
|
await handleAddMoneyClick(interaction);
|
||||||
} else if (customId === 'trade_money_modal') {
|
} else if (customId === TRADE_CUSTOM_IDS.MONEY_MODAL) {
|
||||||
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
|
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);
|
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);
|
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);
|
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);
|
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +83,7 @@ async function handleAddMoneyClick(interaction: Interaction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId: string) {
|
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);
|
const amount = BigInt(amountStr);
|
||||||
|
|
||||||
if (amount < 0n) throw new UserError("Amount must be positive");
|
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} `
|
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 });
|
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(),
|
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 });
|
await interaction.reply({ content: "Select an item to remove:", components, ephemeral: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
export interface TradeItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
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
|
const EMBED_COLOR = 0xFFD700; // Gold
|
||||||
|
|
||||||
@@ -34,11 +34,11 @@ export function getTradeDashboard(session: TradeSession) {
|
|||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||||
.addComponents(
|
.addComponents(
|
||||||
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
|
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.ADD_ITEM).setLabel('Add Item').setStyle(ButtonStyle.Secondary),
|
||||||
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
|
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.ADD_MONEY).setLabel('Add Money').setStyle(ButtonStyle.Success),
|
||||||
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
|
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.REMOVE_ITEM).setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
|
||||||
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
|
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.LOCK).setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
|
||||||
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
|
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.CANCEL).setLabel('Cancel').setStyle(ButtonStyle.Danger),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { embeds: [embed], components: [row] };
|
return { embeds: [embed], components: [row] };
|
||||||
@@ -57,11 +57,11 @@ export function getTradeCompletedEmbed(session: TradeSession) {
|
|||||||
|
|
||||||
export function getTradeMoneyModal() {
|
export function getTradeMoneyModal() {
|
||||||
const modal = new ModalBuilder()
|
const modal = new ModalBuilder()
|
||||||
.setCustomId('trade_money_modal')
|
.setCustomId(TRADE_CUSTOM_IDS.MONEY_MODAL)
|
||||||
.setTitle('Add Money');
|
.setTitle('Add Money');
|
||||||
|
|
||||||
const input = new TextInputBuilder()
|
const input = new TextInputBuilder()
|
||||||
.setCustomId('amount')
|
.setCustomId(TRADE_CUSTOM_IDS.MONEY_AMOUNT_FIELD)
|
||||||
.setLabel("Amount to trade")
|
.setLabel("Amount to trade")
|
||||||
.setStyle(TextInputStyle.Short)
|
.setStyle(TextInputStyle.Short)
|
||||||
.setPlaceholder("100")
|
.setPlaceholder("100")
|
||||||
|
|||||||
7
bot/modules/trivia/trivia.types.ts
Normal file
7
bot/modules/trivia/trivia.types.ts
Normal 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;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MessageFlags } from "discord.js";
|
import { MessageFlags } from "discord.js";
|
||||||
import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service";
|
import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service";
|
||||||
|
import { TRIVIA_CUSTOM_IDS } from "./trivia.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get color based on difficulty level
|
* Get color based on difficulty level
|
||||||
@@ -97,14 +98,14 @@ export function getTriviaQuestionView(session: TriviaSession, username: string):
|
|||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
type: 2, // Button
|
type: 2, // Button
|
||||||
custom_id: `trivia_answer_${sessionId}_${trueIndex}`,
|
custom_id: TRIVIA_CUSTOM_IDS.ANSWER(sessionId, trueIndex),
|
||||||
label: 'True',
|
label: 'True',
|
||||||
style: 3, // Success
|
style: 3, // Success
|
||||||
emoji: { name: '✅' }
|
emoji: { name: '✅' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 2, // Button
|
type: 2, // Button
|
||||||
custom_id: `trivia_answer_${sessionId}_${falseIndex}`,
|
custom_id: TRIVIA_CUSTOM_IDS.ANSWER(sessionId, falseIndex),
|
||||||
label: 'False',
|
label: 'False',
|
||||||
style: 4, // Danger
|
style: 4, // Danger
|
||||||
emoji: { name: '❌' }
|
emoji: { name: '❌' }
|
||||||
@@ -129,7 +130,7 @@ export function getTriviaQuestionView(session: TriviaSession, username: string):
|
|||||||
|
|
||||||
buttonRow.components.push({
|
buttonRow.components.push({
|
||||||
type: 2, // Button
|
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 ? '...' : ''}`,
|
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||||
style: 2, // Secondary
|
style: 2, // Secondary
|
||||||
emoji: { name: emoji }
|
emoji: { name: emoji }
|
||||||
@@ -145,7 +146,7 @@ export function getTriviaQuestionView(session: TriviaSession, username: string):
|
|||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
type: 2, // Button
|
type: 2, // Button
|
||||||
custom_id: `trivia_giveup_${sessionId}`,
|
custom_id: TRIVIA_CUSTOM_IDS.GIVE_UP(sessionId),
|
||||||
label: 'Give Up',
|
label: 'Give Up',
|
||||||
style: 4, // Danger
|
style: 4, // Danger
|
||||||
emoji: { name: '🏳️' }
|
emoji: { name: '🏳️' }
|
||||||
@@ -245,7 +246,7 @@ export function getTriviaResultView(
|
|||||||
|
|
||||||
buttonRow.components.push({
|
buttonRow.components.push({
|
||||||
type: 2, // Button
|
type: 2, // Button
|
||||||
custom_id: `trivia_result_${i}`,
|
custom_id: TRIVIA_CUSTOM_IDS.RESULT(i),
|
||||||
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||||
style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary
|
style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary
|
||||||
emoji: { name: isCorrect ? '✅' : wasUserAnswer ? '❌' : emoji },
|
emoji: { name: isCorrect ? '✅' : wasUserAnswer ? '❌' : emoji },
|
||||||
@@ -318,7 +319,7 @@ export function getTriviaTimeoutView(
|
|||||||
|
|
||||||
buttonRow.components.push({
|
buttonRow.components.push({
|
||||||
type: 2, // Button
|
type: 2, // Button
|
||||||
custom_id: `trivia_timeout_${i}`,
|
custom_id: TRIVIA_CUSTOM_IDS.TIMEOUT(i),
|
||||||
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||||
style: isCorrect ? 3 : 2, // Success : Secondary
|
style: isCorrect ? 3 : 2, // Success : Secondary
|
||||||
emoji: { name: isCorrect ? '✅' : emoji },
|
emoji: { name: isCorrect ? '✅' : emoji },
|
||||||
|
|||||||
3
bot/modules/user/user.types.ts
Normal file
3
bot/modules/user/user.types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const ENROLLMENT_CUSTOM_IDS = {
|
||||||
|
ENROLL: "enrollment",
|
||||||
|
} as const;
|
||||||
53
docs/main.md
53
docs/main.md
@@ -14,14 +14,17 @@ aurora-bot-discord/
|
|||||||
│ ├── commands/ # Slash command implementations
|
│ ├── commands/ # Slash command implementations
|
||||||
│ ├── events/ # Discord event handlers
|
│ ├── events/ # Discord event handlers
|
||||||
│ ├── lib/ # Bot core logic (BotClient, utilities)
|
│ ├── lib/ # Bot core logic (BotClient, utilities)
|
||||||
|
│ ├── modules/ # Feature modules (views, interactions per domain)
|
||||||
|
│ ├── graphics/ # Canvas-based image generation
|
||||||
│ └── index.ts # Bot entry point
|
│ └── index.ts # Bot entry point
|
||||||
├── web/ # REST API server
|
├── api/ # REST API server
|
||||||
│ └── src/routes/ # API route handlers
|
│ └── 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
|
│ ├── db/ # Database schema and Drizzle ORM
|
||||||
│ ├── lib/ # Utilities, config, logger, events
|
│ ├── lib/ # Utilities, config, errors, logger, events
|
||||||
│ ├── modules/ # Domain services (economy, admin, quest)
|
│ └── modules/ # Domain services (economy, admin, inventory, quest, etc.)
|
||||||
│ └── config/ # Configuration files
|
├── panel/ # React admin dashboard (Vite + Tailwind)
|
||||||
|
├── scripts/ # Helper scripts
|
||||||
├── docker-compose.yml # Docker services (app, db)
|
├── docker-compose.yml # Docker services (app, db)
|
||||||
└── package.json # Root package manifest
|
└── 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:
|
- **Commands** (`bot/commands/`): Slash command implementations organized by category:
|
||||||
- `admin/`: Server management commands (config, prune, warnings, notes)
|
- `admin/`: Server management commands (config, prune, warnings, notes)
|
||||||
- `economy/`: Economy commands (balance, daily, pay, trade, trivia)
|
- `economy/`: Economy commands (balance, daily, pay, trade, trivia)
|
||||||
|
- `feedback/`: Feedback commands
|
||||||
- `inventory/`: Item management commands
|
- `inventory/`: Item management commands
|
||||||
- `leveling/`: XP and level tracking
|
- `leveling/`: XP and level tracking
|
||||||
- `quest/`: Quest commands
|
- `quest/`: Quest commands
|
||||||
- `user/`: User profile 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:
|
- **Events** (`bot/events/`): Discord event handlers:
|
||||||
- `interactionCreate.ts`: Command interactions
|
- `interactionCreate.ts`: Command interactions
|
||||||
- `messageCreate.ts`: Message processing
|
- `messageCreate.ts`: Message processing
|
||||||
- `ready.ts`: Bot ready events
|
- `ready.ts`: Bot ready events
|
||||||
- `guildMemberAdd.ts`: New member handling
|
- `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.
|
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
|
- **Stats** (`/api/stats`): Real-time bot metrics and statistics
|
||||||
- **Settings** (`/api/settings`): Configuration management endpoints
|
- **Settings** (`/api/settings`): Configuration management endpoints
|
||||||
|
- **Guild Settings** (`/api/guild-settings`): Per-guild configuration
|
||||||
- **Users** (`/api/users`): User data and profiles
|
- **Users** (`/api/users`): User data and profiles
|
||||||
- **Items** (`/api/items`): Item catalog and management
|
- **Items** (`/api/items`): Item catalog and management
|
||||||
- **Quests** (`/api/quests`): Quest data and progress
|
- **Quests** (`/api/quests`): Quest data and progress
|
||||||
- **Economy** (`/api/transactions`): Economy and transaction data
|
- **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:**
|
**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
|
- Real-time event streaming via WebSocket
|
||||||
- Zod validation for all requests
|
- 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/`):**
|
**Database Layer (`shared/db/`):**
|
||||||
|
|
||||||
@@ -86,19 +101,36 @@ Shared code accessible by both bot and web applications.
|
|||||||
|
|
||||||
**Modules (`shared/modules/`):**
|
**Modules (`shared/modules/`):**
|
||||||
|
|
||||||
- **economy/**: Economy service, lootdrops, daily rewards, trading
|
- **economy/**: Economy service, lootdrops, daily rewards
|
||||||
- **admin/**: Administrative actions (maintenance mode, cache clearing)
|
- **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
|
- **quest/**: Quest creation and tracking
|
||||||
- **dashboard/**: Dashboard statistics and real-time event bus
|
- **class/**: RPG class system
|
||||||
- **leveling/**: XP and leveling logic
|
- **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/`):**
|
**Utilities (`shared/lib/`):**
|
||||||
|
|
||||||
- `config.ts`: Application configuration management
|
- `config.ts`: Application configuration management
|
||||||
- `logger.ts`: Structured logging system
|
- `logger.ts`: Structured logging system
|
||||||
- `env.ts`: Environment variable handling
|
- `env.ts`: Environment variable handling
|
||||||
|
- `errors.ts`: Error classes (UserError, SystemError)
|
||||||
- `events.ts`: Event bus for inter-module communication
|
- `events.ts`: Event bus for inter-module communication
|
||||||
|
- `eventWiring.ts`: Event bus wiring
|
||||||
- `constants.ts`: Application-wide constants
|
- `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
|
## Main Use-Cases
|
||||||
|
|
||||||
@@ -145,6 +177,7 @@ Shared code accessible by both bot and web applications.
|
|||||||
| Web Framework | Bun HTTP Server (REST API) |
|
| Web Framework | Bun HTTP Server (REST API) |
|
||||||
| Database | PostgreSQL 17 |
|
| Database | PostgreSQL 17 |
|
||||||
| ORM | Drizzle ORM |
|
| ORM | Drizzle ORM |
|
||||||
|
| Admin Panel | React + Vite + Tailwind CSS |
|
||||||
| UI | Discord embeds and components |
|
| UI | Discord embeds and components |
|
||||||
| Validation | Zod |
|
| Validation | Zod |
|
||||||
| Containerization | Docker |
|
| Containerization | Docker |
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,908 +1,10 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import { AlertTriangle } from "lucide-react";
|
||||||
Loader2,
|
import { useUsers } from "../lib/useUsers";
|
||||||
AlertTriangle,
|
import { SearchFilterBar } from "./components/SearchFilterBar";
|
||||||
Save,
|
import { UserTable } from "./components/UserTable";
|
||||||
Check,
|
import { UserPagination } from "./components/UserPagination";
|
||||||
UserCircle2,
|
import { UserDetailPanel } from "./components/UserDetailPanel";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main Users Component
|
// Main Users Component
|
||||||
@@ -1025,7 +127,7 @@ export default function Users() {
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
<UserTable users={users} loading={loading} onSelectUser={selectUser} />
|
<UserTable users={users} loading={loading} onSelectUser={selectUser} />
|
||||||
<Pagination
|
<UserPagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={Math.ceil(total / limit)}
|
totalPages={Math.ceil(total / limit)}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
@@ -1037,7 +139,7 @@ export default function Users() {
|
|||||||
|
|
||||||
{/* Detail panel */}
|
{/* Detail panel */}
|
||||||
{selectedUser && userDraft && (
|
{selectedUser && userDraft && (
|
||||||
<DetailPanel
|
<UserDetailPanel
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
userDraft={userDraft}
|
userDraft={userDraft}
|
||||||
onClose={closeDetail}
|
onClose={closeDetail}
|
||||||
|
|||||||
174
panel/src/pages/components/EffectEditor.tsx
Normal file
174
panel/src/pages/components/EffectEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
panel/src/pages/components/ItemPreviewCard.tsx
Normal file
152
panel/src/pages/components/ItemPreviewCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
panel/src/pages/components/ItemSearchPicker.tsx
Normal file
152
panel/src/pages/components/ItemSearchPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
379
panel/src/pages/components/ItemStudioForm.tsx
Normal file
379
panel/src/pages/components/ItemStudioForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
panel/src/pages/components/ItemStudioShared.tsx
Normal file
38
panel/src/pages/components/ItemStudioShared.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
panel/src/pages/components/ItemStudioSubmit.ts
Normal file
185
panel/src/pages/components/ItemStudioSubmit.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
304
panel/src/pages/components/ItemStudioTypes.ts
Normal file
304
panel/src/pages/components/ItemStudioTypes.ts
Normal 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"
|
||||||
|
);
|
||||||
222
panel/src/pages/components/LootPoolEntryEditor.tsx
Normal file
222
panel/src/pages/components/LootPoolEntryEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
panel/src/pages/components/LootboxEditor.tsx
Normal file
111
panel/src/pages/components/LootboxEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
panel/src/pages/components/SearchFilterBar.tsx
Normal file
127
panel/src/pages/components/SearchFilterBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
486
panel/src/pages/components/SettingsFormFields.tsx
Normal file
486
panel/src/pages/components/SettingsFormFields.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
649
panel/src/pages/components/SettingsSections.tsx
Normal file
649
panel/src/pages/components/SettingsSections.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
309
panel/src/pages/components/UserDetailPanel.tsx
Normal file
309
panel/src/pages/components/UserDetailPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
panel/src/pages/components/UserFormFields.tsx
Normal file
179
panel/src/pages/components/UserFormFields.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
134
panel/src/pages/components/UserPagination.tsx
Normal file
134
panel/src/pages/components/UserPagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
panel/src/pages/components/UserTable.tsx
Normal file
171
panel/src/pages/components/UserTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ import { withTransaction } from "@/lib/db";
|
|||||||
import type { Transaction } from "@shared/lib/types";
|
import type { Transaction } from "@shared/lib/types";
|
||||||
|
|
||||||
export const classService = {
|
export const classService = {
|
||||||
|
/** Retrieve all available classes. */
|
||||||
getAllClasses: async () => {
|
getAllClasses: async () => {
|
||||||
return await DrizzleClient.query.classes.findMany();
|
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) => {
|
assignClass: async (userId: string, classId: bigint, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const cls = await txFn.query.classes.findFirst({
|
const cls = await txFn.query.classes.findFirst({
|
||||||
@@ -26,12 +28,14 @@ export const classService = {
|
|||||||
return user;
|
return user;
|
||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
/** Get the current balance for a class, returning 0 if not found. */
|
||||||
getClassBalance: async (classId: bigint) => {
|
getClassBalance: async (classId: bigint) => {
|
||||||
const cls = await DrizzleClient.query.classes.findFirst({
|
const cls = await DrizzleClient.query.classes.findFirst({
|
||||||
where: eq(classes.id, classId),
|
where: eq(classes.id, classId),
|
||||||
});
|
});
|
||||||
return cls?.balance || 0n;
|
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) => {
|
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const cls = await txFn.query.classes.findFirst({
|
const cls = await txFn.query.classes.findFirst({
|
||||||
@@ -55,6 +59,7 @@ export const classService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Update a class record with partial data. */
|
||||||
updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: Transaction) => {
|
updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const [updatedClass] = await txFn.update(classes)
|
const [updatedClass] = await txFn.update(classes)
|
||||||
@@ -65,6 +70,7 @@ export const classService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Create a new class record. */
|
||||||
createClass: async (data: typeof classes.$inferInsert, tx?: Transaction) => {
|
createClass: async (data: typeof classes.$inferInsert, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const [newClass] = await txFn.insert(classes)
|
const [newClass] = await txFn.insert(classes)
|
||||||
@@ -74,6 +80,7 @@ export const classService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Delete a class by ID. */
|
||||||
deleteClass: async (id: bigint, tx?: Transaction) => {
|
deleteClass: async (id: bigint, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
await txFn.delete(classes).where(eq(classes.id, id));
|
await txFn.delete(classes).where(eq(classes.id, id));
|
||||||
|
|||||||
11
shared/modules/economy/CLAUDE.md
Normal file
11
shared/modules/economy/CLAUDE.md
Normal 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.
|
||||||
@@ -8,6 +8,7 @@ import { UserError } from "@shared/lib/errors";
|
|||||||
import { TimerKey, TimerType, TransactionType } from "@shared/lib/constants";
|
import { TimerKey, TimerType, TransactionType } from "@shared/lib/constants";
|
||||||
|
|
||||||
export const economyService = {
|
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) => {
|
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
|
||||||
if (amount <= 0n) {
|
if (amount <= 0n) {
|
||||||
throw new UserError("Amount must be positive");
|
throw new UserError("Amount must be positive");
|
||||||
@@ -69,6 +70,7 @@ export const economyService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Claim the daily reward, applying streak bonuses and weekly bonuses; enforces a UTC-midnight cooldown. */
|
||||||
claimDaily: async (userId: string, tx?: Transaction) => {
|
claimDaily: async (userId: string, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -164,6 +166,7 @@ export const economyService = {
|
|||||||
}, tx);
|
}, 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) => {
|
modifyUserBalance: async (id: string, amount: bigint, type: string, description: string, relatedUserId?: string | null, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
if (amount < 0n) {
|
if (amount < 0n) {
|
||||||
|
|||||||
@@ -213,12 +213,20 @@ async function clearCaches() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const lootdropService = {
|
export const lootdropService = {
|
||||||
|
/** Delete expired lootdrops from the database; optionally includes already-claimed ones. */
|
||||||
cleanupExpiredLootdrops,
|
cleanupExpiredLootdrops,
|
||||||
|
/** Record a message in a channel and return whether a lootdrop should spawn based on activity and RNG. */
|
||||||
trackActivity,
|
trackActivity,
|
||||||
|
/** Calculate a random lootdrop reward amount and currency, with optional overrides. */
|
||||||
calculateReward,
|
calculateReward,
|
||||||
|
/** Save a spawned lootdrop to the database with a 10-minute expiration. */
|
||||||
persistLootdrop,
|
persistLootdrop,
|
||||||
|
/** Remove a lootdrop by message ID and return its channel ID for Discord cleanup. */
|
||||||
removeLootdrop,
|
removeLootdrop,
|
||||||
|
/** Atomically claim a lootdrop for a user; credits reward to their balance. */
|
||||||
tryClaim,
|
tryClaim,
|
||||||
|
/** Get current lootdrop system state including the most active channel and spawn config. */
|
||||||
getLootdropState,
|
getLootdropState,
|
||||||
|
/** Clear all in-memory activity tracking and cooldown caches. */
|
||||||
clearCaches,
|
clearCaches,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface FeatureFlagContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const featureFlagsService = {
|
export const featureFlagsService = {
|
||||||
|
/** Check whether a feature flag is enabled globally. */
|
||||||
isFlagEnabled: async (flagName: string): Promise<boolean> => {
|
isFlagEnabled: async (flagName: string): Promise<boolean> => {
|
||||||
const flag = await DrizzleClient.query.featureFlags.findFirst({
|
const flag = await DrizzleClient.query.featureFlags.findFirst({
|
||||||
where: eq(featureFlags.name, flagName),
|
where: eq(featureFlags.name, flagName),
|
||||||
@@ -17,6 +18,7 @@ export const featureFlagsService = {
|
|||||||
return flag?.enabled ?? false;
|
return flag?.enabled ?? false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Check if a guild/user/role has access to a feature flag; returns false if flag is disabled. */
|
||||||
hasAccess: async (
|
hasAccess: async (
|
||||||
flagName: string,
|
flagName: string,
|
||||||
context: FeatureFlagContext
|
context: FeatureFlagContext
|
||||||
@@ -51,6 +53,7 @@ export const featureFlagsService = {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Create a new feature flag, disabled by default. */
|
||||||
createFlag: async (name: string, description?: string) => {
|
createFlag: async (name: string, description?: string) => {
|
||||||
const [flag] = await DrizzleClient.insert(featureFlags).values({
|
const [flag] = await DrizzleClient.insert(featureFlags).values({
|
||||||
name,
|
name,
|
||||||
@@ -60,6 +63,7 @@ export const featureFlagsService = {
|
|||||||
return flag;
|
return flag;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Enable or disable a feature flag by name; throws if the flag does not exist. */
|
||||||
setFlagEnabled: async (name: string, enabled: boolean) => {
|
setFlagEnabled: async (name: string, enabled: boolean) => {
|
||||||
const [flag] = await DrizzleClient.update(featureFlags)
|
const [flag] = await DrizzleClient.update(featureFlags)
|
||||||
.set({ enabled, updatedAt: new Date() })
|
.set({ enabled, updatedAt: new Date() })
|
||||||
@@ -72,6 +76,7 @@ export const featureFlagsService = {
|
|||||||
return flag;
|
return flag;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Grant a guild, user, or role access to a feature flag. */
|
||||||
grantAccess: async (
|
grantAccess: async (
|
||||||
flagName: string,
|
flagName: string,
|
||||||
access: { guildId?: string; userId?: string; roleId?: string }
|
access: { guildId?: string; userId?: string; roleId?: string }
|
||||||
@@ -90,6 +95,7 @@ export const featureFlagsService = {
|
|||||||
return accessRecord;
|
return accessRecord;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Revoke a specific access record by ID; throws if not found. */
|
||||||
revokeAccess: async (accessId: number) => {
|
revokeAccess: async (accessId: number) => {
|
||||||
const [access] = await DrizzleClient.delete(featureFlagAccess)
|
const [access] = await DrizzleClient.delete(featureFlagAccess)
|
||||||
.where(eq(featureFlagAccess.id, accessId))
|
.where(eq(featureFlagAccess.id, accessId))
|
||||||
@@ -101,18 +107,21 @@ export const featureFlagsService = {
|
|||||||
return access;
|
return access;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Retrieve a single feature flag by name. */
|
||||||
getFlag: async (name: string) => {
|
getFlag: async (name: string) => {
|
||||||
return await DrizzleClient.query.featureFlags.findFirst({
|
return await DrizzleClient.query.featureFlags.findFirst({
|
||||||
where: eq(featureFlags.name, name),
|
where: eq(featureFlags.name, name),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** List all feature flags, ordered by name. */
|
||||||
listFlags: async () => {
|
listFlags: async () => {
|
||||||
return await DrizzleClient.query.featureFlags.findMany({
|
return await DrizzleClient.query.featureFlags.findMany({
|
||||||
orderBy: (flags, { asc }) => [asc(flags.name)],
|
orderBy: (flags, { asc }) => [asc(flags.name)],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** List all access records for a given feature flag. */
|
||||||
listAccess: async (flagName: string) => {
|
listAccess: async (flagName: string) => {
|
||||||
const flag = await DrizzleClient.query.featureFlags.findFirst({
|
const flag = await DrizzleClient.query.featureFlags.findFirst({
|
||||||
where: eq(featureFlags.name, flagName),
|
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) => {
|
deleteFlag: async (name: string) => {
|
||||||
const [flag] = await DrizzleClient.delete(featureFlags)
|
const [flag] = await DrizzleClient.delete(featureFlags)
|
||||||
.where(eq(featureFlags.name, name))
|
.where(eq(featureFlags.name, name))
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ let cacheTimestamp = 0;
|
|||||||
const CACHE_TTL_MS = 30000;
|
const CACHE_TTL_MS = 30000;
|
||||||
|
|
||||||
export const gameSettingsService = {
|
export const gameSettingsService = {
|
||||||
|
/** Retrieve game settings, using a 30-second TTL cache by default. */
|
||||||
getSettings: async (useCache = true): Promise<GameSettingsData | null> => {
|
getSettings: async (useCache = true): Promise<GameSettingsData | null> => {
|
||||||
if (useCache && cachedSettings && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
|
if (useCache && cachedSettings && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
|
||||||
return cachedSettings;
|
return cachedSettings;
|
||||||
@@ -56,6 +57,7 @@ export const gameSettingsService = {
|
|||||||
return cachedSettings;
|
return cachedSettings;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Create or update game settings, merging with existing values and invalidating cache. */
|
||||||
upsertSettings: async (data: Partial<GameSettingsData>) => {
|
upsertSettings: async (data: Partial<GameSettingsData>) => {
|
||||||
const existing = await gameSettingsService.getSettings(false);
|
const existing = await gameSettingsService.getSettings(false);
|
||||||
|
|
||||||
@@ -86,6 +88,7 @@ export const gameSettingsService = {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Update a single configuration section (e.g., "leveling", "economy") and invalidate cache. */
|
||||||
updateSection: async <K extends keyof GameSettingsData>(
|
updateSection: async <K extends keyof GameSettingsData>(
|
||||||
section: K,
|
section: K,
|
||||||
value: GameSettingsData[K]
|
value: GameSettingsData[K]
|
||||||
@@ -102,6 +105,7 @@ export const gameSettingsService = {
|
|||||||
gameSettingsService.invalidateCache();
|
gameSettingsService.invalidateCache();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Enable or disable a specific command in the game settings. */
|
||||||
toggleCommand: async (commandName: string, enabled: boolean) => {
|
toggleCommand: async (commandName: string, enabled: boolean) => {
|
||||||
const settings = await gameSettingsService.getSettings(false);
|
const settings = await gameSettingsService.getSettings(false);
|
||||||
|
|
||||||
@@ -117,11 +121,13 @@ export const gameSettingsService = {
|
|||||||
await gameSettingsService.updateSection("commands", commands);
|
await gameSettingsService.updateSection("commands", commands);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Invalidate the in-memory settings cache, forcing a fresh DB read on next access. */
|
||||||
invalidateCache: () => {
|
invalidateCache: () => {
|
||||||
cachedSettings = null;
|
cachedSettings = null;
|
||||||
cacheTimestamp = 0;
|
cacheTimestamp = 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Return default leveling configuration values. */
|
||||||
getDefaultLeveling: (): LevelingConfig => ({
|
getDefaultLeveling: (): LevelingConfig => ({
|
||||||
base: 100,
|
base: 100,
|
||||||
exponent: 1.5,
|
exponent: 1.5,
|
||||||
@@ -132,6 +138,7 @@ export const gameSettingsService = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Return default economy configuration values. */
|
||||||
getDefaultEconomy: (): EconomyConfig => ({
|
getDefaultEconomy: (): EconomyConfig => ({
|
||||||
daily: {
|
daily: {
|
||||||
amount: "100",
|
amount: "100",
|
||||||
@@ -149,11 +156,13 @@ export const gameSettingsService = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Return default inventory configuration values. */
|
||||||
getDefaultInventory: (): InventoryConfig => ({
|
getDefaultInventory: (): InventoryConfig => ({
|
||||||
maxStackSize: "99",
|
maxStackSize: "99",
|
||||||
maxSlots: 20,
|
maxSlots: 20,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Return default lootdrop configuration values. */
|
||||||
getDefaultLootdrop: (): LootdropConfig => ({
|
getDefaultLootdrop: (): LootdropConfig => ({
|
||||||
activityWindowMs: 300000,
|
activityWindowMs: 300000,
|
||||||
minMessages: 5,
|
minMessages: 5,
|
||||||
@@ -166,6 +175,7 @@ export const gameSettingsService = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Return default trivia configuration values. */
|
||||||
getDefaultTrivia: (): TriviaConfig => ({
|
getDefaultTrivia: (): TriviaConfig => ({
|
||||||
entryFee: "50",
|
entryFee: "50",
|
||||||
rewardMultiplier: 1.8,
|
rewardMultiplier: 1.8,
|
||||||
@@ -175,6 +185,7 @@ export const gameSettingsService = {
|
|||||||
difficulty: "random",
|
difficulty: "random",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Return default moderation configuration values. */
|
||||||
getDefaultModeration: (): ModerationConfig => ({
|
getDefaultModeration: (): ModerationConfig => ({
|
||||||
prune: {
|
prune: {
|
||||||
maxAmount: 100,
|
maxAmount: 100,
|
||||||
@@ -184,10 +195,12 @@ export const gameSettingsService = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Return default quest configuration values. */
|
||||||
getDefaultQuest: (): QuestConfig => ({
|
getDefaultQuest: (): QuestConfig => ({
|
||||||
maxActiveQuests: 3,
|
maxActiveQuests: 3,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Return the complete set of default game settings across all sections. */
|
||||||
getDefaults: (): GameSettingsData => ({
|
getDefaults: (): GameSettingsData => ({
|
||||||
leveling: gameSettingsService.getDefaultLeveling(),
|
leveling: gameSettingsService.getDefaultLeveling(),
|
||||||
economy: gameSettingsService.getDefaultEconomy(),
|
economy: gameSettingsService.getDefaultEconomy(),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface GuildSettingsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const guildSettingsService = {
|
export const guildSettingsService = {
|
||||||
|
/** Retrieve guild settings by guild ID, or null if none exist. */
|
||||||
getSettings: async (guildId: string): Promise<GuildSettingsData | null> => {
|
getSettings: async (guildId: string): Promise<GuildSettingsData | null> => {
|
||||||
const settings = await DrizzleClient.query.guildSettings.findFirst({
|
const settings = await DrizzleClient.query.guildSettings.findFirst({
|
||||||
where: eq(guildSettings.guildId, BigInt(guildId)),
|
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 }) => {
|
upsertSettings: async (data: Partial<GuildSettingsData> & { guildId: string }) => {
|
||||||
const values: typeof guildSettings.$inferInsert = {
|
const values: typeof guildSettings.$inferInsert = {
|
||||||
guildId: BigInt(data.guildId),
|
guildId: BigInt(data.guildId),
|
||||||
@@ -73,6 +75,7 @@ export const guildSettingsService = {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Update a single guild setting by key name; throws if the key is unknown or settings do not exist. */
|
||||||
updateSetting: async (
|
updateSetting: async (
|
||||||
guildId: string,
|
guildId: string,
|
||||||
key: string,
|
key: string,
|
||||||
@@ -128,6 +131,7 @@ export const guildSettingsService = {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Delete all settings for a guild. */
|
||||||
deleteSettings: async (guildId: string) => {
|
deleteSettings: async (guildId: string) => {
|
||||||
const [result] = await DrizzleClient.delete(guildSettings)
|
const [result] = await DrizzleClient.delete(guildSettings)
|
||||||
.where(eq(guildSettings.guildId, BigInt(guildId)))
|
.where(eq(guildSettings.guildId, BigInt(guildId)))
|
||||||
@@ -136,6 +140,7 @@ export const guildSettingsService = {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Add a color role to the guild's allowed list; no-ops if already present. */
|
||||||
addColorRole: async (guildId: string, roleId: string) => {
|
addColorRole: async (guildId: string, roleId: string) => {
|
||||||
const settings = await guildSettingsService.getSettings(guildId);
|
const settings = await guildSettingsService.getSettings(guildId);
|
||||||
const colorRoleIds = settings?.colorRoleIds ?? [];
|
const colorRoleIds = settings?.colorRoleIds ?? [];
|
||||||
@@ -148,6 +153,7 @@ export const guildSettingsService = {
|
|||||||
return await guildSettingsService.upsertSettings({ guildId, colorRoleIds });
|
return await guildSettingsService.upsertSettings({ guildId, colorRoleIds });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Remove a color role from the guild's allowed list. */
|
||||||
removeColorRole: async (guildId: string, roleId: string) => {
|
removeColorRole: async (guildId: string, roleId: string) => {
|
||||||
const settings = await guildSettingsService.getSettings(guildId);
|
const settings = await guildSettingsService.getSettings(guildId);
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|||||||
11
shared/modules/inventory/CLAUDE.md
Normal file
11
shared/modules/inventory/CLAUDE.md
Normal 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.
|
||||||
@@ -13,6 +13,7 @@ import { TransactionType } from "@shared/lib/constants";
|
|||||||
|
|
||||||
|
|
||||||
export const inventoryService = {
|
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) => {
|
addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
// Check if item exists in inventory
|
// Check if item exists in inventory
|
||||||
@@ -74,6 +75,7 @@ export const inventoryService = {
|
|||||||
}, tx);
|
}, 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) => {
|
removeItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const existing = await txFn.query.inventory.findFirst({
|
const existing = await txFn.query.inventory.findFirst({
|
||||||
@@ -110,6 +112,7 @@ export const inventoryService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Retrieve all inventory entries for a user, including item details. */
|
||||||
getInventory: async (userId: string) => {
|
getInventory: async (userId: string) => {
|
||||||
return await DrizzleClient.query.inventory.findMany({
|
return await DrizzleClient.query.inventory.findMany({
|
||||||
where: eq(inventory.userId, BigInt(userId)),
|
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) => {
|
buyItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const item = await txFn.query.items.findFirst({
|
const item = await txFn.query.items.findFirst({
|
||||||
@@ -139,12 +143,14 @@ export const inventoryService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Fetch a single item definition by ID. */
|
||||||
getItem: async (itemId: number) => {
|
getItem: async (itemId: number) => {
|
||||||
return await DrizzleClient.query.items.findFirst({
|
return await DrizzleClient.query.items.findFirst({
|
||||||
where: eq(items.id, itemId),
|
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) => {
|
useItem: async (userId: string, itemId: number, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
// 1. Check Ownership & Quantity
|
// 1. Check Ownership & Quantity
|
||||||
@@ -189,6 +195,7 @@ export const inventoryService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Search a user's usable inventory items by name for autocomplete suggestions. */
|
||||||
getAutocompleteItems: async (userId: string, query: string) => {
|
getAutocompleteItems: async (userId: string, query: string) => {
|
||||||
const entries = await DrizzleClient.select({
|
const entries = await DrizzleClient.select({
|
||||||
quantity: inventory.quantity,
|
quantity: inventory.quantity,
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import { TimerKey, TimerType } from "@shared/lib/constants";
|
|||||||
import { UserError } from "@shared/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
export const levelingService = {
|
export const levelingService = {
|
||||||
// Calculate total XP required to REACH a specific level (Cumulative)
|
/** Calculate the cumulative XP required to reach a specific level. */
|
||||||
// Level 1 = 0 XP
|
|
||||||
// Level 2 = Base * (1^Exp)
|
|
||||||
// Level 3 = Level 2 + Base * (2^Exp)
|
|
||||||
// ...
|
|
||||||
getXpToReachLevel: (level: number) => {
|
getXpToReachLevel: (level: number) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (let l = 1; l < level; l++) {
|
for (let l = 1; l < level; l++) {
|
||||||
@@ -21,7 +17,7 @@ export const levelingService = {
|
|||||||
return total;
|
return total;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Calculate level from Total XP
|
/** Derive a user's level from their total accumulated XP. */
|
||||||
getLevelFromXp: (totalXp: bigint) => {
|
getLevelFromXp: (totalXp: bigint) => {
|
||||||
let level = 1;
|
let level = 1;
|
||||||
let xp = Number(totalXp);
|
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)
|
/** Get the XP needed to advance from the given level to the next. */
|
||||||
// Used internally or for display
|
|
||||||
getXpForNextLevel: (currentLevel: number) => {
|
getXpForNextLevel: (currentLevel: number) => {
|
||||||
return Math.floor(config.leveling.base * Math.pow(currentLevel, config.leveling.exponent));
|
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) => {
|
addXp: async (id: string, amount: bigint, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
// Get current state
|
// Get current state
|
||||||
@@ -77,7 +72,7 @@ export const levelingService = {
|
|||||||
}, tx);
|
}, 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) => {
|
processChatXp: async (id: string, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
// check if an xp cooldown is in place
|
// check if an xp cooldown is in place
|
||||||
|
|||||||
10
shared/modules/moderation/CLAUDE.md
Normal file
10
shared/modules/moderation/CLAUDE.md
Normal 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).
|
||||||
10
shared/modules/quest/CLAUDE.md
Normal file
10
shared/modules/quest/CLAUDE.md
Normal 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).
|
||||||
@@ -11,6 +11,7 @@ import { TransactionType } from "@shared/lib/constants";
|
|||||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||||
|
|
||||||
export const questService = {
|
export const questService = {
|
||||||
|
/** Assign a quest to a user; enforces the maximum active quest limit. */
|
||||||
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
// Check active quest limit
|
// Check active quest limit
|
||||||
@@ -40,6 +41,7 @@ export const questService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Set the progress value for a user's active quest. */
|
||||||
updateProgress: async (userId: string, questId: number, progress: number, tx?: Transaction) => {
|
updateProgress: async (userId: string, questId: number, progress: number, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
return await txFn.update(userQuests)
|
return await txFn.update(userQuests)
|
||||||
@@ -52,6 +54,7 @@ export const questService = {
|
|||||||
}, tx);
|
}, 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) => {
|
handleEvent: async (userId: string, eventName: string, weight: number = 1, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
// 1. Fetch active user quests for this event
|
// 1. Fetch active user quests for this event
|
||||||
@@ -86,6 +89,7 @@ export const questService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Mark a quest as completed and distribute its XP and balance rewards. */
|
||||||
completeQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
completeQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const userQuest = await txFn.query.userQuests.findFirst({
|
const userQuest = await txFn.query.userQuests.findFirst({
|
||||||
@@ -137,6 +141,7 @@ export const questService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Get all quests assigned to a user, including quest details. */
|
||||||
getUserQuests: async (userId: string) => {
|
getUserQuests: async (userId: string) => {
|
||||||
return await DrizzleClient.query.userQuests.findMany({
|
return await DrizzleClient.query.userQuests.findMany({
|
||||||
where: eq(userQuests.userId, BigInt(userId)),
|
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) {
|
async getAvailableQuests(userId: string) {
|
||||||
const userQuestIds = (await DrizzleClient.query.userQuests.findMany({
|
const userQuestIds = (await DrizzleClient.query.userQuests.findMany({
|
||||||
where: eq(userQuests.userId, BigInt(userId)),
|
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: {
|
async createQuest(data: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -181,12 +188,14 @@ export const questService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** List all quest definitions, ordered by ID. */
|
||||||
async getAllQuests() {
|
async getAllQuests() {
|
||||||
return await DrizzleClient.query.quests.findMany({
|
return await DrizzleClient.query.quests.findMany({
|
||||||
orderBy: (quests, { asc }) => [asc(quests.id)],
|
orderBy: (quests, { asc }) => [asc(quests.id)],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Delete a quest definition by ID. */
|
||||||
async deleteQuest(id: number, tx?: Transaction) {
|
async deleteQuest(id: number, tx?: Transaction) {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
return await txFn.delete(quests)
|
return await txFn.delete(quests)
|
||||||
@@ -195,6 +204,7 @@ export const questService = {
|
|||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Update a quest definition with partial data. */
|
||||||
async updateQuest(id: number, data: {
|
async updateQuest(id: number, data: {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
9
shared/modules/trade/CLAUDE.md
Normal file
9
shared/modules/trade/CLAUDE.md
Normal 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.
|
||||||
@@ -101,10 +101,12 @@ export const tradeService = {
|
|||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Get an active trade session by thread ID. */
|
||||||
getSession: (threadId: string): TradeSession | undefined => {
|
getSession: (threadId: string): TradeSession | undefined => {
|
||||||
return sessions.get(threadId);
|
return sessions.get(threadId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Remove a trade session from memory. */
|
||||||
endSession: (threadId: string) => {
|
endSession: (threadId: string) => {
|
||||||
sessions.delete(threadId);
|
sessions.delete(threadId);
|
||||||
},
|
},
|
||||||
@@ -126,6 +128,7 @@ export const tradeService = {
|
|||||||
session.lastInteraction = Date.now();
|
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) => {
|
addItem: (threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) => {
|
||||||
const session = tradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) throw new UserError("Session not found");
|
if (!session) throw new UserError("Session not found");
|
||||||
@@ -145,6 +148,7 @@ export const tradeService = {
|
|||||||
session.lastInteraction = Date.now();
|
session.lastInteraction = Date.now();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Remove an item from a participant's trade offer; unlocks both sides. */
|
||||||
removeItem: (threadId: string, userId: string, itemId: number) => {
|
removeItem: (threadId: string, userId: string, itemId: number) => {
|
||||||
const session = tradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) throw new UserError("Session not found");
|
if (!session) throw new UserError("Session not found");
|
||||||
@@ -158,6 +162,7 @@ export const tradeService = {
|
|||||||
session.lastInteraction = Date.now();
|
session.lastInteraction = Date.now();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Toggle a participant's lock status on their offer; returns the new lock state. */
|
||||||
toggleLock: (threadId: string, userId: string): boolean => {
|
toggleLock: (threadId: string, userId: string): boolean => {
|
||||||
const session = tradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) throw new UserError("Session not found");
|
if (!session) throw new UserError("Session not found");
|
||||||
@@ -199,6 +204,7 @@ export const tradeService = {
|
|||||||
tradeService.endSession(threadId);
|
tradeService.endSession(threadId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Clear all active trade sessions from memory. */
|
||||||
clearSessions: () => {
|
clearSessions: () => {
|
||||||
sessions.clear();
|
sessions.clear();
|
||||||
console.log("[TradeService] All active trade sessions cleared.");
|
console.log("[TradeService] All active trade sessions cleared.");
|
||||||
|
|||||||
11
shared/modules/trivia/CLAUDE.md
Normal file
11
shared/modules/trivia/CLAUDE.md
Normal 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.
|
||||||
@@ -5,6 +5,7 @@ import { withTransaction } from "@/lib/db";
|
|||||||
import type { Transaction } from "@shared/lib/types";
|
import type { Transaction } from "@shared/lib/types";
|
||||||
|
|
||||||
export const userService = {
|
export const userService = {
|
||||||
|
/** Fetch a user by Discord ID, including their class relation. */
|
||||||
getUserById: async (id: string) => {
|
getUserById: async (id: string) => {
|
||||||
const user = await DrizzleClient.query.users.findFirst({
|
const user = await DrizzleClient.query.users.findFirst({
|
||||||
where: eq(users.id, BigInt(id)),
|
where: eq(users.id, BigInt(id)),
|
||||||
@@ -12,10 +13,12 @@ export const userService = {
|
|||||||
});
|
});
|
||||||
return user;
|
return user;
|
||||||
},
|
},
|
||||||
|
/** Fetch a user by their username. */
|
||||||
getUserByUsername: async (username: string) => {
|
getUserByUsername: async (username: string) => {
|
||||||
const user = await DrizzleClient.query.users.findFirst({ where: eq(users.username, username) });
|
const user = await DrizzleClient.query.users.findFirst({ where: eq(users.username, username) });
|
||||||
return user;
|
return user;
|
||||||
},
|
},
|
||||||
|
/** Fetch a user by ID, creating a new record if one does not exist. */
|
||||||
getOrCreateUser: async (id: string, username: string, tx?: Transaction) => {
|
getOrCreateUser: async (id: string, username: string, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
let user = await txFn.query.users.findFirst({
|
let user = await txFn.query.users.findFirst({
|
||||||
@@ -38,6 +41,7 @@ export const userService = {
|
|||||||
return user;
|
return user;
|
||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
/** Get the class assigned to a user, or undefined if none. */
|
||||||
getUserClass: async (id: string) => {
|
getUserClass: async (id: string) => {
|
||||||
const user = await DrizzleClient.query.users.findFirst({
|
const user = await DrizzleClient.query.users.findFirst({
|
||||||
where: eq(users.id, BigInt(id)),
|
where: eq(users.id, BigInt(id)),
|
||||||
@@ -45,6 +49,7 @@ export const userService = {
|
|||||||
});
|
});
|
||||||
return user?.class;
|
return user?.class;
|
||||||
},
|
},
|
||||||
|
/** Create a new user with an optional initial class assignment. */
|
||||||
createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: Transaction) => {
|
createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const [user] = await txFn.insert(users).values({
|
const [user] = await txFn.insert(users).values({
|
||||||
@@ -55,6 +60,7 @@ export const userService = {
|
|||||||
return user;
|
return user;
|
||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
/** Update a user record with partial data. */
|
||||||
updateUser: async (id: string, data: Partial<typeof users.$inferInsert>, tx?: Transaction) => {
|
updateUser: async (id: string, data: Partial<typeof users.$inferInsert>, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const [user] = await txFn.update(users)
|
const [user] = await txFn.update(users)
|
||||||
@@ -64,6 +70,7 @@ export const userService = {
|
|||||||
return user;
|
return user;
|
||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
/** Delete a user by ID. */
|
||||||
deleteUser: async (id: string, tx?: Transaction) => {
|
deleteUser: async (id: string, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
await txFn.delete(users).where(eq(users.id, BigInt(id)));
|
await txFn.delete(users).where(eq(users.id, BigInt(id)));
|
||||||
|
|||||||
Reference in New Issue
Block a user