Compare commits

...

8 Commits

Author SHA1 Message Date
Jill 3b9c66ebfd
recipe creator FINISH 2023-11-18 14:59:29 +03:00
Jill 3da36de9f6
web interface holy FUCK 2023-11-18 02:55:39 +03:00
Jill eb1dd27d6b
basic web stuff 2023-11-17 23:11:50 +03:00
Jill 77dbd8ee3f
recipe grounwork 2023-11-17 21:23:35 +03:00
Jill c714596653
lint fix 2023-11-17 20:30:27 +03:00
Jill 0594cc71db
small tweaks 2023-11-16 20:22:25 +03:00
Jill a2a56f60f1
add fishing 2023-11-16 20:22:24 +03:00
Jill 9eecec4894
fix dupe bug oops! 2023-11-16 20:22:22 +03:00
27 changed files with 3271 additions and 55 deletions

View File

@ -1,3 +1,5 @@
{
"token": "token"
"token": "token",
"sitePort": 15385,
"siteURL": "https://localhost:15385"
}

0
customCraftingRecipes Normal file
View File

View File

@ -17,7 +17,7 @@ rest
const commandFiles = fs.readdirSync("./dist/commands").filter((file) => file.endsWith(".js") && !file.startsWith('.'));
for (const file of commandFiles) {
const command = require(`./dist/commands/${file}`);
const command = require(`./dist/commands/${file}`).default;
commands.push(command);
}

View File

