├── .deepsource.toml ├── .gitattributes ├── .github └── workflows │ └── npm.yml ├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── package.json └── src ├── libs ├── collector │ ├── collector.js │ ├── embed.js │ ├── events │ │ ├── collect.js │ │ └── end.js │ └── handler.js ├── components │ ├── buttons.js │ ├── handler.js │ └── selectMenu.js └── versions │ ├── v13 │ ├── buttons.js │ ├── interactions.js │ └── selectMenu.js │ ├── v14 │ ├── buttons.js │ ├── interactions.js │ └── selectMenu.js │ └── versionManager.js └── paginationHandler.js /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "javascript" 5 | enabled = true -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 22 15 | registry-url: https://registry.npmjs.org/ 16 | - run: npm publish --access public 17 | env: 18 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | package-lock.json 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kris Dookharan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # customizable-discordjs-pagination 2 |
5 | This package features a fully customizable embed pagination for DiscordJS V13 and V14. The User can modify the buttons to their liking and enable/disable Select Menu. 6 | 7 | ## Install package 8 | ```sh 9 | npm install customizable-discordjs-pagination 10 | ``` 11 | 12 | ## Discord.js v13 Example 13 | ```js 14 | const Pagination = require('customizable-discordjs-pagination'); 15 | 16 | // Make Embeds using DiscordJS package 17 | const pages = [embed1, embed2, embed3]; 18 | 19 | const buttons = [ 20 | { label: 'Previous', emoji: '⬅', style: 'DANGER' }, 21 | { label: 'Next', emoji: '➡', style: 'SUCCESS' } 22 | ]; 23 | 24 | new Pagination() 25 | .setCommand(message) 26 | .setPages(pages) 27 | .setButtons(buttons) 28 | .setPaginationCollector({ timeout: 120000 }) 29 | .setSelectMenu({ enable: true }) 30 | .setFooter({ enable: true }) 31 | .send(); 32 | ``` 33 | ## Discord.js v14 Example 34 | ```js 35 | const Pagination = require('customizable-discordjs-pagination'); 36 | const { ButtonStyle } = require('discord.js'); // Discord.js v14+ 37 | 38 | // Make Embeds using DiscordJS package 39 | const pages = [embed1, embed2, embed3]; 40 | 41 | const buttons = [ 42 | { label: 'Previous', emoji: '⬅', style: ButtonStyle.Danger }, 43 | { label: 'Next', emoji: '➡', style: ButtonStyle.Success }, 44 | ] 45 | 46 | new Pagination() 47 | .setCommand(message) 48 | .setPages(pages) 49 | .setButtons(buttons) 50 | .setPaginationCollector({ timeout: 120000 }) 51 | .setSelectMenu({ enable: true }) 52 | .setFooter({ enable: true }) 53 | .send(); 54 | ``` 55 | 56 | ## Screenshots 57 | ##### 2 Buttons - Previous and Next 58 |  59 | ##### 3 Buttons - Previous, Stop and Next 60 |  61 | ##### 4 Buttons - First, Previous, Next, Last 62 |  63 | ##### 5 Buttons - First, Previous, Stop, Next, Last 64 |  65 | 66 | 67 | ## Documentation 68 | - For DiscordJS V13/V14: 69 | ```js 70 | new Pagination() 71 | .setCommand(message) 72 | .setPages(pages) 73 | .setButtons(buttons) 74 | .setPaginationCollector({ timeout: 120000 }) 75 | .setSelectMenu({ enable: true }) 76 | .setFooter({ enable: true }) 77 | .setCustomComponents([]) 78 | .setCustomComponentsFunction(fn) 79 | .send(); 80 | ``` 81 | 82 | ## Methods 83 | | Name | Optional | Details | 84 | | --- | --- | --- | 85 | | setCommand(message / interaction) | ❌ | Message or Slash Interaction Accepted | 86 | | setPages(pages) | ❌ | Array of MessageEmbeds(DiscordJS V13) or EmbedBuilder(DiscordJS V14) | 87 | | send() | ❌ | Executes the pagination | 88 | | setButtons([{ parameters }, { parameters }, ...]) | ✔️ | Array of objects containing styles, labels and/or emojis for the buttons | 89 | | setPaginationCollector({ parameters }) | ✔️ | Optional Method to set Select Menu Options | 90 | | setSelectMenu({ parameters }) | ✔️ | Optional Method to set Collector Options | 91 | | setFooter({ parameters }) | ✔️ | Optional Method to set Footer Options | 92 | | setCustomComponents({ parameters }) | ✔️ | Optional Method to set Custom Component Options | 93 | | setCustomComponentsFunction(fn) | ✔️ | Optional Method to set Custom Component Function | 94 | 95 | ## Optional Methods 96 | ### setButtons([{ parameters }, { parameters }, ...]) 97 | Default: An Empty Array ( [] ) 98 | 99 | | Parameter | Type | Details | 100 | | --- | --- | --- | 101 | | label | String | The text to be displayed on this button | 102 | | emoji | [Emoji](https://discord.js.org/#/docs/discord.js/13.8.0/typedef/EmojiIdentifierResolvable) | The emoji to be displayed on this button | 103 | 104 | ### setFooter({ parameters }) 105 | Defaults: 106 | - {User Tag} - message.member.user.tag || interaction.member.user.tag 107 | - {User Avatar} - message.author.displayAvatarURL({ dynamic: true }) || interaction.user.displayAvatarURL({ dynamic: true }) 108 | 109 | | Parameter | Type | Default | Details | 110 | | --- | --- | --- | --- | 111 | | option | String | 'default' | 'user' - Uses the User's Embed Footer; 'none' - Remove Embed Footer; 'default': The Package Default Footer with parameters modifications(Below) | 112 | | pagePosition | String | 'left' | Adjust the pagePosition to the left, right or none. | 113 | | extraText | String | 'Requested by {User Tag}' | The user can customize this text to be displayed on the footer | 114 | | enableIconURL | Boolean | true | Set tp false to disable Footer Icon(Image) | 115 | | iconURL | String | {User Avatar} | The icon URL of the footer | 116 | 117 | ### setSelectMenu({ parameters }) 118 | 119 | | Parameter | Type | Default | Details | 120 | | --- | --- | --- | --- | 121 | | enable | Boolean | false | Set to true to enable Select Menu | 122 | | placeholder | String | 'Select Page' | The text to be displayed as placeholder for the Select Menu | 123 | | pageOnly | Boolean | false | True: Forced Select Menu Options is page numbers; False: Select Menu Options is the Embed Title(if different), otherwise page numbers | 124 | 125 | ### setPaginationCollector({ parameters }) 126 | 127 | | Parameter | Type | Default | Details | 128 | | --- | --- | --- | --- | 129 | | components | String | 'disable' | Options: 'disable' - Disables the components at the end ; 'disappear' - Remove the components at the end | 130 | | ephemeral | Boolean | false | Set to true to make the Pagination Collector ephemeral | 131 | | resetTimer | Boolean | true | Set to true to reset the Pagination Collector timer | 132 | | startingPage | Number | 1 | Set Default Page Number | 133 | | secondaryUserInteraction | Boolean | false | Set to true to allow secondary user interaction | 134 | | secondaryUserText | String | 'This isn\'t your interaction.' | The text to be displayed for the secondary user | 135 | | timeout | Number | 120000 | The time in milliseconds before the Pagination Collector times out | 136 | 137 | ### setCustomComponents([component, ...]) 138 | Default: An Empty Array ( [] ) 139 | 140 | | Parameter | Type | Details | 141 | | --- | --- | --- | 142 | | component | [ActionRow](https://discord.js.org/#/docs/builders/main/class/ActionRowBuilder) | Represents an action row component | 143 | 144 | ### setCustomComponentsFunction(fn) 145 | 146 | | Parameter | Type | Details | 147 | | --- | --- | --- | 148 | | fn | Function | Function to handle the custom component | 149 | 150 | Example: 151 | ```js 152 | const customFn = function fn({ message, msg, pages, collector, setPage, setPages }, interaction) { 153 | switch(interaction.customId) { 154 | case 'test': 155 | setPage(2); 156 | break; 157 | } 158 | } 159 | 160 | return new Pagination() 161 | .setCustomComponentsFunction(customFn) 162 | . ... 163 | ``` 164 | 165 | ## Bots that use this package 166 | | Avatar | Name | 167 | | --- | --- | 168 | |  | [Toating Bot](https://discord.com/api/oauth2/authorize?client_id=710177042490064958&permissions=4063624560&scope=bot%20applications.commands) | 169 | |  | [Savage Bot](https://discord.com/oauth2/authorize?client_id=823703707522433054&permissions=8&scope=bot%20applications.commands) | 170 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const paginationHandler = require('./src/paginationHandler'); 2 | 3 | module.exports = class Pagination { 4 | constructor() { 5 | this.config = { 6 | command: null, 7 | pages: null, 8 | collector: { 9 | components: 'disable', 10 | ephemeral: false, 11 | resetTimer: true, 12 | startingPage: 1, 13 | secondaryUserInteraction: false, 14 | secondaryUserText: 'This isn\'t your interaction.', 15 | timeout: 120000 16 | }, 17 | component: { 18 | buttons: [], 19 | selectMenu: { 20 | enable: false, 21 | pageOnly: false, 22 | placeholder: 'Select Page' 23 | }, 24 | customComponents: [], 25 | customComponentsFunction: () => null, 26 | footer: { 27 | option: 'default', 28 | pagePosition: 'left', 29 | extraText: null, 30 | iconURL: null 31 | } 32 | } 33 | }; 34 | } 35 | 36 | /** 37 | * @param {} command - Message or Interaction 38 | * @example Message Commands - setCommand(message); 39 | * @example Interaction Commands - setCommand(interaction); 40 | **/ 41 | 42 | setCommand(command) { 43 | if (!command) throw new Error('Message or Interaction is required.'); 44 | this.config.command = command; 45 | const user = command.member.user; 46 | this.config.component.footer.extraText = `Requested by ${user.username}`; 47 | this.config.component.footer.iconURL = command.author ? 48 | command.author.displayAvatarURL({ dynamic: true }) : 49 | user.displayAvatarURL({ dynamic: true }); 50 | return this; 51 | } 52 | 53 | /** 54 | * @param {[{row:ActionRowBuilder,position:Number}]} customComponents - Custom Components Options 55 | * @example setCustomComponents([ new ActionRowBuilder().addComponents(component), ...]); 56 | **/ 57 | 58 | setCustomComponents(components = []) { 59 | this.config.component.customComponents = components; 60 | return this; 61 | } 62 | 63 | /** 64 | * @param {fn} customComponents - Custom Components Function 65 | * @example setCustomComponentsFunction(fn); 66 | **/ 67 | 68 | setCustomComponentsFunction(fn = () => null) { 69 | this.config.component.customComponentsFunction = fn; 70 | return this; 71 | } 72 | 73 | /** 74 | * @param {[Embeds]} pages - Array of Embeds 75 | * @example v13 - [new MessageEmbed().setTitle('Page 1'), new MessageEmbed().setTitle('Page 2')] 76 | * @example v14 - [new EmbedBuilder().setTitle('Page 1'), new EmbedBuilder().setTitle('Page 2')] 77 | */ 78 | 79 | setPages(pages) { 80 | if (!pages) throw new Error('Pages are required.'); 81 | this.config.pages = pages; 82 | return this; 83 | } 84 | 85 | /** 86 | * @param {[{label:String,emoji:EmojiResolvable,style:ButtonStyle}]} buttons - Array of Buttons Objects 87 | * @example v13 - [{ label: '1', emoji: '⬅', style: 'SUCCESS', customId: 'prevBtn' }, { label: '2', emoji: '➡', style: 'SUCCESS', customId: 'nextBtn' }] 88 | * @example v14 - [{ label: '1', emoji: '⬅', style: ButtonStyle.Success, customId: 'prevBtn' }, { label: '2', emoji: '➡', style: ButtonStyle.Success, customId: 'nextBtn' }] 89 | */ 90 | 91 | setButtons(buttons = []) { 92 | this.config.component.buttons = buttons; 93 | return this; 94 | } 95 | 96 | /** 97 | * @param {{option:String,pagePosition:String,extraText:String,enableIconUrl:Boolean,iconURL:String}} footer - Footer Options 98 | * @example setFooter({ option:'default', pagePosition:'left', extraText:'String', enableIconUrl:true, iconURL:'https://somelink.png' }); 99 | **/ 100 | 101 | setFooter({ option = 'default', pagePosition = 'left', extraText, enableIconUrl = true, iconURL } = {}) { 102 | const user = this.config.command.member.user; 103 | this.config.component.footer = { 104 | option: option?.toLowerCase(), 105 | pagePosition: pagePosition?.toLowerCase(), 106 | extraText: extraText || `Requested by ${user.username}`, 107 | iconURL: enableIconUrl ? (iconURL || (this.config.command.author ? 108 | this.config.command.author.displayAvatarURL({ dynamic: true }) : 109 | user.displayAvatarURL({ dynamic: true }))) : null 110 | }; 111 | return this; 112 | } 113 | 114 | /** 115 | * @param {{disableEnd:Boolean,ephemeral:Boolean,resetTimer:Boolean,secondaryUserInteraction:Boolean,secondaryUserText:String,timeout:Number}} PaginationCollector - Pagination Options 116 | * @example 117 | * setPaginationCollector({ ephemeral: true, timeout: 120000, resetTimer: true, disableEnd: true, secondaryUserText: 'This isn\'t your interaction.' }); 118 | */ 119 | 120 | setPaginationCollector({ 121 | components = 'disable', 122 | ephemeral = false, 123 | resetTimer = true, 124 | startingPage = 1, 125 | secondaryUserInteraction = false, 126 | secondaryUserText = 'This isn\'t your interaction.', 127 | timeout = 120000 128 | } = {}) { 129 | this.config.collector = { 130 | components: components?.toLowerCase(), 131 | ephemeral, 132 | resetTimer, 133 | startingPage, 134 | secondaryUserInteraction, 135 | secondaryUserText, 136 | timeout 137 | }; 138 | return this; 139 | } 140 | 141 | /** 142 | * @param {{enable:Boolean,pageOnly:Boolean,placeholder:String}} selectMenu - SelectMenu Options 143 | * @example 144 | * setSelectMenu({ enable:true, pageOnly:true, placeholder:'Select Page' }); 145 | **/ 146 | 147 | setSelectMenu({ enable = false, pageOnly = false, placeholder = 'Select Page' } = {}) { 148 | this.config.component.selectMenu = { enable, pageOnly, placeholder }; 149 | return this; 150 | } 151 | 152 | async send() { 153 | return await paginationHandler(this.config); 154 | } 155 | }; 156 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "customizable-discordjs-pagination", 3 | "version": "2.2.0", 4 | "description": "A Fully Customizable Embed Pagination for DiscordJS", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [ 10 | "discordjs-pagination", 11 | "customizable-pagination", 12 | "customizable-embed-pagination", 13 | "discordjs", 14 | "discordjs-13", 15 | "discordjs-14", 16 | "discordjs-v13", 17 | "discordjs-v14" 18 | ], 19 | "homepage": "https://github.com/VirusLauncher/customizable-discordjs-pagination#readme", 20 | "bugs": { 21 | "url": "https://github.com/VirusLauncher/customizable-discordjs-pagination/issues" 22 | }, 23 | "author": "Kris Dookharan (VirusLauncher)", 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/VirusLauncher/customizable-discordjs-pagination.git" 27 | }, 28 | "license": "MIT" 29 | } 30 | -------------------------------------------------------------------------------- /src/libs/collector/collector.js: -------------------------------------------------------------------------------- 1 | const { InteractionCollector } = require('discord.js'); 2 | const { readdirSync } = require('fs'); 3 | const path = require('path'); 4 | 5 | const activeCollectors = new Map(); 6 | 7 | const loadCollectorEvents = (collectorPath) => { 8 | return readdirSync(collectorPath) 9 | .filter(file => file.endsWith('.js')) 10 | .map(file => ({ 11 | event: require(path.join(collectorPath, file)), 12 | name: file.replace('.js', '') 13 | })); 14 | }; 15 | 16 | const cleanupOldCollector = async (messageId) => { 17 | const oldCollector = activeCollectors.get(messageId); 18 | if (oldCollector) { 19 | try { 20 | oldCollector.stop('NEW_PAGINATION'); 21 | } catch (error) { 22 | console.error('Error stopping old collector:', error); 23 | } 24 | activeCollectors.delete(messageId); 25 | } 26 | }; 27 | 28 | module.exports = async (message, msg, components, footer, pages, paginationCollector, customComponentsFunction) => { 29 | const messageId = message.author ? msg.id : (await message.fetchReply()).id; 30 | 31 | await cleanupOldCollector(messageId); 32 | 33 | const collector = new InteractionCollector(message.client, { 34 | message: message.author ? msg : await message.fetchReply(), 35 | idle: paginationCollector.timeout, 36 | dispose: true 37 | }); 38 | 39 | activeCollectors.set(messageId, collector); 40 | 41 | const eventContext = { 42 | message, 43 | msg, 44 | components, 45 | footer, 46 | pages, 47 | paginationCollector, 48 | collector, 49 | customComponentsFunction 50 | }; 51 | 52 | const collectorPath = path.join(__dirname, 'events'); 53 | const events = loadCollectorEvents(collectorPath); 54 | 55 | collector.once('end', () => { 56 | activeCollectors.delete(messageId); 57 | }); 58 | 59 | events.forEach(({ event }) => { 60 | collector.on(event.name, (...args) => 61 | event.execute(eventContext, ...args) 62 | ); 63 | }); 64 | 65 | return collector; 66 | }; 67 | -------------------------------------------------------------------------------- /src/libs/collector/embed.js: -------------------------------------------------------------------------------- 1 | const createPageText = (pagePosition, currentPage, totalPages, extraText) => { 2 | const pageInfo = `Page ${currentPage + 1} / ${totalPages}`; 3 | 4 | switch (pagePosition) { 5 | case 'left': 6 | return extraText ? `${pageInfo} • ${extraText}` : pageInfo; 7 | case 'right': 8 | return extraText ? `${extraText} • ${pageInfo}` : pageInfo; 9 | case 'none': 10 | return extraText || ''; 11 | default: 12 | throw new Error('Invalid page footer position. Valid positions are left, right, none'); 13 | } 14 | }; 15 | 16 | module.exports = function(footer, page, pages) { 17 | if (!pages || !Array.isArray(pages) || pages.length === 0) throw new Error('Valid pages array is required'); 18 | if (page < 0 || page >= pages.length) throw new Error(`Invalid page number. Must be between 0 and ${pages.length - 1}`); 19 | 20 | const currentEmbed = pages[page]; 21 | 22 | if (!currentEmbed) throw new Error(`No embed found for page ${page}`); 23 | if (!footer) return currentEmbed; 24 | 25 | switch(footer.option) { 26 | case 'user': 27 | return currentEmbed; 28 | case 'none': 29 | return currentEmbed.setFooter({ text: null, iconURL: null }); 30 | case 'default': { 31 | const text = createPageText( 32 | footer.pagePosition || 'left', 33 | page, 34 | pages.length, 35 | footer.extraText 36 | ); 37 | return currentEmbed.setFooter({ text, iconURL: footer.iconURL }); 38 | } 39 | default: 40 | return currentEmbed; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/libs/collector/events/collect.js: -------------------------------------------------------------------------------- 1 | const embed = require('../embed'); 2 | const { getComponents } = require('../../versions/versionManager'); 3 | 4 | const paginationStates = new Map(); 5 | 6 | class PaginationState { 7 | constructor(startingPage, pages) { 8 | this.page = Math.max(0, Math.min(startingPage - 1, pages.length - 1)); 9 | this.pages = pages; 10 | } 11 | 12 | setPage(number) { 13 | if (!number || typeof number !== 'number') throw new Error('A valid page number is required.'); 14 | this.page = Math.max(0, Math.min(number - 1, this.pages.length - 1)); 15 | } 16 | 17 | setPages(pages) { 18 | if (!pages || !Array.isArray(pages)) throw new Error('Valid pages array is required.'); 19 | this.pages = pages; 20 | this.page = Math.max(0, Math.min(this.page, pages.length - 1)); 21 | } 22 | 23 | navigate(action, totalPages) { 24 | switch (action) { 25 | case 'first': 26 | this.page = 0; 27 | break; 28 | case 'last': 29 | this.page = totalPages - 1; 30 | break; 31 | case 'prev': 32 | this.page = this.page !== 0 ? this.page - 1 : totalPages - 1; 33 | break; 34 | case 'next': 35 | this.page = this.page < totalPages - 1 ? this.page + 1 : 0; 36 | break; 37 | } 38 | } 39 | } 40 | 41 | const handleCustomInteraction = async (state, context, interaction) => { 42 | const { message, msg, collector, customComponentsFunction } = context; 43 | await customComponentsFunction({ 44 | message, 45 | msg, 46 | pages: state.pages, 47 | collector, 48 | setPage: state.setPage.bind(state), 49 | setPages: state.setPages.bind(state) 50 | }, interaction); 51 | }; 52 | 53 | const updateEmbed = async (state, context) => { 54 | try { 55 | const { message, msg, components, footer } = context; 56 | const embedContent = embed(footer, state.page, state.pages); 57 | 58 | if (!embedContent) throw new Error('Failed to generate embed content'); 59 | 60 | const options = { 61 | embeds: [embedContent], 62 | components, 63 | ...(message.author ? { fetchReply: true } : { allowedMentions: { repliedUser: false } }) 64 | }; 65 | 66 | await (message.author ? msg.edit(options) : message.editReply(options)); 67 | } catch (error) { 68 | console.error('Failed to update embed:', error); 69 | throw error; 70 | } 71 | }; 72 | 73 | const getMessageId = async (message, msg) => { 74 | return message.author ? msg.id : (await message.fetchReply()).id; 75 | }; 76 | 77 | module.exports = { 78 | name: 'collect', 79 | async execute(context, interaction) { 80 | try { 81 | const { message, msg, paginationCollector, collector, pages } = context; 82 | const { interactions } = getComponents(); 83 | 84 | await interactions.deferUpdate(interaction); 85 | 86 | const isAuthorized = interaction.member.user.id === message.member.id || paginationCollector.secondaryUserInteraction; 87 | 88 | if (!isAuthorized) { 89 | if (!paginationCollector.secondaryUserInteraction) { 90 | await interactions.replyToInteraction( 91 | interaction, 92 | paginationCollector.secondaryUserText, 93 | true 94 | ); 95 | } 96 | return; 97 | } 98 | 99 | if (paginationCollector.resetTimer) collector.resetTimer(paginationCollector.timeout, paginationCollector.timeout); 100 | 101 | const messageId = await getMessageId(message, msg); 102 | let state = paginationStates.get(messageId); 103 | if (!state) { 104 | state = new PaginationState(paginationCollector.startingPage, pages); 105 | paginationStates.set(messageId, state); 106 | 107 | collector.once('end', () => { 108 | paginationStates.delete(messageId); 109 | }); 110 | } 111 | 112 | switch (interaction.customId) { 113 | case 'firstBtn': 114 | state.navigate('first'); 115 | break; 116 | case 'lastBtn': 117 | state.navigate('last', state.pages.length); 118 | break; 119 | case 'prevBtn': 120 | state.navigate('prev', state.pages.length); 121 | break; 122 | case 'nextBtn': 123 | state.navigate('next', state.pages.length); 124 | break; 125 | case 'stopBtn': 126 | collector.stop(); 127 | break; 128 | case 'pageMenu': 129 | state.setPage(Number(interaction.values[0]) + 1); // +1 because values are 0-based 130 | break; 131 | default: 132 | await handleCustomInteraction(state, context, interaction); 133 | break; 134 | } 135 | 136 | await updateEmbed(state, context); 137 | } catch (error) { 138 | console.error('Pagination interaction error:', error); 139 | try { 140 | await interaction.followUp({ 141 | content: 'An error occurred while updating the pagination. Please try again.', 142 | ephemeral: true 143 | }); 144 | } catch (followUpError) { 145 | console.error('Failed to send error message:', followUpError); 146 | } 147 | } 148 | }, 149 | }; 150 | -------------------------------------------------------------------------------- /src/libs/collector/events/end.js: -------------------------------------------------------------------------------- 1 | const handleComponents = (components, action) => { 2 | if (!components || !Array.isArray(components)) return []; 3 | 4 | if (action === 'disappear') return []; 5 | 6 | if (action === 'disable') { 7 | return components.map(row => { 8 | row.components.forEach(component => component.setDisabled(true)); 9 | return row; 10 | }); 11 | } 12 | 13 | return components; 14 | }; 15 | 16 | module.exports = { 17 | name: 'end', 18 | async execute({ message, msg, components, paginationCollector }) { 19 | try { 20 | const updatedComponents = handleComponents( 21 | components, 22 | paginationCollector.components 23 | ); 24 | 25 | const options = { components: updatedComponents }; 26 | 27 | if (message.author) { 28 | if (!msg.deleted) await msg.edit(options); 29 | } else { 30 | try { 31 | await message.editReply(options); 32 | } catch (error) { 33 | if (error.code !== 10062) throw error; 34 | } 35 | } 36 | } catch (error) { 37 | console.error('Error in pagination end event:', error); 38 | } 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /src/libs/collector/handler.js: -------------------------------------------------------------------------------- 1 | const embed = require('./embed') 2 | const collector = require('./collector'); 3 | 4 | module.exports = async (message, components, footer, pages, paginationCollector, customComponentsFunction) => { 5 | try { 6 | const initialEmbed = embed(footer, paginationCollector.startingPage - 1, pages); 7 | const options = { 8 | embeds: [initialEmbed], 9 | components, 10 | ephemeral: paginationCollector.ephemeral, 11 | }; 12 | 13 | const msg = await (message.isReplied || message.deferred 14 | ? message.editReply(options) 15 | : message.reply(options)); 16 | 17 | return await collector( 18 | message, 19 | msg, 20 | components, 21 | footer, 22 | pages, 23 | paginationCollector, 24 | customComponentsFunction 25 | ); 26 | } catch (error) { 27 | console.error('Pagination error:', error); 28 | throw new Error('Failed to initialize pagination'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/libs/components/buttons.js: -------------------------------------------------------------------------------- 1 | const { getComponents } = require('../versions/versionManager'); 2 | 3 | const buttonIdList = { 4 | 2: ['prevBtn', 'nextBtn'], 5 | 3: ['prevBtn', 'stopBtn', 'nextBtn'], 6 | 4: ['firstBtn', 'prevBtn', 'nextBtn', 'lastBtn'], 7 | 5: ['firstBtn', 'prevBtn', 'stopBtn', 'nextBtn', 'lastBtn'] 8 | }; 9 | 10 | module.exports = (buttons) => { 11 | if (!Array.isArray(buttons) || buttons.length <= 1) return null; 12 | 13 | const buttonIds = buttonIdList[buttons.length]; 14 | if (!buttonIds) return null; 15 | 16 | const { buttons: buttonComponents } = getComponents(); 17 | 18 | const buttonList = buttons.map((btn, i) => { 19 | if (!btn.emoji && !btn.label) throw new Error(`Emoji or Label is required. Check button array position ${i}`); 20 | 21 | return buttonComponents.createButton( 22 | btn.style, 23 | buttonIds[i], 24 | btn.emoji, 25 | btn.label 26 | ); 27 | }); 28 | 29 | return buttonComponents.createActionRow(buttonList); 30 | }; 31 | -------------------------------------------------------------------------------- /src/libs/components/handler.js: -------------------------------------------------------------------------------- 1 | const buttonsFn = require('./buttons'); 2 | const selectMenuFn = require('./selectMenu'); 3 | 4 | module.exports = ({ buttons: buttonData, selectMenu: selectMenuData, customComponents = [] } = {}, pages) => { 5 | const components = []; 6 | 7 | const selectMenu = selectMenuData?.enable && selectMenuFn(pages, selectMenuData); 8 | if (selectMenu) components.push(selectMenu); 9 | 10 | const buttons = buttonData?.length && buttonsFn(buttonData); 11 | if (buttons) components.push(buttons); 12 | 13 | if (customComponents.length > 0) components.push(...customComponents); 14 | 15 | if (components.length > 5) throw new Error('Maximum of 5 components allowed.'); 16 | 17 | return components; 18 | } 19 | -------------------------------------------------------------------------------- /src/libs/components/selectMenu.js: -------------------------------------------------------------------------------- 1 | const { getComponents } = require('../versions/versionManager'); 2 | 3 | const createPageOptions = (pages, pageOnly, getTitle) => { 4 | const firstPageTitle = getTitle(pages[0]); 5 | 6 | if (pageOnly || pages.every(page => getTitle(page) === firstPageTitle)) { 7 | return pages.map((_, index) => ({ 8 | label: `Page ${index + 1}`, 9 | value: String(index) 10 | })); 11 | } 12 | 13 | return pages.map((page, index) => ({ 14 | label: getTitle(page), 15 | value: String(index) 16 | })); 17 | }; 18 | 19 | module.exports = (pages, selectMenu) => { 20 | if (!selectMenu?.enable || !Array.isArray(pages) || pages.length === 0) return null; 21 | 22 | const { selectMenu: menuComponents } = getComponents(); 23 | const getTitle = page => page.data?.title || page.title; 24 | 25 | const options = createPageOptions(pages, selectMenu.pageOnly, getTitle); 26 | const menu = menuComponents.createSelectMenu('pageMenu', options, selectMenu.placeholder); 27 | 28 | return menuComponents.createActionRow(menu); 29 | }; 30 | -------------------------------------------------------------------------------- /src/libs/versions/v13/buttons.js: -------------------------------------------------------------------------------- 1 | const { MessageButton, MessageActionRow } = require('discord.js'); 2 | 3 | module.exports = { 4 | createButton: (style, customId, emoji, label) => { 5 | const button = new MessageButton() 6 | .setStyle(style) 7 | .setCustomId(customId); 8 | 9 | if (style.toLowerCase() === 'link') throw new Error('Link styles cannot be used in this package.'); 10 | 11 | if (emoji) button.setEmoji(emoji); 12 | if (label) button.setLabel(label); 13 | 14 | return button; 15 | }, 16 | 17 | createActionRow: (components) => { 18 | return new MessageActionRow().addComponents(components); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/libs/versions/v13/interactions.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | replyToInteraction: async (interaction, content, isEphemeral = false) => { 3 | return await interaction.reply({ 4 | content, 5 | ephemeral: isEphemeral 6 | }); 7 | }, 8 | 9 | editReply: async (interaction, options) => { 10 | return await interaction.editReply({ 11 | ...options, 12 | allowedMentions: { repliedUser: false } 13 | }); 14 | }, 15 | 16 | deferUpdate: async (interaction) => { 17 | return await interaction.deferUpdate(); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/libs/versions/v13/selectMenu.js: -------------------------------------------------------------------------------- 1 | const { MessageSelectMenu, MessageActionRow } = require('discord.js'); 2 | 3 | module.exports = { 4 | createSelectMenu: (customId, options, placeholder) => { 5 | return new MessageSelectMenu() 6 | .setCustomId(customId) 7 | .setOptions(options) 8 | .setPlaceholder(placeholder); 9 | }, 10 | 11 | createActionRow: (components) => { 12 | return new MessageActionRow().addComponents(components); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/libs/versions/v14/buttons.js: -------------------------------------------------------------------------------- 1 | const { ButtonBuilder, ActionRowBuilder, ButtonStyle } = require('discord.js'); 2 | 3 | module.exports = { 4 | createButton: (style, customId, emoji, label) => { 5 | const button = new ButtonBuilder() 6 | .setStyle(style) 7 | .setCustomId(customId); 8 | 9 | if (style === ButtonStyle.Link) throw new Error('Link styles cannot be used in this package.'); 10 | 11 | if (emoji) button.setEmoji(emoji); 12 | if (label) button.setLabel(label); 13 | 14 | return button; 15 | }, 16 | 17 | createActionRow: (components) => { 18 | return new ActionRowBuilder().addComponents(components); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/libs/versions/v14/interactions.js: -------------------------------------------------------------------------------- 1 | const { MessageFlags } = require('discord.js'); 2 | 3 | module.exports = { 4 | replyToInteraction: async (interaction, content, isEphemeral = false) => { 5 | const options = { 6 | content, 7 | flags: isEphemeral ? MessageFlags.Ephemeral : undefined 8 | }; 9 | return await interaction.reply(options); 10 | }, 11 | 12 | editReply: async (interaction, options) => { 13 | return await interaction.editReply({ 14 | ...options, 15 | allowedMentions: { repliedUser: false } 16 | }); 17 | }, 18 | 19 | deferUpdate: async (interaction) => { 20 | try { 21 | return await interaction.deferUpdate(); 22 | } catch (error) { 23 | console.error('Failed to defer interaction update:', error); 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/libs/versions/v14/selectMenu.js: -------------------------------------------------------------------------------- 1 | const { StringSelectMenuBuilder, ActionRowBuilder } = require('discord.js'); 2 | 3 | module.exports = { 4 | createSelectMenu: (customId, options, placeholder) => { 5 | return new StringSelectMenuBuilder() 6 | .setCustomId(customId) 7 | .setOptions(options) 8 | .setPlaceholder(placeholder); 9 | }, 10 | 11 | createActionRow: (components) => { 12 | return new ActionRowBuilder().addComponents(components); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/libs/versions/versionManager.js: -------------------------------------------------------------------------------- 1 | const { version } = require('discord.js'); 2 | 3 | /** 4 | * Compares two version strings (e.g., '14.0.0' vs '13.0.0') 5 | * @param {string} v1 First version string 6 | * @param {string} v2 Second version string 7 | * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal, or throws error if invalid 8 | */ 9 | function compareVersion(v1, v2) { 10 | if (typeof v1 !== 'string' || typeof v2 !== 'string') throw new Error('Version arguments must be strings'); 11 | 12 | if (v1 === v2) return 0; 13 | 14 | const v1Parts = v1.split('.').map(part => { 15 | const num = parseInt(part, 10); 16 | if (isNaN(num)) throw new Error(`Invalid version number: ${v1}`); 17 | return num; 18 | }); 19 | 20 | const v2Parts = v2.split('.').map(part => { 21 | const num = parseInt(part, 10); 22 | if (isNaN(num)) throw new Error(`Invalid version number: ${v2}`); 23 | return num; 24 | }); 25 | 26 | const length = Math.min(v1Parts.length, v2Parts.length); 27 | 28 | for (let i = 0; i < length; i++) { 29 | if (v1Parts[i] > v2Parts[i]) return 1; 30 | if (v1Parts[i] < v2Parts[i]) return -1; 31 | } 32 | 33 | return v1Parts.length === v2Parts.length ? 0 : 34 | v1Parts.length < v2Parts.length ? -1 : 1; 35 | } 36 | 37 | 38 | const v13 = { 39 | buttons: require('./v13/buttons'), 40 | selectMenu: require('./v13/selectMenu'), 41 | interactions: require('./v13/interactions') 42 | }; 43 | 44 | const v14 = { 45 | buttons: require('./v14/buttons'), 46 | selectMenu: require('./v14/selectMenu'), 47 | interactions: require('./v14/interactions') 48 | }; 49 | 50 | const getVersionImplementation = () => { 51 | const isV14Plus = compareVersion(version, '14.0.0') >= 0; 52 | return isV14Plus ? v14 : v13; 53 | }; 54 | 55 | module.exports = { 56 | getComponents: () => { 57 | const impl = getVersionImplementation(); 58 | return { 59 | buttons: impl.buttons, 60 | selectMenu: impl.selectMenu, 61 | interactions: impl.interactions 62 | }; 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/paginationHandler.js: -------------------------------------------------------------------------------- 1 | const { version } = require('discord.js'); 2 | const componentsHandler = require('./libs/components/handler'); 3 | const collectorHandler = require('./libs/collector/handler'); 4 | 5 | const validateData = (data) => { 6 | // Version check 7 | if (version < '13.0.0') throw new Error('Discord.js version 12 and below is not supported.'); 8 | 9 | const {command, pages, component: { selectMenu, buttons }} = data; 10 | 11 | // Required data validation 12 | if (!data) throw new Error('Pagination data is required'); 13 | if (!command) throw new Error('Message or Interaction is required'); 14 | if (!pages || !Array.isArray(pages)) throw new Error('Valid pages array is required'); 15 | 16 | // Component-specific validation 17 | if (selectMenu?.enable && pages.length > 25) throw new Error('Select menu is only available for up to 25 pages.'); 18 | if (!selectMenu?.enable && (!buttons?.length || buttons.length < 2 || buttons.length > 5)) throw new Error(`There must be at least 2 and no more than 5 buttons provided. You provided ${buttons?.length || 0} buttons.`); 19 | }; 20 | 21 | module.exports = async (data) => { 22 | try { 23 | validateData(data); 24 | 25 | const components = await componentsHandler(data.component, data.pages); 26 | 27 | return await collectorHandler( 28 | data.command, 29 | components, 30 | data.component.footer, 31 | data.pages, 32 | data.collector, 33 | data.component?.customComponentsFunction 34 | ); 35 | } catch (error) { 36 | console.error('Pagination error:', error); 37 | throw new Error(`Failed to initialize pagination: ${error.message}`); 38 | } 39 | }; 40 | --------------------------------------------------------------------------------