2025-05-12 15:20:40 -05:00

437 lines
15 KiB
JavaScript

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;