const path = require("path"); const PollDB = require(path.join(__dirname, "../models/polls")); const Embed = require(path.join(__dirname, "./embed")); const Canvas = require('canvas'); // Constants const CANVAS_CONFIG = { WIDTH: 600, PADDING: 10, BAR_HEIGHT: 40, FONT_SIZES: { SMALL: '12px', MEDIUM: '17px' }, COLORS: { BACKGROUND: "#23272A", TEXT_PRIMARY: "#FFFFFF", TEXT_SECONDARY: "#4E535A", BAR_BACKGROUND: "#2C2F33", BAR_FILL: "#A52F05", BAR_INACTIVE: "#24282B", SELECTION: "#717cf4" } }; // Helper function to format date const getFormattedDate = () => { const now = new Date(); const month = (now.getMonth() + 1).toString().padStart(2, '0'); const day = now.getDate().toString().padStart(2, '0'); const hours = now.getHours().toString().padStart(2, '0'); const minutes = now.getMinutes().toString().padStart(2, '0'); return `${month}/${day}/${now.getFullYear()} ${hours}:${minutes}`; }; /** * Polls class for managing and rendering polls */ class Polls { /** * @param {Object} options - Poll configuration options * @param {number} options.time - Duration of the poll in milliseconds * @param {Object} options.client - Discord client instance * @param {Object} options.name - Poll name and description * @param {Object} options.options - Poll options configuration * @param {string[]} [options.users] - Array of user IDs who voted * @param {string[]} [options.avatars] - Array of user avatar URLs * @param {number[]} [options.votes] - Array of vote counts * @param {string} options.owner - Poll owner ID * @param {string} options.lang - Language code */ constructor({ time, client, name, options, users = [], avatars = [], votes, owner, lang }) { this.client = client; this.time = time; this.owner = owner; this.lang = lang; this.users = users; this.avatars = avatars; this.options = { name: name.name, description: name.description }; this.voteOptions = options; // Initialize votes array based on number of options this.votes = votes || new Array(options.name.length).fill(0); // Calculate canvas dimensions based on number of options this.size = this.calculateCanvasSize(options.name.length); } /** * Calculate canvas dimensions based on number of options * @param {number} optionCount - Number of poll options * @returns {Object} Canvas and bar dimensions */ calculateCanvasSize(optionCount) { const baseSize = 200; const increment = 50; const size = baseSize + (optionCount - 2) * increment; return { canvas: size, bar: size - 50 }; } /** * Start the poll * @param {Object} message - Discord message object * @param {Object} poll - Poll instance */ async start(message, poll) { this.client.polls.set(message.id, { poll, messageId: message.id, users: this.users, owner: this.owner, lang: this.lang }); if (this.time < 0) return; // Save poll to database await new PollDB({ owner: this.owner, channelId: message.channelId, messageId: message.id, avatars: this.avatars, users: this.users, votes: this.votes, name: this.options.name, desc: this.options.description, options: this.voteOptions, time: this.time, lang: this.lang, now: Date.now(), }).save(); // Set poll end timer setTimeout(async () => { if (!this.client.polls.get(message.id)) return; await this.update(); const endMessage = this.client.translate.get(this.lang, "Functions.poll.end"); try { await message.edit({ embeds: [new Embed() .setMedia(await this.client.Uploader.upload(poll.canvas.toBuffer(), 'Poll.png')) .setColor('#F24646')], content: endMessage }); } catch (error) { console.error('Failed to edit poll message:', error); } this.client.polls.delete(message.id); await PollDB.findOneAndDelete({ messageId: message.id }); }, this.time); } /** * Calculate text height for canvas * @param {string} text - Text to measure * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {TextMetrics} [metrics] - Optional text metrics * @returns {number} Text height */ textHeight(text, ctx, metrics) { const m = metrics || ctx.measureText(text); return m.actualBoundingBoxAscent + m.actualBoundingBoxDescent; } /** * Draw rounded rectangle on canvas * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} width - Rectangle width * @param {number} height - Rectangle height * @param {number|Object} radius - Corner radius * @param {boolean} fill - Whether to fill the rectangle * @param {boolean} stroke - Whether to stroke the rectangle */ roundRect(ctx, x, y, width, height, radius = 5, fill = true, stroke = true) { const r = typeof radius === 'number' ? { tl: radius, tr: radius, br: radius, bl: radius } : { tl: 0, tr: 0, br: 0, bl: 0, ...radius }; ctx.beginPath(); ctx.moveTo(x + r.tl, y); ctx.lineTo(x + width - r.tr, y); ctx.quadraticCurveTo(x + width, y, x + width, y + r.tr); ctx.lineTo(x + width, y + height - r.br); ctx.quadraticCurveTo(x + width, y + height, x + width - r.br, y + height); ctx.lineTo(x + r.bl, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - r.bl); ctx.lineTo(x, y + r.tl); ctx.quadraticCurveTo(x, y, x + r.tl, y); ctx.closePath(); if (fill) ctx.fill(); if (stroke) ctx.stroke(); } /** * Update the poll canvas */ async update() { const { WIDTH, PADDING, BAR_HEIGHT, COLORS, FONT_SIZES } = CANVAS_CONFIG; const height = this.size.canvas; // Create canvas const canvas = Canvas.createCanvas(WIDTH, height); this.canvas = canvas; const ctx = this.canvas.getContext('2d'); this.ctx = ctx; // Draw background ctx.fillStyle = COLORS.BACKGROUND; this.roundRect(ctx, 0, 0, WIDTH, height, 5, true, false); // Draw title and description const name = this.truncateText(this.options.name, 70); const description = this.truncateText(this.options.description, 80); const nameHeight = this.textHeight(name, ctx); const descHeight = this.textHeight(description, ctx); // Draw title ctx.fillStyle = COLORS.TEXT_SECONDARY; ctx.font = `normal ${FONT_SIZES.SMALL} Sans-Serif`; ctx.fillText(name, PADDING, PADDING + 2 + nameHeight / 2); // Draw description ctx.fillStyle = COLORS.TEXT_PRIMARY; ctx.font = `normal ${FONT_SIZES.MEDIUM} Sans-Serif`; ctx.fillText(description, PADDING, PADDING + 15 + nameHeight + descHeight / 2); const headerHeight = PADDING + descHeight + nameHeight + 15; const dataWidth = WIDTH - PADDING * 2; // Draw vote bars this.drawVoteBars(ctx, dataWidth - 20, BAR_HEIGHT, this.votes, { pad: PADDING, hHeight: headerHeight }, this.voteOptions.name); // Draw footer await this.drawFooter(ctx, PADDING, PADDING + headerHeight + BAR_HEIGHT * 2 + 20, WIDTH, height, PADDING, this.avatars); } /** * Truncate text with ellipsis * @param {string} text - Text to truncate * @param {number} maxLength - Maximum length * @returns {string} Truncated text */ truncateText(text, maxLength) { return text?.length > maxLength ? text.slice(0, maxLength - 3) + "..." : text; } /** * Add a vote to the poll * @param {number} option - Option index * @param {string} user - User ID * @param {string} avatar - User avatar URL * @param {string} id - Message ID * @returns {Canvas} Updated canvas */ async addVote(option, user, avatar, id) { if (this.avatars.length === 6) this.avatars.shift(); this.avatars.push(avatar); this.votes[option]++; await PollDB.findOneAndUpdate( { messageId: id }, { $push: { users: user, avatars: avatar }, $inc: { [`votes.${option}`]: 1 } } ); await this.update(); return this.canvas; } /** * Remove a vote from the poll * @param {number} option - Option index * @param {string} user - User ID * @param {string} avatar - User avatar URL * @param {string} id - Message ID * @returns {Canvas} Updated canvas */ async removeVote(option, user, avatar, id) { this.avatars.splice(this.avatars.indexOf(avatar), 1); this.votes[option]--; await PollDB.findOneAndUpdate( { messageId: id }, { $pull: { users: user, avatars: avatar }, $inc: { [`votes.${option}`]: -1 } } ); await this.update(); return this.canvas; } /** * Draw vote bars on canvas * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {number} width - Bar width * @param {number} height - Bar height * @param {number[]} votes - Vote counts * @param {Object} vars - Position variables * @param {string[]} names - Option names * @param {number} [vote] - Selected vote index */ drawVoteBars(ctx, width, height, votes, vars, names, vote) { const { PADDING, COLORS } = CANVAS_CONFIG; const { pad: padding, hHeight: headerHeight } = vars; const sum = votes.reduce((prev, curr) => prev + curr, 0); const percentages = votes.map(v => Math.floor(v / (sum / 100) * 10) / 10); ctx.save(); ctx.translate(padding, padding + headerHeight); const barPadding = 5; percentages.forEach((percentage, i) => { const y = (height + 10) * i; const paddingLeft = vote !== undefined ? 30 : 0; // Draw bar background ctx.fillStyle = COLORS.BAR_BACKGROUND; this.roundRect(ctx, 20, y, width, height, 5, true, false); // Draw bar fill ctx.fillStyle = (vote === i || percentage) ? COLORS.BAR_FILL : COLORS.BAR_INACTIVE; this.roundRect(ctx, 20, y, width * (votes[i] / (sum / 100) / 100), height, 5, true, false); // Draw option number ctx.fillStyle = COLORS.TEXT_SECONDARY; const numHeight = this.textHeight(i + 1, ctx); ctx.fillText(i + 1, 0, y + height / 2 + numHeight / 2); // Draw option name ctx.fillStyle = COLORS.TEXT_PRIMARY; const name = this.truncateText(names[i], 65); const nameHeight = this.textHeight(name, ctx); ctx.fillText(name, 30 + paddingLeft, y + 13 + nameHeight); // Draw selection circle if voting if (vote !== undefined) { ctx.strokeStyle = COLORS.TEXT_PRIMARY; ctx.fillStyle = COLORS.SELECTION; ctx.beginPath(); ctx.arc(35, y + 10 + nameHeight * 0.75, 6, 0, 2 * Math.PI); ctx.closePath(); ctx.stroke(); if (vote === i) { ctx.beginPath(); ctx.arc(35, y + 10 + nameHeight * 0.75, 3, 0, 2 * Math.PI); ctx.closePath(); ctx.fill(); } } // Draw percentage and vote count const voteText = `${percentage}% (${votes[i]})`; const metrics = ctx.measureText(voteText); const textHeight = this.textHeight(voteText, ctx, metrics); const textY = y + (height - textHeight - barPadding * 2) + barPadding * 2.6; if (vote === i || vote === undefined) { ctx.fillStyle = COLORS.BAR_BACKGROUND; this.roundRect(ctx, width - barPadding - metrics.width - 3, textY - textHeight - 4, metrics.width + 5, textHeight + 12, 5, true, false); } ctx.fillStyle = COLORS.BAR_FILL; ctx.fillText(voteText, width - barPadding - metrics.width, textY); }); ctx.restore(); } /** * Draw footer on canvas * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} width - Canvas width * @param {number} height - Canvas height * @param {number} padding - Padding value * @param {string[]} users - Array of user avatar URLs */ async drawFooter(ctx, x, y, width, height, padding, users) { const { COLORS } = CANVAS_CONFIG; ctx.save(); ctx.translate(10, this.size.bar); const rad = 18; ctx.fillStyle = COLORS.TEXT_SECONDARY; ctx.lineWidth = 2; ctx.strokeStyle = COLORS.TEXT_SECONDARY; // Draw separator line ctx.beginPath(); ctx.moveTo(0 - padding, 0); ctx.lineTo(width, 0); ctx.stroke(); // Draw vote count const totalVotes = this.votes.reduce((p, c) => p + c, 0); const voteText = totalVotes === 1 ? '1 vote' : `${totalVotes} votes`; const metrics = ctx.measureText(voteText); const textHeight = this.textHeight(voteText, ctx, metrics); ctx.fillText(voteText, 5, rad + textHeight); // Draw avatars let pos = rad * users.length + 10 + metrics.width; const yPos = 6; users.reverse(); for (const avatar of users) { const avatarCanvas = Canvas.createCanvas(rad * 2, rad * 2); const avatarCtx = avatarCanvas.getContext('2d'); avatarCtx.beginPath(); avatarCtx.arc(rad, rad, rad, 0, Math.PI * 2, true); avatarCtx.closePath(); avatarCtx.clip(); try { const avatarImage = await Canvas.loadImage(avatar); avatarCtx.drawImage(avatarImage, 0, 0, rad * 2, rad * 2); ctx.drawImage(avatarCanvas, pos, yPos); } catch (error) { console.error('Failed to load avatar:', error); } pos -= rad; } // Draw date const date = getFormattedDate(); const dateMetrics = ctx.measureText(date); const dateHeight = this.textHeight(date, ctx, dateMetrics); ctx.fillText(date, width - 15 - dateMetrics.width, rad + dateHeight); ctx.restore(); } } module.exports = Polls;