Add static website for viewing server-side teams

This commit is contained in:
Mia 2025-04-09 19:36:58 -05:00
parent e8c60fd8c6
commit 8e378c6129
16 changed files with 1112 additions and 8 deletions

5
.gitignore vendored
View File

@ -42,3 +42,8 @@ npm-debug.log
/replay.pokemonshowdown.com/caches/
/replay.pokemonshowdown.com/theme/wrapper.inc.php
/replay.pokemonshowdown.com/ads.txt
/teams.pokemonshowdown.com/js/
/teams.pokemonshowdown.com/caches/
/teams.pokemonshowdown.com/index.html
/teams.pokemonshowdown.com/ads.txt

View File

@ -88,7 +88,7 @@ const compileOpts = Object.assign(eval('(' + fs.readFileSync('.babelrc') + ')'),
if (process.argv[2] === 'full') {
delete compileOpts.ignore;
compiler.compileToDir(
['caches/pokemon-showdown/server/chat-formatter.ts'],
['caches/pokemon-showdown/server/chat-formatter.ts', 'caches/pokemon-showdown/sim/teams.ts'],
'play.pokemonshowdown.com/js/server/',
compileOpts
);
@ -104,6 +104,8 @@ compiledFiles += compiler.compileToDir(`play.pokemonshowdown.com/src`, `play.pok
compiledFiles += compiler.compileToDir(`replay.pokemonshowdown.com/src`, `replay.pokemonshowdown.com/js`, compileOpts);
compiledFiles += compiler.compileToDir(`teams.pokemonshowdown.com/src`, `teams.pokemonshowdown.com/js`, compileOpts);
compiledFiles += compiler.compileToFile(
[
'play.pokemonshowdown.com/src/battle-dex.ts',
@ -145,6 +147,7 @@ function addCachebuster(_, attr, url, urlQuery) {
url = url.replace('/dex.pokemonshowdown.com/', '/' + routes.dex + '/');
url = url.replace('/play.pokemonshowdown.com/', '/' + routes.client + '/');
url = url.replace('/pokemonshowdown.com/', '/' + routes.root + '/');
url = url.replace('/teams.pokemonshowdown.com/', '/' + routes.teams + '/');
if (urlQuery) {
if (url.startsWith('/')) {
@ -157,12 +160,15 @@ function addCachebuster(_, attr, url, urlQuery) {
return attr + '="' + url + '?' + hash + '"';
} else {
// hardcoded to Replays rn; TODO: generalize
// TODO: generalize better
let hash;
try {
const fstr = fs.readFileSync('replay.pokemonshowdown.com/' + url, UTF8);
hash = crypto.createHash('md5').update(fstr).digest('hex').substr(0, 8);
} catch {}
for (const subdir of ['teams', 'replays']) {
if (hash) break;
try {
const fstr = fs.readFileSync(subdir + '.pokemonshowdown.com/' + url, UTF8);
hash = crypto.createHash('md5').update(fstr).digest('hex').substr(0, 8);
} catch {}
}
return attr + '="' + url + '?' + (hash || 'v1') + '"';
}
@ -226,4 +232,8 @@ let replaysContents = fs.readFileSync('replay.pokemonshowdown.com/index.template
replaysContents = replaysContents.replace(URL_REGEX, addCachebuster);
fs.writeFileSync('replay.pokemonshowdown.com/index.php', replaysContents);
let teamsContents = fs.readFileSync('teams.pokemonshowdown.com/index.template.html', UTF8);
teamsContents = teamsContents.replace(URL_REGEX, addCachebuster);
fs.writeFileSync('teams.pokemonshowdown.com/index.html', teamsContents);
console.log("DONE");

View File

@ -3,5 +3,6 @@
"client": "play.pokemonshowdown.com",
"dex": "dex.pokemonshowdown.com",
"replays": "replay.pokemonshowdown.com",
"users": "pokemonshowdown.com/users"
"users": "pokemonshowdown.com/users",
"teams": "teams.pokemonshowdown.com"
}

View File

@ -105,6 +105,8 @@ export default configure([
'play.pokemonshowdown.com/src/*.tsx',
'replay.pokemonshowdown.com/src/*.ts',
'replay.pokemonshowdown.com/src/*.tsx',
'teams.pokemonshowdown.com/src/*.ts',
'teams.pokemonshowdown.com/src/*.tsx',
],
extends: [configs.es3ts],
languageOptions: {

View File

@ -0,0 +1,7 @@
RewriteEngine on
RewriteRule ^api(/.*)?$ http://localhost:9000/api$1 [P,L]
RewriteRule ^([A-Za-z0-9-/]+)$ index.html [L,QSA]
DirectoryIndex index.php index.html /dirindex/dirindex.php
ErrorDocument 404 /dirindex/404.html

View File

@ -0,0 +1,72 @@
<!DOCTYPE html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Team Not Found - Pok&eacute;mon Showdown!</title>
<link rel="stylesheet" href="//pokemonshowdown.com/style/global.css?v16" />
<link rel="stylesheet" href="//play.pokemonshowdown.com/style/font-awesome.css?932f42c7" />
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-26211653-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-26211653-1');
</script>
<!-- End Google Analytics -->
<!-- Venatus Ad Manager - Install in <HEAD> of page -->
<script src="https://hb.vntsm.com/v3/live/ad-manager.min.js" type="text/javascript" data-site-id="642aba63ec9a7b11c3c9c1be" data-mode="scan" async></script>
<!-- / Venatus Ad Manager -->
<div class="body">
<header>
<div class="nav-wrapper"><ul class="nav">
<li><a class="button nav-first" href="//pokemonshowdown.com/"><img src="//pokemonshowdown.com/images/pokemonshowdownbeta.png" srcset="//pokemonshowdown.com/images/pokemonshowdownbeta.png 1x, //pokemonshowdown.com/images/pokemonshowdownbeta@2x.png 2x" alt="Pok&eacute;mon Showdown" width="146" height="44" /> Home</a></li>
<li><a class="button" href="//pokemonshowdown.com/dex/">Pok&eacute;dex</a></li>
<li><a class="button cur" href="/">Replay</a></li>
<li><a class="button purplebutton" href="//smogon.com/dex/" target="_blank">Strategy</a></li>
<li><a class="button nav-last purplebutton" href="//smogon.com/forums/" target="_blank">Forum</a></li>
<li><a class="button greenbutton nav-first nav-last" href="//play.pokemonshowdown.com/">Play</a></li>
</ul></div>
</header>
<div class="main">
<section class="section" style="max-width:200px;margin:20px auto">
<div style="text-align:center">
<img src="//play.pokemonshowdown.com/sprites/gen5ani/unown-n.gif" alt="" style="image-rendering: pixelated"
/><img src="//play.pokemonshowdown.com/sprites/gen5ani/unown-o.gif" alt="" style="image-rendering: pixelated"
/><img src="//play.pokemonshowdown.com/sprites/gen5ani/unown-t.gif" alt="" style="image-rendering: pixelated" />
</div>
<div style="text-align:center">
<img src="//play.pokemonshowdown.com/sprites/gen5ani/unown-f.gif" alt="" style="image-rendering: pixelated"
/><img src="//play.pokemonshowdown.com/sprites/gen5ani/unown-o.gif" alt="" style="image-rendering: pixelated"
/><img src="//play.pokemonshowdown.com/sprites/gen5ani/unown-u.gif" alt="" style="image-rendering: pixelated"
/><img src="//play.pokemonshowdown.com/sprites/gen5ani/unown-n.gif" alt="" style="image-rendering: pixelated"
/><img src="//play.pokemonshowdown.com/sprites/gen5ani/unown-d.gif" alt="" style="image-rendering: pixelated" />
</div>
</section><section class="section">
<h1>Not Found</h1>
<p>
The team you're looking for is unavailable - either nonexistent or hidden. Check the URL and password?
</p>
</section>
</div>
</div>
<script>
if (window.matchMedia) {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.className = 'dark';
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (event) {
document.documentElement.className = event.matches ? "dark" : "";
});
}
</script>

View File

@ -0,0 +1,5 @@
PS teams viewer
===================
This is the code powering https://teams.pokemonshowdown.com/

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -0,0 +1,219 @@
<!DOCTYPE html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Teams - Pok&eacute;mon Showdown!</title>
<link rel="stylesheet" href="//pokemonshowdown.com/style/global.css" />
<link rel="stylesheet" href="//play.pokemonshowdown.com/style/font-awesome.css" />
<link rel="stylesheet" href="//play.pokemonshowdown.com/style/battle.css" />
<link rel="stylesheet" href="//play.pokemonshowdown.com/style/utilichart.css" />
<style>
@media (max-width:820px) {
.battle {
margin: 0 auto;
}
.battle-log {
margin: 7px auto 0;
max-width: 640px;
height: 300px;
position: static;
}
}
.teamlist {
list-style: none;
margin: 0;
padding: 0;
}
.teamlist li {
margin: 3px 0;
padding: 0;
}
.team {
width: 230px;
height: 32px;
padding: 0 5px;
font-size: 9pt;
text-align: left;
font-family: Verdana, Helvetica, Arial, sans-serif;
cursor: pointer;
border-radius: 4px;
border: 1px solid #888888;
background: #EEEEEE;
box-shadow: inset 0 1px 0 #FFFFFF;
background: linear-gradient(to bottom, #edf2f8, #d7e3ec);
color: black;
box-sizing: border-box;
}
.teamlist .team {
display: inline-block;
white-space: nowrap;
width: 350px;
height: 49px;
padding: 1px 6px 1px 6px;
font-size: 8pt;
vertical-align: middle;
text-align: center;
}
.teamlist .dragging button {
visibility: hidden;
}
.teamlist .dragging .team {
opacity: 0.4;
}
.teamlist .team small {
display: block;
padding: 0 45px 0 55px;
}
.teamlist .team .picon {
margin: 0 -2px;
}
.teamlist .team .picon span {
font-size: 9px !important;
display: block;
overflow: visible;
position: relative;
width: 32px;
top: 22px;
color: transparent;
}
.teamlist .team.pc-box {
height: 139px;
}
.optgroup {
display: inline-block;
line-height: 22px;
font-size: 10pt;
vertical-align: top;
}
.optgroup .button {
height: 25px;
padding-top: 0;
padding-bottom: 0;
}
.optgroup button.button {
padding-left: 12px;
padding-right: 12px;
}
.linklist {
list-style: none;
margin: 0.5em 0;
padding: 0;
}
.linklist li {
padding: 2px 0;
}
.sidebar {
float: left;
width: 320px;
}
.bar-wrapper {
max-width: 1100px;
margin: 0 auto;
}
.bar-wrapper.has-sidebar {
max-width: 1430px;
}
.mainbar {
margin: 0;
padding-right: 1px;
}
.mainbar.has-sidebar {
margin-left: 330px;
}
@media (min-width: 1511px) {
.sidebar {
width: 400px;
}
.bar-wrapper.has-sidebar {
max-width: 1510px;
}
.mainbar.has-sidebar {
margin-left: 410px;
}
}
.section.first-section {
margin-top: 9px;
}
.blocklink small {
white-space: normal;
}
.button {
vertical-align: middle;
}
.replay-controls {
padding-top: 10px;
}
.replay-controls h1 {
font-size: 16pt;
font-weight: normal;
color: #CCC;
}
.pagelink {
text-align: center;
}
.pagelink a {
width: 150px;
}
.textbox, .button {
font-size: 11pt;
vertical-align: middle;
}
@media (max-width: 450px) {
.button {
font-size: 9pt;
}
}
</style>
<div>
<header>
<div class="nav-wrapper"><ul class="nav">
<li><a class="button nav-first" href="https://pokemonshowdown.com/"><img src="https://pokemonshowdown.com/images/pokemonshowdownbeta.png" srcset="https://pokemonshowdown.com/images/pokemonshowdownbeta.png 1x, https://pokemonshowdown.com/images/pokemonshowdownbeta@2x.png 2x" alt="Pok&eacute;mon Showdown" width="146" height="44" /> Home</a></li>
<li><a class="button" href="https://pokemonshowdown.com/dex/">Pok&eacute;dex</a></li>
<li><a class="button" href="#">Replay</a></li>
<li><a class="button purplebutton" href="https://smogon.com/dex/" target="_blank">Strategy</a></li>
<li><a class="button nav-last purplebutton" href="https://smogon.com/forums/" target="_blank">Forum</a></li>
<li><a class="button greenbutton nav-first nav-last" href="https://play.pokemonshowdown.com/">Play</a></li>
</ul></div>
</header>
<div class="main" id="main">
<noscript><section class="section">You need to enable JavaScript to use this page; sorry!</section></noscript>
</div>
</div>
<script nomodule src="//play.pokemonshowdown.com/js/lib/ps-polyfill.js?"></script>
<script src="//play.pokemonshowdown.com/js/lib/preact.min.js?"></script>
<script src="//play.pokemonshowdown.com/config/config.js?"></script>
<script src="//play.pokemonshowdown.com/js/battledata.js?"></script>
<script src="//play.pokemonshowdown.com/js/battle-dex-data.js?"></script>
<script src="//play.pokemonshowdown.com/js/battle-dex.js?"></script>
<script src="//play.pokemonshowdown.com/data/pokedex.js?"></script>
<script src="//play.pokemonshowdown.com/data/moves.js?"></script>
<script src="//play.pokemonshowdown.com/data/items.js?"></script>
<script src="//play.pokemonshowdown.com/data/abilities.js?"></script>
<script src="//play.pokemonshowdown.com/data/pokedex-mini.js?"></script>
<script src="//play.pokemonshowdown.com/data/aliases.js?" async></script>
<script src="//play.pokemonshowdown.com/src/battle-log-misc.js?"></script>
<script src="//play.pokemonshowdown.com/js/battle-log.js?"></script>
<script src="//play.pokemonshowdown.com/js/battle.js?"></script>
<script src="//teams.pokemonshowdown.com/js/utils.js?"></script>
<script> Net.defaultRoute = location.protocol + '//teams.pokemonshowdown.com'; </script>
<script src="//teams.pokemonshowdown.com/js/teams-view.js?"></script>
<script src="//teams.pokemonshowdown.com/js/teams-search.js?"></script>
<script src="//teams.pokemonshowdown.com/js/teams-index.js?"></script>
<script src="//teams.pokemonshowdown.com/js/teams.js?"></script>

View File

@ -0,0 +1,75 @@
/** @jsx preact.h */
/** @jsxFrag preact.Fragment */
import preact from '../../play.pokemonshowdown.com/js/lib/preact';
import { Net, MiniTeam, type ServerTeam } from './utils';
import type { PageProps } from './teams';
declare const toID: (val: any) => string;
export class TeamIndex extends preact.Component<PageProps> {
override state = {
teams: [] as ServerTeam[],
loggedIn: false as string | false,
loading: true,
search: null as string | null,
};
constructor(props: PageProps) {
super(props);
this.loadTeams();
}
loadTeams() {
void Net('/api/getteams').get({ query: { full: 1 } }).then(resultText => {
if (resultText.startsWith(']')) resultText = resultText.slice(1);
let result;
try {
result = JSON.parse(resultText);
} catch {
result = { actionerror: "Malformed response received. Try again later." };
}
this.setState({ ...result, loading: false });
});
}
// todo: find proper type. preact docs unhelpful.
onInput({ currentTarget }: any) {
this.setState({ search: currentTarget.value });
}
searchMatch(team: ServerTeam) {
const s = toID(this.state.search);
if (!s) return true;
if (!toID(this.state.search)) return true;
if (toID(team.name).includes(s)) return true;
if (toID(team.format).includes(s)) return true;
if (team.team.split(',').map(toID).some(x => x.includes(s))) return true;
if (`${team.teamid}`.startsWith(s)) return true;
return false;
}
render() {
if (this.state.loading) {
return <div class="section" style={{ wordWrap: 'break-word' }}>Loading...</div>;
}
const teamsByFormat: Record<string, ServerTeam[]> = {};
for (const team of this.state.teams) {
// this way if a category is empty it doesn't just fill space, since it doesn't
// get added unless it exists
if (!this.searchMatch(team)) continue;
if (!teamsByFormat[team.format]) teamsByFormat[team.format] = [];
teamsByFormat[team.format].push(team);
}
return <div class="section" style={{ wordWrap: 'break-word' }}>
<h2>Hi, {this.state.loggedIn || "guest"}!</h2>
<label>Search all teams:</label> <a class="button" href="/search/">Go</a><br /><br />
<label>Search your teams: </label>
<input value={this.state.search || ""} onInput={e => this.onInput(e)} label="Search teams/formats"></input>
<hr />
{!this.state.teams.length ?
<em>You have no teams lol</em> :
Object.entries(teamsByFormat).map(([format, teams]) => (
<><h4>{format}:</h4>
<ul class="teamlist">{
teams.map(team => <li><MiniTeam team={team} /></li>)
}</ul>
<hr /></>
))}
</div>;
}
}

View File

@ -0,0 +1,115 @@
/** @jsx preact.h */
/** @jsxFrag preact.Fragment */
import preact from '../../play.pokemonshowdown.com/js/lib/preact';
import { Net, type ServerTeam, MiniTeam } from './utils';
import type { PageProps } from './teams';
declare const toID: (val: any) => string;
const SEARCH_KEYS = ['format', 'owner', 'gen'];
export class TeamSearcher extends preact.Component<PageProps> {
override state = {
result: [] as ServerTeam[],
curCount: 20,
search: {} as Record<string, string>,
loading: false,
searchUnchanged: null as null | boolean,
};
constructor(props: PageProps) {
super(props);
const url = new URL(location.href);
let makeSearch = false;
for (const key of SEARCH_KEYS) {
const val = url.searchParams.get(key);
if (toID(val)) {
this.state.search[key] = toID(val);
makeSearch = true;
}
if (this.props.args.type === key) { // prioritize url where applicable
const propVal = toID(this.props.args.val);
if (propVal) {
this.state.search[key] = propVal;
makeSearch = true;
}
}
}
const count = Number(url.searchParams.get('count'));
if (!isNaN(count) && count > 0) {
this.state.curCount = count;
}
if (makeSearch) {
this.search(0, true);
}
}
// todo: find proper type. preact docs unhelpful.
onInput(key: string, { currentTarget }: any) {
this.state.search[key] = toID(currentTarget.value);
this.setState({ search: this.state.search });
}
// format, owner, gen, count params. maxes out at 200. start at 20 and paginate
search(incrementCount = 0, noSetUrl = false) {
this.state.curCount += incrementCount;
this.setState({
loading: true,
stateUnchanged: true,
curCount: this.state.curCount,
});
const url = new URL(location.href);
// clear out old ones so they don't dupe
for (const val in url.searchParams) url.searchParams.delete(val);
// then set new ones
for (const k in this.state.search) {
url.searchParams.set(k, this.state.search[k]);
}
url.searchParams.set('count', `${this.state.curCount}`);
if (!noSetUrl) history.pushState({}, '', url);
void Net('/api/searchteams').get({
query: { ...this.state.search, count: this.state.curCount },
}).then(resultText => {
if (resultText.startsWith(']')) resultText = resultText.slice(1);
let result;
try {
result = JSON.parse(resultText);
} catch {
result = { actionerror: "Malformed response received. Try again later." };
}
this.setState({ ...result, loading: false });
});
}
render() {
if (this.state.loading) {
return <div class="section" style={{ wordWrap: 'break-word' }}>Loading...</div>;
}
return <div class="section" style={{ wordWrap: 'break-word', textAlign: 'center' }}>
<h1>Search Teams</h1>
<br />
<div name="searchsection">
<label>Format: </label>
<input value={this.state.search.format} onInput={e => this.onInput('format', e)} /><br />
<label>Owner: </label>
<input value={this.state.search.owner} onInput={e => this.onInput('owner', e)} /><br />
<label>Generation: </label>
<input value={this.state.search.gen} onInput={e => this.onInput('gen', e)} /><br />
<button class="button notifying" onClick={() => this.search()}>Search!</button>
</div>
<hr />
{!this.state.result.length ? <></> :
<ul class="teamlist">{
this.state.result.map(team => <li><MiniTeam team={team} fullTeam /></li>)
}</ul>}
{(this.state.result as any).actionerror ?
<div class="message-error">{(this.state.result as any).actionerror}</div> :
<></>}
{
this.state.result.length ?
<button class="button notifying" onClick={() => this.search(20)}>More</button> :
<></>
}
</div>;
}
}

View File

@ -0,0 +1,158 @@
/** @jsx preact.h */
/** @jsxFrag preact.Fragment */
import preact from '../../play.pokemonshowdown.com/js/lib/preact';
import { Net, PSIcon, unpackTeam } from './utils';
import { BattleLog } from '../../play.pokemonshowdown.com/src/battle-log';
import type { PageProps } from './teams';
import { Dex } from '../../play.pokemonshowdown.com/src/battle-dex';
import { BattleStatNames } from '../../play.pokemonshowdown.com/src/battle-dex-data';
interface Team {
team: string;
title: string;
views: number;
ownerid: string;
format: string;
teamid: string;
}
function PokemonSet({ set }: { set: Dex.PokemonSet }) {
return <div>
{set.name && set.name !== set.species ? <>{set.name} ({set.species})</> : <>{set.species}</>}
{set.gender ? <> ({set.gender})</> : <></>}
{set.item ? <> @ {set.item} </> : <></>}
<br />
{set.ability ? <>Ability: {set.ability}<br /></> : <></>}
{set.level && set.level !== 100 ? <>Level: {set.level}<br /></> : <></>}
{set.shiny ? <>Shiny: Yes<br /></> : <></>}
{set.teraType ? <>Tera Type: {set.teraType}</> : <></>}
{set.evs ? <>{Dex.statNames.filter(stat => set.evs![stat]).map((stat, index, arr) => (
<>
{index === 0 ? 'EVs: ' : ''}
{set.evs![stat]} {BattleStatNames[stat]}
{index !== (arr.length - 1) ? ' / ' : ''}
</>
))}<br /></> : <></>}
{set.nature ? <>{set.nature} Nature<br /></> : <></>}
{set.ivs ? <>{Dex.statNames
.filter(stat => !(set.ivs![stat] === undefined || isNaN(set.ivs![stat]) || set.ivs![stat] === 31))
.map((stat, index, arr) =>
<>
{index === 0 ? 'IVs: ' : ''}
{set.ivs![stat]} {BattleStatNames[stat]}
{index !== (arr.length - 1) ? ' / ' : ''}
</>
)}<br /></> : <></>}
{set.moves ? set.moves.map(move => {
if (move.substr(0, 13) === 'Hidden Power ') {
const hpType = move.slice(13);
move = move.slice(0, 13);
move = `${move}[${hpType}]`;
}
// hide the alt so it doesn't interfere w/ copy/pasting
return <>- {move} <PSIcon type={Dex.moves.get(move).type} hideAlt /><br /></>;
}) : <></>}
{typeof set.happiness === 'number' && set.happiness !== 255 && !isNaN(set.happiness) ?
<>Happiness: {set.happiness}<br /></> :
<></>}
{typeof set.dynamaxLevel === 'number' && set.dynamaxLevel !== 10 && !isNaN(set.dynamaxLevel) ?
<>Dynamax Level: {set.dynamaxLevel}<br /></> :
<></>}
{set.gigantamax ? <>Gigantamax: Yes<br /></> : <></>}
</div>;
}
export class TeamViewer extends preact.Component<PageProps> {
id: string;
pw?: string;
override state = {
team: undefined as null | void | Team, error: undefined as string | undefined,
};
constructor(props: PageProps) {
super(props);
this.id = props.args.id;
this.checkTeamID();
}
render() {
if (this.state.error) {
return <div class="message-error">{this.state.error}</div>;
}
if (!this.state.team) {
return <div class="section" style={{ textAlign: 'center' }}>{
typeof this.state.team === 'undefined' ?
JSON.stringify(this.state) :
<>
<h2 class="message-error">Team not found.</h2><br />
<em>Either it doesn't exist or it's password protected. Check the link?</em>
</>
}</div>;
}
const { team, title, ownerid, format, views } = this.state.team;
const teamData = unpackTeam(team);
const gen = Number(/\d+/.exec(format)?.[0]) || 6;
return <div class="section" style={{ wordWrap: 'break-word' }}>
<h2>{title}</h2>
Owner: <strong style={{ color: BattleLog.usernameColor(ownerid as any) }}>{ownerid}</strong><br />
Format: {format}<br />
Views: {views}<br />
<label>Shortlink: </label><a href={`https://psim.us/t/${this.id}`}>https://psim.us/t/{this.id}</a><br />
<hr />
<div name="sets" style={{ display: 'flex', flexWrap: 'wrap', rowGap: '1rem' }}>
{teamData.map(
set => <>
<div style={{ flex: '0 0 20%' }}>
<img src={
Dex.getSpriteData(
Dex.species.get(set.species),
true,
{ gen, shiny: set.shiny, gender: set.gender as 'F' }
).url
}
/>
{set.item ? <PSIcon item={set.item} /> : <></>}
</div>
<div style={{ flex: "0 0 80%", textAlign: 'left' }}>
<PokemonSet set={set} />
</div>
</>
)}
</div>
</div>;
}
checkTeamID() {
if (this.id.includes('-')) {
[this.id, this.pw] = this.id.split('-');
}
if (!/^\d+$/.test(this.id)) {
this.setState({ error: "Invalid team ID: " + JSON.stringify(this.props.args) });
return;
}
this.loadTeamData();
}
loadTeamData() {
void Net('/api/getteam').get({ query: { teamid: this.id, password: this.pw, full: 1 } }).then(resultText => {
if (resultText.startsWith(']')) resultText = resultText.slice(1);
let result;
try {
result = JSON.parse(resultText);
} catch {
result = { actionerror: "Malformed response received. Try again later." };
}
if (result.actionerror) {
this.setState({ error: result.actionerror });
} else {
this.setState({ team: result.team === null ? result.team : result });
}
}).catch(e => {
this.setState({ error: `HTTP${e.code}: ${e.message}` });
});
}
}

View File

@ -0,0 +1,64 @@
/** @jsx preact.h */
import preact from '../../play.pokemonshowdown.com/js/lib/preact';
import { TeamViewer } from './teams-view';
import { TeamIndex } from './teams-index';
import { TeamSearcher } from './teams-search';
export type PageProps = { args: Record<string, string> };
export const PSRouter = new class {
routes: Record<string, (new (props: PageProps) => preact.Component<PageProps>)> = {};
setRoutes(routes: typeof PSRouter['routes']) {
Object.assign(this.routes, routes);
}
// @ts-expect-error 'no reachable end point' YES THERE IS changing href stops execution after it
redir(path: string): never {
location.href = path;
}
go() {
const params = location.pathname.split('/');
let args: PageProps['args'] = {};
let Element;
for (const k in this.routes) {
let matched = false;
const routeParts = k.split('/');
for (let i = 0; i < routeParts.length; i++) {
const part = params[i];
if (routeParts[i].startsWith('?')) {
routeParts[i] = routeParts[i].slice(1);
if (!part.trim()) break; // can end here
}
if (routeParts[i]?.startsWith(':')) {
args[routeParts[i].slice(1)] = part;
continue;
}
if (part !== routeParts[i]) {
matched = false;
args = {}; // don't accidentally dupe over args
break;
} else {
matched = true;
}
}
if (matched) {
Element = this.routes[k];
break;
}
}
if (!Element) {
this.redir('//' + Config.routes.teams + "/404.html");
} else {
preact.render(<Element args={args} />, document.getElementById('main')!);
}
}
};
PSRouter.setRoutes({
'/view/:id': TeamViewer,
'/': TeamIndex,
'/search/?:type/?:val': TeamSearcher,
});
PSRouter.go();

View File

@ -0,0 +1,370 @@
/**********************************************************************
* Net
*********************************************************************/
/** @jsx preact.h */
import preact from "../../play.pokemonshowdown.com/js/lib/preact";
import { Dex } from "../../play.pokemonshowdown.com/src/battle-dex";
declare const toID: (str: any) => string;
export interface PostData {
[key: string]: string | number | undefined;
}
export interface NetRequestOptions {
method?: 'GET' | 'POST';
body?: string | PostData;
query?: PostData;
}
export class HttpError extends Error {
statusCode?: number;
body: string;
constructor(message: string, statusCode: number | undefined, body: string) {
super(message);
this.name = 'HttpError';
this.statusCode = statusCode;
this.body = body;
try {
(Error as any).captureStackTrace(this, HttpError);
} catch {}
}
}
export class NetRequest {
uri: string;
constructor(uri: string) {
this.uri = uri;
}
/**
* Makes a basic http/https request to the URI.
* Returns the response data.
*
* Will throw if the response code isn't 200 OK.
*
* @param opts request opts
*/
get(opts: NetRequestOptions = {}): Promise<string> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
let uri = this.uri;
if (opts.query) {
uri += (uri.includes('?') ? '&' : '?') + Net.encodeQuery(opts.query);
}
xhr.open(opts.method || 'GET', uri);
xhr.onreadystatechange = function () {
const DONE = 4;
if (xhr.readyState === DONE) {
if (xhr.status === 200) {
resolve(xhr.responseText || '');
return;
}
const err = new HttpError(xhr.statusText || "Connection error", xhr.status, xhr.responseText);
reject(err);
}
};
if (opts.body) {
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.send(Net.encodeQuery(opts.body));
} else {
xhr.send();
}
});
}
/**
* Makes a http/https POST request to the given link.
* @param opts request opts
* @param body POST body
*/
post(opts: Omit<NetRequestOptions, 'body'>, body: PostData | string): Promise<string>;
/**
* Makes a http/https POST request to the given link.
* @param opts request opts
*/
post(opts?: NetRequestOptions): Promise<string>;
post(opts: NetRequestOptions = {}, body?: PostData | string) {
if (!body) body = opts.body;
return this.get({
...opts,
method: 'POST',
body,
});
}
}
export function Net(uri: string) {
if (uri.startsWith('/') && !uri.startsWith('//') && Net.defaultRoute) uri = Net.defaultRoute + uri;
if (uri.startsWith('//') && document.location.protocol === 'file:') uri = 'https:' + uri;
return new NetRequest(uri);
}
/** Prepends URLs starting with `/` with this string. Used by testclient. */
Net.defaultRoute = '';
Net.encodeQuery = function (data: string | PostData) {
if (typeof data === 'string') return data;
let urlencodedData = '';
for (const key in data) {
if ((data as any)[key] === undefined) continue;
if (urlencodedData) urlencodedData += '&';
urlencodedData += encodeURIComponent(key) + '=' + encodeURIComponent((data as any)[key]);
}
return urlencodedData;
};
Net.decodeQuery = function (query: string): { [key: string]: string } {
let out: { [key: string]: string } = {};
const questionIndex = query.indexOf('?');
if (questionIndex >= 0) query = query.slice(questionIndex + 1);
for (const queryPart of query.split('&')) {
const [key, value] = queryPart.split('=');
out[decodeURIComponent(key)] = decodeURIComponent(value || '');
}
return out;
};
/**********************************************************************
* Models
*********************************************************************/
export class PSSubscription {
observable: PSModel | PSStreamModel<any>;
listener: (value?: any) => void;
constructor(observable: PSModel | PSStreamModel<any>, listener: (value?: any) => void) {
this.observable = observable;
this.listener = listener;
}
unsubscribe() {
const index = this.observable.subscriptions.indexOf(this);
if (index >= 0) this.observable.subscriptions.splice(index, 1);
}
}
/**
* PS Models roughly implement the Observable spec. Not the entire
* spec - just the parts we use. PSModel just notifies subscribers of
* updates - a simple model for React.
*/
export class PSModel {
subscriptions = [] as PSSubscription[];
subscribe(listener: () => void) {
const subscription = new PSSubscription(this, listener);
this.subscriptions.push(subscription);
return subscription;
}
subscribeAndRun(listener: () => void) {
const subscription = this.subscribe(listener);
subscription.listener();
return subscription;
}
update() {
for (const subscription of this.subscriptions) {
subscription.listener();
}
}
}
/**
* PS Models roughly implement the Observable spec. PSStreamModel
* streams some data out. This is very not-React, which generally
* expects the DOM to be a pure function of state. Instead PSModels
* which hold state, PSStreamModels give state directly to views,
* so that the model doesn't need to hold a redundant copy of state.
*/
export class PSStreamModel<T = string> {
subscriptions = [] as PSSubscription[];
updates = [] as T[];
subscribe(listener: (value: T) => void) {
// TypeScript bug
const subscription: PSSubscription = new PSSubscription(this, listener);
this.subscriptions.push(subscription);
if (this.updates.length) {
for (const update of this.updates) {
subscription.listener(update);
}
this.updates = [];
}
return subscription;
}
subscribeAndRun(listener: (value: T) => void) {
const subscription = this.subscribe(listener);
subscription.listener(null);
return subscription;
}
update(value: T) {
if (!this.subscriptions.length) {
// save updates for later
this.updates.push(value);
}
for (const subscription of this.subscriptions) {
subscription.listener(value);
}
}
}
export class PSIcon extends preact.Component<{
pokemon?: string, item?: string, type?: string, category?: string, hideAlt?: boolean,
}> {
render() {
if (this.props.pokemon) {
return <span
class="picon"
style={{ background: Dex.getPokemonIcon(this.props.pokemon).replace('background:', '') }}
></span>;
} else if (this.props.item) {
return <span
className="itemicon"
style={{ background: Dex.getItemIcon(this.props.item).replace('background:', '') }}
></span>;
} else if (this.props.type) {
let type = Dex.types.get(this.props.type).name;
if (!type) type = '???';
let sanitizedType = type.replace(/\?/g, '%3f');
return <img
src={`${Dex.resourcePrefix}sprites/types/${sanitizedType}.png`}
alt={this.props.hideAlt ? undefined : type}
height="14"
width="32"
class="pixelated"
/>;
} else if (this.props.category) {
const categoryID = toID(this.props.category);
let sanitizedCategory = '';
switch (categoryID) {
case 'physical':
case 'special':
case 'status':
sanitizedCategory = categoryID.charAt(0).toUpperCase() + categoryID.slice(1);
break;
default:
sanitizedCategory = 'undefined';
break;
}
return <img
src={`${Dex.resourcePrefix}sprites/categories/${sanitizedCategory}.png`}
alt={this.props.hideAlt ? undefined : sanitizedCategory}
height="14"
width="32"
class="pixelated"
/>;
} else {
return <span></span>;
}
}
}
export type ServerTeam = {
teamid: string,
format: string,
private: string,
team: string,
name?: string,
title?: string,
};
export function unpackTeam(buf: string) {
if (!buf) return [];
let team: Dex.PokemonSet[] = [];
for (const setBuf of buf.split(`]`)) {
const parts = setBuf.split(`|`);
if (parts.length < 11) continue;
let set: Dex.PokemonSet = { species: '', moves: [] };
team.push(set);
// name
set.name = parts[0];
// species
set.species = Dex.species.get(parts[1]).name || set.name;
// item
set.item = Dex.items.get(parts[2]).name;
// ability
const species = Dex.species.get(set.species);
set.ability =
parts[3] === '-' ? '' :
(species.baseSpecies === 'Zygarde' && parts[3] === 'H') ? 'Power Construct' :
['', '0', '1', 'H', 'S'].includes(parts[3]) ?
species.abilities[parts[3] as '0' || '0'] || (parts[3] === '' ? '' : '!!!ERROR!!!') :
Dex.abilities.get(parts[3]).name;
// moves
set.moves = parts[4].split(',').map(moveid =>
Dex.moves.get(moveid).name
);
// nature
set.nature = parts[5] as Dex.NatureName;
if (set.nature as any === 'undefined') set.nature = undefined;
// evs
if (parts[6]) {
if (parts[6].length > 5) {
const evs = parts[6].split(',');
set.evs = {
hp: Number(evs[0]) || 0,
atk: Number(evs[1]) || 0,
def: Number(evs[2]) || 0,
spa: Number(evs[3]) || 0,
spd: Number(evs[4]) || 0,
spe: Number(evs[5]) || 0,
};
} else if (parts[6] === '0') {
set.evs = { hp: 0, atk: 0, def: 0, spa: 0, spd: 0, spe: 0 };
}
}
// gender
if (parts[7]) set.gender = parts[7];
// ivs
if (parts[8]) {
const ivs = parts[8].split(',');
set.ivs = {
hp: ivs[0] === '' ? 31 : Number(ivs[0]),
atk: ivs[1] === '' ? 31 : Number(ivs[1]),
def: ivs[2] === '' ? 31 : Number(ivs[2]),
spa: ivs[3] === '' ? 31 : Number(ivs[3]),
spd: ivs[4] === '' ? 31 : Number(ivs[4]),
spe: ivs[5] === '' ? 31 : Number(ivs[5]),
};
}
// shiny
if (parts[9]) set.shiny = true;
// level
if (parts[10]) set.level = parseInt(parts[9], 10);
// happiness
if (parts[11]) {
let misc = parts[11].split(',', 4);
set.happiness = (misc[0] ? Number(misc[0]) : undefined);
set.hpType = misc[1];
set.pokeball = misc[2];
set.gigantamax = !!misc[3];
set.dynamaxLevel = (misc[4] ? Number(misc[4]) : 10);
}
}
return team;
}
export class MiniTeam extends preact.Component<{ team: ServerTeam, fullTeam?: boolean }> {
render() {
const team = this.props.team;
return <>
<a class="team" href={`/view/${team.teamid}${team.private ? `-${team.private}` : ''}`}>
<strong>{team.name || team.title || "Untitled " + team.teamid}</strong>
<br />
<small>
{(this.props.fullTeam ?
unpackTeam(team.team).map(x => x.species) :
team.team.split(',')
).map(x => <PSIcon pokemon={x} />) || <em>(Empty team)</em>}
</small>
</a>
</>;
}
}

View File

@ -16,6 +16,7 @@
"include": [
"./play.pokemonshowdown.com/js/lib/preact.d.ts",
"./play.pokemonshowdown.com/src/*",
"./replay.pokemonshowdown.com/src/*"
"./replay.pokemonshowdown.com/src/*",
"./teams.pokemonshowdown.com/src/*"
]
}