437 lines
15 KiB
JavaScript
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; |