Migrate replay management to New Replays

This involved redesigning the left-right scroll system on low-height
displays a little bit
This commit is contained in:
Guangcong Luo
2026-07-27 05:48:21 +00:00
parent 11deab22f6
commit 94ebd6bcde
10 changed files with 227 additions and 700 deletions

View File

@@ -11,7 +11,7 @@ RewriteRule ^([A-Za-z0-9-]+)$ caches/index.php?name=$1 [L,QSA]
RewriteRule ^search\.json$ /api/replays/search.json [L,QSA]
RewriteRule ^recent\.json$ /api/replays/search.json [L,QSA]
RewriteRule ^download$ download.html [L,QSA]
RewriteRule ^([A-Za-z0-9-]+)/manage$ replay-manage.php?name=$1&manage [L,QSA]
RewriteRule ^([A-Za-z0-9-]+)/manage$ /$1?manage [R=302,L,QSA]
# RewriteRule ^([A-Za-z0-9-]+)\.log$ replay.log.php?name=$1 [L,QSA]
# RewriteRule ^([A-Za-z0-9-]+)\.inputlog$ replay.log.php?inputlog&name=$1 [L,QSA]

View File

@@ -180,6 +180,30 @@ https://replay.pokemonshowdown.com/gen7randomdoublesbattle-865046831.log
.mainbar.has-sidebar {
margin-left: 330px;
}
.bar-wrapper.has-sidebar:not(.short-viewport) .mainbar {
position: sticky;
top: 0;
}
.bar-wrapper.has-sidebar.short-viewport {
max-width: none;
}
.bar-wrapper.has-sidebar.short-viewport .sidebar {
box-sizing: border-box;
position: sticky;
top: 0;
max-height: 100vh;
overflow-y: auto;
}
@media (min-width: 1431px) and (max-width: 1510px) {
.bar-wrapper.has-sidebar.short-viewport .sidebar {
width: calc(50% - 395px);
padding-left: calc(50% - 715px);
}
.bar-wrapper.has-sidebar.short-viewport .mainbar {
margin-right: calc(50% - 715px);
margin-left: calc(50% - 385px);
}
}
@media (min-width: 1511px) {
.sidebar {
width: 400px;
@@ -190,6 +214,14 @@ https://replay.pokemonshowdown.com/gen7randomdoublesbattle-865046831.log
.mainbar.has-sidebar {
margin-left: 410px;
}
.bar-wrapper.has-sidebar.short-viewport .sidebar {
width: calc(50% - 355px);
padding-left: calc(50% - 755px);
}
.bar-wrapper.has-sidebar.short-viewport .mainbar {
margin-right: calc(50% - 755px);
margin-left: calc(50% - 345px);
}
}
.section.first-section {
margin-top: 9px;

View File

@@ -1,223 +0,0 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', TRUE);
ini_set('display_startup_errors', TRUE);
require_once __DIR__ . '/../config/config.inc.php';
require_once __DIR__ . '/theme/panels.lib.php';
$id = $_REQUEST['name'] ?? '';
$password = '';
if (!$id) {
include '404.php';
die();
}
$manage = false;
$csrfOk = false;
// this no longer needs to be in an if block, since the only reason to access
// this at /manage is for managing. just assume they're always doing that
require_once '../lib/ntbb-session.lib.php';
if (!$users->isLeader()) die("access denied");
$csrfOk = !!$users->csrfCheck();
$manage = true;
header('Cache-Control: max-age=0, no-cache, no-store, must-revalidate');
if (preg_match('/[^A-Za-z0-9-]/', $id)) die("access denied");
$fullid = $id;
if (substr($id, -2) === 'pw') {
$dashpos = strrpos($id, '-');
$password = substr($id, $dashpos + 1, -2);
$id = substr($id, 0, $dashpos);
// die($id . ' ' . $password);
}
$replay = null;
$cached = false;
// $forcecache = isset($_REQUEST['forcecache8723']);
$forcecache = false;
if (file_exists('caches/' . $id . '.inc.php')) {
include 'caches/' . $id . '.inc.php';
$replay['formatid'] = '';
$cached = true;
} else {
require_once 'replays.lib.php';
if (!$Replays->db && !$forcecache) {
include '503.php';
die();
}
$replay = $Replays->get($id, $forcecache);
}
if (!$replay || (@$replay['private'] === 3 && !$manage)) {
include '404.php';
die();
}
$fullid = $id . (@$replay['password'] ? '-' . $replay['password'] . 'pw' : '');
if (@$replay['private']) {
header('X-Robots-Tag: noindex');
}
if (@$replay['password']) {
if (!$password && !$manage) {
header('Cache-Control: max-age=0, no-cache, no-store, must-revalidate');
require_once '../lib/ntbb-session.lib.php';
if ($curuser['userid'] !== $p1id && $curuser['userid'] !== $p2id) {
die("Access denied (you must be logged into " . $p1id . " or " . $p2id . ")");
}
$url = '/' . $id . '-' . $replay['password'] . 'pw';
echo '<p>This private replay now has a new harder-to-guess URL:</p>';
echo '<p><a href="' . $url . '" data-target="replace">https://' . $psconfig['routes']['replays'] . $url . '</a></p>';
die();
}
if ($password !== $replay['password'] && !$manage) {
die("Access denied (please ask " . $p1id . " or " . $p2id . " for the password)");
}
}
if ($forcecache) {
file_put_contents('caches/' . $id . '.inc.php', '<?php $replay = ' . var_export($replay, true) . ';');
}
function userid($username) {
if (!$username) $username = '';
$username = strtr($username, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz");
return preg_replace('/[^A-Za-z0-9]+/','',$username);
}
$replay['log'] = str_replace("\r","",$replay['log']);
// $matchSuccess = preg_match('/\\|player\\|p1\\|([^|]*)(\\|[^|]*)?\\n\\|player\\|p2\\|([^|]+)(\\|[^|]*)?\\n(\\|gametype\\|[^|]*\\n)?\\|tier\\|([^|]*)\\n/', $replay['log'], $matches);
$matchSuccess = preg_match('/\\n\\|tier\\|([^|]*)\\n/', $replay['log'], $matches);
$format = $replay['format'];
if ($matchSuccess) $format = $matches[1];
$p1 = $replay['players'][0];
$p2 = $replay['players'][1];
$p1id = $users->userid($p1);
$p2id = $users->userid($p2);
$panels->setPageTitle($format.' replay: '.$p1.' vs. '.$p2);
$panels->setPageDescription('Watch a replay of a Pokémon battle between ' . $p1 . ' and ' . $p2 . ' (' . $format . ')');
$panels->setTab('replay');
$panels->start();
?>
<div class="pfx-panel"><div class="pfx-body" style="max-width:1180px">
<div class="wrapper replay-wrapper">
<div class="battle"><div class="playbutton"><button disabled>Loading...</button></div></div>
<div class="battle-log"></div>
<div class="replay-controls">
<button data-action="start"><i class="fa fa-play"></i> Play</button>
</div>
<div class="replay-controls-2">
<div class="chooser leftchooser speedchooser">
<em>Speed:</em>
<div><button value="hyperfast">Hyperfast</button><button value="fast">Fast</button><button value="normal" class="sel">Normal</button><button value="slow">Slow</button><button value="reallyslow">Really Slow</button></div>
</div>
<div class="chooser colorchooser">
<em>Color&nbsp;scheme:</em>
<div><button class="sel" value="light">Light</button><button value="dark">Dark</button></div>
</div>
<div class="chooser soundchooser" style="display:none">
<em>Music:</em>
<div><button class="sel" value="on">On</button><button value="off">Off</button></div>
</div>
</div>
<!--[if lte IE 8]>
<div class="error"><p>&#3232;_&#3232; <strong>You're using an old version of Internet Explorer.</strong></p>
<p>We use some transparent backgrounds, rounded corners, and other effects that your old version of IE doesn't support.</p>
<p>Please install <em>one</em> of these: <a href="http://www.google.com/chrome">Chrome</a> | <a href="http://www.mozilla.org/en-US/firefox/">Firefox</a> | <a href="http://windows.microsoft.com/en-US/internet-explorer/products/ie/home">Internet Explorer 9</a></p></div>
<![endif]-->
<?php if (@$replay['private']) echo '<strong>THIS REPLAY IS PRIVATE</strong> - make sure you have the owner\'s permission to share<br />'; ?>
<pre class="urlbox" style="word-wrap: break-word;"><?php echo htmlspecialchars('https://'.$psconfig['routes']['replays'].'/'.$fullid); ?></pre>
<h1 style="font-weight:normal;text-align:left"><strong><?= htmlspecialchars($format) ?></strong>: <a href="//<?= $psconfig['routes']['users'] ?>/<?= userid($p1) ?>" class="subtle"><?= htmlspecialchars($p1) ?></a> vs. <a href="//<?= $psconfig['routes']['users'] ?>/<?= userid($p2) ?>" class="subtle"><?= htmlspecialchars($p2) ?></a></h1>
<p style="padding:0 1em;margin-top:0">
<small class="uploaddate" data-timestamp="<?= @$replay['uploadtime'] ?? @$replay['date'] ?>"><em>Uploaded:</em> <?php echo date("M j, Y", @$replay['uploadtime'] ?? @$replay['date']); ?><?= @$replay['rating'] ? ' | <em>Rating:</em> ' . $replay['rating'] : '' ?></small>
</p>
<div id="loopcount"></div>
<?php
if ($manage) {
if ($csrfOk && isset($_POST['private'])) {
$replay['private'] = intval($_POST['private']);
$Replays->edit($replay);
echo '<p>Edited.</p>';
}
?>
Change privacy: <form action="/<?= $replay['id'] ?>/manage" method="post" style="display: inline" data-target="replace">
<?php $users->csrfData(); ?>
<input type="hidden" name="private" value="3" />
<button type="submit" name="private" value="3"<?= @$replay['private'] === 3 ? ' disabled' : '' ?>>Deleted</button>
</form>
<form action="/<?= $replay['id'] ?>/manage" method="post" style="display: inline" data-target="replace">
<?php $users->csrfData(); ?>
<input type="hidden" name="private" value="1" />
<button type="submit" name="private" value="1"<?= @$replay['private'] === 1 && $replay['password'] ? ' disabled' : '' ?>>Private</button>
</form>
<form action="/<?= $replay['id'] ?>/manage" method="post" style="display: inline" data-target="replace">
<?php $users->csrfData(); ?>
<input type="hidden" name="private" value="2" />
<button type="submit" name="private" value="2"<?= @$replay['private'] === 1 && !$replay['password'] ? ' disabled' : '' ?>>Private (no password)</button>
</form>
<form action="/<?= $replay['id'] ?>/manage" method="post" style="display: inline" data-target="replace">
<?php $users->csrfData(); ?>
<input type="hidden" name="private" value="0" />
<button type="submit" name="private" value="0"<?= !@$replay['private'] ? ' disabled' : '' ?>>Public</button>
</form>
<?php
}
?>
</div>
<input type="hidden" name="replayid" value="<?php echo htmlspecialchars($replay['id']); ?>" />
<!--
You can get this log directly at https://<?php echo $psconfig['routes']['replays']; ?>/<?php echo $replay['id']; ?>.log
Or with metadata at https://<?php echo $psconfig['routes']['replays']; ?>/<?php echo $replay['id']; ?>.json
Most PS pages you'd want to scrape will have a .json version!
-->
<script type="text/plain" class="log"><?php if ($replay['id'] === 'smogtours-ou-509') readfile('js/smogtours-ou-509.log'); else if ($replay['id'] === 'ou-305002749') readfile('js/ou-305002749.log'); else echo str_replace('/','\\/',$replay['log']); ?></script>
<?php
if (substr($replay['formatid'], -12) === 'randombattle' || substr($replay['formatid'], -19) === 'randomdoublesbattle' || $replay['formatid'] === 'gen7challengecup' || $replay['formatid'] === 'gen7challengecup1v1' || $replay['formatid'] === 'gen7battlefactory' || $replay['formatid'] === 'gen7bssfactory' || $replay['formatid'] === 'gen7hackmonscup' || $manage) {
?>
<script type="text/plain" class="inputlog"><?php echo str_replace('</','<\\/',$replay['inputlog']); ?></script>
<?php
}
?>
<?php
if ($panels->output === 'normal') {
?>
<div><script>
google_ad_client = "ca-pub-6535472412829264";
/* PS replay */
google_ad_slot = "6865298132";
google_ad_width = 728;
google_ad_height = 90;
</script>
<script src="//pagead2.googlesyndication.com/pagead/show_ads.js"></script>
</div>
<?php
}
?>
<a href="/" class="pfx-backbutton" data-target="back"><i class="fa fa-chevron-left"></i> <?= $cached ? 'Other' : 'More' ?> replays</a>
</div></div>
<?php
$panels->end();
?>

View File

@@ -1,243 +0,0 @@
// preserved for replay management
/* function updateProgress(done, a, b)
{
if (!battle.paused) return;
//$('#battle').html('<div class="playbutton"><button data-action="start">Play</button>'+(done?'':'<br />Buffering: '+parseInt(100*a/b)+'%')+'</div>');
} */
//setTimeout(function(){updateProgress(true)}, 10000);
// Panels
var Topbar = Panels.Topbar.extend({
height: 51
});
var ReplaySidebarPanel = Panels.StaticPanel.extend({
minWidth: 300,
maxWidth: 600,
events: {
'click button[name=moreResults]': 'moreResults'
},
moreResults: function(e) {
var elem = e.currentTarget;
elem.disabled = true;
// var offset = elem.value;
var page = Number(elem.value);
if (!page) page = 2;
var user = elem.dataset.user;
var format = elem.dataset.format;
var priv = !!elem.dataset.private;
var self = this;
elem.innerHTML = 'Loading...<br /><i class="fa fa-caret-down"></i>'
$.get('/search', Object.assign({
user: user,
format: format,
page: page,
output: 'html'
}, priv ? {private: 1} : {}), function (data) {
self.$('ul.linklist').append(data);
// var $nextOffset = self.$('input.offset');
// var val = $nextOffset.val();
// $nextOffset.remove();
var $more = self.$('input.more');
if (!$more.length) {
$(elem).remove();
return;
} else {
$more.remove();
}
elem.innerHTML = 'More<br /><i class="fa fa-caret-down"></i>'
elem.disabled = false;
elem.value = page + 1;
});
}
});
var ReplayPanel = Panels.StaticPanel.extend({
minWidth: 960,
maxWidth: 1180,
moveTo: function() {
Panels.Panel.prototype.moveTo.apply(this, arguments);
this.$el.css('top', $(window).height() > 495 ? '51px' : '0');
},
events: {
'click .chooser button': 'clickChangeSetting',
'click .replayDownloadButton': 'clickReplayDownloadButton'
},
clickChangeSetting: function(e) {
e.preventDefault();
var $chooser = $(e.currentTarget).closest('.chooser');
var value = e.currentTarget.value;
this.changeSetting($chooser, value, $(e.currentTarget));
},
changeSetting: function(type, value, valueElem) {
var $chooser;
if (typeof type === 'string') {
$chooser = this.$('.'+type+'chooser');
} else {
$chooser = type;
type = '';
if ($chooser.hasClass('colorchooser')) {
type = 'color';
} else if ($chooser.hasClass('soundchooser')) {
type = 'sound';
} else if ($chooser.hasClass('speedchooser')) {
type = 'speed';
}
}
if (!valueElem) valueElem = $chooser.find('button[value='+value+']');
$chooser.find('button').removeClass('sel');
valueElem.addClass('sel');
switch (type) {
case 'color':
if (value === 'dark') {
$(document.body).addClass('dark');
} else {
$(document.body).removeClass('dark');
}
break;
case 'sound':
// remember this is reversed: sound[off] === muted[true]
this.muted = (value === 'off');
this.battle.setMute(this.muted);
this.$('.startsoundchooser').remove();
break;
case 'speed':
var fadeTable = {
hyperfast: 40,
fast: 50,
normal: 300,
slow: 500,
reallyslow: 1000
};
var delayTable = {
hyperfast: 1,
fast: 1,
normal: 1,
slow: 1000,
reallyslow: 3000
};
this.battle.messageShownTime = delayTable[value];
this.battle.messageFadeTime = fadeTable[value];
this.battle.scene.updateAcceleration();
break;
}
},
battle: null,
muted: null,
updateContent: function() {
this.$el.css('overflow-x', 'hidden');
var $battle = this.$('.battle');
if (!$battle.length) return;
var replayid = this.$('input[name=replayid]').val() || '';
var log = (this.$('script.log').text() || '').replace(/\\\//g,'/');
var self = this;
this.battle = new Battle({
id: replayid,
$frame: $battle,
$logFrame: this.$('.battle-log'),
log: log.split('\n'),
isReplay: true,
paused: true
})
this.$('.urlbox').css('margin-right', 120).before('<a class="button replayDownloadButton" style="float:right;margin-top:7px;margin-right:7px" href="#"><i class="fa fa-download"></i> Download</a>');
// this works around a WebKit/Blink bug relating to float layout
var rc2 = this.$('.replay-controls-2')[0];
if (rc2) rc2.innerHTML = rc2.innerHTML;
if (window.HTMLAudioElement) this.$('.soundchooser, .startsoundchooser').show();
this.battle.subscribe(function (state) { self.update(state); });
this.update();
},
update: function (state) {
if (state === 'error') {
var m = /^([a-z0-9]+)-[a-z0-9]+-[0-9]+$/.exec(this.battle.id);
if (m) {
this.battle.log('<hr /><div class="chat">This replay was uploaded from a third-party server (<code>' + BattleLog.escapeHTML(m[1]) + '</code>). It contains errors.</div><div class="chat">Replays uploaded from third-party servers can contain errors if the server is running custom code, or the server operator has otherwise incorrectly configured their server.</div>', true);
}
return;
}
if (BattleSound.muted && !this.muted) this.changeSetting('sound', 'off');
if (this.battle.paused) {
var resetDisabled = !this.battle.started ? ' disabled' : '';
this.$('.replay-controls').html('<button data-action="play"><i class="fa fa-play"></i> Play</button><button data-action="reset"' + resetDisabled + '><i class="fa fa-undo"></i> Reset</button> <button data-action="rewind"><i class="fa fa-step-backward"></i> Last turn</button><button data-action="ff"><i class="fa fa-step-forward"></i> Next turn</button> <button data-action="ffto"><i class="fa fa-fast-forward"></i> Go to turn...</button> <button data-action="switchViewpoint"><i class="fa fa-random"></i> Switch sides</button>');
} else {
this.$('.replay-controls').html('<button data-action="pause"><i class="fa fa-pause"></i> Pause</button><button data-action="reset"><i class="fa fa-undo"></i> Reset</button> <button data-action="rewind"><i class="fa fa-step-backward"></i> Last turn</button><button data-action="ff"><i class="fa fa-step-forward"></i> Next turn</button> <button data-action="ffto"><i class="fa fa-fast-forward"></i> Go to turn...</button> <button data-action="switchViewpoint"><i class="fa fa-random"></i> Switch sides</button>');
}
},
clickReplayDownloadButton: function (e) {
var filename = (this.battle.tier || 'Battle').replace(/[^A-Za-z0-9]/g, '');
// ladies and gentlemen, JavaScript dates
var timestamp = parseInt(this.$('.uploaddate').data('timestamp'), 10) * 1000;
var date = new Date(timestamp);
filename += '-' + date.getFullYear();
filename += (date.getMonth() >= 9 ? '-' : '-0') + (date.getMonth() + 1);
filename += (date.getDate() >= 10 ? '-' : '-0') + date.getDate();
filename += '-' + toID(this.battle.p1.name);
filename += '-' + toID(this.battle.p2.name);
e.currentTarget.href = BattleLog.createReplayFileHref(this);
e.currentTarget.download = filename + '.html';
e.stopPropagation();
},
pause: function() {
this.battle.pause();
},
play: function() {
this.battle.play();
},
reset: function() {
this.battle.reset();
},
ff: function() {
this.battle.skipTurn();
},
rewind: function() {
this.battle.seekBy(-1);
},
ffto: function() {
var turn = prompt('Turn?');
if (!turn.trim()) return;
if (turn === 'e' || turn === 'end' || turn === 'f' || turn === 'finish') turn = Infinity;
turn = Number(turn);
if (isNaN(turn) || turn < 0) alert("Invalid turn");
this.battle.seekTurn(turn);
},
switchViewpoint: function() {
this.battle.switchViewpoint();
},
remove: function() {
this.battle.destroy();
Panels.StaticPanel.prototype.remove.call(this);
},
});
var App = Panels.App.extend({
topbarView: Topbar,
states: {
'*path': ReplaySidebarPanel, // catch-all default
':replay': ReplayPanel,
':replay/manage': ReplayPanel,
'search': ReplaySidebarPanel,
'search/': ReplaySidebarPanel,
'search:query': ReplaySidebarPanel,
'search/:query': ReplaySidebarPanel
},
});
var app = new App();

View File

@@ -56,7 +56,7 @@ class BattleLogDiv extends preact.Component {
}
}
export class BattlePanel extends preact.Component<{ id: string }> {
export class BattlePanel extends preact.Component<{ id: string, user: PSReplays['user'] }> {
result: {
uploadtime: number,
id: string,
@@ -66,9 +66,12 @@ export class BattlePanel extends preact.Component<{ id: string }> {
views: number,
rating: number,
private: number,
password: string,
password: string | null,
} | null | undefined = undefined;
resultError = '';
privacySaving: number | null = null;
manageError = '';
manageOpen = false;
battle!: Battle | null;
/** debug purposes */
lastUsedKeyCode = '0';
@@ -81,17 +84,28 @@ export class BattlePanel extends preact.Component<{ id: string }> {
}
override componentWillReceiveProps(nextProps: this['props']) {
if (this.stripQuery(this.props.id) !== this.stripQuery(nextProps.id)) {
if (this.replayID(this.props.id) !== this.replayID(nextProps.id)) {
this.manageOpen = false;
}
this.loadBattle(nextProps.id);
}
}
stripQuery(id: string) {
return id.includes('?') ? id.slice(0, id.indexOf('?')) : id;
}
replayID(id: string) {
const fullid = this.stripQuery(id);
if (!fullid.endsWith('pw')) return fullid;
const passwordIndex = fullid.lastIndexOf('-');
return passwordIndex > 0 ? fullid.slice(0, passwordIndex) : fullid;
}
loadBattle(id: string) {
if (this.battle) this.battle.destroy();
this.battle = null;
this.result = undefined;
this.resultError = '';
this.privacySaving = null;
this.manageError = '';
this.forceUpdate();
const elem = document.getElementById(`replaydata-${id}`);
@@ -367,9 +381,97 @@ export class BattlePanel extends preact.Component<{ id: string }> {
e?.preventDefault();
this.forceUpdate();
};
renderError(position: any) {
manageURL(extension: 'json' | 'log' | 'inputlog') {
return `${Net.defaultRoute}/${this.stripQuery(this.props.id)}.${extension}?manage`;
}
toggleManage = () => {
this.manageOpen = !this.manageOpen;
this.forceUpdate();
};
currentPrivacy() {
if (!this.result) return 0;
if (this.result.private === 1 && !this.result.password) return 2;
return this.result.private;
}
shareURL() {
if (!this.result) return '';
const fullid = this.result.id + (this.result.password ? `-${this.result.password}pw` : '');
return `https://psim.us/r/${fullid}`;
}
selectShareURL = (e: Event) => {
(e.currentTarget as HTMLInputElement).select();
};
changePrivacy = (e: Event) => {
const privateValue = Number((e.target as HTMLSelectElement).value);
if (!this.result || this.privacySaving !== null) return;
if (privateValue === 3 && !confirm("Delete this replay?")) return;
const replay = this.result;
this.privacySaving = privateValue;
this.manageError = '';
this.forceUpdate();
Net(`/api/replays/edit`).post({}, {
id: replay.id,
private: privateValue,
}).then(resultText => {
if (resultText.startsWith(']')) resultText = resultText.slice(1);
const result = JSON.parse(resultText);
if (result.actionerror) throw new Error(result.actionerror);
if (this.result !== replay) return;
replay.private = privateValue;
replay.password = result.password || null;
this.privacySaving = null;
const fullid = replay.id + (replay.password ? `-${replay.password}pw` : '');
PSRouter.replace(fullid);
PSRouter.update();
}).catch(error => {
if (this.result !== replay) return;
this.privacySaving = null;
this.manageError = error instanceof Error ? error.message : String(error);
this.forceUpdate();
});
};
renderManagement() {
if (!this.manageOpen) return null;
if (!this.props.user?.isLeader) {
return <p class="section message-error">You do not have permission to manage this replay.</p>;
}
const privacy = this.currentPrivacy();
return <section class="section" style={{ clear: 'right', marginTop: '12px', marginRight: '0', maxWidth: '500px' }}>
<button type="button" class="button" style="float:right" onClick={this.toggleManage}>
<i class="fa fa-times"></i> Close
</button>
<h2 style="margin-top:0">Manage replay</h2>
<p>
Privacy: {}
<button class="button button-first" disabled={privacy === 0} value={0} onClick={this.changePrivacy}>
Public
</button>
<button class="button button-middle" disabled={privacy === 1} value={1} onClick={this.changePrivacy}>
Private
</button>
<button class="button button-middle" disabled={privacy === 2} value={2} onClick={this.changePrivacy}>
Private (no password)
</button>
<button class="button button-last" disabled={privacy === 3} value={3} onClick={this.changePrivacy}>
Deleted
</button> {}
{this.privacySaving !== null && <em class="button cur">Saving...</em>}
</p>
{this.manageError && <p class="message-error">{this.manageError}</p>}
<p>
<a class="button" href={this.manageURL('json')}>JSON</a> {}
<a class="button" href={this.manageURL('log')}>Log</a> {}
<a class="button" href={this.manageURL('inputlog')}>Input log</a>
</p>
</section>;
}
renderError() {
if (this.resultError) {
return <div class={PSRouter.showingLeft() ? 'mainbar has-sidebar' : 'mainbar'} style={position}>
return <div class={PSRouter.showingLeft() ? 'mainbar has-sidebar' : 'mainbar'}>
<section class="section">
<h1>Error</h1>
<p>
@@ -383,7 +485,7 @@ export class BattlePanel extends preact.Component<{ id: string }> {
// never link to a nonexistent replay, but this might happen if e.g.
// a replay gets deleted or made private after you searched for it
// but before you clicked it.
return <div class={PSRouter.showingLeft() ? 'mainbar has-sidebar' : 'mainbar'} style={position}>
return <div class={PSRouter.showingLeft() ? 'mainbar has-sidebar' : 'mainbar'}>
<section class="section" style={{ maxWidth: '200px' }}>
<div style={{ textAlign: 'center' }}>
<img src="//play.pokemonshowdown.com/sprites/gen5ani/unown-n.gif" alt="" style={{ imageRendering: 'pixelated' }} />
@@ -506,36 +608,48 @@ export class BattlePanel extends preact.Component<{ id: string }> {
</label>
</p>
{this.result ? <h1>
<strong>{this.result.format}</strong>: {this.result.players.join(' vs. ')}
<strong>{this.result.format}</strong>: {}
{!!this.result.private && <i class="fa fa-lock" aria-hidden></i>} {this.result.players.join(' vs. ')}
</h1> : <h1>
<em>Loading...</em>
</h1>}
{!!this.result?.private && <p>
<strong><i class="fa fa-lock" aria-hidden></i> PRIVATE</strong> - make sure you have the owner's permission to share
</p>}
<p>
<label>
Short URL: <input
name="shareurl" type="text" class="textbox" readOnly size={60}
style="max-width:99%;box-sizing:border-box;field-sizing:content;padding-right:20px"
value={this.shareURL()} onFocus={this.selectShareURL}
/>
</label>
</p>
{this.result ? <p>
<a class="button" href="/download" onClick={this.clickDownload} style={{ float: 'right' }}>
<i class="fa fa-download" aria-hidden></i> Download
</a>
<span style={{ float: 'right' }}>
{this.props.user?.isLeader && <button
type="button" class={`button${this.manageOpen ? ' cur' : ''}`} onClick={this.toggleManage}
>
<i class="fa fa-wrench" aria-hidden></i> Manage
</button>} {}
<a class="button" href="/download" onClick={this.clickDownload}>
<i class="fa fa-download" aria-hidden></i> Download
</a>
</span>
{this.result.uploadtime ? new Date(this.result.uploadtime * 1000).toDateString() : "Unknown upload date"}
{this.result.rating ? [` | `, <em>Rating:</em>, ` ${this.result.rating}`] : ''}
{/* {} <code>{this.keyCode}</code> */}
</p> : <p>&nbsp;</p>}
{this.renderManagement()}
{!PSRouter.showingLeft() && <p>
<a href={PSRouter.href(PSRouter.leftLoc)} class="button"><i class="fa fa-caret-left" aria-hidden></i> More replays</a>
</p>}
</div>;
}
override render() {
let position: any = {};
if (PSRouter.showingLeft()) {
if (PSRouter.stickyRight) {
position = { position: 'sticky', top: '0' };
} else {
position = { position: 'sticky', bottom: '0' };
}
}
if (this.result === null) return this.renderError();
if (this.result === null) return this.renderError(position);
return <div class={PSRouter.showingLeft() ? 'mainbar has-sidebar' : 'mainbar'} style={position}>
return <div class={PSRouter.showingLeft() ? 'mainbar has-sidebar' : 'mainbar'}>
<div style={{ position: 'relative' }}>
<BattleDiv />
<BattleLogDiv />

View File

@@ -1,6 +1,6 @@
/** @jsx preact.h */
import preact from '../../play.pokemonshowdown.com/js/lib/preact';
import { PSRouter } from "./replays";
import { type PSReplays, PSRouter } from "./replays";
import { Net } from './utils';
type ID = Lowercase<string>;
@@ -37,7 +37,7 @@ function ReplayLink(props: {
</a>;
}
export class SearchPanel extends preact.Component<{ id: string }> {
export class SearchPanel extends preact.Component<{ id: string, user: PSReplays['user'] }> {
results: ReplayResult[] | null = null;
resultError: string | null = null;
format = '';
@@ -45,17 +45,8 @@ export class SearchPanel extends preact.Component<{ id: string }> {
isPrivate = false;
byRating = false;
page = 1;
loggedInUser: string | null = null;
loggedInUserIsSysop = false;
sort = 'date';
override componentDidMount() {
if (!Net.defaultRoute) Net(`/api/replays/check-login`).get().then(result => {
if (!result.startsWith(']')) return;
const [userid, sysop] = result.slice(1).split(',');
this.loggedInUser = userid;
this.loggedInUserIsSysop = !!sysop;
this.forceUpdate();
});
this.updateSearch(Net.decodeQuery(this.props.id));
}
override componentDidUpdate(previousProps: this['props']) {
@@ -171,8 +162,8 @@ export class SearchPanel extends preact.Component<{ id: string }> {
this.search('', '');
};
searchLoggedIn = (e: Event) => {
if (!this.loggedInUser) return; // shouldn't happen
this.base!.querySelector<HTMLInputElement>('input[name=user]')!.value = this.loggedInUser;
if (!this.props.user) return; // shouldn't happen
this.base!.querySelector<HTMLInputElement>('input[name=user]')!.value = this.props.user.id;
this.submitForm(e);
};
url(replay: ReplayResult) {
@@ -214,8 +205,10 @@ export class SearchPanel extends preact.Component<{ id: string }> {
<label>
Username: <small class="gray">(separate multiple usernames by commas)</small><br />
<input type="search" class="textbox" name="user" placeholder="(blank = any user)" size={20} /> {}
{this.loggedInUser &&
<button type="button" class="button" onClick={this.searchLoggedIn}>{this.loggedInUser}'s replays</button>}
{this.props.user &&
<button type="button" class="button" onClick={this.searchLoggedIn}>
{this.props.user.id}'s replays
</button>}
</label>
</p>
<p>

View File

@@ -3,6 +3,7 @@ import preact from '../../play.pokemonshowdown.com/js/lib/preact';
import { Net, PSModel } from './utils';
import { BattlePanel } from './replays-battle';
import { SearchPanel } from './replays-index';
import type { ID } from '../../play.pokemonshowdown.com/src/battle-dex';
declare const Config: any;
export const PSRouter = new class extends PSModel {
@@ -10,7 +11,7 @@ export const PSRouter = new class extends PSModel {
leftLoc: string | null = null;
rightLoc: string | null = null;
forceSinglePanel = false;
stickyRight = true;
shortViewport = false;
constructor() {
super();
const baseLocSlashIndex = document.location.href.lastIndexOf('/');
@@ -39,15 +40,16 @@ export const PSRouter = new class extends PSModel {
}
setSinglePanel(init?: boolean) {
const singlePanel = window.innerWidth < 1300;
const stickyRight = (window.innerHeight > 614);
if (this.forceSinglePanel !== singlePanel || this.stickyRight !== stickyRight) {
const shortViewport = window.innerHeight <= 660;
if (this.forceSinglePanel !== singlePanel || this.shortViewport !== shortViewport) {
this.forceSinglePanel = singlePanel;
this.stickyRight = stickyRight;
this.shortViewport = shortViewport;
if (!init) this.update();
}
}
push(href: string): boolean {
if (!href.startsWith(this.baseLoc)) return false;
if (href.includes('.json') || href.includes('.log') || href.includes('.inputlog')) return false;
if (this.go(href)) {
window.history?.pushState([this.leftLoc, this.rightLoc], '', href);
@@ -95,6 +97,7 @@ export const PSRouter = new class extends PSModel {
export class PSReplays extends preact.Component {
static darkMode: 'dark' | 'light' | 'auto' = 'auto';
user: { id: ID, isLeader: boolean } | null = null;
static updateDarkMode() {
let dark = this.darkMode === 'dark' ? 'dark' : '';
if (this.darkMode === 'auto') {
@@ -122,15 +125,21 @@ export class PSReplays extends preact.Component {
const data = JSON.parse(response);
Object.assign(Config.customcolors, data);
});
if (!Net.defaultRoute) Net(`/api/replays/check-login`).get().then(result => {
if (!result.startsWith(']')) return;
const [userid, isLeader] = result.slice(1).split(',');
this.user = { id: userid as ID, isLeader: !!isLeader };
this.forceUpdate();
});
}
override render() {
const position = PSRouter.showingLeft() && PSRouter.showingRight() && !PSRouter.stickyRight ?
{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' } : {};
return <div
class={'bar-wrapper' + (PSRouter.showingLeft() && PSRouter.showingRight() ? ' has-sidebar' : '')} style={position}
>
{PSRouter.showingLeft() && <SearchPanel id={PSRouter.leftLoc!} />}
{PSRouter.showingRight() && <BattlePanel id={PSRouter.rightLoc!} />}
const twoColumn = PSRouter.showingLeft() && PSRouter.showingRight();
const className = `bar-wrapper${twoColumn ? ' has-sidebar' : ''}${
twoColumn && PSRouter.shortViewport ? ' short-viewport' : ''
}`;
return <div class={className}>
{PSRouter.showingLeft() && <SearchPanel id={PSRouter.leftLoc!} user={this.user} />}
{PSRouter.showingRight() && <BattlePanel id={PSRouter.rightLoc!} user={this.user} />}
<div style={{ clear: 'both' }}></div>
</div>;
}

View File

@@ -62,6 +62,30 @@
.mainbar.has-sidebar {
margin-left: 330px;
}
.bar-wrapper.has-sidebar:not(.short-viewport) .mainbar {
position: sticky;
top: 0;
}
.bar-wrapper.has-sidebar.short-viewport {
max-width: none;
}
.bar-wrapper.has-sidebar.short-viewport .sidebar {
box-sizing: border-box;
position: sticky;
top: 0;
max-height: 100vh;
overflow-y: auto;
}
@media (min-width: 1431px) and (max-width: 1510px) {
.bar-wrapper.has-sidebar.short-viewport .sidebar {
width: calc(50% - 395px);
padding-left: calc(50% - 715px);
}
.bar-wrapper.has-sidebar.short-viewport .mainbar {
margin-right: calc(50% - 715px);
margin-left: calc(50% - 385px);
}
}
@media (min-width: 1511px) {
.sidebar {
width: 400px;
@@ -72,6 +96,14 @@
.mainbar.has-sidebar {
margin-left: 410px;
}
.bar-wrapper.has-sidebar.short-viewport .sidebar {
width: calc(50% - 355px);
padding-left: calc(50% - 755px);
}
.bar-wrapper.has-sidebar.short-viewport .mainbar {
margin-right: calc(50% - 755px);
margin-left: calc(50% - 345px);
}
}
.section.first-section {
margin-top: 9px;

View File

@@ -1,77 +0,0 @@
<?php
include_once dirname(__FILE__).'/wrapper.inc.php';
class Panels {
var $output = 'normal';
var $tab = false;
var $root;
var $name;
function __construct() {
global $psconfig;
$this->root = '/';
$this->name = 'Showdown';
switch (@$_REQUEST['output']) {
case 'html':
case 'json':
$this->output = $_REQUEST['output'];
break;
}
}
var $pagetitle = false;
function setPageTitle($title) {
if ($this->pagetitle === false) {
$this->pagetitle = $title;
}
}
var $pagedescription = false;
function setPageDescription($description) {
if ($this->pagedescription === false) {
$this->pagedescription = $description;
}
}
function setTab($tab) {
if ($this->tab === false) {
$this->tab = $tab;
}
}
var $depth = 0;
var $scriptsIncluded = false;
function start() {
$this->depth++;
if ($this->depth != 1) {
return;
}
if ($this->output === 'normal') {
ThemeHeaderTemplate();
}
}
function noScripts() {
$this->scriptsIncluded = true;
}
function scripts() {
if ($this->output !== 'normal') return;
if ($this->scriptsIncluded) return;
$this->scriptsIncluded = true;
ThemeScriptsTemplate();
}
function end() {
$this->depth--;
if ($this->depth != 0) {
return;
}
if ($this->output === 'normal') {
ThemeFooterTemplate();
}
}
}
$panels = new Panels();

View File

@@ -1,110 +0,0 @@
<?php
// NOTE: this is Old Replays. Mostly unused except for `/manage`
if ((substr($_SERVER['REMOTE_ADDR'],0,11) === '69.164.163.') ||
(substr(@$_SERVER['HTTP_X_FORWARDED_FOR'],0,11) === '69.164.163.')) {
die('website disabled');
}
/********************************************************************
* Header
********************************************************************/
function ThemeHeaderTemplate() {
global $panels;
?>
<!DOCTYPE html>
<html><head>
<meta charset="utf-8" />
<title><?php if ($panels->pagetitle) echo htmlspecialchars($panels->pagetitle).' - '; ?>Pok&eacute;mon Showdown</title>
<?php if ($panels->pagedescription) { ?>
<meta name="description" content="<?php echo htmlspecialchars($panels->pagedescription); ?>" />
<?php } ?>
<meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=IE8" />
<link rel="stylesheet" href="//play.pokemonshowdown.com/style/font-awesome.css" />
<link rel="stylesheet" href="//pokemonshowdown.com/theme/panels.css" />
<link rel="stylesheet" href="//pokemonshowdown.com/theme/main.css" />
<link rel="stylesheet" href="//play.pokemonshowdown.com/style/battle.css?" />
<link rel="stylesheet" href="//play.pokemonshowdown.com/style/replay.css?" />
<link rel="stylesheet" href="//play.pokemonshowdown.com/style/utilichart.css?" />
<!-- Workarounds for IE bugs to display trees correctly. -->
<!--[if lte IE 6]><style> li.tree { height: 1px; } </style><![endif]-->
<!--[if IE 7]><style> li.tree { zoom: 1; } </style><![endif]-->
<script type="text/javascript">
var _gaq = _gaq || [];
_gaq.push(['_setAccount', 'UA-26211653-1']);
_gaq.push(['_setDomainName', 'pokemonshowdown.com']);
_gaq.push(['_setAllowLinker', true]);
_gaq.push(['_trackPageview']);
(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
</script>
</head><body>
<div class="pfx-topbar">
<div class="header">
<ul class="nav">
<li><a class="button nav-first<?php if ($panels->tab === 'home') echo ' cur'; ?>" href="//pokemonshowdown.com/"><img src="//pokemonshowdown.com/images/pokemonshowdownbeta.png" alt="Pok&eacute;mon Showdown! (beta)" /> Home</a></li>
<li><a class="button<?php if ($panels->tab === 'pokedex') echo ' cur'; ?>" href="//dex.pokemonshowdown.com/">Pok&eacute;dex</a></li>
<li><a class="button<?php if ($panels->tab === 'replay') echo ' 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>
</ul>
<ul class="nav nav-play">
<li><a class="button greenbutton nav-first nav-last" href="http://play.pokemonshowdown.com/">Play</a></li>
</ul>
<div style="clear:both"></div>
</div>
</div>
<?php
}
/********************************************************************
* Footer
********************************************************************/
function ThemeScriptsTemplate() {
?>
<script src="//play.pokemonshowdown.com/js/lib/jquery-1.11.0.min.js"></script>
<script src="//play.pokemonshowdown.com/js/lib/lodash.core.js"></script>
<script src="//play.pokemonshowdown.com/js/lib/backbone.js"></script>
<script src="//dex.pokemonshowdown.com/js/panels.js"></script>
<?php
}
function ThemeFooterTemplate() {
global $panels;
?>
<?php $panels->scripts(); ?>
<script src="//play.pokemonshowdown.com/js/lib/jquery-cookie.js"></script>
<script src="//play.pokemonshowdown.com/js/lib/html-sanitizer-minified.js"></script>
<script src="//play.pokemonshowdown.com/js/battle-sound.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/data/pokedex-mini.js?"></script>
<script src="//play.pokemonshowdown.com/data/pokedex-mini-bw.js?"></script>
<script src="//play.pokemonshowdown.com/data/graphics.js?"></script>
<script src="//play.pokemonshowdown.com/data/pokedex.js?"></script>
<script src="//play.pokemonshowdown.com/data/items.js?"></script>
<script src="//play.pokemonshowdown.com/data/moves.js?"></script>
<script src="//play.pokemonshowdown.com/data/abilities.js?"></script>
<script src="//play.pokemonshowdown.com/data/teambuilder-tables.js?"></script>
<script src="//play.pokemonshowdown.com/js/battle-tooltips.js?"></script>
<script src="//play.pokemonshowdown.com/js/battle.js?"></script>
<script src="/js/replay.js?"></script>
</body></html>
<?php
}