Skip to content
This repository was archived by the owner on Oct 9, 2025. It is now read-only.
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 206 additions & 28 deletions src/modules/Pagination.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import { emojis } from '#main/config/Constants.js';
import Logger from '#main/utils/Logger.js';
import { getReplyMethod } from '#utils/Utils.js';
import { stripIndents } from 'common-tags';
import {
type BaseMessageOptions,
type RepliableInteraction,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ComponentType,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
type BaseMessageOptions,
type EmbedBuilder,
type ModalSubmitInteraction,
type RepliableInteraction,
} from 'discord.js';

type PaginationInteraction = Exclude<RepliableInteraction, ModalSubmitInteraction>;

type ButtonEmojis = {
back: string;
exit: string;
next: string;
search: string;
select: string;
};

type RunOptions = {
Expand All @@ -23,7 +34,13 @@ type RunOptions = {

export class Pagination {
private pages: BaseMessageOptions[] = [];
private emojis: ButtonEmojis = { back: emojis.previous, exit: emojis.delete, next: emojis.next };
private emojis: ButtonEmojis = {
back: emojis.previous,
exit: emojis.delete,
next: emojis.next,
search: emojis.search,
select: '#️⃣',
};

constructor(opts?: { emojis?: ButtonEmojis }) {
if (opts?.emojis) this.emojis = opts.emojis;
Expand All @@ -33,32 +50,169 @@ export class Pagination {
this.pages.push(page);
return this;
}

public setEmojis(btnEmojis: ButtonEmojis) {
this.emojis = btnEmojis;
return this;
}

public addPages(pageArr: BaseMessageOptions[]) {
pageArr.forEach((page) => this.pages.push(page));
return this;
}

public getPage(index: number) {
return this.pages[index];
}

/**
* Paginates through a collection of embed pages and handles user ctxs with pagination buttons.
* @param ctx - The command or message component ctx.
* @param pages - An array of EmbedBuilder objects representing the pages to be displayed.
* @param options - Optional configuration for the paginator.
*/
public async run(ctx: RepliableInteraction, options?: RunOptions) {
private getPageContent(page: BaseMessageOptions): string {
const searchableContent: string[] = [];

if (page.content) {
searchableContent.push(page.content);
}

const embedArray = Array.isArray(page.embeds) ? page.embeds : [page.embeds].filter(Boolean);

for (const embed of embedArray) {
if (!embed) continue;

const embedData = (embed as EmbedBuilder).data || embed;

if (embedData.title) searchableContent.push(embedData.title);
if (embedData.description) searchableContent.push(embedData.description);
if (embedData.author?.name) searchableContent.push(embedData.author.name);
if (embedData.footer?.text) searchableContent.push(embedData.footer.text);

if (embedData.fields?.length) {
embedData.fields.forEach((field) => {
searchableContent.push(field.name, field.value);
});
}
}

return searchableContent.join(' ').toLowerCase();
}

private async handlePageSelect(
interaction: PaginationInteraction,
totalPages: number,
): Promise<number | null> {
const modal = new ModalBuilder().setCustomId('page_select_modal').setTitle('Go to Page');

const pageInput = new TextInputBuilder()
.setCustomId('page_number_input')
.setLabel(`Enter page number (1-${totalPages})`)
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMinLength(1)
.setMaxLength(4);

const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(pageInput);
modal.addComponents(actionRow);

await interaction.showModal(modal);

try {
const modalSubmit = await interaction.awaitModalSubmit({
time: 30000,
filter: (i) => i.customId === 'page_select_modal',
});

const pageNumber = parseInt(modalSubmit.fields.getTextInputValue('page_number_input'));

if (isNaN(pageNumber) || pageNumber < 1 || pageNumber > totalPages) {
await modalSubmit.reply({
content: `Please enter a valid page number between 1 and ${totalPages}`,
ephemeral: true,
});
return null;
}

await modalSubmit.reply({
content: `Going to page ${pageNumber}`,
ephemeral: true,
});
return pageNumber - 1; // Convert to 0-based index
}
catch (error) {
Logger.error('Page selection error:', error);
return null;
}
}

private async handleSearch(interaction: PaginationInteraction): Promise<number | null> {
const modal = new ModalBuilder().setCustomId('search_modal').setTitle('Search Pages');

const searchInput = new TextInputBuilder()
.setCustomId('search_input')
.setLabel('Enter search term')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMinLength(1)
.setMaxLength(100);

const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(searchInput);
modal.addComponents(actionRow);

await interaction.showModal(modal);

try {
const modalSubmit = await interaction.awaitModalSubmit({
time: 30000,
filter: (i) => i.customId === 'search_modal',
});

const searchTerm = modalSubmit.fields.getTextInputValue('search_input').toLowerCase();
const results: { page: number; matches: number }[] = [];

for (let i = 0; i < this.pages.length; i++) {
const content = this.getPageContent(this.pages[i]);
const matchCount = (content.match(new RegExp(searchTerm, 'g')) || []).length;

if (matchCount > 0) {
results.push({ page: i, matches: matchCount });
}
}

if (results.length > 0) {
results.sort((a, b) => b.matches - a.matches);

const topResult = results[0];
const totalMatches = results.reduce((sum, result) => sum + result.matches, 0);
const otherResultsStr =
results.length > 1
? `-# ${emojis.info} Also found in the following pages: ${results.map((r) => r.page + 1).join(', ')}`
: '';

await modalSubmit.reply({
content: stripIndents`
**${emojis.search} Found ${totalMatches} match${totalMatches !== 1 ? 'es' : ''} across ${results.length} page${results.length !== 1 ? 's' : ''}.**
Jumping to page ${topResult.page + 1} with ${topResult.matches} match${topResult.matches !== 1 ? 'es' : ''}.

${otherResultsStr}`,
ephemeral: true,
});

return topResult.page;
}

await modalSubmit.reply({ content: 'No matches found', ephemeral: true });
return null;
}
catch (error) {
Logger.error('Search error:', error);
return null;
}
}

public async run(ctx: PaginationInteraction, options?: RunOptions) {
const replyMethod = getReplyMethod(ctx);
if (this.pages.length < 1) {
await ctx[replyMethod]({
content: `${emojis.tick} No results to display!`,
ephemeral: true,
});

return;
}

Expand All @@ -85,17 +239,35 @@ export class Pagination {
return;
}

// inc/dec the index
if (i.customId === 'page_:search') {
const newIndex = await this.handleSearch(i);
if (newIndex !== null) {
index = newIndex;
const newRow = this.createButtons(index, this.pages.length);
const newBody = this.formatMessage(newRow, this.pages[index]);
await listMessage.edit(newBody);
}
return;
}

if (i.customId === 'page_:select') {
const newIndex = await this.handlePageSelect(i, this.pages.length);
if (newIndex !== null) {
index = newIndex;
const newRow = this.createButtons(index, this.pages.length);
const newBody = this.formatMessage(newRow, this.pages[index]);
await listMessage.edit(newBody);
}
return;
}

index = this.adjustIndex(i.customId, index);

const newRow = this.createButtons(index, this.pages.length);
const newBody = this.formatMessage(newRow, this.pages[index]);

// edit the message only if the customId is one of the paginator buttons
await i.update(newBody);
});

// bad code dont look
col.on('end', async (interactions) => {
const interaction = interactions.first();

Expand All @@ -109,22 +281,20 @@ export class Pagination {
return;
}

if (options?.deleteOnEnd) {
// acknowledge the interaction if it hasn't been acknowledged yet
if (!interaction.replied && !interaction.deferred) {
await interaction.update({ components: [] });
}
await interaction.deleteReply();
}
else {
await interaction.editReply({ components: [] });
let ackd = false;
if (!interaction.replied && !interaction.deferred) {
await interaction.update({ components: [] });
ackd = true;
}

if (options?.deleteOnEnd) await interaction.deleteReply();
else if (ackd === false) await interaction.editReply({ components: [] });
});
}

private adjustIndex(customId: string, index: number) {
if (customId === 'page_:back') return index - 1;
else if (customId === 'page_:next') return index + 1;
if (customId === 'page_:back') return Math.max(0, index - 1);
if (customId === 'page_:next') return index + 1;
return index;
}

Expand All @@ -137,6 +307,10 @@ export class Pagination {

private createButtons(index: number, totalPages: number) {
return new ActionRowBuilder<ButtonBuilder>().addComponents([
new ButtonBuilder()
.setEmoji(this.emojis.select)
.setCustomId('page_:select')
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setEmoji(this.emojis.back)
.setCustomId('page_:back')
Expand All @@ -145,13 +319,17 @@ export class Pagination {
new ButtonBuilder()
.setEmoji(this.emojis.exit)
.setCustomId('page_:exit')
.setStyle(ButtonStyle.Danger)
.setLabel(`Page ${index + 1} of ${totalPages}`),
.setLabel(`Page ${index + 1} of ${totalPages}`)
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setEmoji(this.emojis.next)
.setCustomId('page_:next')
.setStyle(ButtonStyle.Secondary)
.setDisabled(totalPages <= index + 1),
new ButtonBuilder()
.setEmoji(this.emojis.search)
.setCustomId('page_:search')
.setStyle(ButtonStyle.Secondary),
]);
}
}