chrxmaticc-framework 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/ChrxCommandBuilder.js +17 -1
- package/core/ChrxComponentsV3.js +652 -0
- package/core/ChrxEmbedBuilder.js +127 -0
- package/core/ChrxModalBuilder.js +151 -0
- package/core/ChrxReply.js +236 -0
- package/core/ChrxRouter.js +233 -0
- package/core/ChrxTimedReply.js +50 -0
- package/index.js +51 -17
- package/package.json +1 -1
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/ChrxComponentsV3.js
|
|
3
|
+
* Components V3 — every Discord component simplified into clean Chrx builders.
|
|
4
|
+
*
|
|
5
|
+
* Includes:
|
|
6
|
+
* - ChrxButton
|
|
7
|
+
* - ChrxSelectMenu
|
|
8
|
+
* - ChrxPagination
|
|
9
|
+
* - ChrxContextMenu
|
|
10
|
+
* - ChrxMenu
|
|
11
|
+
* - ChrxForm
|
|
12
|
+
* - ChrxConfirm
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
ButtonBuilder,
|
|
17
|
+
ButtonStyle,
|
|
18
|
+
ActionRowBuilder,
|
|
19
|
+
StringSelectMenuBuilder,
|
|
20
|
+
StringSelectMenuOptionBuilder,
|
|
21
|
+
ContextMenuCommandBuilder,
|
|
22
|
+
ApplicationCommandType,
|
|
23
|
+
EmbedBuilder,
|
|
24
|
+
ComponentType,
|
|
25
|
+
} = require("discord.js");
|
|
26
|
+
|
|
27
|
+
// ── Style map ─────────────────────────────────────────────────────────────
|
|
28
|
+
const BUTTON_STYLE_MAP = {
|
|
29
|
+
primary: ButtonStyle.Primary,
|
|
30
|
+
secondary: ButtonStyle.Secondary,
|
|
31
|
+
success: ButtonStyle.Success,
|
|
32
|
+
danger: ButtonStyle.Danger,
|
|
33
|
+
link: ButtonStyle.Link,
|
|
34
|
+
blurple: ButtonStyle.Primary,
|
|
35
|
+
grey: ButtonStyle.Secondary,
|
|
36
|
+
gray: ButtonStyle.Secondary,
|
|
37
|
+
green: ButtonStyle.Success,
|
|
38
|
+
red: ButtonStyle.Danger,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
42
|
+
// ChrxButton
|
|
43
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
44
|
+
|
|
45
|
+
class ChrxButton {
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} options
|
|
48
|
+
* @param {string} options.label Button label
|
|
49
|
+
* @param {string} [options.style] "primary"|"secondary"|"success"|"danger"|"link" (default: primary)
|
|
50
|
+
* @param {string} [options.id] Custom ID (not needed for link buttons)
|
|
51
|
+
* @param {string} [options.url] URL for link buttons
|
|
52
|
+
* @param {string} [options.emoji] Emoji for button
|
|
53
|
+
* @param {boolean} [options.disabled] Disable the button
|
|
54
|
+
* @param {Function} [options.onClick] Handler (interaction) => void
|
|
55
|
+
*/
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
if (!options.label) throw new Error("[ChrxButton] label is required.");
|
|
58
|
+
|
|
59
|
+
this._options = options;
|
|
60
|
+
this._handler = options.onClick ?? null;
|
|
61
|
+
|
|
62
|
+
const btn = new ButtonBuilder().setLabel(options.label);
|
|
63
|
+
|
|
64
|
+
if (options.style === "link" || options.url) {
|
|
65
|
+
btn.setStyle(ButtonStyle.Link).setURL(options.url);
|
|
66
|
+
} else {
|
|
67
|
+
btn.setStyle(BUTTON_STYLE_MAP[options.style ?? "primary"] ?? ButtonStyle.Primary);
|
|
68
|
+
btn.setCustomId(options.id ?? `chrx-btn-${Math.random().toString(36).slice(2, 9)}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.emoji) btn.setEmoji(options.emoji);
|
|
72
|
+
if (options.disabled) btn.setDisabled(true);
|
|
73
|
+
|
|
74
|
+
this._button = btn;
|
|
75
|
+
this.customId = options.id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build into an ActionRow ready to send.
|
|
80
|
+
*/
|
|
81
|
+
toRow() {
|
|
82
|
+
return new ActionRowBuilder().addComponents(this._button);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Send the button with a message and listen for clicks.
|
|
87
|
+
* Auto cleans up the collector after timeout.
|
|
88
|
+
*
|
|
89
|
+
* @param {import("discord.js").CommandInteraction} interaction
|
|
90
|
+
* @param {object} [options]
|
|
91
|
+
* @param {string} [options.content] Message content
|
|
92
|
+
* @param {object} [options.embed] EmbedBuilder to attach
|
|
93
|
+
* @param {boolean} [options.ephemeral]
|
|
94
|
+
* @param {number} [options.timeout] Collector timeout in ms (default: 60000)
|
|
95
|
+
*/
|
|
96
|
+
async send(interaction, options = {}) {
|
|
97
|
+
const payload = {
|
|
98
|
+
components: [this.toRow()],
|
|
99
|
+
ephemeral: options.ephemeral ?? false,
|
|
100
|
+
};
|
|
101
|
+
if (options.content) payload.content = options.content;
|
|
102
|
+
if (options.embed) payload.embeds = [options.embed];
|
|
103
|
+
|
|
104
|
+
const reply = await interaction.reply({ ...payload, fetchReply: true });
|
|
105
|
+
|
|
106
|
+
if (!this._handler) return;
|
|
107
|
+
|
|
108
|
+
const collector = reply.createMessageComponentCollector({
|
|
109
|
+
componentType: ComponentType.Button,
|
|
110
|
+
time: options.timeout ?? 60000,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
collector.on("collect", async (i) => {
|
|
114
|
+
if (i.customId !== (this._options.id ?? this._button.data.custom_id)) return;
|
|
115
|
+
try {
|
|
116
|
+
await this._handler(i);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error("[ChrxButton] onClick error:", err);
|
|
119
|
+
i.reply({ content: err.message || "❌ Something went wrong.", ephemeral: true }).catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
collector.on("end", () => {
|
|
124
|
+
reply.edit({ components: [] }).catch(() => {});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
130
|
+
// ChrxSelectMenu
|
|
131
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
132
|
+
|
|
133
|
+
class ChrxSelectMenu {
|
|
134
|
+
/**
|
|
135
|
+
* @param {object} options
|
|
136
|
+
* @param {string} options.id Custom ID
|
|
137
|
+
* @param {string} options.placeholder Placeholder text
|
|
138
|
+
* @param {object[]} options.options Menu options
|
|
139
|
+
* @param {string} options.options[].label
|
|
140
|
+
* @param {string} options.options[].value
|
|
141
|
+
* @param {string} [options.options[].description]
|
|
142
|
+
* @param {string} [options.options[].emoji]
|
|
143
|
+
* @param {boolean} [options.options[].default]
|
|
144
|
+
* @param {number} [options.min] Min selections (default: 1)
|
|
145
|
+
* @param {number} [options.max] Max selections (default: 1)
|
|
146
|
+
* @param {Function} [options.onSelect] Handler (interaction, values) => void
|
|
147
|
+
*/
|
|
148
|
+
constructor(options = {}) {
|
|
149
|
+
if (!options.id) throw new Error("[ChrxSelectMenu] id is required.");
|
|
150
|
+
if (!options.placeholder) throw new Error("[ChrxSelectMenu] placeholder is required.");
|
|
151
|
+
if (!options.options?.length) throw new Error("[ChrxSelectMenu] options are required.");
|
|
152
|
+
|
|
153
|
+
this._options = options;
|
|
154
|
+
this._handler = options.onSelect ?? null;
|
|
155
|
+
|
|
156
|
+
const menu = new StringSelectMenuBuilder()
|
|
157
|
+
.setCustomId(options.id)
|
|
158
|
+
.setPlaceholder(options.placeholder)
|
|
159
|
+
.setMinValues(options.min ?? 1)
|
|
160
|
+
.setMaxValues(options.max ?? 1)
|
|
161
|
+
.addOptions(
|
|
162
|
+
options.options.map((opt) => {
|
|
163
|
+
const o = new StringSelectMenuOptionBuilder()
|
|
164
|
+
.setLabel(opt.label)
|
|
165
|
+
.setValue(opt.value);
|
|
166
|
+
if (opt.description) o.setDescription(opt.description);
|
|
167
|
+
if (opt.emoji) o.setEmoji(opt.emoji);
|
|
168
|
+
if (opt.default) o.setDefault(true);
|
|
169
|
+
return o;
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
this._menu = menu;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
toRow() {
|
|
177
|
+
return new ActionRowBuilder().addComponents(this._menu);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Send the select menu and listen for selections.
|
|
182
|
+
*/
|
|
183
|
+
async send(interaction, options = {}) {
|
|
184
|
+
const payload = {
|
|
185
|
+
components: [this.toRow()],
|
|
186
|
+
ephemeral: options.ephemeral ?? false,
|
|
187
|
+
};
|
|
188
|
+
if (options.content) payload.content = options.content;
|
|
189
|
+
if (options.embed) payload.embeds = [options.embed];
|
|
190
|
+
|
|
191
|
+
const reply = await interaction.reply({ ...payload, fetchReply: true });
|
|
192
|
+
|
|
193
|
+
if (!this._handler) return;
|
|
194
|
+
|
|
195
|
+
const collector = reply.createMessageComponentCollector({
|
|
196
|
+
componentType: ComponentType.StringSelect,
|
|
197
|
+
time: options.timeout ?? 60000,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
collector.on("collect", async (i) => {
|
|
201
|
+
if (i.customId !== this._options.id) return;
|
|
202
|
+
try {
|
|
203
|
+
await this._handler(i, i.values);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error("[ChrxSelectMenu] onSelect error:", err);
|
|
206
|
+
i.reply({ content: err.message || "❌ Something went wrong.", ephemeral: true }).catch(() => {});
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
collector.on("end", () => {
|
|
211
|
+
reply.edit({ components: [] }).catch(() => {});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
217
|
+
// ChrxPagination
|
|
218
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
219
|
+
|
|
220
|
+
class ChrxPagination {
|
|
221
|
+
/**
|
|
222
|
+
* Auto paginated embed system with next/prev buttons.
|
|
223
|
+
*
|
|
224
|
+
* @param {object} options
|
|
225
|
+
* @param {EmbedBuilder[]} options.pages Array of EmbedBuilders
|
|
226
|
+
* @param {number} [options.timeout] Collector timeout ms (default: 120000)
|
|
227
|
+
* @param {boolean} [options.ephemeral]
|
|
228
|
+
* @param {boolean} [options.showPageNumber] Show "Page X/Y" in footer (default: true)
|
|
229
|
+
*/
|
|
230
|
+
constructor(options = {}) {
|
|
231
|
+
if (!options.pages?.length) throw new Error("[ChrxPagination] pages array is required.");
|
|
232
|
+
this._options = options;
|
|
233
|
+
this._page = 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
_getButtons(page, total) {
|
|
237
|
+
const prev = new ButtonBuilder()
|
|
238
|
+
.setCustomId("chrx-page-prev")
|
|
239
|
+
.setLabel("◀")
|
|
240
|
+
.setStyle(ButtonStyle.Secondary)
|
|
241
|
+
.setDisabled(page === 0);
|
|
242
|
+
|
|
243
|
+
const next = new ButtonBuilder()
|
|
244
|
+
.setCustomId("chrx-page-next")
|
|
245
|
+
.setLabel("▶")
|
|
246
|
+
.setStyle(ButtonStyle.Secondary)
|
|
247
|
+
.setDisabled(page === total - 1);
|
|
248
|
+
|
|
249
|
+
const counter = new ButtonBuilder()
|
|
250
|
+
.setCustomId("chrx-page-counter")
|
|
251
|
+
.setLabel(`${page + 1} / ${total}`)
|
|
252
|
+
.setStyle(ButtonStyle.Primary)
|
|
253
|
+
.setDisabled(true);
|
|
254
|
+
|
|
255
|
+
return new ActionRowBuilder().addComponents(prev, counter, next);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_getPage(index) {
|
|
259
|
+
const page = this._options.pages[index];
|
|
260
|
+
const total = this._options.pages.length;
|
|
261
|
+
if (this._options.showPageNumber !== false) {
|
|
262
|
+
page.setFooter({ text: `Page ${index + 1} of ${total}` });
|
|
263
|
+
}
|
|
264
|
+
return page;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Send the paginated embed.
|
|
269
|
+
*/
|
|
270
|
+
async send(interaction) {
|
|
271
|
+
const total = this._options.pages.length;
|
|
272
|
+
const reply = await interaction.reply({
|
|
273
|
+
embeds: [this._getPage(0)],
|
|
274
|
+
components: total > 1 ? [this._getButtons(0, total)] : [],
|
|
275
|
+
ephemeral: this._options.ephemeral ?? false,
|
|
276
|
+
fetchReply: true,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (total <= 1) return;
|
|
280
|
+
|
|
281
|
+
const collector = reply.createMessageComponentCollector({
|
|
282
|
+
componentType: ComponentType.Button,
|
|
283
|
+
time: this._options.timeout ?? 120000,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
collector.on("collect", async (i) => {
|
|
287
|
+
if (i.user.id !== interaction.user.id) {
|
|
288
|
+
return i.reply({ content: "❌ These controls aren't for you.", ephemeral: true });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (i.customId === "chrx-page-prev") this._page = Math.max(0, this._page - 1);
|
|
292
|
+
if (i.customId === "chrx-page-next") this._page = Math.min(total - 1, this._page + 1);
|
|
293
|
+
|
|
294
|
+
await i.update({
|
|
295
|
+
embeds: [this._getPage(this._page)],
|
|
296
|
+
components: [this._getButtons(this._page, total)],
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
collector.on("end", () => {
|
|
301
|
+
reply.edit({ components: [] }).catch(() => {});
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
307
|
+
// ChrxContextMenu
|
|
308
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
309
|
+
|
|
310
|
+
class ChrxContextMenu {
|
|
311
|
+
/**
|
|
312
|
+
* Right click context menu command.
|
|
313
|
+
*
|
|
314
|
+
* @param {object} options
|
|
315
|
+
* @param {string} options.name Context menu name
|
|
316
|
+
* @param {string} options.type "user" | "message"
|
|
317
|
+
* @param {Function} options.run Handler (interaction) => void
|
|
318
|
+
*/
|
|
319
|
+
constructor(options = {}) {
|
|
320
|
+
if (!options.name) throw new Error("[ChrxContextMenu] name is required.");
|
|
321
|
+
if (!options.type) throw new Error("[ChrxContextMenu] type is required. Use 'user' or 'message'.");
|
|
322
|
+
if (!options.run) throw new Error("[ChrxContextMenu] run function is required.");
|
|
323
|
+
|
|
324
|
+
const builder = new ContextMenuCommandBuilder()
|
|
325
|
+
.setName(options.name)
|
|
326
|
+
.setType(
|
|
327
|
+
options.type === "user"
|
|
328
|
+
? ApplicationCommandType.User
|
|
329
|
+
: ApplicationCommandType.Message
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
this.data = builder;
|
|
333
|
+
this.execute = async (interaction) => {
|
|
334
|
+
try {
|
|
335
|
+
await options.run(interaction);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
console.error(`[ChrxContextMenu] Error in "${options.name}":`, err);
|
|
338
|
+
const msg = { content: err.message || "❌ Something went wrong.", ephemeral: true };
|
|
339
|
+
if (interaction.replied || interaction.deferred) {
|
|
340
|
+
interaction.followUp(msg).catch(() => {});
|
|
341
|
+
} else {
|
|
342
|
+
interaction.reply(msg).catch(() => {});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
350
|
+
// ChrxMenu
|
|
351
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
352
|
+
|
|
353
|
+
class ChrxMenu {
|
|
354
|
+
/**
|
|
355
|
+
* Full interactive menu — combines embed, buttons and select menus together.
|
|
356
|
+
*
|
|
357
|
+
* @param {object} options
|
|
358
|
+
* @param {object} options.embed Embed options for ChrxEmbedBuilder.build()
|
|
359
|
+
* @param {object[]} [options.buttons] Array of ChrxButton-like option objects
|
|
360
|
+
* @param {object} [options.select] ChrxSelectMenu-like option object
|
|
361
|
+
* @param {number} [options.timeout] Collector timeout ms (default: 60000)
|
|
362
|
+
* @param {boolean} [options.ephemeral]
|
|
363
|
+
*/
|
|
364
|
+
constructor(options = {}) {
|
|
365
|
+
if (!options.embed) throw new Error("[ChrxMenu] embed is required.");
|
|
366
|
+
this._options = options;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async send(interaction) {
|
|
370
|
+
const { EmbedBuilder } = require("discord.js");
|
|
371
|
+
const embed = new EmbedBuilder();
|
|
372
|
+
|
|
373
|
+
if (this._options.embed.title) embed.setTitle(this._options.embed.title);
|
|
374
|
+
if (this._options.embed.desc) embed.setDescription(this._options.embed.desc);
|
|
375
|
+
if (this._options.embed.color) embed.setColor(this._options.embed.color);
|
|
376
|
+
if (this._options.embed.footer) embed.setFooter({ text: this._options.embed.footer });
|
|
377
|
+
if (this._options.embed.thumbnail) embed.setThumbnail(this._options.embed.thumbnail);
|
|
378
|
+
if (this._options.embed.timestamp) embed.setTimestamp();
|
|
379
|
+
|
|
380
|
+
const rows = [];
|
|
381
|
+
|
|
382
|
+
// ── Buttons ───────────────────────────────────────────────────────────
|
|
383
|
+
if (this._options.buttons?.length) {
|
|
384
|
+
const btnRow = new ActionRowBuilder();
|
|
385
|
+
const handlers = {};
|
|
386
|
+
|
|
387
|
+
for (const btnOpt of this._options.buttons) {
|
|
388
|
+
const btn = new ButtonBuilder()
|
|
389
|
+
.setLabel(btnOpt.label)
|
|
390
|
+
.setStyle(BUTTON_STYLE_MAP[btnOpt.style ?? "primary"] ?? ButtonStyle.Primary)
|
|
391
|
+
.setCustomId(btnOpt.id ?? `chrx-menu-btn-${Math.random().toString(36).slice(2, 7)}`);
|
|
392
|
+
|
|
393
|
+
if (btnOpt.emoji) btn.setEmoji(btnOpt.emoji);
|
|
394
|
+
if (btnOpt.disabled) btn.setDisabled(true);
|
|
395
|
+
|
|
396
|
+
handlers[btnOpt.id] = btnOpt.onClick;
|
|
397
|
+
btnRow.addComponents(btn);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
rows.push(btnRow);
|
|
401
|
+
|
|
402
|
+
const reply = await interaction.reply({
|
|
403
|
+
embeds: [embed],
|
|
404
|
+
components: rows,
|
|
405
|
+
ephemeral: this._options.ephemeral ?? false,
|
|
406
|
+
fetchReply: true,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const collector = reply.createMessageComponentCollector({
|
|
410
|
+
time: this._options.timeout ?? 60000,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
collector.on("collect", async (i) => {
|
|
414
|
+
const handler = handlers[i.customId];
|
|
415
|
+
if (handler) {
|
|
416
|
+
try {
|
|
417
|
+
await handler(i);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
i.reply({ content: err.message || "❌ Error.", ephemeral: true }).catch(() => {});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
collector.on("end", () => {
|
|
425
|
+
reply.edit({ components: [] }).catch(() => {});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── Select only ───────────────────────────────────────────────────────
|
|
432
|
+
if (this._options.select) {
|
|
433
|
+
const menu = new ChrxSelectMenu(this._options.select);
|
|
434
|
+
rows.push(menu.toRow());
|
|
435
|
+
await interaction.reply({
|
|
436
|
+
embeds: [embed],
|
|
437
|
+
components: rows,
|
|
438
|
+
ephemeral: this._options.ephemeral ?? false,
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── Embed only ────────────────────────────────────────────────────────
|
|
444
|
+
await interaction.reply({
|
|
445
|
+
embeds: [embed],
|
|
446
|
+
ephemeral: this._options.ephemeral ?? false,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
452
|
+
// ChrxForm
|
|
453
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
454
|
+
|
|
455
|
+
class ChrxForm {
|
|
456
|
+
/**
|
|
457
|
+
* Combines a modal AND processes the response in one block.
|
|
458
|
+
* No need to set up onModalSubmit separately.
|
|
459
|
+
*
|
|
460
|
+
* @param {object} options
|
|
461
|
+
* @param {string} options.id Modal ID
|
|
462
|
+
* @param {string} options.title Modal title
|
|
463
|
+
* @param {object[]} options.inputs Same as ChrxModalBuilder inputs
|
|
464
|
+
* @param {Function} options.onSubmit (interaction, values) => void
|
|
465
|
+
* values is an object keyed by input id e.g. { answer: "yes" }
|
|
466
|
+
*/
|
|
467
|
+
constructor(options = {}) {
|
|
468
|
+
if (!options.id) throw new Error("[ChrxForm] id is required.");
|
|
469
|
+
if (!options.title) throw new Error("[ChrxForm] title is required.");
|
|
470
|
+
if (!options.inputs?.length) throw new Error("[ChrxForm] inputs are required.");
|
|
471
|
+
if (!options.onSubmit) throw new Error("[ChrxForm] onSubmit is required.");
|
|
472
|
+
|
|
473
|
+
this._options = options;
|
|
474
|
+
this._modal = null;
|
|
475
|
+
|
|
476
|
+
// Build the modal
|
|
477
|
+
const {
|
|
478
|
+
ModalBuilder,
|
|
479
|
+
TextInputBuilder,
|
|
480
|
+
TextInputStyle,
|
|
481
|
+
ActionRowBuilder,
|
|
482
|
+
} = require("discord.js");
|
|
483
|
+
|
|
484
|
+
const STYLE_MAP = {
|
|
485
|
+
short: TextInputStyle.Short,
|
|
486
|
+
paragraph: TextInputStyle.Paragraph,
|
|
487
|
+
long: TextInputStyle.Paragraph,
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const modal = new ModalBuilder()
|
|
491
|
+
.setCustomId(options.id)
|
|
492
|
+
.setTitle(options.title);
|
|
493
|
+
|
|
494
|
+
const rows = options.inputs.map((input) => {
|
|
495
|
+
const textInput = new TextInputBuilder()
|
|
496
|
+
.setCustomId(input.id)
|
|
497
|
+
.setLabel(input.label)
|
|
498
|
+
.setStyle(STYLE_MAP[input.type ?? "short"] ?? TextInputStyle.Short);
|
|
499
|
+
|
|
500
|
+
if (input.required) textInput.setRequired(input.required);
|
|
501
|
+
if (input.placeholder) textInput.setPlaceholder(input.placeholder);
|
|
502
|
+
if (input.value) textInput.setValue(input.value);
|
|
503
|
+
if (input.minLength) textInput.setMinLength(input.minLength);
|
|
504
|
+
if (input.maxLength) textInput.setMaxLength(input.maxLength);
|
|
505
|
+
|
|
506
|
+
return new ActionRowBuilder().addComponents(textInput);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
modal.addComponents(...rows);
|
|
510
|
+
this._modal = modal;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Show the form and handle submission automatically.
|
|
515
|
+
* @param {import("discord.js").CommandInteraction} interaction
|
|
516
|
+
*/
|
|
517
|
+
async show(interaction) {
|
|
518
|
+
await interaction.showModal(this._modal);
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
const submitted = await interaction.awaitModalSubmit({
|
|
522
|
+
time: 300000, // 5 minutes to fill out the form
|
|
523
|
+
filter: (i) => i.customId === this._options.id && i.user.id === interaction.user.id,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// Build values object keyed by input id
|
|
527
|
+
const values = {};
|
|
528
|
+
for (const input of this._options.inputs) {
|
|
529
|
+
values[input.id] = submitted.fields.getTextInputValue(input.id);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
await this._options.onSubmit(submitted, values);
|
|
534
|
+
} catch (err) {
|
|
535
|
+
console.error(`[ChrxForm] onSubmit error:`, err);
|
|
536
|
+
const msg = { content: err.message || "❌ Something went wrong.", ephemeral: true };
|
|
537
|
+
if (submitted.replied || submitted.deferred) {
|
|
538
|
+
submitted.followUp(msg).catch(() => {});
|
|
539
|
+
} else {
|
|
540
|
+
submitted.reply(msg).catch(() => {});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
} catch {
|
|
544
|
+
// User didn't submit in time — silently ignore
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
550
|
+
// ChrxConfirm
|
|
551
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
552
|
+
|
|
553
|
+
class ChrxConfirm {
|
|
554
|
+
/**
|
|
555
|
+
* Yes/No confirmation prompt in two lines.
|
|
556
|
+
*
|
|
557
|
+
* @param {object} options
|
|
558
|
+
* @param {string} options.question Question to ask
|
|
559
|
+
* @param {Function} options.onConfirm Called when user clicks Yes
|
|
560
|
+
* @param {Function} [options.onCancel] Called when user clicks No
|
|
561
|
+
* @param {string} [options.confirmLabel] Yes button label (default: "Confirm")
|
|
562
|
+
* @param {string} [options.cancelLabel] No button label (default: "Cancel")
|
|
563
|
+
* @param {string} [options.color] Embed color (default: #5865F2)
|
|
564
|
+
* @param {boolean} [options.ephemeral]
|
|
565
|
+
* @param {number} [options.timeout] ms before auto cancel (default: 30000)
|
|
566
|
+
*/
|
|
567
|
+
constructor(options = {}) {
|
|
568
|
+
if (!options.question) throw new Error("[ChrxConfirm] question is required.");
|
|
569
|
+
if (!options.onConfirm) throw new Error("[ChrxConfirm] onConfirm is required.");
|
|
570
|
+
this._options = options;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Send the confirmation prompt.
|
|
575
|
+
*/
|
|
576
|
+
async send(interaction) {
|
|
577
|
+
const confirmId = `chrx-confirm-yes-${Math.random().toString(36).slice(2, 7)}`;
|
|
578
|
+
const cancelId = `chrx-confirm-no-${Math.random().toString(36).slice(2, 7)}`;
|
|
579
|
+
|
|
580
|
+
const embed = new EmbedBuilder()
|
|
581
|
+
.setColor(this._options.color ?? "#5865F2")
|
|
582
|
+
.setDescription(`❓ ${this._options.question}`);
|
|
583
|
+
|
|
584
|
+
const row = new ActionRowBuilder().addComponents(
|
|
585
|
+
new ButtonBuilder()
|
|
586
|
+
.setCustomId(confirmId)
|
|
587
|
+
.setLabel(this._options.confirmLabel ?? "Confirm")
|
|
588
|
+
.setStyle(ButtonStyle.Success),
|
|
589
|
+
new ButtonBuilder()
|
|
590
|
+
.setCustomId(cancelId)
|
|
591
|
+
.setLabel(this._options.cancelLabel ?? "Cancel")
|
|
592
|
+
.setStyle(ButtonStyle.Danger)
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
const reply = await interaction.reply({
|
|
596
|
+
embeds: [embed],
|
|
597
|
+
components: [row],
|
|
598
|
+
ephemeral: this._options.ephemeral ?? false,
|
|
599
|
+
fetchReply: true,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const collector = reply.createMessageComponentCollector({
|
|
603
|
+
componentType: ComponentType.Button,
|
|
604
|
+
time: this._options.timeout ?? 30000,
|
|
605
|
+
max: 1,
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
collector.on("collect", async (i) => {
|
|
609
|
+
if (i.user.id !== interaction.user.id) {
|
|
610
|
+
return i.reply({ content: "❌ This isn't your confirmation.", ephemeral: true });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
if (i.customId === confirmId) {
|
|
615
|
+
await this._options.onConfirm(i);
|
|
616
|
+
} else {
|
|
617
|
+
if (this._options.onCancel) {
|
|
618
|
+
await this._options.onCancel(i);
|
|
619
|
+
} else {
|
|
620
|
+
await i.update({ embeds: [embed.setDescription("❌ Cancelled.").setColor("#ED4245")], components: [] });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
} catch (err) {
|
|
624
|
+
console.error("[ChrxConfirm] Error:", err);
|
|
625
|
+
i.reply({ content: err.message || "❌ Something went wrong.", ephemeral: true }).catch(() => {});
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
collector.on("end", (collected) => {
|
|
630
|
+
if (collected.size === 0) {
|
|
631
|
+
reply.edit({
|
|
632
|
+
embeds: [embed.setDescription("⏱ Confirmation timed out.").setColor("#ED4245")],
|
|
633
|
+
components: [],
|
|
634
|
+
}).catch(() => {});
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
641
|
+
// Exports
|
|
642
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
643
|
+
|
|
644
|
+
module.exports = {
|
|
645
|
+
ChrxButton,
|
|
646
|
+
ChrxSelectMenu,
|
|
647
|
+
ChrxPagination,
|
|
648
|
+
ChrxContextMenu,
|
|
649
|
+
ChrxMenu,
|
|
650
|
+
ChrxForm,
|
|
651
|
+
ChrxConfirm,
|
|
652
|
+
};
|