// ==UserScript==
// @name [PS] Homepage Enhancements
// @namespace https://greasyfork.org/en/users/1357767-indigeau
// @version 0.1
// @description Improves the Pokemon Showdown homepage.
// @match https://play.pokemonshowdown.com/*
// @exclude https://play.pokemonshowdown.com/sprites/*
// @author indigeau
// @license GNU GPLv3
// @icon https://www.google.com/s2/favicons?sz=64&domain=pokemonshowdown.com
// @grant none
// ==/UserScript==
// The gap between .leftmenu and its surrounding elements
const GAP_MAIN = 20;
// The gap between .pm-window elements
const GAP_PM = 10;
// The gap between the edge of .pmbox and .pm-window
const GAP_PMBOX = 10;
// The height of #header
const HEIGHT_HEADER = 50;
// The height of .maintabbarbottom (height=6px + top or bottom border=1px)
const HEIGHT_STRIP = 7;
// The width of .mainmenu
const WIDTH_BATTLE = 270;
// The width of .pm-window
const WIDTH_PM = 270;
const styleSheet = (() => {
const styleElement = document.createElement('style');
document.head.appendChild(styleElement);
return styleElement.sheet;
})();
// Style changes
(() => {
for (const rule of [
['body > #header', [['z-index', '1']]],
['#room-.ps-room', [
['top', `${HEIGHT_HEADER}px`],
['height', `calc(100% - ${HEIGHT_HEADER + 2}px)`],
['scrollbar-width', 'none'],
]],
['.mainmenuwrapper', [
['display', 'flex'],
['align-items', 'center'],
['width', '100%'],
['height', '100%'],
]],
['.leftmenu', [
['display', 'flex'],
['flex-wrap', 'nowrap'],
['flex-direction', 'row-reverse'],
['width', '100%'],
['max-width', '100%'], // for when .ps-room.tiny-layout overrwrites width to auto
['height', '100%'],
['box-sizing', 'border-box'],
['padding', '0px'],
['justify-content', 'center'],
['padding', `${GAP_MAIN + HEIGHT_STRIP - 1}px 0`],
['position', 'absolute'],
['top', '0'],
]],
['.activitymenu', [['display', 'contents']]],
['.pmbox', [
['flex-grow', '1'],
['display', 'flex'],
['flex-direction', 'column'],
['height', '100%'],
['flex-wrap', 'wrap'],
['overflow', 'auto'],
['background', 'rgba(0, 0, 0, .2)'],
['border-radius', '20px'],
['padding', `${GAP_PMBOX}px`],
['align-content', 'flex-start'],
['margin-right', `${GAP_MAIN}px`],
['box-sizing', 'border-box'],
['align-items', 'center'],
['scroll-snap-type', 'x mandatory'],
]],
['.pmbox > *', [
['width', `${WIDTH_PM}px`],
['max-width', `calc(100% - ${GAP_PMBOX * 2}px)`],
['margin', `${GAP_PM}px`],
['scroll-snap-align', 'center'],
['overflow', 'hidden'],
]],
['.ps-room.tiny-layout .pmbox > *', [
['margin', `${GAP_PM}px`],
]],
['.pm-log', [
['max-height', 'calc(100% - 22px) !important'], // Overwrites an element style. header=22px
['min-height', '0'],
// Fixes an issue with the element failing to lose its scrollbar when it no longer needs it
['scrollbar-gutter', 'stable'],
['overflow-x', 'hidden'],
]],
// Handle parent's scrollbar-gutter value
['.pm-log > *', [
['width', 'calc(100% + 30px)'],
['box-sizing', 'border-box'],
['padding-right', '35px'],
]],
['.pm-log:has(+ .pm-log-add)', [
['max-height', 'calc(100% - 53px) !important'], // Overwrites an element style. header=22px + .pm-log-add=31px
]],
['.mainmenu', [
['width', `${WIDTH_BATTLE}px`],
['padding', '0'],
['margin', `0 ${GAP_MAIN}px`],
['height', '100%'],
['display', 'flex'],
['flex-direction', 'column'],
['overflow-y', 'auto'],
['scrollbar-width', 'none'],
['background', 'rgba(0, 0, 0, .2)'],
['border-radius', '20px'],
['justify-content', 'space-evenly'],
]],
['.mainmenu > .menugroup', [
['background', 'none'],
['margin', '0'],
['padding', '0'],
]],
['.mainmenu > .menugroup:not(:first-child) p', [['margin-top', '-1px']]],
['.mainmenu > .menugroup:not(:first-child) p > button', [['box-shadow', 'inset #000d1733 0 0 200px 0px']]],
['.dark > body .mainmenufooter', [['background', 'rgba(0, 0, 0, .3)']]],
[':not(.dark) > body .mainmenufooter', [['background', 'url(../fx/client-topbar-bg.png) repeat-x left top scroll']]],
['.mainmenufooter', [
['height', `${HEIGHT_HEADER}px`],
['bottom', `-${HEIGHT_HEADER}px`],
['width', '100%'],
['left', '0'],
['display', 'flex'],
]],
['.mainmenufooter small', [
['flex-grow', '1'],
['display', 'flex'],
['font-size', '0'],
['border-top', '1px solid #34373b'],
['justify-content', 'center'],
]],
['.dark > body .mainmenufooter > small > a', [
['color', '#fff'],
['box-shadow', 'inset -0.5px -0.5px 1px 0.5px rgba(255, 255, 255, .5)'],
]],
['.dark > body .mainmenufooter > small > a:visited', [['color', '#fff']]],
[':not(.dark) > body .mainmenufooter > small > a', [
['color', '#222'],
['box-shadow', 'inset -0.5px -0.5px 1px 0.5px rgba(255, 255, 255, .5)'],
]],
[':not(.dark) > body .mainmenufooter > small > a:visited', [['color', '#222']]],
['.mainmenufooter > small > a', [
['font-size', '12pt'],
['text-align', 'center'],
['text-decoration', 'none'],
['border-top-right-radius', '0'],
['border-top-left-radius', '0'],
['margin', '0 5px'],
['padding', '4px 12px'],
['height', '28px'],
]],
['.mainmenufooter > small > a:hover', [
['text-decoration', 'none'],
]],
]) {
styleSheet.insertRule(`${rule[0]}{${rule[1].map(([property, value]) => `${property}:${value};`).join('')}}`);
}
for (const button of document.querySelectorAll('.mainmenufooter > small > a')) {
button.classList.add('roomtab', 'button');
}
})();
// Helper for hiding things when the screen's too narrow
class ConditionalDisplay {
static source = document.querySelector('.mainmenuwrapper');
static listeners = [];
constructor(width) {
this.width = width;
this.isShown = this.doShow();
}
doShow() {
return ConditionalDisplay.source.clientWidth >= this.width;
}
add(listener) {
ConditionalDisplay.listeners.push(() => {
if (this.doShow() === this.isShown) {
return;
}
this.isShown = !this.isShown;
listener(this.isShown);
});
listener(this.isShown);
}
}
(new ResizeObserver(() => {
for (const listener of ConditionalDisplay.listeners) {
listener();
}
})).observe(ConditionalDisplay.source);
// Footer extras
(() => {
// Helpers
const getButton = () => {
const button = document.createElement('img');
button.classList.add('icon', 'button');
button.style.margin = '0 3px';
button.style.height = '21px';
button.style.borderRadius = '5px';
button.style.cursor = 'pointer';
button.style.padding = '2px';
button.style.boxShadow = '.5px 1px 2px rgba(255, 255, 255, .45), inset .5px 1px 1px rgba(255, 255, 255, .5)';
return button;
};
// Setup container
const container = document.createElement('div');
container.style.fontSize = '12pt';
container.style.flexGrow = '1';
container.style.margin = `0 ${GAP_MAIN}px`;
container.style.alignItems = 'center';
const showContainer = (doShow) => {
container.style.display = doShow ? 'flex' : 'none';
};
(new ConditionalDisplay(870)).add(showContainer);
// Setup text
const text = document.createElement('span');
text.style.marginLeft = '6px';
text.innerText = 'Script by indigeau';
// Setup pm button
const psButton = getButton();
psButton.src = 'https://www.google.com/s2/favicons?sz=64&domain=pokemonshowdown.com';
psButton.addEventListener('click', () => {
app.rooms[''].focusPM('indigeau');
});
// Setup feedback button
const gfButton = getButton();
gfButton.src = 'https://www.google.com/s2/favicons?sz=64&domain=greasyfork.org';
gfButton.addEventListener('click', () => {
open('https://greasyfork.org/en/scripts/506533-ps-homepage-enhancements/feedback');
});
// Setup seperator
const tabBar = document.createElement('div');
tabBar.classList.add('maintabbarbottom');
tabBar.style.top = `-${HEIGHT_STRIP - 1}px`;
// Add to DOM
const footer = document.querySelector('.mainmenufooter');
const parent = footer.lastElementChild;
container.append(psButton, gfButton, text);
parent.insertBefore(container, parent.firstChild);
footer.append(tabBar);
})();
// Sidebar
(() => {
// CSS for :hover style because it's easier than using listeners
const IMAGE_BUTTON_CLASS = 'home-style-team-image';
for (let rule of [
[`.${IMAGE_BUTTON_CLASS}:hover + *`, [
['scale', '1.2'],
['filter', 'drop-shadow(black 3px 4px 2px) drop-shadow(black 0 0 10px)'],
['z-index', '1'],
]],
]) {
styleSheet.insertRule(`${rule[0]}{${rule[1].map(([property, value]) => `${property}:${value};`).join('')}}`);
}
// Helpers
const swapImages = (toShow, toHide) => {
for (const image of toHide) {
image.style.display = 'none';
}
for (const image of toShow) {
image.style.removeProperty('display');
}
};
const update = (backupImages, teamImages, teamButtons, doListen = true) => {
const button = document.querySelector('.select.teamselect');
if (doListen) {
// tampermonkey seems to conditionally pass unsafeWindow depending on the script's @grant values
const {data} = (unsafeWindow ?? window).Storage.prefs;
for (const property of ['bwgfx', 'noanim', 'nopastgens']) {
let value = data[property] ?? false;
Object.defineProperty(data, property, {
set(_value) {
value = _value;
update(backupImages, teamImages, teamButtons, false);
},
get() {
return value;
},
});
}
(new MutationObserver(() => {
update(backupImages, teamImages, teamButtons, false);
})).observe(button.parentElement, {
characterData: true,
childList: true,
subtree: true,
});
}
const teamIndex = Number.parseInt(button.value);
if (!Number.isInteger(teamIndex)) {
swapImages(backupImages, teamImages);
return;
}
const teamName = button.firstChild.innerText;
const team = Storage.teams.find(({name}) => name === teamName);
if (!team) {
swapImages(backupImages, teamImages);
return;
}
swapImages(teamImages, backupImages);
for (const [i, mon] of Teams.unpack(team.team).entries()) {
const {url, pixelated, h, w} = Dex.getSpriteData(mon.species, true, {...mon, ...team, noScale: true});
const image = teamImages[i];
if (h > w) {
image.style.scale = `${w / h}`;
} else {
image.style.removeProperty('scale');
}
image.src = url;
// pixelated just always looks better
image.style.imageRendering = pixelated ? 'pixelated' : 'pixelated';
teamButtons[i].onclick = () => {
app.addRoom('teambuilder');
app.focusRoom('teambuilder');
const {teambuilder} = app.rooms;
teambuilder.edit(teamIndex);
teambuilder.selectPokemon(i);
teambuilder.stats();
// You need to listen for this assignment because it clears the stats
if (teambuilder.formatResources[team.format] === true) {
Object.defineProperty(teambuilder.formatResources, team.format, {
// Avatar change listener
set(value) {
delete teambuilder.formatResources[team.format];
teambuilder.formatResources[team.format] = value;
window.setTimeout(() => {
teambuilder.updateChart();
}, 0);
},
get() {
return true;
},
});
}
};
}
};
const getTeamImage = (index) => {
const container = document.createElement('div');
const image = document.createElement('img');
container.style.position = 'absolute';
container.style.top = `${(index + 1) / 7 * 100}%`;
container.style.transform = 'translateY(-50%) scaleX(-1)';
container.style.width = '100%';
container.style.pointerEvents = 'none';
container.style.transformOrigin = 'top';
image.style.width = '100%';
container.append(image);
return {container, image};
};
const getTeamButton = (index) => {
const button = document.createElement('div');
button.classList.add(IMAGE_BUTTON_CLASS);
button.style.position = 'absolute';
button.style.top = `${((index + 1) * 2 - 1) / 14 * 100}%`;
button.style.width = '75%';
button.style.height = `${1 / 7 * 100}%`;
button.style.cursor = 'pointer';
button.style.alignSelf = 'center';
return button;
};
const getBackupImage = (src) => {
const image = document.createElement('img');
image.src = src;
image.style.imageRendering = 'pixelated';
image.style.transform = 'translateX(-2%) scaleX(-1)';
return image;
};
// Setup container
const container = document.createElement('div');
container.style.height = '100%';
container.style.background = 'rgba(0, 0, 0, .2)';
container.style.alignItems = 'stretch';
container.style.position = 'relative';
container.style.overflow = 'hidden';
container.style.flexDirection = 'column';
container.style.marginLeft = `${GAP_MAIN}px`;
container.style.borderRadius = '20px';
container.style.maxWidth = '300px';
container.style.width = '10%';
container.style.placeContent = 'stretch space-around';
container.style.flexDirection = 'column';
container.style.justifyContent = 'center';
const showContainer = (doShow) => {
container.style.display = doShow ? 'flex' : 'none';
};
(new ConditionalDisplay((() => {
// Minimum screen width when sidebar isn't visible
const unconditionalWidth = GAP_MAIN * 3 + WIDTH_BATTLE + WIDTH_PM + GAP_PM * 2 + GAP_PMBOX * 2;
// Sidebar width is 10% + GAP_MAIN; (100 / 0.9)% minimum width + GAP_MAIN must be available to accomodate it
return Math.ceil(unconditionalWidth * (1 / 0.9)) + GAP_MAIN;
})())).add(showContainer);
// Setup mascots
const backupImages = [];
for (const src of [
'https://play.pokemonshowdown.com/sprites/gen5ani/meloetta.gif',
'https://play.pokemonshowdown.com/sprites/gen5ani/meloetta-pirouette.gif',
]) {
const image = getBackupImage(src);
container.append(image);
backupImages.push(image);
}
// Setup team members
const teamImages = [];
const teamButtons = [];
for (let i = 0; i < 6; ++i) {
const image = getTeamImage(i);
const button = getTeamButton(i);
container.append(button, image.container);
teamImages.push(image.image);
teamButtons.push(button);
}
// Initialise & setup team change listener
update(backupImages, teamImages, teamButtons);
// Add to DOM
const parent = document.querySelector('.leftmenu');
parent.append(container);
})();
/* global app Dex Teams */