@kyvrixon/utils 1.0.10 → 1.0.12

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/README.md CHANGED
@@ -4,6 +4,4 @@ General utility files I use for alot of projects! Designed for use with the [bun
4
4
 
5
5
  ```bash
6
6
  bun install @kyvrixon/utils
7
- # or
8
- bun install github:kyvrixon/utils#1.0.10
9
- ```
7
+ ```
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.10",
4
+ "version": "1.0.12",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "license": "MIT",
@@ -9,21 +9,20 @@
9
9
  "src"
10
10
  ],
11
11
  "engines": {
12
- "bun": "1.3.10"
12
+ "bun": "1.3.11"
13
13
  },
14
14
  "repository": {
15
15
  "url": "https://github.com/kyvrixon/utils"
16
16
  },
17
17
  "scripts": {
18
- "pub": "bun prebuild.ts && bun publish --access public",
18
+ "pub": "bun publish --access public",
19
19
  "pretty": "bun biome format --write ."
20
20
  },
21
- "packageManager": "bun@1.3.10",
22
21
  "types": "./src/index.ts",
23
22
  "devDependencies": {
24
- "@biomejs/biome": "2.4.6",
25
- "@types/bun": "1.3.10",
26
- "typescript": "5.9.3"
23
+ "@biomejs/biome": "2.4.9",
24
+ "@types/bun": "1.3.11",
25
+ "typescript": "6.0.2"
27
26
  },
28
27
  "dependencies": {
29
28
  "chalk": "5.6.2",
package/src/index.ts CHANGED
@@ -3,4 +3,4 @@ export * from "./lib/toOrdinal";
3
3
  export * from "./lib/discord-utils/Command";
4
4
  export * from "./lib/discord-utils/createPagination";
5
5
  export * from "./lib/discord-utils/Event";
6
- export * from "./lib/modules/LoggerModule";
6
+ export * from "./lib/modules/LoggerModule";
@@ -7,6 +7,10 @@ import type {
7
7
  SlashCommandSubcommandsOnlyBuilder,
8
8
  } from "discord.js";
9
9
 
10
+ /**
11
+ * Wraps a discord.js slash command with typed `execute` and optional `autocomplete` handlers.
12
+ * @typeParam C - The bot's `Client` type.
13
+ */
10
14
  export class DiscordCommand<C extends Client<boolean>> {
11
15
  public readonly data:
12
16
  | SlashCommandBuilder
@@ -1,61 +1,61 @@
1
1
  /** biome-ignore-all lint/suspicious/noExplicitAny: Its fine */
2
2
  import type { Client, ClientEvents, RestEvents } from "discord.js";
3
3
 
4
- // biome-ignore lint/suspicious/noEmptyInterface: Its fine
5
- export interface DiscordEventCustomType {}
6
4
  /**
7
- * To define custom types:
5
+ * Augment this interface via module declaration to register custom event types.
8
6
  * @example
9
7
  * declare module "@kyvrixon/utils" {
10
- * interface DiscordEventCustomType {
11
- * myEventName: [data: string];
12
- * }
13
- * }
14
- * // You can also add these to `ClientEvents` if you wish for better typing like so:
15
- * declare module "discord.js" {
16
- * interface ClientEvents extends DiscordEventCustomType {}
8
+ * interface DiscordEventCustomType {
9
+ * myEvent: [data: string];
10
+ * }
17
11
  * }
18
12
  */
13
+ // biome-ignore lint/suspicious/noEmptyInterface: Its fine
14
+ export interface DiscordEventCustomType {}
15
+
16
+ /** Maps an event type discriminant to its corresponding event map. */
17
+ type EventMap<T extends "client" | "rest" | "custom"> = T extends "client"
18
+ ? ClientEvents
19
+ : T extends "rest"
20
+ ? RestEvents
21
+ : keyof DiscordEventCustomType extends never
22
+ ? { "sooo.. there's no custom events..": [] }
23
+ : DiscordEventCustomType;
24
+
25
+ /** Resolves the argument tuple for a given event type + key pair. */
26
+ type EventArgs<
27
+ T extends "client" | "rest" | "custom",
28
+ K extends keyof EventMap<T>,
29
+ > = Extract<EventMap<T>[K], any[]>;
30
+
31
+ /**
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.
36
+ */
19
37
  export class DiscordEvent<
20
38
  V extends Client,
21
- T extends "client" | "custom" | "rest" = "client",
22
- K extends T extends "custom"
23
- ? keyof DiscordEventCustomType
24
- : T extends "client"
25
- ? keyof ClientEvents
26
- : keyof RestEvents = T extends "custom"
27
- ? keyof DiscordEventCustomType
28
- : T extends "client"
29
- ? keyof ClientEvents
30
- : keyof RestEvents,
39
+ T extends "client" | "rest" | "custom",
40
+ K extends keyof EventMap<T> = keyof EventMap<T>,
31
41
  > {
32
42
  public readonly type: T;
33
43
  public readonly name: K;
34
44
  public readonly once: boolean;
35
45
  public readonly method: (
36
46
  client: V,
37
- ...args: T extends "client"
38
- ? K extends keyof ClientEvents
39
- ? ClientEvents[K]
40
- : any[]
41
- : T extends "rest"
42
- ? K extends keyof RestEvents
43
- ? RestEvents[K]
44
- : any[]
45
- : K extends keyof DiscordEventCustomType
46
- ? DiscordEventCustomType[K]
47
- : any[]
48
- ) => Promise<void>;
47
+ ...args: EventArgs<T, K>
48
+ ) => void | Promise<void>;
49
49
 
50
50
  constructor(opts: {
51
51
  type: T;
52
52
  name: K;
53
- once?: boolean;
53
+ once: boolean;
54
54
  method: DiscordEvent<V, T, K>["method"];
55
55
  }) {
56
56
  this.type = opts.type;
57
57
  this.name = opts.name;
58
- this.once = opts.once ?? false;
58
+ this.once = opts.once;
59
59
  this.method = opts.method;
60
60
  }
61
61
  }
@@ -8,14 +8,14 @@ import {
8
8
  type ChatInputCommandInteraction,
9
9
  ComponentType,
10
10
  ContainerBuilder,
11
- type FileBuilder,
11
+ FileBuilder,
12
12
  LabelBuilder,
13
- type MediaGalleryBuilder,
13
+ MediaGalleryBuilder,
14
14
  type MentionableSelectMenuBuilder,
15
15
  ModalBuilder,
16
16
  type RoleSelectMenuBuilder,
17
- type SectionBuilder,
18
- type SeparatorBuilder,
17
+ SectionBuilder,
18
+ SeparatorBuilder,
19
19
  type StringSelectMenuBuilder,
20
20
  TextDisplayBuilder,
21
21
  TextInputBuilder,
@@ -23,6 +23,7 @@ import {
23
23
  type UserSelectMenuBuilder,
24
24
  } from "discord.js";
25
25
 
26
+ /** An action row containing any interactive message component. */
26
27
  export type MessageActionRow = ActionRowBuilder<
27
28
  | ButtonBuilder
28
29
  | StringSelectMenuBuilder
@@ -32,8 +33,24 @@ export type MessageActionRow = ActionRowBuilder<
32
33
  | MentionableSelectMenuBuilder
33
34
  >;
34
35
 
35
- export type LeaderboardComponentType =
36
+ const BUTTONS_SYMBOL: unique symbol = Symbol("pagination-buttons");
37
+ const DATA_SYMBOL: unique symbol = Symbol("pagination-data");
38
+
39
+ /** Valid component types that can appear in a pagination page layout. */
40
+ export type PaginationInput =
41
+ | string
42
+ | TextDisplayBuilder
43
+ | SectionBuilder
44
+ | SeparatorBuilder
45
+ | FileBuilder
46
+ | MediaGalleryBuilder
47
+ | MessageActionRow
48
+ | typeof BUTTONS_SYMBOL
49
+ | typeof DATA_SYMBOL;
50
+
51
+ type InternalComponent =
36
52
  | { type: "buttons" }
53
+ | { type: "data" }
37
54
  | { type: "display"; component: TextDisplayBuilder }
38
55
  | { type: "section"; component: SectionBuilder }
39
56
  | { type: "separator"; component: SeparatorBuilder }
@@ -41,287 +58,356 @@ export type LeaderboardComponentType =
41
58
  | { type: "gallery"; component: MediaGalleryBuilder }
42
59
  | { type: "actionrow"; component: MessageActionRow };
43
60
 
44
- export interface LeaderboardOptions {
45
- contentMarker?: string;
61
+ export interface PaginationOptions {
62
+ /** Number of list entries shown per page (default: 5). */
46
63
  entriesPerPage?: number;
64
+ /** Key-value pairs replaced in rendered content. */
47
65
  replacements?: Record<string, string>;
66
+ /** Per-page container styling overrides. */
48
67
  styling?: Array<{ accent_color?: number; spoiler?: boolean }>;
68
+ /** Whether the pagination message is ephemeral. */
49
69
  ephemeral?: boolean;
50
70
  }
51
71
 
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
- );
61
- }
72
+ /**
73
+ * Discord Components V2 paginator. Renders a `ContainerBuilder`-based layout
74
+ * with Prev/Next/page-jump buttons. Collector expires after 60 seconds.
75
+ *
76
+ * Use `DiscordPagination.BUTTONS` and `DiscordPagination.DATA` as sentinel
77
+ * values in the structure array to position the navigation row and list data.
78
+ *
79
+ * @example
80
+ * const pagination = new DiscordPagination(
81
+ * entries,
82
+ * [
83
+ * [
84
+ * "# Leaderboard",
85
+ * new SeparatorBuilder(),
86
+ * DiscordPagination.DATA,
87
+ * new SeparatorBuilder(),
88
+ * DiscordPagination.BUTTONS,
89
+ * ],
90
+ * ],
91
+ * {
92
+ * entriesPerPage: 5,
93
+ * ephemeral: false,
94
+ * styling: [{ accent_color: 0x5865f2 }],
95
+ * },
96
+ * );
97
+ */
98
+ export class DiscordPagination {
99
+ /** Sentinel — marks where the pagination buttons should render. */
100
+ static readonly BUTTONS: typeof BUTTONS_SYMBOL = BUTTONS_SYMBOL;
101
+ /** Sentinel — marks where the paginated list entries should render. */
102
+ static readonly DATA: typeof DATA_SYMBOL = DATA_SYMBOL;
103
+
104
+ private readonly list: string[];
105
+ private readonly structure: Array<Array<InternalComponent>>;
106
+ private readonly entriesPerPage: number;
107
+ private readonly replacements?: Record<string, string>;
108
+ private readonly styling?: PaginationOptions["styling"];
109
+ private readonly ephemeral: boolean;
110
+ private readonly prefix: string;
111
+ private readonly totalPages: number;
112
+
113
+ private currentIndex = 0;
114
+ private ended = false;
115
+ private interaction!: ButtonInteraction | ChatInputCommandInteraction;
116
+
117
+ constructor(
118
+ list: string[],
119
+ structure: Array<Array<PaginationInput>>,
120
+ options: PaginationOptions = {},
121
+ ) {
122
+ const {
123
+ entriesPerPage = 5,
124
+ replacements,
125
+ styling,
126
+ ephemeral = false,
127
+ } = options;
128
+
129
+ if (entriesPerPage <= 0)
130
+ throw new Error("entriesPerPage must be greater than 0");
131
+
132
+ this.list = list;
133
+ this.entriesPerPage = entriesPerPage;
134
+ this.replacements = replacements;
135
+ this.styling = styling;
136
+ this.ephemeral = ephemeral;
137
+ this.prefix = `~PAGINATION_${randomUUIDv7()}_`;
138
+ this.totalPages = Math.ceil(list.length / entriesPerPage);
139
+ this.structure = this.expandStructure(
140
+ structure.map((page) => page.map((input) => this.normalize(input))),
141
+ );
142
+ }
62
143
 
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
- );
90
- }
144
+ /**
145
+ * Sends the paginated message and starts the button collector.
146
+ * @param interaction - The interaction to reply to.
147
+ */
148
+ public async send(
149
+ interaction: ButtonInteraction | ChatInputCommandInteraction,
150
+ ): Promise<void> {
151
+ this.interaction = interaction;
152
+
153
+ if (!this.list.length) {
154
+ await interaction
155
+ .reply({
156
+ allowedMentions: { parse: [], repliedUser: false },
157
+ components: [
158
+ new ContainerBuilder().addTextDisplayComponents(
159
+ new TextDisplayBuilder().setContent("No data to show"),
160
+ ),
161
+ ],
162
+ flags: this.ephemeral
163
+ ? ["Ephemeral", "IsComponentsV2"]
164
+ : ["IsComponentsV2"],
165
+ })
166
+ .catch(() => {});
167
+ return;
168
+ }
91
169
 
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"),
170
+ if (!interaction.replied && !interaction.deferred) {
171
+ await interaction
172
+ .deferReply({
173
+ withResponse: true,
174
+ flags: this.ephemeral ? ["Ephemeral"] : [],
175
+ })
176
+ .catch(() => null);
177
+ }
178
+
179
+ const channel = interaction.channel;
180
+ if (!channel || !("createMessageComponentCollector" in channel)) {
181
+ throw new Error("Invalid channel type");
182
+ }
183
+
184
+ await this.render();
185
+
186
+ const collector = channel.createMessageComponentCollector({
187
+ componentType: ComponentType.Button,
188
+ time: 60000,
189
+ });
190
+
191
+ collector.on("collect", async (btn) => {
192
+ if (btn.user.id !== interaction.user.id) {
193
+ return void btn.deferUpdate();
194
+ }
195
+ if (!btn.customId.startsWith(this.prefix)) return;
196
+
197
+ this.ended = false;
198
+ collector.resetTimer();
199
+
200
+ if (btn.customId === `${this.prefix}info`) {
201
+ await this.handlePageJump(btn);
202
+ } else {
203
+ this.currentIndex +=
204
+ btn.customId === `${this.prefix}back`
205
+ ? -this.entriesPerPage
206
+ : this.entriesPerPage;
207
+ this.currentIndex = Math.max(
208
+ 0,
209
+ Math.min(
210
+ this.currentIndex,
211
+ (this.totalPages - 1) * this.entriesPerPage,
124
212
  ),
125
- ],
126
- flags: ephemeral ? ["Ephemeral", "IsComponentsV2"] : ["IsComponentsV2"],
127
- })
128
- .catch(() => {});
129
- return;
213
+ );
214
+ await btn.deferUpdate().catch(() => {});
215
+ }
216
+
217
+ await this.render();
218
+ });
219
+
220
+ collector.on("end", async () => {
221
+ this.ended = true;
222
+ await this.render();
223
+ });
130
224
  }
131
225
 
132
- const totalPages = Math.ceil(list.length / entriesPerPage);
133
- const lastPage = structure[structure.length - 1];
226
+ private normalize(input: PaginationInput): InternalComponent {
227
+ if (input === BUTTONS_SYMBOL) return { type: "buttons" };
228
+ if (input === DATA_SYMBOL) return { type: "data" };
229
+ if (typeof input === "string")
230
+ return {
231
+ type: "display",
232
+ component: new TextDisplayBuilder().setContent(input),
233
+ };
234
+ if (input instanceof TextDisplayBuilder)
235
+ return { type: "display", component: input };
236
+ if (input instanceof SeparatorBuilder)
237
+ return { type: "separator", component: input };
238
+ if (input instanceof SectionBuilder)
239
+ return { type: "section", component: input };
240
+ if (input instanceof FileBuilder) return { type: "file", component: input };
241
+ if (input instanceof MediaGalleryBuilder)
242
+ return { type: "gallery", component: input };
243
+ return { type: "actionrow", component: input };
244
+ }
134
245
 
135
- if (!lastPage) throw new Error("createLeaderboard is in a corrupted state");
246
+ private expandStructure(
247
+ structure: Array<Array<InternalComponent>>,
248
+ ): Array<Array<InternalComponent>> {
249
+ const lastPage = structure[structure.length - 1];
250
+ if (!lastPage) throw new Error("Structure must have at least one page");
251
+
252
+ while (structure.length < this.totalPages) {
253
+ structure.push(this.clonePage(lastPage));
254
+ }
255
+
256
+ return structure;
257
+ }
136
258
 
137
- while (structure.length < totalPages) {
138
- structure.push(
139
- lastPage.map((comp) => {
140
- if (comp.type === "display") {
141
- return {
142
- type: "display",
259
+ private clonePage(page: Array<InternalComponent>): Array<InternalComponent> {
260
+ return page.map((comp) =>
261
+ comp.type === "display"
262
+ ? {
263
+ type: "display" as const,
143
264
  component: new TextDisplayBuilder(comp.component.toJSON()),
144
- };
145
- }
146
- return comp;
147
- }),
265
+ }
266
+ : comp,
267
+ );
268
+ }
269
+
270
+ private async handlePageJump(btn: ButtonInteraction): Promise<void> {
271
+ const modal = new ModalBuilder()
272
+ .setCustomId(`${this.prefix}modal`)
273
+ .setTitle("Page Indexer")
274
+ .addLabelComponents(
275
+ new LabelBuilder()
276
+ .setLabel("Input a page number")
277
+ .setTextInputComponent(
278
+ new TextInputBuilder()
279
+ .setCustomId(`${this.prefix}number`)
280
+ .setRequired(true)
281
+ .setMinLength(1)
282
+ .setStyle(TextInputStyle.Short),
283
+ ),
284
+ );
285
+
286
+ await btn.showModal(modal).catch((e) => console.error(e));
287
+ const modalSubmit = await btn
288
+ .awaitModalSubmit({ time: 60_000 })
289
+ .catch(() => null);
290
+
291
+ if (!modalSubmit) {
292
+ await btn
293
+ .followUp({
294
+ content: "Modal timed out.",
295
+ flags: ["Ephemeral"],
296
+ })
297
+ .catch(() => null);
298
+ return;
299
+ }
300
+
301
+ const pageNumber = Number(
302
+ modalSubmit.fields.getTextInputValue(`${this.prefix}number`),
148
303
  );
304
+
305
+ if (
306
+ !Number.isInteger(pageNumber) ||
307
+ pageNumber < 1 ||
308
+ pageNumber > this.totalPages
309
+ ) {
310
+ await modalSubmit
311
+ .reply({
312
+ content: `Invalid page! Choose a number between **1** and **${this.totalPages}**.`,
313
+ flags: ["Ephemeral"],
314
+ allowedMentions: { parse: [], repliedUser: false },
315
+ })
316
+ .catch(() => null);
317
+ return;
318
+ }
319
+
320
+ await modalSubmit.deferUpdate().catch(() => null);
321
+ this.currentIndex = (pageNumber - 1) * this.entriesPerPage;
149
322
  }
150
323
 
151
- function generateContainer(page: number): ContainerBuilder {
152
- const pageStructure = structure[page];
324
+ private generateContainer(page: number): ContainerBuilder {
325
+ const pageStructure = this.structure[page];
153
326
  if (!pageStructure) throw new Error(`Page ${page} structure not found`);
154
327
 
155
328
  return new ContainerBuilder({
156
329
  components: pageStructure.map((comp) => {
157
330
  switch (comp.type) {
158
331
  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
- }
172
-
173
- const content = list
332
+ return this.getPaginationRow().toJSON();
333
+
334
+ case "data": {
335
+ const content = this.list
174
336
  .slice(
175
- page * entriesPerPage,
176
- page * entriesPerPage + entriesPerPage,
337
+ page * this.entriesPerPage,
338
+ (page + 1) * this.entriesPerPage,
177
339
  )
178
340
  .join("\n");
179
- comp.component.data.content = applyReplacements(
180
- content,
181
- replacements,
182
- );
183
- return comp.component.toJSON();
341
+ return new TextDisplayBuilder()
342
+ .setContent(this.applyReplacements(content))
343
+ .toJSON();
184
344
  }
185
345
 
186
- case "section":
187
- case "separator":
188
- case "file":
189
- case "gallery":
190
- case "actionrow":
346
+ // !! Default clause applies to the below as well
347
+ // case "display":
348
+ // case "section":
349
+ // case "separator":
350
+ // case "file":
351
+ // case "gallery":
352
+ // case "actionrow":
353
+ default:
191
354
  return comp.component.toJSON();
192
-
193
- default: {
194
- return comp;
195
- }
196
355
  }
197
356
  }),
198
- accent_color: styling?.[page]?.accent_color,
199
- spoiler: styling?.[page]?.spoiler,
357
+ accent_color: this.styling?.[page]?.accent_color,
358
+ spoiler: this.styling?.[page]?.spoiler,
200
359
  });
201
360
  }
202
361
 
203
- if (!interaction.replied && !interaction.deferred) {
204
- await interaction
205
- .deferReply({
206
- withResponse: true,
207
- flags: ephemeral ? ["Ephemeral"] : [],
208
- })
209
- .catch(() => null);
362
+ private getPaginationRow(): ActionRowBuilder<ButtonBuilder> {
363
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
364
+ new ButtonBuilder()
365
+ .setCustomId(`${this.prefix}back`)
366
+ .setLabel("Prev")
367
+ .setStyle(ButtonStyle.Secondary)
368
+ .setDisabled(this.ended || this.currentIndex === 0),
369
+ new ButtonBuilder()
370
+ .setCustomId(`${this.prefix}info`)
371
+ .setLabel(
372
+ `${Math.floor(this.currentIndex / this.entriesPerPage) + 1}/${this.totalPages}`,
373
+ )
374
+ .setStyle(ButtonStyle.Secondary)
375
+ .setDisabled(this.ended || this.totalPages === 1),
376
+ new ButtonBuilder()
377
+ .setCustomId(`${this.prefix}forward`)
378
+ .setLabel("Next")
379
+ .setStyle(ButtonStyle.Secondary)
380
+ .setDisabled(
381
+ this.ended ||
382
+ this.currentIndex + this.entriesPerPage >= this.list.length,
383
+ ),
384
+ );
210
385
  }
211
386
 
212
- const channel = interaction.channel;
213
- if (!channel || !("createMessageComponentCollector" in channel)) {
214
- throw new Error("Invalid channel type");
387
+ private applyReplacements(content: string): string {
388
+ if (!this.replacements) return content;
389
+ return Object.entries(this.replacements).reduce(
390
+ (acc, [key, value]) => acc.replaceAll(key, value),
391
+ content,
392
+ );
215
393
  }
216
394
 
217
- async function render(): Promise<void> {
395
+ private async render(): Promise<void> {
218
396
  try {
219
- await interaction.editReply({
397
+ await this.interaction.editReply({
220
398
  components: [
221
- generateContainer(Math.floor(currentIndex / entriesPerPage)),
399
+ this.generateContainer(
400
+ Math.floor(this.currentIndex / this.entriesPerPage),
401
+ ),
222
402
  ],
223
403
  flags: ["IsComponentsV2"],
224
- allowedMentions: {
225
- parse: [],
226
- repliedUser: false,
227
- },
404
+ allowedMentions: { parse: [], repliedUser: false },
228
405
  });
229
406
  } catch (error) {
230
407
  const e = error as Error;
231
408
  if (!e.message.includes("Unknown Message")) {
232
- console.error("Failed to render leaderboard:", error);
409
+ console.error("Failed to render pagination:", error);
233
410
  }
234
411
  }
235
412
  }
236
-
237
- await render();
238
-
239
- const collector = channel.createMessageComponentCollector({
240
- componentType: ComponentType.Button,
241
- time: 60000,
242
- });
243
-
244
- collector.on("collect", async (btn) => {
245
- if (btn.user.id !== interaction.user.id) {
246
- return void btn.deferUpdate();
247
- }
248
-
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
- );
270
-
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));
275
-
276
- if (!modalSubmit) {
277
- await btn
278
- .followUp({
279
- content: "Modal timed out.",
280
- flags: ["Ephemeral"],
281
- })
282
- .catch(() => null);
283
- return;
284
- }
285
-
286
- await modalSubmit.deferUpdate().catch(() => null);
287
- const pageNumber = Number(
288
- modalSubmit.fields.getTextInputValue(`${prefix}number`),
289
- );
290
-
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
- }
308
-
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(() => {});
318
- }
319
-
320
- await render();
321
- });
322
-
323
- collector.on("end", async () => {
324
- ended = true;
325
- await render();
326
- });
327
- }
413
+ }
@@ -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
  }
@@ -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
  }