diff --git a/.gitignore b/.gitignore index 67be0bcd1..dc5b2b1f9 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,8 @@ package-lock.json /website/images/ /website/ads.txt -/DH2/* \ No newline at end of file +/DH2/* +/DH2/ +/website/replays/index.html +/website/replays/js/ +node_modules diff --git a/.htaccess b/.htaccess index 6852f5435..6bedf899f 100644 --- a/.htaccess +++ b/.htaccess @@ -21,7 +21,7 @@ RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC] RewriteCond %{QUERY_STRING} !^insecure [NC] RewriteRule ^([A-Za-z0-9-]*)$ https://play.pokemonshowdown.com/$1 [R=307,NE,L] -# basic stuff +# redirects RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC] RewriteRule ^appeals?\/?$ https://play.pokemonshowdown.com/view-help-request--appeal [R=302,L] RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC] @@ -55,6 +55,8 @@ RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC] RewriteRule ^insecure\/?$ http://play.pokemonshowdown.com/?insecure [R=302,L] RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC] RewriteRule ^devdiscord\/?$ https://discord.com/invite/D8QwhsH [R=302,L] +RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC] +RewriteRule ^formatsuggestions/?$ https://pokemonshowdown.com/pages/formatsuggestions [R=302,L] # rewrite old sprite directories/files RewriteRule ^sprites\/xyani(.*)?$ sprites/ani$1 [L,QSA] @@ -121,16 +123,16 @@ Header set Pragma "no-cache" env=INDEX_PAGE Header set Expires "0" env=INDEX_PAGE # No direct linking to the lobby. -RewriteCond %{ENV:SCRIPT_URL} ^/(lobby/?)?$ -RewriteCond %{HTTP_REFERER} !^$ -RewriteCond %{HTTP_REFERER} !^https?:\/\/([a-z0-9-]+.)?(pokemonshowdown\.com|appjs) -RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ -RewriteCond %{REMOTE_ADDR} !=127.0.0.1 -RewriteCond %{REMOTE_ADDR} !=162.243.13.96 -RewriteCond %{HTTP:CF-Connecting-IP} !=173.252.196.254 -RewriteCond %{HTTP:CF-Connecting-IP} !=198.27.67.31 -RewriteCond %{HTTP:CF-Connecting-IP} !=162.243.13.96 -RewriteRule ^.* https://pokemonshowdown.com/ [R=303,L] +# RewriteCond %{ENV:SCRIPT_URL} ^/(lobby/?)?$ +# RewriteCond %{HTTP_REFERER} !^$ +# RewriteCond %{HTTP_REFERER} !^https?:\/\/([a-z0-9-]+.)?(pokemonshowdown\.com|# appjs) +# RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ +# RewriteCond %{REMOTE_ADDR} !=127.0.0.1 +# RewriteCond %{REMOTE_ADDR} !=162.243.13.96 +# RewriteCond %{HTTP:CF-Connecting-IP} !=173.252.196.254 +# RewriteCond %{HTTP:CF-Connecting-IP} !=198.27.67.31 +# RewriteCond %{HTTP:CF-Connecting-IP} !=162.243.13.96 +# RewriteRule ^.* https://pokemonshowdown.com/ [R=303,L] AddType 'text/plain; charset=UTF-8' json5 diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..a88115149 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "build", + "group": "build", + "problemMatcher": [], + "label": "npm: build", + "detail": "node build" + } + ] +} \ No newline at end of file diff --git a/build-tools/update b/build-tools/update index 14ec20694..bca0f5ca0 100755 --- a/build-tools/update +++ b/build-tools/update @@ -101,7 +101,7 @@ if (process.argv[2] === 'full') { compiledFiles += compiler.compileToDir(`src`, `js`, compileOpts); -compiledFiles += compiler.compileToDir(`src`, `js`, compileOpts); +compiledFiles += compiler.compileToDir(`website/replays/src`, `website/replays/js`, compileOpts); compiledFiles += compiler.compileToFile( ['src/battle-dex.ts', 'src/battle-dex-data.ts', 'src/battle-log.ts', 'src/battle-log-misc.js', 'data/pokemon-showdown/server/chat-formatter.ts', 'data/text.js', 'src/battle-text-parser.ts'], @@ -128,7 +128,7 @@ console.log( process.stdout.write("Updating cachebuster and URLs... "); -const URL_REGEX = /(src|href)="\/(.*?)(\?[a-z0-9]*?)?"/g; +const URL_REGEX = /(src|href)="(.*?)(\?[a-z0-9]*?)?"/g; function updateURL(a, b, c, d) { c = c.replace('/replay.pokemonshowdown.com/', '/' + routes.replays + '/'); @@ -137,16 +137,27 @@ function updateURL(a, b, c, d) { c = c.replace('/pokemonshowdown.com/', '/' + routes.root + '/'); if (d) { - let hash = Math.random(); // just in case creating the hash fails - try { - const filename = c.replace('/' + routes.client + '/', ''); - const fstr = fs.readFileSync(filename, {encoding: 'utf8'}); - hash = crypto.createHash('md5').update(fstr).digest('hex').substr(0, 8); - } catch (e) {} - - return b + '="/' + c + '?' + hash + '"'; + if (c.startsWith('/')) { + let hash = Math.random(); // just in case creating the hash fails + try { + const filename = c.slice(1).replace('/' + routes.client + '/', ''); + const fstr = fs.readFileSync(filename, {encoding: 'utf8'}); + hash = crypto.createHash('md5').update(fstr).digest('hex').substr(0, 8); + } catch (e) {} + + return b + '="' + c + '?' + hash + '"'; + } else { + // hardcoded to Replays rn; TODO: generalize + let hash; + try { + const fstr = fs.readFileSync('website/replays/' + c, {encoding: 'utf8'}); + hash = crypto.createHash('md5').update(fstr).digest('hex').substr(0, 8); + } catch (e) {} + + return b + '="' + c + '?' + (hash || 'v1') + '"'; + } } else { - return b + '="/' + c + '"'; + return b + '="' + c + '"'; } } @@ -156,6 +167,11 @@ function writeFiles(indexContents, preactIndexContents, crossprotocolContents, r fs.writeFileSync('crossprotocol.html', crossprotocolContents); process.stdout.write("Writing replay-embed.js... "); fs.writeFileSync('js/replay-embed.js', replayEmbedContents); + + let replaysContents = fs.readFileSync('website/replays/index.template.html', {encoding: 'utf8'}); + replaysContents = replaysContents.replace(URL_REGEX, updateURL); + fs.writeFileSync('website/replays/index.html', replaysContents); + console.log("DONE"); } diff --git a/favicon-16.png b/favicon-16.png index 19eabf169..01634f7fa 100644 Binary files a/favicon-16.png and b/favicon-16.png differ diff --git a/favicon-256.png b/favicon-256.png index 99cd58833..00fe674b8 100644 Binary files a/favicon-256.png and b/favicon-256.png differ diff --git a/favicon-32.png b/favicon-32.png index 3924e60d4..50cdf4d62 100644 Binary files a/favicon-32.png and b/favicon-32.png differ diff --git a/favicon-notify.ico b/favicon-notify.ico index ab9376f5a..58594f813 100644 Binary files a/favicon-notify.ico and b/favicon-notify.ico differ diff --git a/favicon.ico b/favicon.ico index d103d50c7..cf7439e08 100644 Binary files a/favicon.ico and b/favicon.ico differ diff --git a/fx/bg-gen1-spl.png b/fx/bg-gen1-spl.png index b95df9610..94d57a064 100644 Binary files a/fx/bg-gen1-spl.png and b/fx/bg-gen1-spl.png differ diff --git a/fx/bg-gen1.png b/fx/bg-gen1.png index fe9c46f50..bb85bc1f6 100644 Binary files a/fx/bg-gen1.png and b/fx/bg-gen1.png differ diff --git a/js/client-mainmenu.js b/js/client-mainmenu.js index d83c7f88c..a992382ae 100644 --- a/js/client-mainmenu.js +++ b/js/client-mainmenu.js @@ -37,7 +37,7 @@ } else { buf += '

Pokémon Showdown is offline due to technical difficulties!

'; } - buf += '

Bear with us as we freak out.

'; + buf += '

Bear with us as we freak out.

'; buf += '

(We\'ll be back up in a few hours.)

'; buf += ''; } else { @@ -1224,27 +1224,73 @@ }); var FormatPopup = this.FormatPopup = this.Popup.extend({ + events: { + 'keyup input[name=search]': 'updateSearch', + 'click details': 'updateOpen', + 'click i.fa': 'updateStar', + }, initialize: function (data) { - var curFormat = data.format; + this.data = data; + if (!this.open) { + // todo: maybe make this configurable? not sure since it will cache what users toggle. + // avoiding that decision for now because it requires either an ugly hack + // or an overhaul of BattleFormats. + this.open = Storage.prefs('openformats') || { + "S/V Singles": true, "S/V Doubles": true, "National Dex": true, "OM of the Month": true, + "Other Metagames": true, "Randomized Format Spotlight": true, "RoA Spotlight": true, + }; + } + if (!this.starred) this.starred = Storage.prefs('starredformats') || {}; + if (!this.search) this.search = ""; this.onselect = data.onselect; - var selectType = data.selectType; - if (!selectType) selectType = (this.sourceEl.closest('form').data('search') ? 'search' : 'challenge'); + this.selectType = data.selectType; + if (!this.selectType) this.selectType = (this.sourceEl.closest('form').data('search') ? 'search' : 'challenge'); + + + var html = '

'; + html += this.renderFormats(); + html += '

'; + this.$el.html(html); + }, + renderFormats: function () { + var data = this.data; + var curFormat = data.format; var bufs = []; var curBuf = 0; - if (selectType === 'watch') { + if (this.selectType === 'watch' && !this.search) { bufs[1] = '
  • '; } + + for (var i in this.starred) { + if (!bufs[1]) bufs[1] = ''; + var format = BattleFormats[i]; + if (!format) { + delete this.starred[i]; + continue; + } + if (!this.shouldDisplayFormat(format)) continue; + if (this.search && !i.includes(toID(this.search))) { + continue; + } + // + var formatName = BattleLog.escapeFormat(BattleFormats[i].id); + bufs[1] += ( + '
  • ' + ); + } + var curSection = ''; for (var i in BattleFormats) { var format = BattleFormats[i]; - if (selectType === 'teambuilder') { - if (!format.isTeambuilderFormat) continue; - } else { - if (format.effectType !== 'Format' || format.battleFormat) continue; - if (selectType != 'watch' && !format[selectType + 'Show']) continue; - } + if (!this.shouldDisplayFormat(format)) continue; + if (this.search && !format.id.includes(toID(this.search))) continue; + if (this.starred[i]) continue; // only show it in the starred section if (format.section && format.section !== curSection) { + if (curSection) bufs[curBuf] += '

    '; curSection = format.section; if (!app.supports['formatColumns']) { curBuf = (curSection === 'Doubles' || curSection === 'Past Generations') ? 2 : 1; @@ -1254,7 +1300,10 @@ if (!bufs[curBuf]) { bufs[curBuf] = ''; } - bufs[curBuf] += '
  • ' + BattleLog.escapeHTML(curSection) + '

  • '; + var open = (this.open[curSection] || toID(this.search)) ? ' open' : ''; + bufs[curBuf] += '

    '; + bufs[curBuf] += '

    '; + bufs[curBuf] += BattleLog.escapeHTML(curSection) + ''; } var formatName = BattleLog.escapeFormat(format.id); if (formatName.charAt(0) !== '[') formatName = '[Gen 6] ' + formatName; @@ -1262,23 +1311,65 @@ formatName = formatName.replace('[Gen 9 ', '['); formatName = formatName.replace('[Gen 8 ', '['); formatName = formatName.replace('[Gen 7 ', '['); - bufs[curBuf] += '
  • '; + bufs[curBuf] += ( + '
  • ' + ); } - var html = ''; - for (var i = 1, l = bufs.length; i < l; i++) { - html += ''; } - html += '
    '; - this.$el.html(html); + return html; + }, + update: function () { + var $formatEl = this.$el.find('span[name=formats]'); + $formatEl.empty(); + $formatEl.html(this.renderFormats()); + }, + updateStar: function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + var format = $(ev.target).parent().attr('value'); + if (this.starred[format]) { + delete this.starred[format]; + } else { + this.starred[format] = true; + } + Storage.prefs('starredformats', this.starred); + this.update(); + }, + updateOpen: function (ev) { + var section = $(ev.currentTarget).attr('section'); + this.open[section] = !this.open[section]; + Storage.prefs('openformats', this.open); + }, + updateSearch: function (event) { + this.search = $(event.currentTarget).val(); + this.update(); + }, + shouldDisplayFormat: function (format) { + if (this.selectType === 'teambuilder') { + if (!format.isTeambuilderFormat) return false; + } else { + if (format.effectType !== 'Format' || format.battleFormat) return false; + if (this.selectType != 'watch' && !format[this.selectType + 'Show']) return false; + } + return true; }, selectFormat: function (format) { if (this.onselect) { diff --git a/js/client.js b/js/client.js index cf60ed632..9e624b278 100644 --- a/js/client.js +++ b/js/client.js @@ -406,6 +406,7 @@ function toId() { // down // if (document.location.hostname === 'play.pokemonshowdown.com') this.down = true; + // this.down = true; this.addRoom(''); this.topbar = new Topbar({el: $('#header')}); diff --git a/js/replay-embed.template.js b/js/replay-embed.template.js index b037fec05..15c533be9 100644 --- a/js/replay-embed.template.js +++ b/js/replay-embed.template.js @@ -82,6 +82,7 @@ var Replays = { log: log.split('\n'), isReplay: true, paused: true, + autoresize: true, }); this.$('.replay-controls-2').html('
    Speed:
    Color scheme:
    '); @@ -210,3 +211,12 @@ var Replays = { window.onload = function () { Replays.init(); }; + +if (window.matchMedia) { + if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + document.body.className = 'dark'; + } + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (event) { + document.body.className = event.matches ? "dark" : ""; + }); +} diff --git a/js/storage.js b/js/storage.js index 30ccd2a54..5192a126d 100644 --- a/js/storage.js +++ b/js/storage.js @@ -585,6 +585,35 @@ Storage.loadTeams = function () { } catch (e) {} }; +/** returns false to add the team, true to not add it, 'rename' to add it under a diff name */ +Storage.compareTeams = function (serverTeam, localTeam) { + // if titles match exactly and mons are the same, assume they're the same team + // if they don't match, it might be edited, but we'll go ahead and add it to the user's + // teambuilder since they may want that old version around. just go ahead and edit the name + var mons = serverTeam.team.split(','); + var matches = 0; + var otherMons = Storage.unpackTeam(localTeam.team); + for (var i = 0; i < mons.length; i++) { + for (var j = 0; j < otherMons.length; j++) { + if (toID(otherMons[j].species) === toID(mons[i])) { + matches++; + } + } + } + var sanitize = function (name) { + return (name || "").replace(/\s+\(server version\)/g, '').trim(); + }; + var nameMatches = sanitize(serverTeam.name) === sanitize(localTeam.name); + if (!(nameMatches && serverTeam.format === localTeam.format)) { + return false; + } + // if it's been edited since, invalidate the team id on this one (count it as new) + // and load from server + if (mons.length !== otherMons.length || matches !== otherMons.length) return 'rename'; + if (serverTeam.teamid === localTeam.teamid && localTeam.teamid) return true; + return true; +}; + Storage.loadRemoteTeams = function (after) { $.get(app.user.getActionPHP(), {act: 'getteams'}, Storage.safeJSON(function (data) { if (data.actionerror) { @@ -595,12 +624,19 @@ Storage.loadRemoteTeams = function (after) { var matched = false; for (var j = 0; j < Storage.teams.length; j++) { var curTeam = Storage.teams[j]; - if (curTeam.teamid === team.teamid) { + var match = Storage.compareTeams(team, curTeam); + if (match === true) { // prioritize locally saved teams over remote // as so to not overwrite changes matched = true; break; } + if (match === 'rename') { + delete curTeam.teamid; + if (!team.name.endsWith(' (server version)')) { + team.name += ' (server version)'; + } + } } team.loaded = false; if (!matched) { diff --git a/replays/battle.log.php b/replays/battle.log.php index b9e4e46dc..5b5aeb615 100644 --- a/replays/battle.log.php +++ b/replays/battle.log.php @@ -56,6 +56,9 @@ } if (isset($_REQUEST['json'])) { + $matchSuccess = preg_match('/\\n\\|tier\\|([^|]*)\\n/', $replay['log'], $matches); + if ($matchSuccess) $replay['format'] = $matches[1]; + header('Content-Type: application/json'); header('Access-Control-Allow-Origin: *'); die(json_encode($replay)); diff --git a/src/battle-dex-search.ts b/src/battle-dex-search.ts index db9b1602c..80bdab33a 100644 --- a/src/battle-dex-search.ts +++ b/src/battle-dex-search.ts @@ -1673,8 +1673,8 @@ class BattleMoveSearch extends BattleTypedSearch<'move'> { const isSTABmons = (format.includes('stabmons') || format.includes('stylemons')|| format === 'staaabmons'); const isTradebacks = (format.includes('tradebacks') || this.mod === 'gen1expansionpack' || this.mod === 'gen1burgundy'); const regionBornLegality = dex.gen >= 6 && - /^battle(spot|stadium|festival)/.test(format) || format.startsWith('vgc') || - (dex.gen === 9 && this.formatType !== 'natdex'); + (/^battle(spot|stadium|festival)/.test(format) || format.startsWith('vgc') || + (dex.gen === 9 && this.formatType !== 'natdex')); // Hoenn Gaiden Baton Pass Gaiden Declaration const isHoennGaiden = this.modFormat === 'gen3hoenngaiden' || this.modFormat.endsWith('hoenngaiden'); diff --git a/src/battle.ts b/src/battle.ts index c8669997b..c98cfa00e 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -1195,10 +1195,12 @@ export class Battle { const scale = (width / 640); this.scene.$frame?.css('transform', 'scale(' + scale + ')'); this.scene.$frame?.css('transform-origin', 'top left'); + this.scene.$frame?.css('margin-bottom', '' + (360 * scale - 360) + 'px'); // this.$foeHint.css('transform', 'scale(' + scale + ')'); } else { this.scene.$frame?.css('transform', 'none'); // this.$foeHint.css('transform', 'none'); + this.scene.$frame?.css('margin-bottom', '0'); } }; diff --git a/style/battle-log.css b/style/battle-log.css index d8f1a4af2..9663422bd 100644 --- a/style/battle-log.css +++ b/style/battle-log.css @@ -55,6 +55,7 @@ button, summary { cursor: pointer; + touch-action: manipulation; } .button { outline: none; diff --git a/style/battle.css b/style/battle.css index 5f0ddb04c..81a20d86a 100644 --- a/style/battle.css +++ b/style/battle.css @@ -569,8 +569,9 @@ License: GPLv2 display: block; position: absolute; top: 0; left: 0; bottom: 0; right: 0; - background: #FFFFFF; + background: #CCCCCC; color: #000000; + font-size: 14px; } .seeking strong { display: block; diff --git a/style/client.css b/style/client.css index e4d4f62c7..e059dfbae 100644 --- a/style/client.css +++ b/style/client.css @@ -486,6 +486,17 @@ select { content: "\f0e6"; } +i.subtle { + opacity: 0.25; + filter: alpha(opacity=25); +} + +i.subtle:hover { + opacity: 1.0; + filter: alpha(opacity=100); +} + + .tabbar li, .tabbar ul { display: block; diff --git a/website/.htaccess b/website/.htaccess index 1b7ecc215..58266a864 100644 --- a/website/.htaccess +++ b/website/.htaccess @@ -11,9 +11,9 @@ allow from all order deny,allow deny from all -#AuthName pokemonshowdown.com -#AuthUserFile /home/pokemon/public_html/_vti_pvt/service.pwd -#AuthGroupFile /home/pokemon/public_html/_vti_pvt/service.grp +# AuthName pokemonshowdown.com +# AuthUserFile /home/pokemon/public_html/_vti_pvt/service.pwd +# AuthGroupFile /home/pokemon/public_html/_vti_pvt/service.grp AddType text/plain .phps AddType application/octet-stream .heapsnapshot @@ -35,7 +35,7 @@ RewriteRule ^(.*)$ https://pokemonshowdown.com/$1 [R=302,NE,L] RewriteCond %{HTTP_HOST} ^(www\.)?forum\.pokemonshowdown\.com$ [NC] RewriteRule ^(.*)$ https://pokemonshowdown.com/forums/ [R=301,NE,L] -RewriteCond %{HTTP_HOST} !^pokemonshowdown\.com$ +RewriteCond %{HTTP_HOST} ^www\.pokemonshowdown\.com$ RewriteRule ^(.*)$ https://pokemonshowdown.com/$1 [R=301,NE,L] RewriteRule ^secure$ ./ [L,QSA] @@ -77,6 +77,8 @@ RewriteRule ^servers\/([A-Za-z0-9-]+)\.json$ servers/server.php?id=$1&json [L,QS # RewriteRule ^replay\/?search/?$ replay/search.php [L,QSA] # RewriteRule ^replay\/?([A-Za-z0-9-]+)$ replay/battle.php?name=$1 [L,QSA] +RewriteRule ^replays/([a-z0-9-]+)$ replays/index.html [L,QSA] + RewriteRule ^replay\/(.*)$ https://replay.pokemonshowdown.com/$1 [R=302,L] RewriteRule ^dex\/(.*)$ https://dex.pokemonshowdown.com/$1 [R=302,L] RewriteRule ^pokedex\/(.*)$ https://dex.pokemonshowdown.com/$1 [R=302,L] diff --git a/website/favicon.ico b/website/favicon.ico index 34b1536b1..cf7439e08 100644 Binary files a/website/favicon.ico and b/website/favicon.ico differ diff --git a/website/lib/serverlist.inc.php b/website/lib/serverlist.inc.php index 0fb0c7fa6..d6978a2d6 100644 --- a/website/lib/serverlist.inc.php +++ b/website/lib/serverlist.inc.php @@ -59,7 +59,7 @@ function servercmp($a, $b) { $more = true; } ?> -
  • ',$server['name'],'
    (official server)'; else echo $server['name']; ?>
  • +
  • ',$server['name'],'
    (official server)'; else echo $server['name']; ?>
  • ', $summary); $summary = str_replace("[b]", '', $summary); $summary = str_replace("[/b]", '', $summary); + $summary = str_replace("[i]", '', $summary); + $summary = str_replace("[/i]", '', $summary); $summary = '

    '.$summary.'

    '; $time = ''.time(); diff --git a/website/replays/index.template.html b/website/replays/index.template.html new file mode 100644 index 000000000..395a2c7df --- /dev/null +++ b/website/replays/index.template.html @@ -0,0 +1,101 @@ + + + + +Replays - Pokémon Showdown! + + + + + + + + + + + + + + + + +
    + +
    + +
    + +
    + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/replays/replay-api.php b/website/replays/replay-api.php new file mode 100644 index 000000000..1a8693c8e --- /dev/null +++ b/website/replays/replay-api.php @@ -0,0 +1,7 @@ +isSysop() ? '1' : ''; diff --git a/website/replays/search.json.php b/website/replays/search.json.php new file mode 100644 index 000000000..c714bea56 --- /dev/null +++ b/website/replays/search.json.php @@ -0,0 +1,51 @@ +toID($username); +$isPrivateAllowed = false; +if ($isPrivate) { + header('HTTP/1.1 400 Bad Request'); + die('"ERROR: you cannot access private replays with the public API"'); +} + +$page = intval($_REQUEST['page'] ?? 0); + +$replays = null; +if ($page > 25) { + die('"ERROR: page limit is 25"'); +} else if ($username || $format) { + $replays = $Replays->search([ + "username" => $username, + "username2" => $username2, + "format" => $format, + "byRating" => $byRating, + "isPrivate" => $isPrivate, + "page" => $page + ]); +} else if ($contains) { + $replays = $Replays->fullSearch($contains, $page); +} else { + $replays = $Replays->recent(); +} + +if ($replays) { + foreach ($replays as &$replay) { + if ($replay['password'] ?? null) { + $replay['id'] .= '-' . $replay['password']; + } + unset($replay['password']); + } +} + +echo json_encode($replays); diff --git a/website/replays/search.notjson.php b/website/replays/search.notjson.php new file mode 100644 index 000000000..83f72ab90 --- /dev/null +++ b/website/replays/search.notjson.php @@ -0,0 +1,51 @@ +userid($username); +$isPrivateAllowed = ($username === $curuser['userid'] || $users->isSysop()); +if ($isPrivate && !$isPrivateAllowed) { + header('HTTP/1.1 403 Forbidden'); + die('"ERROR: access denied"'); +} + +$page = intval($_REQUEST['page'] ?? 0); + +$replays = null; +if ($page > 25) { + die('"ERROR: page limit is 25"'); +} else if ($username || $format) { + $replays = $Replays->search([ + "username" => $username, + "username2" => $username2, + "format" => $format, + "byRating" => $byRating, + "isPrivate" => $isPrivate, + "page" => $page + ]); +} else if ($contains) { + $replays = $Replays->fullSearch($contains, $page); +} else { + $replays = $Replays->recent(); +} + +if ($replays) { + foreach ($replays as &$replay) { + if ($replay['password'] ?? null) { + $replay['id'] .= '-' . $replay['password']; + } + unset($replay['password']); + } +} + +echo ']' . json_encode($replays); diff --git a/website/replays/src/replays.tsx b/website/replays/src/replays.tsx new file mode 100644 index 000000000..c076004e1 --- /dev/null +++ b/website/replays/src/replays.tsx @@ -0,0 +1,463 @@ +/** @jsx preact.h */ +import preact from 'preact'; +import {Net} from './utils'; +import {Battle} from '../../../src/battle'; +import {BattleSound} from '../../../src/battle-sound'; +import $ from 'jquery'; + +function showAd(id: string) { + // @ts-expect-error + window.top.__vm_add = window.top.__vm_add || []; + + //this is a x-browser way to make sure content has loaded. + + (function (success) { + if (window.document.readyState !== "loading") { + success(); + } else { + window.document.addEventListener("DOMContentLoaded", function () { + success(); + }); + } + })(function () { + var placement = document.createElement("div"); + placement.setAttribute("class", "vm-placement"); + if (window.innerWidth > 1000) { + //load desktop placement + placement.setAttribute("data-id", "6452680c0b35755a3f09b59b"); + } else { + //load mobile placement + placement.setAttribute("data-id", "645268557bc7b571c2f06f62"); + } + document.querySelector("#" + id)!.appendChild(placement); + // @ts-expect-error + window.top.__vm_add.push(placement); + }); +} + +class SearchPanel extends preact.Component { + results: { + uploadtime: number; + id: string; + format: string; + p1: string; + p2: string; + }[] | null = null; + format = ''; + user = ''; + sort = 'date'; + moreFun = false; + moreCompetitive = false; + override componentDidMount() { + Net('https://replay.pokemonshowdown.com/search.json').get().then(result => { + this.results = JSON.parse(result); + this.forceUpdate(); + }); + } + search(format: string, user: string) { + this.format = format; + this.user = user; + this.results = null; + this.moreFun = false; + this.moreCompetitive = false; + this.forceUpdate(); + Net('https://replay.pokemonshowdown.com/search.json').get({ + query: {user: this.user, format: this.format}, + }).then(result => { + this.results = JSON.parse(result); + this.forceUpdate(); + }); + } + submitForm = (e: Event) => { + e.preventDefault(); + // @ts-expect-error + const format = document.getElementsByName('format')[0]?.value || ''; + // @ts-expect-error + const user = document.getElementsByName('user')[0]?.value || ''; + this.search(format, user); + }; + cancelForm = (e: Event) => { + e.preventDefault(); + this.search('', ''); + }; + showMoreFun = (e: Event) => { + e.preventDefault(); + this.moreFun = true; + this.forceUpdate(); + }; + showMoreCompetitive = (e: Event) => { + e.preventDefault(); + this.moreCompetitive = true; + this.forceUpdate(); + }; + override render() { + const searchResults = ; + const activelySearching = !!(this.format || this.user); + return
    +

    Search replays

    +
    +

    +

    +

    {activelySearching && }

    + {activelySearching &&

    Results

    } + {activelySearching && searchResults} +
    +
    {!activelySearching &&
    +

    Featured replays

    + +
    }{!activelySearching &&
    +

    Recent replays

    + +
    }
    ; + } +} + +class BattleDiv extends preact.Component { + override shouldComponentUpdate() { + return false; + } + override render() { + return
    ; + } +} +class BattleLogDiv extends preact.Component { + override shouldComponentUpdate() { + return false; + } + override render() { + return
    ; + } +} + +class BattlePanel extends preact.Component<{id: string}> { + result: { + uploadtime: number; + id: string; + format: string; + p1: string; + p2: string; + log: string; + views: number; + p1id: string; + p2id: string; + rating: number; + private: number; + password: string; + } | null | undefined = undefined; + battle: Battle; + speed = 'normal'; + override componentDidMount() { + Net(`https://replay.pokemonshowdown.com/${this.props.id}.json`).get().then(result => { + const replay: NonNullable = JSON.parse(result); + this.result = replay; + const $base = $(this.base!); + this.battle = new Battle({ + id: replay.id, + $frame: $base.find('.battle'), + $logFrame: $base.find('.battle-log'), + log: replay.log.split('\n'), + isReplay: true, + paused: true, + autoresize: true, + }); + // for ease of debugging + (window as any).battle = this.battle; + this.battle.subscribe(_ => { + this.forceUpdate(); + }); + this.forceUpdate(); + }).catch(_ => { + this.result = null; + this.forceUpdate(); + }); + showAd('LeaderboardBTF'); + } + override componentWillUnmount(): void { + this.battle.destroy(); + (window as any).battle = null; + } + play = () => { + this.battle.play(); + }; + replay = () => { + this.battle.reset(); + this.battle.play(); + this.forceUpdate(); + }; + pause = () => { + this.battle.pause(); + }; + nextTurn = () => { + this.battle.seekBy(1); + }; + prevTurn = () => { + this.battle.seekBy(-1); + }; + firstTurn = () => { + this.battle.seekTurn(0); + }; + lastTurn = () => { + this.battle.seekTurn(Infinity); + }; + goToTurn = () => { + const turn = prompt('Turn?'); + if (!turn?.trim()) return; + let turnNum = Number(turn); + if (turn === 'e' || turn === 'end' || turn === 'f' || turn === 'finish') turnNum = Infinity; + if (isNaN(turnNum) || turnNum < 0) alert("Invalid turn"); + this.battle.seekTurn(turnNum); + }; + switchSides = () => { + this.battle.switchSides(); + }; + changeSpeed = (e: Event) => { + this.speed = (e.target as HTMLSelectElement).value; + const fadeTable = { + hyperfast: 40, + fast: 50, + normal: 300, + slow: 500, + reallyslow: 1000 + }; + const delayTable = { + hyperfast: 1, + fast: 1, + normal: 1, + slow: 1000, + reallyslow: 3000 + }; + this.battle.messageShownTime = delayTable[this.speed]; + this.battle.messageFadeTime = fadeTable[this.speed]; + this.battle.scene.updateAcceleration(); + }; + changeSound = (e: Event) => { + const muted = (e.target as HTMLSelectElement).value; + this.battle.setMute(muted === 'off'); + }; + renderNotFound() { + return
    + {/*
    + + + +
    +
    + + + + + +
    */} +
    + + + +
    +
    + + + + + +
    +
    +

    Not Found

    +

    + The battle you're looking for has expired. Battles expire after 15 minutes of inactivity unless they're saved. +

    +

    + In the future, remember to click Upload and share replay to save a replay permanently. +

    +
    ; + } + override render() { + const atEnd = this.battle?.atQueueEnd; + const atStart = !this.battle?.started; + if (this.result === null) return this.renderNotFound(); + return
    + + +
    +

    + {atEnd ? + + : this.battle?.paused ? + + : + + } {} + + + + +

    +

    + {} + +

    +

    + {} + +

    +

    {this.result?.format}: {this.result?.p1} vs. {this.result?.p2}

    +

    + Uploaded: {new Date(this.result?.uploadtime! * 1000 || 0).toDateString()} + {this.result?.rating ? ` | Rating: ${this.result?.rating}` : ''} +

    +
    +
    +
    ; + } +} + +class PSReplays extends preact.Component { + override render() { + return
    { + document.location.pathname === '/replays/' ? + : + }
    ; + } +} + +preact.render(, document.getElementById('main')!); + +if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) { + document.body.className = 'dark'; +} +window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', event => { + document.body.className = event.matches ? "dark" : ""; +}); diff --git a/website/replays/src/utils.ts b/website/replays/src/utils.ts new file mode 100644 index 000000000..38298cef4 --- /dev/null +++ b/website/replays/src/utils.ts @@ -0,0 +1,245 @@ + +/********************************************************************** + * Polyfills + *********************************************************************/ + +if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function (searchElement, fromIndex) { + for (let i = (fromIndex || 0); i < this.length; i++) { + if (this[i] === searchElement) return i; + } + return -1; + }; +} +if (!Array.prototype.includes) { + Array.prototype.includes = function (thing) { + return this.indexOf(thing) !== -1; + }; +} +if (!String.prototype.includes) { + String.prototype.includes = function (thing) { + return this.indexOf(thing) !== -1; + }; +} +if (!String.prototype.startsWith) { + String.prototype.startsWith = function (thing) { + return this.slice(0, thing.length) === thing; + }; +} +if (!String.prototype.endsWith) { + String.prototype.endsWith = function (thing) { + return this.slice(-thing.length) === thing; + }; +} +if (!String.prototype.trim) { + String.prototype.trim = function () { + return this.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, ''); + }; +} +if (!Object.assign) { + Object.assign = function (thing: any, rest: any) { + for (let i = 1; i < arguments.length; i++) { + let source = arguments[i]; + for (let k in source) { + thing[k] = source[k]; + } + } + return thing; + }; +} +if (!Object.create) { + Object.create = function (proto: any) { + function F() {} + F.prototype = proto; + return new (F as any)(); + }; +} +if (!window.console) { + // in IE8, the console object is only defined when devtools is open + // I don't actually know if this will cause problems when you open devtools, + // but that's something I can figure out if I ever bother testing in IE8 + (window as any).console = { + log() {}, + }; +} + +/********************************************************************** + * Net + *********************************************************************/ + +export interface PostData { + [key: string]: string | number; +} +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 (err) {} + } +} +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 { + 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, body: PostData | string): Promise; + /** + * Makes a http/https POST request to the given link. + * @param opts request opts + */ + post(opts?: NetRequestOptions): Promise; + post(opts: NetRequestOptions = {}, body?: PostData | string) { + if (!body) body = opts.body; + return this.get({ + ...opts, + method: 'POST', + body, + }); + } +} + +export function Net(uri: string) { + return new NetRequest(uri); +} + +Net.encodeQuery = function (data: string | PostData) { + if (typeof data === 'string') return data; + let urlencodedData = ''; + for (const key in data) { + if (urlencodedData) urlencodedData += '&'; + urlencodedData += encodeURIComponent(key) + '=' + encodeURIComponent((data as any)[key]); + } + return urlencodedData; +}; + +/********************************************************************** + * Models + *********************************************************************/ + +export class PSSubscription { + observable: PSModel | PSStreamModel; + listener: (value?: any) => void; + constructor(observable: PSModel | PSStreamModel, 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 { + 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); + } + } +} diff --git a/website/style/global.css b/website/style/global.css index 45d4cf5d4..b3b67f12f 100644 --- a/website/style/global.css +++ b/website/style/global.css @@ -49,7 +49,7 @@ header { left: 0; top: 0; } -.nav a { +.nav a, .dark .nav a { color: white; background: #3a4f88; background: linear-gradient(to bottom, #4c63a3, #273661); @@ -63,10 +63,11 @@ header { margin-left: -1px; font-size: 11pt; } -.nav a:hover { +.nav a:hover, .dark .nav a:hover { background: linear-gradient(to bottom, #5a77c7, #2f447f); + border: 1px solid #222c4a; } -.nav a:active { +.nav a:active, .dark .nav a:active { background: linear-gradient(to bottom, #273661, #4c63a3); box-shadow: 0.5px 1px 2px rgba(255, 255, 255, 0.45), inset 0.5px 1px -1px rgba(255, 255, 255, 0.5); } @@ -176,6 +177,9 @@ h1 { padding: 0 0 5px; margin: 0; } +.dark h1 { + border-bottom-color: #888; +} .section { color: black; @@ -188,6 +192,11 @@ h1 { backdrop-filter: blur(4px); } +.dark .section { + border-color: rgba(255, 255, 255, .4); + background: rgba(50, 50, 50, .5); + color: #DDD; +} /********************************************************* * Defaults