New Replays: Two column support

Old Replays's two column support was the Panel system most notably
shown off by PSDex. It was definitely very nice but a bit hard to
port to Preact.

This new one, written from scratch, has a few niceities:

- topbar can scroll offscreen
- only one scrollable area (scroll wheel works everywhere, and
  the PageUp/PageDown/Spacebar keys are unambiguous)
- uses very little JavaScript when resizing; most of the layout
  work is done by CSS

With the drawbacks:

- no animation
- only two columns supported (not relevant to Replays which has
  never used over two columns)
- uses a lot of modern CSS (overflow: sticky, and flexbox) but
  should degrade gracefully
This commit is contained in:
Guangcong Luo 2023-10-28 16:49:05 +00:00
parent 689531d3b4
commit 3bb2271bbc
5 changed files with 225 additions and 56 deletions

View File

@ -1639,7 +1639,11 @@ export class BattleScene implements BattleSceneStub {
}
destroy() {
this.log.destroy();
if (this.$frame) this.$frame.empty();
if (this.$frame) {
this.$frame.empty();
// listeners set by BattleTooltips
this.$frame.off();
}
if (this.bgm) {
this.bgm.destroy();
this.bgm = null;

View File

@ -55,6 +55,33 @@
.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;
}
.section.first-section {
margin-top: 9px;
}
.blocklink small {
white-space: normal;
}
.button {
vertical-align: middle;
}
</style>
<div>

View File

@ -1,6 +1,6 @@
/** @jsx preact.h */
import preact from 'preact';
import {Net} from './utils';
import {Net, PSModel} from './utils';
import {Battle} from '../../../src/battle';
import {BattleSound} from '../../../src/battle-sound';
import $ from 'jquery';
@ -53,10 +53,6 @@ class SearchPanel extends preact.Component {
loggedInUserIsSysop = false;
sort = 'date';
override componentDidMount() {
Net('https://replay.pokemonshowdown.com/search.json').get().then(result => {
this.results = JSON.parse(result);
this.forceUpdate();
});
Net('check-login.php').get().then(result => {
if (result.charAt(0) !== ']') return;
const [userid, sysop] = result.slice(1).split(',');
@ -64,7 +60,10 @@ class SearchPanel extends preact.Component {
this.loggedInUserIsSysop = !!sysop;
this.forceUpdate();
});
this.base!.querySelector<HTMLInputElement>('input[name=private]')!.checked = true;
const user = decodeURIComponent(/\buser=([^&]*)/.exec(PSRouter.leftLoc || '')?.[1] || '');
const format = decodeURIComponent(/\bformat=([^&]*)/.exec(PSRouter.leftLoc || '')?.[1] || '');
const isPrivate = PSRouter.leftLoc!.includes('private=1');
this.search(user, format, isPrivate);
}
parseResponse(response: string, isPrivate?: boolean) {
this.results = null;
@ -84,12 +83,26 @@ class SearchPanel extends preact.Component {
}
this.results = results;
}
search(format: string, user: string, isPrivate?: boolean) {
search(user: string, format: string, isPrivate?: boolean) {
this.base!.querySelector<HTMLInputElement>('input[name=user]')!.value = user;
this.base!.querySelector<HTMLInputElement>('input[name=format]')!.value = format;
this.base!.querySelectorAll<HTMLInputElement>('input[name=private]')[isPrivate ? 1 : 0]!.checked = true;
if (!format && !user) return this.recent();
this.format = format;
this.user = user;
this.format = format;
this.results = null;
this.resultError = null;
if (!format && !user) {
PSRouter.replace('')
} else {
PSRouter.replace('?' + Net.encodeQuery({
user: user || undefined,
format: format || undefined,
private: isPrivate ? '1' : undefined,
}));
}
this.forceUpdate();
Net(`/api/replays/${isPrivate ? 'searchprivate' : 'search'}`).get({
query: {username: this.user, format: this.format},
@ -123,7 +136,7 @@ class SearchPanel extends preact.Component {
const format = this.base!.querySelector<HTMLInputElement>('input[name=format]')?.value || '';
const user = this.base!.querySelector<HTMLInputElement>('input[name=user]')?.value || '';
const isPrivate = !this.base!.querySelector<HTMLInputElement>('input[name=private]')?.checked;
this.search(format, user, isPrivate);
this.search(user, format, isPrivate);
};
cancelForm = (e: Event) => {
e.preventDefault();
@ -137,6 +150,16 @@ class SearchPanel extends preact.Component {
url(replay: ReplayResult) {
return replay.id + (replay.password ? `-${replay.password}pw` : '');
}
formatid(replay: ReplayResult) {
let formatid = replay.format;
if (!formatid.startsWith('gen') || !/[0-9]/.test(formatid.charAt(3))) {
formatid = 'gen6' + formatid;
}
if (!/^gen[0-9]+-/.test(formatid)) {
formatid = formatid.slice(0, 4) + '-' + formatid.slice(4);
}
return formatid;
}
override render() {
const searchResults = <ul class="linklist">
{(this.resultError && <li>
@ -147,13 +170,13 @@ class SearchPanel extends preact.Component {
</li>) ||
(this.results?.map(result => <li>
<a href={this.url(result)} class="blocklink">
<small>[{result.format}]<br /></small>
<small>[{this.formatid(result)}]<br /></small>
<strong>{result.p1}</strong> vs. <strong>{result.p2}</strong>
</a>
</li>))}
</ul>;
const activelySearching = !!(this.format || this.user);
return <div><section class="section">
return <div class={PSRouter.showingRight() ? 'sidebar' : ''}><section class="section first-section">
<h1>Search replays</h1>
<form onSubmit={this.submitForm}>
<p>
@ -173,7 +196,7 @@ class SearchPanel extends preact.Component {
<button type="submit" class="button"><i class="fa fa-search" aria-hidden></i> <strong>Search</strong></button> {}
{activelySearching && <button class="button" onClick={this.cancelForm}>Cancel</button>}
</p>
{activelySearching && <h2>Results</h2>}
{activelySearching && <h1 aria-label="Results"></h1>}
{activelySearching && searchResults}
</form>
</section>{!activelySearching && <FeaturedReplays />}{!activelySearching && <section class="section">
@ -204,17 +227,17 @@ class FeaturedReplays extends preact.Component {
<ul class="linklist">
<li><h2>Fun</h2></li>
<li><a href="oumonotype-82345404" class="blocklink">
<small>[oumonotype]<br /></small>
<small>[gen6-oumonotype]<br /></small>
<strong>kdarewolf</strong> vs. <strong>Onox</strong>
<small><br />Protean + prediction</small>
</a></li>
<li><a href="anythinggoes-218380995" class="blocklink">
<small>[anythinggoes]<br /></small>
<small>[gen6-anythinggoes]<br /></small>
<strong>Anta2</strong> vs. <strong>dscottnew</strong>
<small><br />Cheek Pouch</small>
</a></li>
<li><a href="uberssuspecttest-147833524" class="blocklink">
<small>[ubers]<br /></small>
<small>[gen6-ubers]<br /></small>
<strong>Metal Brellow</strong> vs. <strong>zig100</strong>
<small><br />Topsy-Turvy</small>
</a></li>
@ -222,43 +245,43 @@ class FeaturedReplays extends preact.Component {
<button class="button" onClick={this.showMoreFun}>More <i class="fa fa-caret-right" aria-hidden></i></button>
</li>}
{this.moreFun && <li><a href="smogondoubles-75588440" class="blocklink">
<small>[smogondoubles]<br /></small>
<small>[gen6-smogondoubles]<br /></small>
<strong>jamace6</strong> vs. <strong>DubsWelder</strong>
<small><br />Garchomp sweeps 11 pokemon</small>
</a></li>}
{this.moreFun && <li><a href="ou-20651579" class="blocklink">
<small>[ou]<br /></small>
<small>[gen5-ou]<br /></small>
<strong>RainSeven07</strong> vs. <strong>my body is regi</strong>
<small><br />An entire team based on Assist V-create</small>
</a></li>}
{this.moreFun && <li><a href="balancedhackmons7322360" class="blocklink">
<small>[balancedhackmons]<br /></small>
<small>[gen5-balancedhackmons]<br /></small>
<strong>a ver</strong> vs. <strong>Shuckie</strong>
<small><br />To a ver's frustration, PP stall is viable in Balanced Hackmons</small>
</a></li>}
<h2>Competitive</h2>
<li><a href="doublesou-232753081" class="blocklink">
<small>[doubles ou]<br /></small>
<small>[gen6-doublesou]<br /></small>
<strong>Electrolyte</strong> vs. <strong>finally</strong>
<small><br />finally steals Electrolyte's spot in the finals of the Doubles Winter Seasonal by outplaying Toxic Aegislash.</small>
</a></li>
<li><a href="smogtours-gen5ou-59402" class="blocklink">
<small>[bw ou]<br /></small>
<small>[gen5-ou]<br /></small>
<strong>Reymedy</strong> vs. <strong>Leftiez</strong>
<small><br />Reymedy's superior grasp over BW OU lead to his claim of victory over Leftiez in the No Johns Tournament.</small>
</a></li>
<li><a href="smogtours-gen3ou-56583" class="blocklink">
<small>[adv ou]<br /></small>
<small>[gen3-ou]<br /></small>
<strong>pokebasket</strong> vs. <strong>Alf'</strong>
<small><br />pokebasket proved Blissey isn't really one to take a Focus Punch well in his victory match over Alf' in the Fuck Trappers ADV OU tournament.</small>
</a></li>
<li><a href="smogtours-ou-55891" class="blocklink">
<small>[oras ou]<br /></small>
<small>[gen6-ou]<br /></small>
<strong>Marshall.Law</strong> vs. <strong>Malekith</strong>
<small><br />In a "match full of reverses", Marshall.Law takes on Malekith in the finals of It's No Use.</small>
</a></li>
<li><a href="smogtours-ubers-54583" class="blocklink">
<small>[custom]<br /></small>
<small>[gen6-custom]<br /></small>
<strong>hard</strong> vs. <strong>panamaxis</strong>
<small><br />Dark horse panamaxis proves his worth as the rightful winner of The Walkthrough Tournament in this exciting final versus hard.</small>
</a></li>
@ -266,27 +289,27 @@ class FeaturedReplays extends preact.Component {
<button class="button" onClick={this.showMoreCompetitive}>More <i class="fa fa-caret-right" aria-hidden></i></button>
</li>}
{this.moreCompetitive && <li><a href="smogtours-ubers-34646" class="blocklink">
<small>[oras ubers]<br /></small>
<small>[gen6-ubers]<br /></small>
<strong>steelphoenix</strong> vs. <strong>Jibaku</strong>
<small><br />In this SPL Week 4 battle, Jibaku's clever plays with Mega Sableye keep the momentum mostly in his favor.</small>
</a></li>}
{this.moreCompetitive && <li><a href="smogtours-uu-36860" class="blocklink">
<small>[oras uu]<br /></small>
<small>[gen6-uu]<br /></small>
<strong>IronBullet93</strong> vs. <strong>Laurel</strong>
<small><br />Laurel outplays IronBullet's Substitute Tyrantrum with the sly use of a Shuca Berry Cobalion, but luck was inevitably the deciding factor in this SPL Week 6 match.</small>
</a></li>}
{this.moreCompetitive && <li><a href="smogtours-gen5ou-36900" class="blocklink">
<small>[bw ou]<br /></small>
<small>[gen5-ou]<br /></small>
<strong>Lowgock</strong> vs. <strong>Meridian</strong>
<small><br />This SPL Week 6 match features impressive plays, from Jirachi sacrificing itself to paralysis to avoid a burn to some clever late-game switches.</small>
</a></li>}
{this.moreCompetitive && <li><a href="smogtours-gen4ou-36782" class="blocklink">
<small>[dpp ou]<br /></small>
<small>[gen4-ou]<br /></small>
<strong>Heist</strong> vs. <strong>liberty32</strong>
<small><br />Starting out as an entry hazard-filled stallfest, this close match is eventually decided by liberty32's efficient use of Aerodactyl.</small>
</a></li>}
{this.moreCompetitive && <li><a href="randombattle-213274483" class="blocklink">
<small>[randombattle]<br /></small>
<small>[gen6-randombattle]<br /></small>
<strong>The Immortal</strong> vs. <strong>Amphinobite</strong>
<small><br />Substitute Lugia and Rotom-Fan take advantage of Slowking's utility and large HP stat, respectively, in this high ladder match.</small>
</a></li>}
@ -330,7 +353,19 @@ class BattlePanel extends preact.Component<{id: string}> {
battle: Battle | null;
speed = 'normal';
override componentDidMount() {
Net(`https://replay.pokemonshowdown.com/${this.props.id}.json`).get().then(result => {
this.loadBattle(this.props.id);
showAd('LeaderboardBTF');
}
override componentWillReceiveProps(nextProps) {
if (this.props.id !== nextProps.id) {
this.loadBattle(nextProps.id);
}
}
loadBattle(id: string) {
if (this.battle) this.battle.destroy();
this.battle = null;
this.result = undefined;
Net(`https://replay.pokemonshowdown.com/${id}.json`).get().then(result => {
const replay: NonNullable<BattlePanel['result']> = JSON.parse(result);
this.result = replay;
const $base = $(this.base!);
@ -348,12 +383,14 @@ class BattlePanel extends preact.Component<{id: string}> {
this.battle.subscribe(_ => {
this.forceUpdate();
});
if (PSRouter.rightLoc!.includes('?p2')) {
this.battle.switchSides();
}
this.forceUpdate();
}).catch(_ => {
this.result = null;
this.forceUpdate();
});
showAd('LeaderboardBTF');
}
override componentWillUnmount(): void {
this.battle?.destroy();
@ -419,7 +456,7 @@ class BattlePanel extends preact.Component<{id: string}> {
this.battle?.setMute(muted === 'off');
};
renderNotFound() {
return <div><section class="section" style={{maxWidth: '200px'}}>
return <div class={PSRouter.showingLeft() ? 'mainbar has-sidebar' : 'mainbar'}><section class="section" style={{maxWidth: '200px'}}>
{/* <div style={{margin: '0 -20px'}}>
<img src="//play.pokemonshowdown.com/sprites/dex/unown-n.png" alt="" width={120} height={120} style={{marginRight: '-60px'}} />
<img src="//play.pokemonshowdown.com/sprites/dex/unown-o.png" alt="" width={120} height={120} style={{marginRight: '-60px'}} />
@ -458,11 +495,21 @@ class BattlePanel extends preact.Component<{id: string}> {
const atEnd = this.battle?.atQueueEnd;
const atStart = !this.battle?.started;
if (this.result === null) return this.renderNotFound();
return <div style={{maxWidth: 1100, position: 'relative', margin: '0 auto'}}><div>
let position: any = {};
if (PSRouter.showingLeft()) {
if (PSRouter.stickyRight) {
position = {position: 'sticky', top: '0'};
} else {
position = {position: 'sticky', bottom: '0'};
}
}
return <div class={PSRouter.showingLeft() ? 'mainbar has-sidebar' : 'mainbar'} style={position}><div style={{position: 'relative'}}>
<BattleDiv />
<BattleLogDiv />
<div style={{paddingTop: 10}}>
<p>
<p>
{atEnd ?
<button onClick={this.replay} class="button" style={{width: '5em'}}>
<i class="fa fa-undo"></i><br />Replay
@ -487,9 +534,7 @@ class BattlePanel extends preact.Component<{id: string}> {
</button>
<button class={"button button-last" + (atEnd ? " disabled" : "")} onClick={this.lastTurn}>
<i class="fa fa-fast-forward"></i><br />Skip to end
</button>
</p>
<p>
</button> {}
<button class="button" onClick={this.switchSides}>
<i class="fa fa-random"></i> Switch sides
</button> {}
@ -516,31 +561,113 @@ class BattlePanel extends preact.Component<{id: string}> {
</select>
</label>
</p>
<h1 style={{fontWeight: 'normal'}}><strong>{this.result?.format}</strong>: {this.result?.p1} vs. {this.result?.p2}</h1>
<p>
Uploaded: {new Date(this.result?.uploadtime! * 1000 || 0).toDateString()}
{this.result?.rating ? ` | Rating: ${this.result?.rating}` : ''}
</p>
{!this.result && <h1><em>Loading...</em></h1>}
{this.result && <h1 style={{fontWeight: 'normal'}}>
<strong>{this.result.format}</strong>: {this.result.p1} vs. {this.result.p2}
</h1>}
{this.result && <p>
{this.result.uploadtime ? new Date(this.result.uploadtime * 1000).toDateString() : "Unknown upload date"}
{this.result.rating ? ` | Rating: ${this.result.rating}` : ''}
</p>}
{!PSRouter.showingLeft() && <p>
<a href="." class="button"><i class="fa fa-caret-left"></i> More replays</a>
</p>}
</div>
<div id="LeaderboardBTF"></div>
</div></div>;
}
}
const PSRouter = new class extends PSModel {
baseLoc: string;
leftLoc: string | null = null;
rightLoc: string | null = null;
forceSinglePanel = false;
stickyRight = true;
constructor() {
super();
const baseLocSlashIndex = document.location.href.lastIndexOf('/');
this.baseLoc = document.location.href.slice(0, baseLocSlashIndex + 1);
this.go(document.location.href);
this.setSinglePanel(true);
if (window.history) window.addEventListener('popstate', e => {
PSRouter.popState(e);
this.update();
});
window.onresize = () => {
PSRouter.setSinglePanel();
};
}
showingLeft() {
return this.leftLoc !== null && (!this.forceSinglePanel || this.rightLoc === null);
}
showingRight() {
return this.rightLoc !== null;
}
setSinglePanel(init?: boolean) {
const singlePanel = window.innerWidth < 1300;
const stickyRight = (window.innerHeight > 614);
if (this.forceSinglePanel !== singlePanel || this.stickyRight !== stickyRight) {
this.forceSinglePanel = singlePanel;
this.stickyRight = stickyRight;
if (!init) this.update();
}
}
push(href: string): boolean {
if (!href.startsWith(this.baseLoc)) return false;
if (this.go(href)) {
window.history?.pushState([this.leftLoc, this.rightLoc], '', href);
}
return true;
}
/** returns whether the URL should change */
go(href: string): boolean {
if (!href.startsWith(this.baseLoc)) return false;
const loc = href.slice(this.baseLoc.length);
if (!loc || loc.startsWith('?')) {
this.leftLoc = loc;
if (this.forceSinglePanel) {
this.rightLoc = null;
} else {
return this.rightLoc === null;
}
} else {
this.rightLoc = loc;
}
return true;
}
replace(loc: string) {
const href = this.baseLoc + loc;
if (this.go(href)) {
window.history?.replaceState([this.leftLoc, this.rightLoc], '', href);
}
return true;
}
popState(e: PopStateEvent) {
if (Array.isArray(e.state)) {
const [leftLoc, rightLoc] = e.state;
this.leftLoc = leftLoc;
this.rightLoc = rightLoc;
if (this.forceSinglePanel) this.leftLoc = null;
} else {
this.leftLoc = null;
this.rightLoc = null;
this.go(document.location.href);
}
this.update();
}
};
class PSReplays extends preact.Component {
override componentDidMount() {
PSRouter.subscribe(() => this.forceUpdate());
if (window.history) {
window.addEventListener('popstate', e => {
this.forceUpdate();
});
const baseLocSlashIndex = document.location.href.lastIndexOf('/');
const baseLoc = document.location.href.slice(0, baseLocSlashIndex + 1);
this.base!.addEventListener('click', e => {
let el = e.target as HTMLElement;
for (; el; el = el.parentNode as HTMLElement) {
if (el.tagName === 'A' && (el as HTMLAnchorElement).href.startsWith(baseLoc)) {
const href = (el as HTMLAnchorElement).href;
history.pushState(null, '', href);
if (el.tagName === 'A' && PSRouter.push((el as HTMLAnchorElement).href)) {
e.preventDefault();
e.stopImmediatePropagation();
this.forceUpdate();
@ -551,10 +678,13 @@ class PSReplays extends preact.Component {
}
}
override render() {
return <div>{
document.location.pathname === '/replays/' ?
<SearchPanel /> : <BattlePanel id={document.location.pathname.slice(9)} />
}</div>;
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 />}
{PSRouter.showingRight() && <BattlePanel id={PSRouter.rightLoc!} />}
<div style={{clear: 'both'}}></div>
</div>;
}
}

View File

@ -68,7 +68,7 @@ if (!window.console) {
*********************************************************************/
export interface PostData {
[key: string]: string | number;
[key: string]: string | number | undefined;
}
export interface NetRequestOptions {
method?: 'GET' | 'POST';
@ -159,6 +159,7 @@ 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]);
}

View File

@ -49,7 +49,7 @@ header {
left: 0;
top: 0;
}
.nav a, .dark .nav a {
.nav a {
color: white;
background: #3a4f88;
background: linear-gradient(to bottom, #4c63a3, #273661);
@ -63,6 +63,13 @@ header {
margin-left: -1px;
font-size: 11pt;
}
.dark .nav a {
/* make sure other styling doesn't override */
color: white;
background: #3a4f88;
background: linear-gradient(to bottom, #4c63a3, #273661);
border: 1px solid #222c4a;
}
.nav a:hover, .dark .nav a:hover {
background: linear-gradient(to bottom, #5a77c7, #2f447f);
border: 1px solid #222c4a;