From a5457c6cc6a96b22e983501c9c60c127617df954 Mon Sep 17 00:00:00 2001 From: grngxd <36968271+grngxd@users.noreply.github.com> Date: Thu, 31 Jul 2025 20:44:22 +0100 Subject: [PATCH] feat: add list command to retrieve and paginate user files --- cmd/index.ts | 4 +- cmd/list.ts | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/user.ts | 18 +++------ index.ts | 17 ++++++-- lib/b64.ts | 12 ++++++ lib/embed.ts | 12 ++++++ 6 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 cmd/list.ts create mode 100644 lib/b64.ts create mode 100644 lib/embed.ts diff --git a/cmd/index.ts b/cmd/index.ts index a703f5d..27d9814 100644 --- a/cmd/index.ts +++ b/cmd/index.ts @@ -1,7 +1,9 @@ +import list from "./list"; import register from "./register"; import user from "./user"; export default [ user, - register + register, + list ] \ No newline at end of file diff --git a/cmd/list.ts b/cmd/list.ts new file mode 100644 index 0000000..619bb26 --- /dev/null +++ b/cmd/list.ts @@ -0,0 +1,107 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CacheType, ChatInputCommandInteraction, Events, Interaction, InteractionReplyOptions, MessageFlags, SlashCommandBuilder, User } from "discord.js"; +import { db } from "../db/db"; +import { b64decode, b64encode } from "../lib/b64"; +import { createEmbed } from "../lib/embed"; +import { formatSize } from "../lib/files"; +import { StereoFile } from "../types/files"; + +export const data = new SlashCommandBuilder() + .setName("list") + .setDescription("list all of your files") + .addIntegerOption(option => + option.setName("page") + .setDescription("page number to view") + .setRequired(false) + .setMinValue(1) + ); + +export async function renderList(user: User, userId: string, page: number): Promise { + const amount = 2; + + const { count } = await db.get<{ count: number }>` + SELECT COUNT(*) as count FROM files WHERE owner = ${userId} + `; + const totalPages = Math.max(1, Math.ceil(count / amount)); + const pages = Math.max(1, Math.min(page, totalPages)); + + const files = await db.all` + SELECT * FROM files WHERE owner = ${userId} + ORDER BY created_at DESC + LIMIT ${amount} OFFSET ${(pages - 1) * amount} + `; + + if (files.length === 0) { + return { content: "you havn't uploaded any files yet, visit the website to get started!", flags: MessageFlags.Ephemeral }; + } + + const embed = createEmbed(user) + .setTitle("your files") + .addFields(...files.map(file => ({ + name: file.name, + value: `${formatSize(file.size)}\n${new Date(file.created_at).toLocaleString()}\n[open in browser](${process.env.API}/${file.id})`, + inline: true + }))) + .setFooter({ text: `page ${pages} of ${totalPages}` }); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(b64encode({ dir: "prev", user: userId, page: pages })) + .setLabel('<') + .setStyle(ButtonStyle.Danger) + .setDisabled(pages === 1), + new ButtonBuilder() + .setCustomId(b64encode({ dir: "next", user: userId, page: pages })) + .setLabel('>') + .setStyle(ButtonStyle.Danger) + .setDisabled(pages === totalPages) + ); + + return { embeds: [embed], components: [row] }; +} + +export const execute = async (interaction: ChatInputCommandInteraction) => { + const page = interaction.options.getInteger("page") || 1; + const userId = interaction.user.id; + const reply = await renderList(interaction.user, userId, page); + await interaction.reply(reply); +}; + +export const on: { + [event in Events]?: (interaction: Interaction) => Promise; +} = { + [Events.InteractionCreate]: async (interaction: Interaction) => { + if (!interaction.isButton()) return; + // old, cursed code :vomit: + + // const match = interaction.customId.match(/^s\.list\.(prev|next)\.(\d+)$/); + // if (!match) return; + + // let page = parseInt(match[2] || "1", 10); + // page = match[1] === "next" ? page + 1 : page - 1; + + // const dirtyReply = await renderList(interaction.user, interaction.user.id, page); + // const { flags, ...reply } = dirtyReply; + // await interaction.update({ ...reply }); + + // new, clean code :sunglasses: + + const data = b64decode<{ + dir: "next" | "prev"; + user: string; + page: number; + }>(interaction.customId); + + if (interaction.user.id !== data.user) { + await interaction.reply({ content: "hey, stop that!!!", flags: MessageFlags.Ephemeral }); + return; + } + + const page = data.dir === "next" ? data.page + 1 : data.page - 1; + const dirty = await renderList(interaction.user, interaction.user.id, page); + const { flags, ...r } = dirty; + await interaction.update({ ...r }); + } +} + +export default [data, execute, on] as const; \ No newline at end of file diff --git a/cmd/user.ts b/cmd/user.ts index 20f0a63..8c747d5 100644 --- a/cmd/user.ts +++ b/cmd/user.ts @@ -1,6 +1,7 @@ // commands/overview.ts -import { ChatInputCommandInteraction, EmbedBuilder, MessageFlags, SlashCommandBuilder } from "discord.js"; +import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js"; import { db } from "../db/db"; +import { createEmbed } from "../lib/embed"; import { formatSize } from "../lib/files"; import { StereoFile } from "../types/files"; @@ -40,20 +41,13 @@ export const execute = async (interaction: ChatInputCommandInteraction) => { const files = await db.all`SELECT * FROM files WHERE owner = ${user.id}`; const totalSize = files.reduce((a, b) => a + b.size, 0); - const embed = new EmbedBuilder() - .setColor(0xff264e) - .setAuthor({ - name: `${user.globalName || "user"} on stereo`, - iconURL: user.avatarURL({ size: 512 }) || "" - }) + const embed = createEmbed(user) .setDescription("Here's your overview:") .addFields( - { name: "Uploads", value: `${files.length} files`, inline: true }, - { name: "Uploaded", value: `${formatSize(totalSize)} / 15 GB`, inline: true }, - { name: "Plan", value: `Free`, inline: true } + { name: "Uploads", value: `${files.length} files`, inline: true }, + { name: "Uploaded", value: `${formatSize(totalSize)} / 15 GB`, inline: true }, + { name: "Plan", value: `Free`, inline: true } ) - .setFooter({ text: "powered by stereo" }) - .setTimestamp(); await interaction.reply({ embeds: [embed] }); } diff --git a/index.ts b/index.ts index d66a3df..8145569 100644 --- a/index.ts +++ b/index.ts @@ -3,7 +3,7 @@ import commands from "./cmd"; import { db } from "./db/db"; import { StereoFile } from "./types/files"; -const client = new Client({ +const bot = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, @@ -14,7 +14,7 @@ const client = new Client({ const rest = new REST({ version: "10" }) .setToken(process.env.TOKEN); -client.once(Events.ClientReady, async (c) => { +bot.once(Events.ClientReady, async (c) => { await rest.put( Routes.applicationCommands(process.env.CLIENT_ID), { body: commands.map(([data, _]) => data) } @@ -29,7 +29,7 @@ client.once(Events.ClientReady, async (c) => { console.log(c.user.tag); }); -client.on(Events.InteractionCreate, async interaction => { +bot.on(Events.InteractionCreate, async interaction => { if (!interaction.isChatInputCommand()) return; const cmd = commands.find(([data]) => data.name === interaction.commandName); if (!cmd) return; @@ -42,7 +42,16 @@ client.on(Events.InteractionCreate, async interaction => { } }); -client.login(process.env.TOKEN).catch((err) => { +for (const [_, __, on] of commands) { + if (!on) continue; + + for (const [event, handler] of Object.entries(on)) { + if (!Object.values(Events).includes(event as Events)) { console.warn(`Unknown event: ${event}`); continue; } + bot.on(event, handler); + } +} + +bot.login(process.env.TOKEN).catch((err) => { console.error("Failed to login:", err) process.exit(1) }); \ No newline at end of file diff --git a/lib/b64.ts b/lib/b64.ts new file mode 100644 index 0000000..485cff1 --- /dev/null +++ b/lib/b64.ts @@ -0,0 +1,12 @@ +// i actually love this +export const b64encode = (obj: object): string => { + return Buffer.from(JSON.stringify(obj)).toString("base64url"); +} + +export const b64decode = (str: string): T => { + try { + return JSON.parse(Buffer.from(str, "base64url").toString()); + } catch { + throw new Error("invalid base64-encoded object string: " + str); + } +} \ No newline at end of file diff --git a/lib/embed.ts b/lib/embed.ts new file mode 100644 index 0000000..3abbe00 --- /dev/null +++ b/lib/embed.ts @@ -0,0 +1,12 @@ +import { EmbedBuilder, User } from "discord.js"; + +export const createEmbed = (user: User) => { + return new EmbedBuilder() + .setColor(0xff264e) + .setAuthor({ + name: `${user.globalName || "user"} on stereo`, + iconURL: user.avatarURL({ size: 512 }) || "" + }) + .setFooter({ text: "powered by stereo" }) + .setTimestamp() +} \ No newline at end of file