@kyvrixon/utils 1.0.11 → 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kyvrixon/utils",
3
3
  "main": "./src/index.ts",
4
- "version": "1.0.11",
4
+ "version": "1.4.0",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "license": "MIT",
@@ -11,6 +11,7 @@
11
11
  "engines": {
12
12
  "bun": "1.3.11"
13
13
  },
14
+ "engineStrict": true,
14
15
  "repository": {
15
16
  "url": "https://github.com/kyvrixon/utils"
16
17
  },
@@ -18,12 +19,11 @@
18
19
  "pub": "bun publish --access public",
19
20
  "pretty": "bun biome format --write ."
20
21
  },
21
- "packageManager": "bun@1.3.10",
22
22
  "types": "./src/index.ts",
23
23
  "devDependencies": {
24
- "@biomejs/biome": "2.4.6",
24
+ "@biomejs/biome": "2.4.9",
25
25
  "@types/bun": "1.3.11",
26
- "typescript": "5.9.3"
26
+ "typescript": "6.0.2"
27
27
  },
28
28
  "dependencies": {
29
29
  "chalk": "5.6.2",
@@ -33,6 +33,10 @@
33
33
  ".": {
34
34
  "types": "./src/index.ts",
35
35
  "import": "./src/index.ts"
36
+ },
37
+ "./*": {
38
+ "types": "./src/lib/*/index.ts",
39
+ "import": "./src/lib/*/index.ts"
36
40
  }
37
41
  }
38
42
  }
@@ -7,7 +7,21 @@ import type {
7
7
  SlashCommandSubcommandsOnlyBuilder,
8
8
  } from "discord.js";
9
9
 