@ -0,0 +1,27 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
.createTable('customCraftingRecipes', (table) => {
table.increments('id');
table.string('station');
})
.createTable('customCraftingRecipeItems', (table) => {
table.integer('id').references('id').inTable('customCraftingRecipes').notNullable();
table.integer('item').notNullable();
table.integer('quantity').defaultTo(1);
table.enum('type', ['input', 'output', 'requirement']);
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema
.dropTable('customCraftingRecipes')
.dropTable('customCraftingRecipes');
};

View File

@ -15,6 +15,7 @@
"chalk": "^4.1.2",
"d3-array": "^2.12.1",
"discord.js": "^14.14.1",
"express": "^4.18.2",
"got": "^11.8.6",
"knex": "^3.0.1",
"outdent": "^0.8.0",
@ -25,6 +26,7 @@
},
"devDependencies": {
"@types/d3-array": "^3.2.1",
"@types/express": "^4.17.21",
"@types/parse-color": "^1.0.3",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { AutocompleteInteraction, GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { CraftingStationCooldown, db } from '../lib/db';
import { getStation, canUseStation, craftingStations, verb } from '../lib/rpg/craftingStations';
import { getStation, canUseStation, craftingStations, verb, CraftingStation } from '../lib/rpg/craftingStations';
import { formatItem, getItemQuantity, formatItems, getMaxStack, giveItem, formatItemsArray } from '../lib/rpg/items';
import { getRecipe, defaultRecipes, formatRecipe } from '../lib/rpg/recipes';
import { Command } from '../types/index';
@ -95,15 +95,21 @@ export default {
nextUsableAt = Date.now() + station.cooldown * 1000;
}
return interaction.followUp(`${station.emoji} ${verb(station)} ${formatItemsArray(outputs)}!${nextUsableAt ? `\n${station.name} usable again <t:${Math.floor(nextUsableAt / 1000)}:R>` : ''}`);
return interaction.followUp(`${station.emoji} ${verb(station)} ${formatItemsArray(outputs)}!${outputs.length === 1 ? `\n_${outputs[0].item.description}_` : ''}${nextUsableAt ? `\n${station.name} usable again <t:${Math.floor(nextUsableAt / 1000)}:R>` : ''}`);
},
autocomplete: async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused(true);
if (focused.name === 'station') {
const found = craftingStations
.filter(station => canUseStation(interaction.user.id, station))
const found = (await Promise.all(
craftingStations
.map(async station => [station, await canUseStation(interaction.user.id, station)])
))
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_station, usable]) => usable)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(([station, _]) => station as CraftingStation)
.filter(station => station.name.toLowerCase().includes(focused.value.toLowerCase()))
.map(station => ({
name: `${station.emoji} ${station.name}`,

View File

@ -24,7 +24,7 @@ export default {
const items = (await Promise.all(itemsList.map(async i => ({item: await getItem(i.item), quantity: i.quantity})))).filter(i => i.item);
await interaction.followUp(
`Your inventory:\n${items.length === 0 ? '_Your inventory is empty!_' : items.map(i => `- ${formatItems(i.item!, i.quantity)}`).join('\n')}`
`Your inventory:\n${items.length === 0 ? '_Your inventory is empty!_' : items.map(i => `- ${formatItems(i.item!, i.quantity)}\n_${i.item!.description}_`).join('\n')}`
);
}
} satisfies Command;

207
src/commands/recipe.ts Normal file
View File

@ -0,0 +1,207 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, ComponentType, Events, ModalBuilder, SlashCommandBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from 'discord.js';
import { Command } from '../types/index';
import { Items, getItem } from '../lib/rpg/items';
import { formatRecipe } from '../lib/rpg/recipes';
import { craftingStations, getStation } from '../lib/rpg/craftingStations';
import { CustomCraftingRecipe, CustomCraftingRecipeItem, db } from '../lib/db';
export default {
data: new SlashCommandBuilder()
.setName('recipe')
.setDescription('[ADMIN] Manage custom recipes for items')
.addSubcommand(sub =>
sub
.setName('create')
.setDescription('[ADMIN] Create a custom recipe')
)
.setDMPermission(false)
.setDefaultMemberPermissions(0),
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
await interaction.deferReply({ ephemeral: true });
const sub = interaction.options.getSubcommand(true);
if (sub === 'create') {
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`recipe-create-${interaction.guildId}`).setLabel('I\'ve got my string ready!').setStyle(ButtonStyle.Primary)
);
await interaction.followUp({
ephemeral: true,
content: `To create a recipe, go here: ${interaction.client.config.siteURL}/create-recipe/?guild=${interaction.guildId}\nOnce done, click the button below and paste the resulting string in.`,
components: [row]
});
}
},
async onClientReady(bot) {
bot.on(Events.InteractionCreate, async (interaction) => {
if (!('customId' in interaction)) return;
const id = interaction.customId;
if (!id.startsWith('recipe-')) return;
if (!interaction.member) return;
if (id.startsWith('recipe-create-')) {
//const guildID = id.split('-')[2];
if (interaction.isMessageComponent()) {
const modal = new ModalBuilder()
.setCustomId(interaction.customId)
.setTitle('Recipe Creator');
const input = new TextInputBuilder()
.setCustomId('recipe-create-textbox')
.setLabel('Paste in your recipe string here:')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true);
const row = new ActionRowBuilder<TextInputBuilder>().addComponents(input);
modal.addComponents(row);
interaction.showModal(modal);
} else if (interaction.isModalSubmit()) {
const field = interaction.fields.getField('recipe-create-textbox', ComponentType.TextInput);
const recipeString = field.value;
await interaction.deferReply({ ephemeral: true });
let parsed;
try {
parsed = await Promise.all(
recipeString
.split('|')
.map(items =>
Promise.all(
items
.split(';')
.map(itemStack =>
itemStack.split(',')
)
.map(async ([itemID, quantity]) => (
{
item: (await getItem(parseInt(itemID)))!,
quantity: parseInt(quantity)
}
))
)
)
) as Items[][];
} catch (err) {
await interaction.followUp(`This is not a valid string!: \`${(err as Error).message}\``);
return;
}
const
inputs = parsed[0] || [],
requirements = parsed[1] || [],
outputs = parsed[2] || [];
const recipe = {
inputs, requirements, outputs,
station: 'hands',
id: 0
};
const components = [
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.addOptions(
...craftingStations
.map(station => ({
label: `${station.emoji} ${station.name}`,
value: station.key,
description: station.description
}))
)
.setMinValues(1)
.setMaxValues(1)
.setCustomId('recipe-select-station')
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('recipe-select-done')
.setLabel('Done')
.setStyle(ButtonStyle.Primary)
.setDisabled(true)
)
];
const msg = await interaction.followUp({
content: `${formatRecipe(recipe)}\n_Select a crafting station, and you're good to go!_`,
components: components,
fetchReply: true
});
const selectCollector = msg.createMessageComponentCollector({
componentType: ComponentType.StringSelect,
time: 60_000 * 5,
});
selectCollector.on('collect', selectInteraction => {
const newStation = selectInteraction.values[0];
recipe.station = newStation;
components[1].components[0].setDisabled(false);
interaction.editReply({
content: `${formatRecipe(recipe)}\n_Select a crafting station, and you're good to go!_`,
components: components
});
const station = getStation(newStation);
selectInteraction.reply({
content: `Set station to ${station?.emoji} **${station?.name}**`,
ephemeral: true
});
});
selectCollector.on('end', () => {
interaction.editReply({
content: msg.content,
components: []
});
});
const buttonInteraction = await msg.awaitMessageComponent({ componentType: ComponentType.Button, time: 60_000 * 5 });
selectCollector.stop();
const [customRecipe] = await db<CustomCraftingRecipe>('customCraftingRecipes')
.insert({
station: recipe.station
})
.returning('id');
for (const input of recipe.inputs) {
await db<CustomCraftingRecipeItem>('customCraftingRecipeItems')
.insert({
id: customRecipe.id,
item: input.item.id,
quantity: input.quantity,
type: 'input'
});
}
for (const req of recipe.requirements) {
await db<CustomCraftingRecipeItem>('customCraftingRecipeItems')
.insert({
id: customRecipe.id,
item: req.item.id,
quantity: req.quantity,
type: 'requirement'
});
}
for (const output of recipe.outputs) {
await db<CustomCraftingRecipeItem>('customCraftingRecipeItems')
.insert({
id: customRecipe.id,
item: output.item.id,
quantity: output.quantity,
type: 'output'
});
}
buttonInteraction.reply({
ephemeral: true,
content: 'Your recipe has been created 🎉'
});
}
}
});
}
} satisfies Command;