10
- export class DiscordCommand<C extends Client<boolean>> {
10
+ export interface DiscordCommandMetadata extends Record<string, unknown> {}
11
+
12
+ /**
13
+ * Wraps a discord.js slash command with typed `execute` and optional `autocomplete` handlers.
14
+ * @typeParam C - The bot's `Client` type. Inferred from args[0] in `method`.
15
+ *
16
+ * @example To extend the `metadata` types
17
+ * declare module "@kyvrixon/utils" {
18
+ * interface DiscordCommandMetadata {
19
+ * cooldown?: number;
20
+ * category?: string;
21
+ * }
22
+ * }
23
+ */
24
+ export class DiscordCommand<C extends Client> {
11
25
  public readonly data:
12
26
  | SlashCommandBuilder
13
27
  | SlashCommandOptionsOnlyBuilder
@@ -20,13 +34,16 @@ export class DiscordCommand<C extends Client<boolean>> {
20
34
  client: C,
21
35
  interaction: AutocompleteInteraction,
22
36
  ) => Promise<void>;
37
+ public metadata: DiscordCommandMetadata;
23
38
 
24
39
  constructor(ops: {
25
40
  data: DiscordCommand<C>["data"];
41
+ metadata: DiscordCommand<C>["metadata"];
26
42
  execute: DiscordCommand<C>["execute"];
27
43
  autocomplete?: DiscordCommand<C>["autocomplete"];
28
44
  }) {
29
45
  this.data = ops.data;
46
+ this.metadata = ops.metadata;
30
47
  this.execute = ops.execute;
31
48
  this.autocomplete = ops.autocomplete;
32
49
  }
@@ -1,6 +1,15 @@
1
1
  /** biome-ignore-all lint/suspicious/noExplicitAny: Its fine */
2
2
  import type { Client, ClientEvents, RestEvents } from "discord.js";
3
3
 
4
+ /**
5
+ * Augment this interface via module declaration to register custom event types.
6
+ * @example
7
+ * declare module "@kyvrixon/utils" {
8
+ * interface DiscordEventCustomType {
9
+ * myEvent: [data: string];
10
+ * }
11
+ * }
12
+ */
4
13
  // biome-ignore lint/suspicious/noEmptyInterface: Its fine
5
14
  export interface DiscordEventCustomType {}
6
15
 
@@ -20,13 +29,10 @@ type EventArgs<
20
29
  > = Extract<EventMap<T>[K], any[]>;
21
30
 
22
31
  /**
23
- * To define custom types:
24
- * @example
25
- * declare module "@kyvrixon/utils" {
26
- * interface DiscordEventCustomType {
27
- * myEventName: [data: string];
28
- * }
29
- * }
32
+ * Wraps a discord.js event handler. Supports `"client"`, `"rest"`, and `"custom"` event types.
33
+ * @typeParam V - The bot's `Client` type.
34
+ * @typeParam T - The event source discriminant.
35
+ * @typeParam K - The event name within the chosen source.
30
36
  */
31
37
  export class DiscordEvent<
32
38
  V extends Client,
@@ -8,14 +8,16 @@ import {
8
8
  type ChatInputCommandInteraction,
9
9
  ComponentType,
10
10
  ContainerBuilder,
11
- type FileBuilder,
11
+ EmbedBuilder,
12
+ FileBuilder,
12
13
  LabelBuilder,
13
- type MediaGalleryBuilder,
14
+ MediaGalleryBuilder,
14
15
  type MentionableSelectMenuBuilder,
16
+ type Message,
15
17
  ModalBuilder,
16
18
  type RoleSelectMenuBuilder,
17
- type SectionBuilder,
18
- type SeparatorBuilder,
19
+ SectionBuilder,
20
+ SeparatorBuilder,
19
21
  type StringSelectMenuBuilder,
20
22
  TextDisplayBuilder,
21
23
  TextInputBuilder,
@@ -23,6 +25,7 @@ import {
23
25
  type UserSelectMenuBuilder,
24
26
  } from "discord.js";
25
27
 
28
+ /** An action row containing any interactive message component. */
26
29
  export type MessageActionRow = ActionRowBuilder<
27
30
  | ButtonBuilder
28
31
  | StringSelectMenuBuilder
@@ -32,8 +35,24 @@ export type MessageActionRow = ActionRowBuilder<
32
35
  | MentionableSelectMenuBuilder
33
36
  >;
34
37
 
35
- export type LeaderboardComponentType =
38
+ export const BUTTONS_SYMBOL: unique symbol = Symbol("pagination-buttons");
39
+ export const DATA_SYMBOL: unique symbol = Symbol("pagination-data");
40
+
41
+ /** Valid component types that can appear in a pagination page layout. */
42
+ export type PaginationInput =
43
+ | string
44
+ | TextDisplayBuilder
45
+ | SectionBuilder
46
+ | SeparatorBuilder
47
+ | FileBuilder
48
+ | MediaGalleryBuilder
49
+ | MessageActionRow
50
+ | typeof BUTTONS_SYMBOL
51
+ | typeof DATA_SYMBOL;
52
+
53
+ type InternalComponent =
36
54
  | { type: "buttons" }
55
+ | { type: "data" }
37
56
  | { type: "display"; component: TextDisplayBuilder }
38
57
  | { type: "section"; component: SectionBuilder }
39
58
  | { type: "separator"; component: SeparatorBuilder }
@@ -41,287 +60,464 @@ export type LeaderboardComponentType =
41
60
  | { type: "gallery"; component: MediaGalleryBuilder }
42
61
  | { type: "actionrow"; component: MessageActionRow };
43
62
 
44
- export interface LeaderboardOptions {
45
- contentMarker?: string;
63
+ /** Shared options for all pagination modes. */
64
+ export interface PaginationBaseOptions {
65
+ /** Number of list entries shown per page (default: 5). */
46
66
  entriesPerPage?: number;
67
+ /** Key-value pairs replaced in rendered content. */
47
68
  replacements?: Record<string, string>;
48
- styling?: Array<{ accent_color?: number; spoiler?: boolean }>;
69
+ /** Whether the pagination message is ephemeral. */
49
70
  ephemeral?: boolean;
50
71
  }
51
72
 
52
- function applyReplacements(
53
- content: string,
54
- replacements?: Record<string, string>,
55
- ): string {
56
- if (!replacements) return content;
57
- return Object.entries(replacements).reduce(
58
- (acc, [key, value]) => acc.replaceAll(key, value),
59
- content,
60
- );
73
+ /**
74
+ * Options for **container** mode (Components V2).
75
+ * Uses a `ContainerBuilder`-based layout with the `IsComponentsV2` message flag.
76
+ */
77
+ export interface PaginationContainerOptions extends PaginationBaseOptions {
78
+ /** Selects container mode. */
79
+ type: "container";
80
+ /** Single layout template using sentinels `DiscordPagination.DATA` and `DiscordPagination.BUTTONS`. */
81
+ layout: PaginationInput[];
82
+ /** Container accent color. */
83
+ accentColor?: number;
84
+ /** Whether the container is a spoiler. */
85
+ spoiler?: boolean;
61
86
  }
62
87
 
63
- function getPaginationRow(
64
- prefix: string,
65
- currentIndex: number,
66
- entriesPerPage: number,
67
- totalPages: number,
68
- listLength: number,
69
- ended: boolean,
70
- ): ActionRowBuilder<ButtonBuilder> {
71
- return new ActionRowBuilder<ButtonBuilder>().addComponents(
72
- new ButtonBuilder()
73
- .setCustomId(`${prefix}back`)
74
- .setLabel("Prev")
75
- .setStyle(ButtonStyle.Secondary)
76
- .setDisabled(ended || currentIndex === 0),
77
- new ButtonBuilder()
78
- .setCustomId(`${prefix}info`)
79
- .setLabel(
80
- `${Math.floor(currentIndex / entriesPerPage) + 1}/${totalPages}`,
81
- )
82
- .setStyle(ButtonStyle.Secondary)
83
- .setDisabled(ended || totalPages === 1),
84
- new ButtonBuilder()
85
- .setCustomId(`${prefix}forward`)
86
- .setLabel("Next")
87
- .setStyle(ButtonStyle.Secondary)
88
- .setDisabled(ended || currentIndex + entriesPerPage >= listLength),
89
- );
88
+ /**
89
+ * Options for **embed** mode.
90
+ * Uses a standard `EmbedBuilder` with an `ActionRow` for navigation buttons.
91
+ * The embed's `description` and `footer` are reserved for page data and the page counter.
92
+ */
93
+ export interface PaginationEmbedOptions extends PaginationBaseOptions {
94
+ /** Selects embed mode. */
95
+ type: "embed";
96
+ /** EmbedBuilder template. Description and footer are overwritten per page. */
97
+ embed: EmbedBuilder;
90
98
  }
91
99
 
92
- export async function createPagination(
93
- list: Array<string>,
94
- structure: Array<Array<LeaderboardComponentType>>,
95
- interaction: ButtonInteraction | ChatInputCommandInteraction,
96
- options: LeaderboardOptions = {},
97
- ) {
98
- const {
99
- entriesPerPage = 5,
100
- replacements,
101
- styling,
102
- ephemeral = false,
103
- contentMarker = "--DATA_INPUT--",
104
- } = options;
105
-
106
- if (entriesPerPage <= 0)
107
- throw new Error("entriesPerPage must be greater than 0");
108
-
109
- const uID = randomUUIDv7();
110
- const prefix = `~PAGINATION_${uID}_`;
111
- let currentIndex = 0;
112
- let ended = false;
113
-
114
- if (!list.length) {
115
- await interaction
116
- .reply({
117
- allowedMentions: {
118
- parse: [],
119
- repliedUser: false,
120
- },
121
- components: [
122
- new ContainerBuilder().addTextDisplayComponents(
123
- new TextDisplayBuilder().setContent("No data to show"),
100
+ /** Discriminated union of all pagination option types. Use the `type` field to select a mode. */
101
+ export type PaginationOptions =
102
+ | PaginationContainerOptions
103
+ | PaginationEmbedOptions;
104
+
105
+ /**
106
+ * Discord paginator supporting both **Components V2** (`ContainerBuilder`) and
107
+ * **Embed** (`EmbedBuilder`) modes.
108
+ *
109
+ * @example Container mode
110
+ * ```ts
111
+ * const pagination = new DiscordPagination(entries, {
112
+ * type: "container",
113
+ * layout: [
114
+ * "# Leaderboard",
115
+ * new SeparatorBuilder(),
116
+ * DiscordPagination.DATA,
117
+ * new SeparatorBuilder(),
118
+ * DiscordPagination.BUTTONS,
119
+ * ],
120
+ * entriesPerPage: 5,
121
+ * accentColor: 0x5865f2,
122
+ * });
123
+ * ```
124
+ *
125
+ * @example Embed mode
126
+ * ```ts
127
+ * const pagination = new DiscordPagination(entries, {
128
+ * type: "embed",
129
+ * embed: new EmbedBuilder().setTitle("Leaderboard").setColor(0x5865f2),
130
+ * entriesPerPage: 5,
131
+ * });
132
+ * ```
133
+ */
134
+ export class DiscordPagination {
135
+ /** Sentinel — marks where the pagination buttons should render. */
136
+ static readonly BUTTONS: typeof BUTTONS_SYMBOL = BUTTONS_SYMBOL;
137
+ /** Sentinel — marks where the paginated list entries should render. */
138
+ static readonly DATA: typeof DATA_SYMBOL = DATA_SYMBOL;
139
+
140
+ private readonly list: string[];
141
+ private readonly entriesPerPage: number;
142
+ private readonly replacements?: Record<string, string>;
143
+ private readonly ephemeral: boolean;
144
+ private readonly prefix: string;
145
+ private readonly totalPages: number;
146
+ private readonly mode: "container" | "embed";
147
+
148
+ // Container mode
149
+ private readonly layout?: InternalComponent[];
150
+ private readonly accentColor?: number;
151
+ private readonly spoiler?: boolean;
152
+
153
+ // Embed mode
154
+ private readonly embedTemplate?: EmbedBuilder;
155
+
156
+ // Runtime state
157
+ private currentIndex = 0;
158
+ private ended = false;
159
+ private isMessage = false;
160
+ private interaction?: ButtonInteraction | ChatInputCommandInteraction;
161
+ private replyMessage?: Message;
162
+
163
+ constructor(list: string[], options: PaginationOptions) {
164
+ const { entriesPerPage = 5, replacements, ephemeral = false } = options;
165
+
166
+ if (entriesPerPage <= 0)
167
+ throw new Error("entriesPerPage must be greater than 0");
168
+
169
+ this.list = list;
170
+ this.entriesPerPage = entriesPerPage;
171
+ this.replacements = replacements;
172
+ this.ephemeral = ephemeral;
173
+ this.prefix = `~PAGINATION_${randomUUIDv7()}_`;
174
+ this.totalPages = Math.ceil(list.length / entriesPerPage);
175
+
176
+ this.mode = options.type;
177
+
178
+ if (options.type === "container") {
179
+ this.layout = options.layout.map((input) => this.normalize(input));
180
+ this.accentColor = options.accentColor;
181
+ this.spoiler = options.spoiler;
182
+ } else {
183
+ this.embedTemplate = options.embed;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Sends the paginated message and starts the button collector.
189
+ * @param target - The interaction or message to reply to.
190
+ */
191
+ public async send(
192
+ target: ButtonInteraction | ChatInputCommandInteraction | Message,
193
+ ): Promise<void> {
194
+ this.isMessage = !("deferReply" in target);
195
+ const userId = this.isMessage
196
+ ? (target as Message).author.id
197
+ : (target as ButtonInteraction | ChatInputCommandInteraction).user.id;
198
+
199
+ if (!this.list.length) {
200
+ await this.sendEmpty(target);
201
+ return;
202
+ }
203
+
204
+ if (this.isMessage) {
205
+ this.replyMessage = await (target as Message).reply(this.buildPayload());
206
+ } else {
207
+ const interaction = target as
208
+ | ButtonInteraction
209
+ | ChatInputCommandInteraction;
210
+ this.interaction = interaction;
211
+
212
+ if (!interaction.replied && !interaction.deferred) {
213
+ const response = await interaction
214
+ .deferReply({
215
+ withResponse: true,
216
+ flags: this.ephemeral ? ["Ephemeral"] : [],
217
+ })
218
+ .catch(() => null);
219
+ this.replyMessage =
220
+ response?.resource?.message ??
221
+ (await interaction.fetchReply().catch(() => undefined));
222
+ } else {
223
+ this.replyMessage = await interaction
224
+ .fetchReply()
225
+ .catch(() => undefined);
226
+ }
227
+
228
+ await this.render();
229
+ }
230
+
231
+ if (!this.replyMessage) return;
232
+
233
+ const collector = this.replyMessage.createMessageComponentCollector({
234
+ componentType: ComponentType.Button,
235
+ time: 60_000,
236
+ });
237
+
238
+ collector.on("collect", async (btn) => {
239
+ if (btn.user.id !== userId) {
240
+ return void btn.deferUpdate();
241
+ }
242
+ if (!btn.customId.startsWith(this.prefix)) return;
243
+
244
+ this.ended = false;
245
+ collector.resetTimer();
246
+
247
+ if (btn.customId === `${this.prefix}info`) {
248
+ await this.handlePageJump(btn);
249
+ } else {
250
+ this.currentIndex +=
251
+ btn.customId === `${this.prefix}back`
252
+ ? -this.entriesPerPage
253
+ : this.entriesPerPage;
254
+ this.currentIndex = Math.max(
255
+ 0,
256
+ Math.min(
257
+ this.currentIndex,
258
+ (this.totalPages - 1) * this.entriesPerPage,
124
259
  ),
125
- ],
126
- flags: ephemeral ? ["Ephemeral", "IsComponentsV2"] : ["IsComponentsV2"],
127
- })
128
- .catch(() => {});
129
- return;
260
+ );
261
+ await btn.deferUpdate().catch(() => {});
262
+ }
263
+
264
+ await this.render();
265
+ });
266
+
267
+ collector.on("end", async () => {
268
+ this.ended = true;
269
+ await this.render();
270
+ });
130
271
  }
131
272
 
132
- const totalPages = Math.ceil(list.length / entriesPerPage);
133
- const lastPage = structure[structure.length - 1];
273
+ private async sendEmpty(
274
+ target: ButtonInteraction | ChatInputCommandInteraction | Message,
275
+ ): Promise<void> {
276
+ if (this.mode === "embed" && !this.embedTemplate)
277
+ throw new Error(
278
+ "[@kyvrixon/utils]: Pagination: embedTemplate is in a corrupted state",
279
+ );
134
280
 
135
- if (!lastPage) throw new Error("createLeaderboard is in a corrupted state");
281
+ const allowedMentions = { parse: [] as const, repliedUser: false };
282
+
283
+ if (this.isMessage) {
284
+ const payload =
285
+ this.mode === "container"
286
+ ? {
287
+ components: [
288
+ new ContainerBuilder().addTextDisplayComponents(
289
+ new TextDisplayBuilder().setContent("No data to show"),
290
+ ),
291
+ ],
292
+ flags: ["IsComponentsV2"] as const,
293
+ allowedMentions,
294
+ }
295
+ : {
296
+ embeds: [
297
+ new EmbedBuilder(this.embedTemplate?.toJSON()).setDescription(
298
+ "No data to show",
299
+ ),
300
+ ],
301
+ allowedMentions,
302
+ };
303
+ await (target as Message).reply(payload).catch(() => {});
304
+ } else {
305
+ const payload =
306
+ this.mode === "container"
307
+ ? {
308
+ components: [
309
+ new ContainerBuilder().addTextDisplayComponents(
310
+ new TextDisplayBuilder().setContent("No data to show"),
311
+ ),
312
+ ],
313
+ flags: this.ephemeral
314
+ ? (["Ephemeral", "IsComponentsV2"] as const)
315
+ : (["IsComponentsV2"] as const),
316
+ allowedMentions,
317
+ }
318
+ : {
319
+ embeds: [
320
+ new EmbedBuilder(this.embedTemplate?.toJSON()).setDescription(
321
+ "No data to show",
322
+ ),
323
+ ],
324
+ flags: this.ephemeral ? (["Ephemeral"] as const) : ([] as const),
325
+ allowedMentions,
326
+ };
327
+ await (target as ButtonInteraction | ChatInputCommandInteraction)
328
+ .reply(payload)
329
+ .catch(() => {});
330
+ }
331
+ }
136
332
 
137
- while (structure.length < totalPages) {
138
- structure.push(
139
- lastPage.map((comp) => {
140
- if (comp.type === "display") {
141
- return {
142
- type: "display",
143
- component: new TextDisplayBuilder(comp.component.toJSON()),
144
- };
145
- }
146
- return comp;
147
- }),
148
- );
333
+ private normalize(input: PaginationInput): InternalComponent {
334
+ if (input === BUTTONS_SYMBOL) return { type: "buttons" };
335
+ if (input === DATA_SYMBOL) return { type: "data" };
336
+ if (typeof input === "string")
337
+ return {
338
+ type: "display",
339
+ component: new TextDisplayBuilder().setContent(input),
340
+ };
341
+ if (input instanceof TextDisplayBuilder)
342
+ return { type: "display", component: input };
343
+ if (input instanceof SeparatorBuilder)
344
+ return { type: "separator", component: input };
345
+ if (input instanceof SectionBuilder)
346
+ return { type: "section", component: input };
347
+ if (input instanceof FileBuilder) return { type: "file", component: input };
348
+ if (input instanceof MediaGalleryBuilder)
349
+ return { type: "gallery", component: input };
350
+ return { type: "actionrow", component: input };
351
+ }
352
+
353
+ private buildPayload() {
354
+ if (this.mode === "embed") {
355
+ return {
356
+ embeds: [this.generateEmbed()],
357
+ components: [this.getPaginationRow()],
358
+ allowedMentions: { parse: [] as const, repliedUser: false },
359
+ };
360
+ }
361
+
362
+ return {
363
+ components: [this.generateContainer()],
364
+ flags: ["IsComponentsV2"] as const,
365
+ allowedMentions: { parse: [] as const, repliedUser: false },
366
+ };
149
367
  }
150
368
 
151
- function generateContainer(page: number): ContainerBuilder {
152
- const pageStructure = structure[page];
153
- if (!pageStructure) throw new Error(`Page ${page} structure not found`);
369
+ private generateContainer(): ContainerBuilder {
370
+ if (!this.layout)
371
+ throw new Error(
372
+ "[@kyvrixon/utils]: Pagination: layout is in a corrupted state",
373
+ );
374
+
375
+ const page = Math.floor(this.currentIndex / this.entriesPerPage);
154
376
 
155
377
  return new ContainerBuilder({
156
- components: pageStructure.map((comp) => {
378
+ components: this.layout.map((comp) => {
157
379
  switch (comp.type) {
158
380
  case "buttons":
159
- return getPaginationRow(
160
- prefix,
161
- currentIndex,
162
- entriesPerPage,
163
- totalPages,
164
- list.length,
165
- ended,
166
- ).toJSON();
167
-
168
- case "display": {
169
- if (comp.component.data.content !== contentMarker) {
170
- return comp.component.toJSON();
171
- }
381
+ return this.getPaginationRow().toJSON();
172
382
 
173
- const content = list
383
+ case "data": {
384
+ const content = this.list
174
385
  .slice(
175
- page * entriesPerPage,
176
- page * entriesPerPage + entriesPerPage,
386
+ page * this.entriesPerPage,
387
+ (page + 1) * this.entriesPerPage,
177
388
  )
178
389
  .join("\n");
179
- comp.component.data.content = applyReplacements(
180
- content,
181
- replacements,
182
- );
183
- return comp.component.toJSON();
390
+ return new TextDisplayBuilder()
391
+ .setContent(this.applyReplacements(content))
392
+ .toJSON();
184
393
  }
185
394
 
186
- case "section":
187
- case "separator":
188
- case "file":
189
- case "gallery":
190
- case "actionrow":
395
+ default:
191
396
  return comp.component.toJSON();
192
-
193
- default: {
194
- return comp;
195
- }
196
397
  }
197
398
  }),
198
- accent_color: styling?.[page]?.accent_color,
199
- spoiler: styling?.[page]?.spoiler,
399
+ accent_color: this.accentColor,
400
+ spoiler: this.spoiler,
200
401
  });
201
402
  }
202
403
 
203
- if (!interaction.replied && !interaction.deferred) {
204
- await interaction
205
- .deferReply({
206
- withResponse: true,
207
- flags: ephemeral ? ["Ephemeral"] : [],
208
- })
209
- .catch(() => null);
210
- }
404
+ private generateEmbed(): EmbedBuilder {
405
+ if (!this.embedTemplate)
406
+ throw new Error(
407
+ "[@kyvrixon/utils]: Pagination: embedTemplate is in a corrupted state",
408
+ );
211
409
 
212
- const channel = interaction.channel;
213
- if (!channel || !("createMessageComponentCollector" in channel)) {
214
- throw new Error("Invalid channel type");
410
+ const page = Math.floor(this.currentIndex / this.entriesPerPage);
411
+ const content = this.list
412
+ .slice(page * this.entriesPerPage, (page + 1) * this.entriesPerPage)
413
+ .join("\n");
414
+
415
+ return new EmbedBuilder(this.embedTemplate.toJSON())
416
+ .setDescription(this.applyReplacements(content))
417
+ .setFooter({ text: `Page ${page + 1}/${this.totalPages}` });
215
418
  }
216
419
 
217
- async function render(): Promise<void> {
218
- try {
219
- await interaction.editReply({
220
- components: [
221
- generateContainer(Math.floor(currentIndex / entriesPerPage)),
222
- ],
223
- flags: ["IsComponentsV2"],
224
- allowedMentions: {
225
- parse: [],
226
- repliedUser: false,
227
- },
228
- });
229
- } catch (error) {
230
- const e = error as Error;
231
- if (!e.message.includes("Unknown Message")) {
232
- console.error("Failed to render leaderboard:", error);
233
- }
234
- }
420
+ private getPaginationRow(): ActionRowBuilder<ButtonBuilder> {
421
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
422
+ new ButtonBuilder()
423
+ .setCustomId(`${this.prefix}back`)
424
+ .setLabel("Prev")
425
+ .setStyle(ButtonStyle.Secondary)
426
+ .setDisabled(this.ended || this.currentIndex === 0),
427
+ new ButtonBuilder()
428
+ .setCustomId(`${this.prefix}info`)
429
+ .setLabel(
430
+ `${Math.floor(this.currentIndex / this.entriesPerPage) + 1}/${this.totalPages}`,
431
+ )
432
+ .setStyle(ButtonStyle.Secondary)
433
+ .setDisabled(this.ended || this.totalPages === 1 || this.isMessage),
434
+ new ButtonBuilder()
435
+ .setCustomId(`${this.prefix}forward`)
436
+ .setLabel("Next")
437
+ .setStyle(ButtonStyle.Secondary)
438
+ .setDisabled(
439
+ this.ended ||
440
+ this.currentIndex + this.entriesPerPage >= this.list.length,
441
+ ),
442
+ );
235
443
  }
236
444
 
237
- await render();
445
+ private async handlePageJump(btn: ButtonInteraction): Promise<void> {
446
+ const modal = new ModalBuilder()
447
+ .setCustomId(`${this.prefix}modal`)
448
+ .setTitle("Page Indexer")
449
+ .addLabelComponents(
450
+ new LabelBuilder()
451
+ .setLabel("Input a page number")
452
+ .setTextInputComponent(
453
+ new TextInputBuilder()
454
+ .setCustomId(`${this.prefix}number`)
455
+ .setRequired(true)
456
+ .setMinLength(1)
457
+ .setStyle(TextInputStyle.Short),
458
+ ),
459
+ );
238
460
 
239
- const collector = channel.createMessageComponentCollector({
240
- componentType: ComponentType.Button,
241
- time: 60000,
242
- });
461
+ await btn.showModal(modal).catch((e) => console.error(e));
462
+ const modalSubmit = await btn
463
+ .awaitModalSubmit({ time: 60_000 })
464
+ .catch(() => null);
243
465
 
244
- collector.on("collect", async (btn) => {
245
- if (btn.user.id !== interaction.user.id) {
246
- return void btn.deferUpdate();
466
+ if (!modalSubmit) {
467
+ await btn
468
+ .followUp({
469
+ content: "Modal timed out.",
470
+ flags: ["Ephemeral"],
471
+ })
472
+ .catch(() => null);
473
+ return;
247
474
  }
248
475
 
249
- if (!btn.customId.startsWith(prefix)) return;
250
-
251
- ended = false;
252
- collector.resetTimer();
253
-
254
- if (btn.customId === `${prefix}info`) {
255
- const modal = new ModalBuilder()
256
- .setCustomId(`${prefix}modal`)
257
- .setTitle("Page Indexer")
258
- .addLabelComponents(
259
- new LabelBuilder()
260
- // .setDescription("Input a page number")
261
- .setLabel("Input a page number")
262
- .setTextInputComponent(
263
- new TextInputBuilder()
264
- .setCustomId(`${prefix}number`)
265
- .setRequired(true)
266
- .setMinLength(1)
267
- .setStyle(TextInputStyle.Short),
268
- ),
269
- );
476
+ const pageNumber = Number(
477
+ modalSubmit.fields.getTextInputValue(`${this.prefix}number`),
478
+ );
270
479
 
271
- await btn.showModal(modal).catch((e) => console.error(e));
272
- const modalSubmit = await btn
273
- .awaitModalSubmit({ time: 60_000 })
274
- .catch((e) => console.error(e));
480
+ if (
481
+ !Number.isInteger(pageNumber) ||
482
+ pageNumber < 1 ||
483
+ pageNumber > this.totalPages
484
+ ) {
485
+ await modalSubmit
486
+ .reply({
487
+ content: `Invalid page! Choose a number between **1** and **${this.totalPages}**.`,
488
+ flags: ["Ephemeral"],
489
+ allowedMentions: { parse: [], repliedUser: false },
490
+ })
491
+ .catch(() => null);
492
+ return;
493
+ }
275
494
 
276
- if (!modalSubmit) {
277
- await btn
278
- .followUp({
279
- content: "Modal timed out.",
280
- flags: ["Ephemeral"],
281
- })
282
- .catch(() => null);
283
- return;
284
- }
495
+ await modalSubmit.deferUpdate().catch(() => null);
496
+ this.currentIndex = (pageNumber - 1) * this.entriesPerPage;
497
+ }
285
498
 
286
- await modalSubmit.deferUpdate().catch(() => null);
287
- const pageNumber = Number(
288
- modalSubmit.fields.getTextInputValue(`${prefix}number`),
289
- );
499
+ private applyReplacements(content: string): string {
500
+ if (!this.replacements) return content;
501
+ return Object.entries(this.replacements).reduce(
502
+ (acc, [key, value]) => acc.replaceAll(key, value),
503
+ content,
504
+ );
505
+ }
290
506
 
291
- if (
292
- Number.isNaN(pageNumber) ||
293
- pageNumber < 1 ||
294
- pageNumber > totalPages
295
- ) {
296
- await modalSubmit
297
- .reply({
298
- content: `Invalid page! Choose a number between **1** and **${totalPages}**.`,
299
- flags: ["Ephemeral"],
300
- allowedMentions: {
301
- parse: [],
302
- repliedUser: false,
303
- },
304
- })
305
- .catch(() => null);
306
- return;
307
- }
507
+ private async render(): Promise<void> {
508
+ try {
509
+ const payload = this.buildPayload();
308
510
 
309
- currentIndex = (pageNumber - 1) * entriesPerPage;
310
- } else {
311
- currentIndex +=
312
- btn.customId === `${prefix}back` ? -entriesPerPage : entriesPerPage;
313
- currentIndex = Math.max(
314
- 0,
315
- Math.min(currentIndex, (totalPages - 1) * entriesPerPage),
316
- );
317
- await btn.deferUpdate().catch(() => {});
511
+ if (this.isMessage && this.replyMessage) {
512
+ await this.replyMessage.edit(payload);
513
+ } else if (this.interaction) {
514
+ await this.interaction.editReply(payload);
515
+ }
516
+ } catch (error) {
517
+ const e = error as Error;
518
+ if (!e.message.includes("Unknown Message")) {
519
+ console.error("Failed to render pagination:", error);
520
+ }
318
521
  }
319
-
320
- await render();
321
- });
322
-
323
- collector.on("end", async () => {
324
- ended = true;
325
- await render();
326
- });
522
+ }
327
523
  }
@@ -0,0 +1,3 @@
1
+ export * from "./Command";
2
+ export * from "./createPagination";
3
+ export * from "./Event";
@@ -25,6 +25,16 @@ const ALL_UNITS_ORDER: Array<TimeUnitTypes> = [
25
25
  "ms",
26
26
  ];
27
27
 
28
+ /**
29
+ * Calendar-aware duration formatter. Converts raw seconds into a human-readable string.
30
+ * @param seconds - The duration in seconds to format.
31
+ * @param options - Formatting options.
32
+ * @param options.format - `"long"` (default) for full words, `"short"` for abbreviated units.
33
+ * @param options.onlyUnits - Restrict output to specific time units.
34
+ * @param options.includeZeroUnits - Include units with a value of zero.
35
+ * @param options.customFormatter - Override per-unit rendering.
36
+ * @returns A formatted duration string (e.g. `"2 hours and 30 minutes"` or `"2h 30m"`).
37
+ */
28
38
  export function formatSeconds(
29
39
  seconds: number,
30
40
  options: {
@@ -44,10 +54,13 @@ export function formatSeconds(
44
54
  format = "long",
45
55
  customFormatter,
46
56
  } = options;
57
+
58
+ if (!Number.isFinite(seconds)) return format === "short" ? "0s" : "0 seconds";
59
+
47
60
  let totalMs = Math.max(0, Math.round(seconds * 1000));
48
- const unitsToDisplay = ALL_UNITS_ORDER.filter((u) =>
49
- onlyUnits.length ? onlyUnits.includes(u) : true,
50
- );
61
+ const unitsToDisplay = onlyUnits.length
62
+ ? ALL_UNITS_ORDER.filter((u) => onlyUnits.includes(u))
63
+ : ALL_UNITS_ORDER;
51
64
 
52
65
  const diff: Partial<Record<TimeUnitTypes, number>> = {};
53
66
  const now = new Date();
@@ -55,28 +68,29 @@ export function formatSeconds(
55
68
 
56
69
  if (unitsToDisplay.includes("y")) {
57
70
  let y = end.getFullYear() - now.getFullYear();
58
- const tempDate = new Date(now);
59
- tempDate.setFullYear(now.getFullYear() + y);
60
- if (tempDate > end) y--;
61
- diff.y = y;
62
- totalMs -= new Date(now).setFullYear(now.getFullYear() + y) - now.getTime();
71
+ const afterYears = new Date(now);
72
+ afterYears.setFullYear(now.getFullYear() + y);
73
+ if (afterYears > end) y--;
74
+ diff.y = Math.max(0, y);
75
+ totalMs -=
76
+ new Date(now).setFullYear(now.getFullYear() + diff.y) - now.getTime();
63
77
  }
64
78
 
65
79
  if (unitsToDisplay.includes("mo")) {
66
80
  const startTotalMonths = now.getFullYear() * 12 + now.getMonth();
67
81
  const endTotalMonths = end.getFullYear() * 12 + end.getMonth();
68
82
  let mo = endTotalMonths - startTotalMonths;
69
- if (diff.y) mo -= diff.y * 12;
83
+ if (diff.y !== undefined) mo -= diff.y * 12;
70
84
 
71
- const tempDate = new Date(now);
72
- tempDate.setFullYear(now.getFullYear() + (diff.y || 0));
73
- tempDate.setMonth(now.getMonth() + mo);
74
- if (tempDate > end) mo--;
85
+ const afterYearsAndMonths = new Date(now);
86
+ afterYearsAndMonths.setFullYear(now.getFullYear() + (diff.y ?? 0));
87
+ afterYearsAndMonths.setMonth(now.getMonth() + mo);
88
+ if (afterYearsAndMonths > end) mo--;
75
89
 
76
90
  diff.mo = Math.max(0, mo);
77
91
  const jumpDate = new Date(now);
78
- jumpDate.setFullYear(now.getFullYear() + (diff.y || 0));
79
- jumpDate.setMonth(now.getMonth() + (diff.mo || 0));
92
+ jumpDate.setFullYear(now.getFullYear() + (diff.y ?? 0));
93
+ jumpDate.setMonth(now.getMonth() + diff.mo);
80
94
  totalMs = end.getTime() - jumpDate.getTime();
81
95
  }
82
96
 
@@ -10,6 +10,10 @@ const formatter = new Intl.DateTimeFormat("en-AU", {
10
10
  hour12: false,
11
11
  });
12
12
 
13
+ /**
14
+ * Chalk-based structured logger with timestamped, color-coded output.
15
+ * Supports levels: `notif`, `alert`, `error`, `debug`.
16
+ */
13
17
  export class LoggerModule {
14
18
  private readonly colors: Record<LogLevel, typeof chalk> = {
15
19
  notif: cyan,
@@ -37,10 +41,15 @@ export class LoggerModule {
37
41
  const timestamp = gray(`[${this.getTimestamp()}]`);
38
42
  const levelLabel = bold(this.colors[level](level.toUpperCase().padEnd(5)));
39
43
 
40
- const content =
41
- message instanceof Error
42
- ? `${red(message.message)}\n${this.sanitizeStack(message.stack || "")}`
43
- : String(message);
44
+ let content: string;
45
+ if (message instanceof Error) {
46
+ const stack = this.sanitizeStack(message.stack || "");
47
+ content = stack
48
+ ? `${red(message.message)}\n${stack}`
49
+ : red(message.message);
50
+ } else {
51
+ content = String(message);
52
+ }
44
53
 
45
54
  return `${timestamp} ${levelLabel} ${dim("»")} ${content}`;
46
55
  }
@@ -76,21 +85,32 @@ export class LoggerModule {
76
85
  console[this.logMethods[level]](msg);
77
86
  }
78
87
 
88
+ /** Logs at the `notif` (info) level. If `raw` is true, returns the string instead of printing. */
79
89
  public notif(m: unknown, raw = false) {
80
90
  return this.log("notif", m, raw);
81
91
  }
92
+ /** Logs at the `alert` (warn) level. If `raw` is true, returns the string instead of printing. */
82
93
  public alert(m: unknown, raw = false) {
83
94
  return this.log("alert", m, raw);
84
95
  }
96
+ /**
97
+ * Logs at the `error` level. Pass an optional `Error` to include its sanitized stack trace.
98
+ * If `raw` is true, returns the string instead of printing.
99
+ */
85
100
  public error(m: unknown, e?: Error, raw = false) {
86
101
  return this.log("error", e ?? m, raw);
87
102
  }
103
+ /** Logs at the `debug` level. If `raw` is true, returns the string instead of printing. */
88
104
  public debug(m: unknown, raw = false) {
89
105
  return this.log("debug", m, raw);
90
106
  }
91
107
 
108
+ /** Prints a centered `─` divider line with the given text. */
92
109
  public divider(text: string): void {
93
- const line = dim("─".repeat(Math.max(0, (50 - text.length - 2) / 2)));
94
- console.log(`\n${line} ${bold(text.trim())} ${line}`);
110
+ const trimmed = text.trim();
111
+ const remaining = Math.max(0, 50 - trimmed.length - 2);
112
+ const left = dim("─".repeat(Math.ceil(remaining / 2)));
113
+ const right = dim("─".repeat(Math.floor(remaining / 2)));
114
+ console.log(`\n${left} ${bold(trimmed)} ${right}`);
95
115
  }
96
116
  }
@@ -0,0 +1 @@
1
+ export * from "./LoggerModule";
@@ -9,6 +9,12 @@ const suffixes: Record<Intl.LDMLPluralRule, string> = {
9
9
  many: "th",
10
10
  };
11
11
 
12
+ /**
13
+ * Converts a number to its English ordinal string (e.g. 1 → "1st", 12 → "12th").
14
+ * Handles teens, zero, negatives, and infinities.
15
+ * @param n - The number to convert.
16
+ * @returns The number with its ordinal suffix appended.
17
+ */
12
18
  export function toOrdinal(n: number): string {
13
19
  return `${n}${suffixes[pr.select(n)]}`;
14
20
  }