View File

@ -733,6 +733,7 @@ export default {
});
bot.on(Events.InteractionCreate, interaction => {
if (!interaction.isModalSubmit()) return;
if (!interaction.customId.startsWith('survey-')) return;
if (!interaction.member) return;
const member = interaction.member as GuildMember;

View File

@ -1,12 +1,13 @@
import { Client, GatewayIntentBits, Events, Collection, CommandInteraction, CommandInteractionOption, ApplicationCommandOptionType } from 'discord.js';
import * as fs from 'fs';
const { token } = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
const { token, sitePort, siteURL } = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
import * as path from 'path';
import { initializeAnnouncements } from './lib/subscriptions';
import * as log from './lib/log';
import chalk from 'chalk';
import prettyBytes from 'pretty-bytes';
import { Command } from './types/index';
import { startServer } from './web';
const bot = new Client({
intents: [
@ -20,9 +21,16 @@ const bot = new Client({
],
});
bot.config = {
token, sitePort, siteURL
};
async function init() {
log.nonsense('booting chip...');
log.nonsense('starting up web interface...');
await startServer(sitePort);
log.nonsense('setting up connection...');
try {
@ -44,7 +52,7 @@ bot.on(Events.ClientReady, async () => {
bot.commands = new Collection();
const cmdFiles = fs.readdirSync(path.join(__dirname, './commands')).filter((file) => file.endsWith('.js'));
for (const file of cmdFiles) {
const cmd = (await import(`./commands/${file}`)) as Command;
const cmd = (await import(`./commands/${file}`)).default as Command;
bot.commands.set(cmd.data.name, cmd);
if (cmd.onClientReady) cmd.onClientReady(bot);
}

View File

@ -68,4 +68,14 @@ export interface CraftingStationCooldown {
station: string,
user: string,
usedAt: number
}
export interface CustomCraftingRecipe {
id: number,
station: string
}
export interface CustomCraftingRecipeItem {
id: number,
item: number,
quantity: number,
type: 'input' | 'output' | 'requirement'
}

View File

@ -323,6 +323,19 @@ function changeCounterInteractionBuilder(linked: boolean) {
return interaction.followUp(`You cannot **${amount > 0 ? (linked ? 'put' : 'produce') : (linked ? 'take' : 'consume')}** ${counter.emoji}.`);
}
const min = await getCounterConfig(counter.id, 'min') as number;
const max = await getCounterConfig(counter.id, 'max') as number;
if (counter.value + amount < min) {
if (min === 0) {
return interaction.followUp(`You cannot remove more than how much is in the counter (${counter.value}x ${counter.emoji})!`);
} else {
return interaction.followUp(`You cannot decrement past the minimum value (${min})!`);
}
}
if (counter.value + amount > max) {
return interaction.followUp(`You are adding more than the counter can hold (${max}x)!`);
}
let item;
let newInv;
if (linked) {
@ -346,19 +359,6 @@ function changeCounterInteractionBuilder(linked: boolean) {
newInv = await giveItem(member.id, item, amtInv);
}
const min = await getCounterConfig(counter.id, 'min') as number;
const max = await getCounterConfig(counter.id, 'max') as number;
if (counter.value + amount < min) {
if (min === 0) {
return interaction.followUp(`You cannot remove more than how much is in the counter (${counter.value}x ${counter.emoji})!`);
} else {
return interaction.followUp(`You cannot decrement past the minimum value (${min})!`);
}
}
if (counter.value + amount > max) {
return interaction.followUp(`You are adding more than the counter can hold (${max}x)!`);
}
const newCount = await changeCounter(counter.id, amount);
await updateCounter(interaction.client, counter, newCount);
await announceCounterUpdate(interaction.client, member, amount, counter, newCount, linked);

View File

@ -1,5 +1,5 @@
import { pickRandom } from '../util';
import { DefaultItems, Item, Items, formatItems, formatItemsArray, getDefaultItem, getItemQuantity } from './items';
import { DefaultItems, Item, Items, formatItem, formatItems, formatItemsArray, getDefaultItem, getItemQuantity } from './items';
export interface CraftingStation {
key: string,
@ -65,7 +65,27 @@ export const craftingStations: CraftingStation[] = [
description: 'A place for you to work with tools, for simple things',
emoji: '🛠️',
requires: getDefaultItem(DefaultItems.WORKBENCH)
}
},
{
key: 'fishing',
name: 'Fishing',
verb: 'Fished up',
description: 'fish gaming wednesday',
emoji: '🎣',
requires: getDefaultItem(DefaultItems.FISHING_ROD),
formatRecipe: (inputs, requirements, outputs, disableBold = false) =>
`${formatItemsArray(inputs, disableBold)} => ${outputs.map(i => formatItem(i.item, disableBold) + '?').join(' ')}`,
// weighted random
manipulateResults: (outputs) => {
const pool: Item[] = [];
for (const out of outputs) {
for (let i = 0; i < out.quantity; i++) {
pool.push(out.item);
}
}
return [{ item: pickRandom(pool), quantity: 1 }];
}
},
];
export async function canUseStation(user: string, station: CraftingStation) {

View File

@ -19,6 +19,13 @@ export enum DefaultItems {
LOG = 7,
AXE = 8,
BLOOD = 9,
BAIT = 10,
FISHING_ROD = 11,
CARP = 12,
PUFFERFISH = 13,
EXOTIC_FISH = 14,
SHOVEL = 15,
DIRT = 16,
}
export const defaultItems: DefaultItem[] = [
@ -102,6 +109,69 @@ export const defaultItems: DefaultItem[] = [
maxStack: 1024,
untradable: false
},
{
id: -10,
name: 'Bait',
description: 'I guess you could eat this.',
emoji: '🪱',
type: 'consumable',
maxStack: 128,
untradable: false
},
{
id: -11,
name: 'Fishing Rod',
description: 'Give a man a fish, and he will eat for a day',
emoji: '🎣',
type: 'plain',
maxStack: 1,
untradable: false
},
{
id: -12,
name: 'Carp',
description: 'wow',
emoji: '🐟️',
type: 'plain',
maxStack: 16,
untradable: false
},
{
id: -13,
name: 'Pufferfish',
description: 'yummy!',
emoji: '🐡',
type: 'plain',
maxStack: 16,
untradable: false
},
{
id: -14,
name: 'Exotic Fish',
description: 'lucky!',
emoji: '🐠',
type: 'plain',
maxStack: 16,
untradable: false,
},
{
id: -15,
name: 'Shovel',
description: 'Did you know there\'s no shovel emoji',
emoji: '♠️',
type: 'plain',
maxStack: 1,
untradable: false,
},
{
id: -16,
name: 'Dirt',
description: 'https://media.discordapp.net/attachments/819472665291128873/1081454188325785650/ezgif-2-5ccc7dedf8.gif',
emoji: '🟫',
type: 'consumable',
maxStack: 64,
untradable: false,
},
];

View File

@ -91,7 +91,57 @@ export const defaultRecipes: DefaultRecipe[] = [
outputs: [
{ item: getDefaultItem(DefaultItems.BLOOD), quantity: 6 },
]
}
},
{
id: -8,
station: 'fishing',
inputs: [
{ item: getDefaultItem(DefaultItems.BAIT), quantity: 1 }
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.CARP), quantity: 12 },
{ item: getDefaultItem(DefaultItems.PUFFERFISH), quantity: 4 },
{ item: getDefaultItem(DefaultItems.EXOTIC_FISH), quantity: 1 },
]
},
{
id: -9,
station: 'workbench',
inputs: [
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 2 },
{ item: getDefaultItem(DefaultItems.BAIT), quantity: 1 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.FISHING_ROD), quantity: 1 }
]
},
{
id: -10,
station: 'forage',
inputs: [],
requirements: [
{ item: getDefaultItem(DefaultItems.SHOVEL), quantity: 1 },
],
outputs: [
{ item: getDefaultItem(DefaultItems.BAIT), quantity: 3 },
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 1 },
{ item: getDefaultItem(DefaultItems.DIRT), quantity: 1 },
],
},
{
id: -11,
station: 'workbench',
inputs: [
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 3 },
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 2 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.SHOVEL), quantity: 1 },
]
},
];
export function getDefaultRecipe(id: number): DefaultRecipe | undefined {

15
src/types/index.d.ts vendored
View File

@ -1,15 +1,22 @@
import { Collection, SlashCommandBuilder, CommandInteraction, Client } from 'discord.js';
export interface Command {
data: Pick<SlashCommandBuilder, "toJSON" | "name">,
execute: (interaction: CommandInteraction) => Promise<any>,
autocomplete?: (interaction: AutocompleteInteraction) => Promise<any>,
onClientReady?: (client: Client) => Promise<any>,
data: Pick<SlashCommandBuilder, 'toJSON' | 'name'>,
execute: (interaction: CommandInteraction) => Promise<unknown>,
autocomplete?: (interaction: AutocompleteInteraction) => Promise<unknown>,
onClientReady?: (client: Client) => Promise<unknown>,
serverWhitelist?: string[],
}
export interface Config {
token: string,
sitePort: number,
siteURL: string
}
declare module 'discord.js' {
export interface Client {
config: Config,
commands: Collection<string, Command>;
}
}

28
src/web.ts Normal file
View File

@ -0,0 +1,28 @@
import express from 'express';
import * as log from './lib/log';
import { CustomItem, db } from './lib/db';
import { defaultItems } from './lib/rpg/items';
export async function startServer(port: number) {
const app = express();
app.use(express.static('static/'));
app.get('/api/items', async (req, res) => {
const guildID = req.query.guild;
let customItems : Partial<CustomItem>[];
if (guildID) {
customItems = await db<CustomItem>('customItems')
.select('emoji', 'name', 'id', 'description')
.where('guild', guildID)
.limit(25);
} else {
customItems = [];
}
res.json([...defaultItems, ...customItems]);
});
app.listen(port, () => log.info(`web interface listening on ${port}`));
}

BIN
static/assets/jillo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>jillo</title>
<meta name="theme-color" content="light dark">
<link href="/style.css" rel="stylesheet">
<link rel="shortcut icon" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Balsamiq+Sans&display=swap" rel="stylesheet">
<script src="/create-recipe/script.js"></script>
</head>
<body>
<div id="content">
<div class="header">
<div class="bg"></div>
<div class="left">
<a href="/">jillo</a>
</div>
<div class="links">
<a href="https://discord.com/oauth2/authorize?client_id=898850107892596776&scope=bot" target="_blank" rel="noopener">invite</a>
&middot;
<a href="https://git.oat.zone/dark-firepit/jillo-bot" target="_blank" rel="noopener">repo</a>
<img src="/assets/jillo_small.png">
</div>
</div>
<h1>Recipe Creator</h1>
<div class="items">
loading available items...
</div>
<p class="note">Drag items from above into the below lists:</p>
<h2>Inputs <span class="subtitle">Ingredients necessary to create the outputs</span></h2>
<div class="item-list" data-type="inputs">
</div>
<h2>Requirements <span class="subtitle">Unlike inputs, these are not consumed, but are necessary</span></h2>
<div class="item-list" data-type="requirements">
</div>
<h2>Outputs <span class="subtitle">The result of the recipe</span></h2>
<div class="item-list" data-type="outputs">
</div>
<p class="note">Drag an item out of the list in order to remove it</p>
<h1>Recipe string</h1>
<pre id="recipe-string"></pre>
Copy-paste this into Jillo to create your recipe
</div>
</body>
</html>

View File

@ -0,0 +1,145 @@
let resolveLoaded;
const loaded = new Promise(resolve => resolveLoaded = resolve);
function e(unsafeText) {
let div = document.createElement('div');
div.innerText = unsafeText;
return div.innerHTML;
}
/**
* @type {import('../../src/lib/rpg/items').Item | null}
*/
let draggedItem = null;
/**
* @type {Record<string, import('../../src/lib/rpg/items').Items[]>}
*/
let itemLists = {};
function listToString(list) {
return list.map(stack => `${stack.item.id},${stack.quantity}`).join(';');
}
function updateString() {
document.querySelector('#recipe-string').innerText = [
listToString(itemLists['inputs'] || []),
listToString(itemLists['requirements'] || []),
listToString(itemLists['outputs'] || []),
].join('|');
}
/**
* @param {import('../../src/lib/rpg/items').Item} item
*/
function renderItem(item) {
const i = document.createElement('div');
i.innerHTML = `
<div class="icon">
${e(item.emoji)}
</div>
<div class="right">
<div class="name">${e(item.name)}</div>
<div class="description">${item.description ? e(item.description) : '<i>No description</i>'}</div>
</div>
`;
i.classList.add('item');
i.draggable = true;
i.addEventListener('dragstart', event => {
draggedItem = item;
event.target.classList.add('dragging');
});
i.addEventListener('dragend', event => {
draggedItem = null;
event.target.classList.remove('dragging');
});
return i;
}
function renderItemStack(item, quantity, type) {
const i = document.createElement('div');
i.innerHTML = `
<div class="icon">
${e(item.emoji)}
</div>
<div class="right">
x<b>${quantity}</b>
</div>
`;
i.classList.add('itemstack');
i.draggable = true;
i.addEventListener('dragstart', event => {
event.target.classList.add('dragging');
});
i.addEventListener('dragend', event => {
event.target.classList.remove('dragging');
itemLists[type] = itemLists[type] || [];
const items = itemLists[type];
const stackIdx = items.findIndex(n => n.item.id === item.id);
if (stackIdx !== -1) items.splice(stackIdx, 1);
document.querySelector(`.item-list[data-type="${type}"]`).replaceWith(renderItemList(items, type));
updateString();
});
return i;
}
function renderItemList(items, type) {
const i = document.createElement('div');
i.textContent = '';
items.forEach(itemStack => {
i.appendChild(renderItemStack(itemStack.item, itemStack.quantity, type));
});
i.dataset.type = type;
i.classList.add('item-list');
// prevent default to allow drop
i.addEventListener('dragover', (event) => event.preventDefault(), false);
i.addEventListener('dragenter', event => draggedItem && event.target.classList.add('dropping'));
i.addEventListener('dragleave', event => draggedItem && event.target.classList.remove('dropping'));
i.addEventListener('drop', (event) => {
event.preventDefault();
event.target.classList.remove('dropping');
if (!draggedItem) return;
itemLists[type] = itemLists[type] || [];
const items = itemLists[type];
const itemStack = items.find(v => v.item.id === draggedItem.id);
if (!itemStack) {
items.push({
item: draggedItem,
quantity: 1
});
} else {
itemStack.quantity = itemStack.quantity + 1;
}
updateString();
draggedItem = null;
event.target.replaceWith(renderItemList(items, type));
});
return i;
}
Promise.all([
fetch(`/api/items${document.location.search}`)
.then(res => res.json()),
loaded
]).then(items => {
const itemsContainer = document.querySelector('.items');
itemsContainer.textContent = '';
items[0].forEach(item =>
itemsContainer.appendChild(renderItem(item))
);
document.querySelectorAll('.item-list').forEach(elem => {
const type = elem.dataset.type;
elem.replaceWith(renderItemList([], type));
});
updateString();
});
document.addEventListener('DOMContentLoaded', () => resolveLoaded());

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

27
static/index.html Normal file
View File

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>jillo</title>
<meta name="theme-color" content="light dark">
<link href="/style.css" rel="stylesheet">
<link rel="shortcut icon" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Balsamiq+Sans&display=swap" rel="stylesheet">
</head>
<body>
<div id="main">
<img src="/assets/jillo.png" width="150" height="200">
<h1>jillo!</h1>
<div>
<a href="https://discord.com/oauth2/authorize?client_id=898850107892596776&scope=bot" target="_blank" rel="noopener">invite</a>
&middot;
<a href="https://git.oat.zone/dark-firepit/jillo-bot" target="_blank" rel="noopener">repo</a>
</div>
</div>
</body>
</html>

250
static/style.css Normal file
View File

@ -0,0 +1,250 @@
:root {
--accent-color: #f17d10;
}
body {
padding: 0px;
margin: 0px;
overflow-x: hidden;
color: var(--text-color);
background-color: var(--background-color);
font-family: 'Balsamiq Sans', sans-serif;
font-weight: 300;
width: 100%;
text-underline-offset: 3px;
font-size: 16px;
color-scheme: light dark;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
:root {
--text-color: #111;
--text-color-light: #444;
--background-color: #fefefd;
--background-color-dark: #fafafb;
--background-color-dark-2: #f8f8f9;
}
@media (prefers-color-scheme: dark) {
:root {
--text-color: #eee;
--text-color-light: #aaa;
--background-color: #111110;
--background-color-dark: #151514;
--background-color-dark-2: #171718;
}
}
a {
text-decoration: none;
color: var(--accent-color);
}
a:hover {
text-decoration: underline;
}
#main {
display: flex;
text-align: center;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
}
#main img {
display: block;
height: 18rem;
width: auto;
animation: 1s popup;
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
transition: transform 0.15s, opacity 0.1s;
}
#main img:active {
transform: scale(0.97);
opacity: 0.9;
}
#main :not(img) {
animation: 0.8s fadein;
}
#main h1 {
font-size: 4rem;
margin-top: 0.5rem;
margin-bottom: 1rem;
}
@keyframes fadein {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes popup {
0% { transform: scale(0) rotate(40deg) }
100% { transform: scale(1) rotate(0deg) }
}
#content {
max-width: 1000px;
width: 100%;
margin: auto;
margin-bottom: 6rem;
}
.header {
height: 3rem;
display: flex;
align-items: stretch;
padding: 0rem 1rem;
flex-direction: row;
text-shadow: 2px 2px 2px #000000;
margin-bottom: 2rem;
}
.header .bg {
position: absolute;
left: 0%;
right: 0%;
height: 3rem;
background: linear-gradient(#444, #222);
z-index: -1;
border-bottom: 2px ridge #aaa;
}
.header .left {
font-size: 1.5rem;
flex: 1 1 0px;
display: flex;
align-items: center;
}
.header .left a {
text-decoration: none !important;
color: #fff;
}
.header .links {
flex: 0 0 auto;
text-align: right;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
}
.header .links img {
display: block;
width: auto;
height: 100%;
}
.items {
max-width: 500px;
padding: 1em;
height: 200px;
overflow: auto;
background: linear-gradient(var(--background-color-dark), var(--background-color-dark-2));
border-radius: 1em;
display: flex;
flex-direction: column;
}
.item {
display: flex;
flex-direction: row;
height: 3rem;
gap: 0.5rem;
outline: 0px solid rgba(255, 255, 255, 0.0);
transition: outline 0.1s;
padding: 0.5rem;
border-radius: 2rem;
cursor: grab;
}
.item:hover {
outline: 1px solid var(--text-color-light);
}
.item.dragging {
outline: 2px dashed var(--text-color-light);
transition: none;
background-color: var(--background-color);
}
.item .icon {
flex: 0 0 auto;
font-size: 1rem;
line-height: 1;
background-color: rgba(199, 199, 199, 0.25);
border-radius: 4rem;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1 / 1;
user-select: none;
}
.item .right {
flex: 1 1 0px;
display: flex;
flex-direction: column;
line-height: 1;
}
.item .right, .item .right > * {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.item .right .description {
font-size: 75%;
color: var(--text-color-light);
}
.item-list {
min-height: 2rem;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.item-list.dropping::after {
display: flex;
align-items: center;
justify-content: center;
content: '+';
height: 1.5rem;
width: 1.5rem;
outline: 2px dashed var(--text-color-light);
border-radius: 2rem;
padding: 0.5rem;
margin: 0.25rem;
}
.itemstack {
display: flex;
align-items: center;
height: 1.5rem;
line-height: 1;
padding: 0.5rem;
margin: 0.25rem;
outline: 1px solid var(--text-color-light);
background-color: var(--background-color-dark);
border-radius: 2rem;
gap: 0.5rem;
}
.itemstack.dragging {
outline: 2px dashed var(--text-color-light);
transition: none;
background-color: var(--background-color);
}
.subtitle {
color: var(--text-color-light);
font-size: 1rem;
font-weight: normal;
margin-left: 0.5rem;
}
.subtitle::before {
content: '·';
margin-right: 0.5rem;
}
pre {
background: linear-gradient(var(--background-color-dark), var(--background-color-dark-2));
overflow: auto;
word-break: normal;
padding: 0.5rem;
}
.note {
font-style: italic;
color: var(--text-color-light);
}

7
svelte.config.mjs Normal file
View File

@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
};

7
vite.config.mjs Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte()],
});