diff --git a/.eslintignore b/.eslintignore index a7b160847..abf74f7d2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -10,6 +10,11 @@ node_modules/ /js/battle-dex.js /js/battle-dex-data.js /js/battle-animations-moves.js -/js/battle-scene-stub.js /js/battle-animations.js /js/battle-tooltips.js +/js/battle-scene-stub.js +/js/client-core.js +/js/client-main.js +/js/client-frame.js +/js/panel-mainmenu.js +/js/panel-rooms.js diff --git a/.gitignore b/.gitignore index f36d7417e..ea73073e9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,9 @@ package-lock.json /js/battle-animations.js /js/battle-tooltips.js /js/battle-scene-stub.js +/js/client-core.js +/js/client-frame.js +/js/client-main.js +/js/panel-mainmenu.js .vscode diff --git a/src/battle-dex-data.ts b/src/battle-dex-data.ts index f8c2ff377..9cd94dd08 100644 --- a/src/battle-dex-data.ts +++ b/src/battle-dex-data.ts @@ -14,6 +14,9 @@ * @license MIT */ +/** + * String that contains only lowercase alphanumeric characters. + */ type ID = string & {__isID: true}; const BattleNatures: {[k in NatureName]: {plus?: StatName, minus?: StatName}} = { diff --git a/src/battle-dex.ts b/src/battle-dex.ts index 1a5bf967b..d12315d63 100644 --- a/src/battle-dex.ts +++ b/src/battle-dex.ts @@ -12,6 +12,8 @@ * - The client as a whole is AGPLv3 * - The battle replay/animation engine (battle-*.ts) by itself is MIT * + * Compiled into battledata.js which includes all dependencies + * * @author Guangcong Luo * @license MIT */ diff --git a/src/client-core.ts b/src/client-core.ts new file mode 100644 index 000000000..67f381f8d --- /dev/null +++ b/src/client-core.ts @@ -0,0 +1,356 @@ +/** + * Client core + * + * No dependencies. + * Does three unrelated things: + * 1. sets up polyfills where necessary + * 2. sets up PS's model base classes + * 3. sets up the model and view for PS's backgrounds + * + * The background is mostly here so the new background can be loaded ASAP. + * + * @author Guangcong Luo + * @license AGPLv3 + */ + +/********************************************************************** + * Polyfills + *********************************************************************/ + + if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function (searchElement, fromIndex) { + for (var 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 (!Object.assign) { + Object.assign = function (thing: any, rest: any) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + for (var 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)(); + }; +} + +/********************************************************************** + * PS Models + *********************************************************************/ +// PS's model classes are defined here + +const PSURL = (() => { + let prefix = ''; + if (document.location.protocol !== 'http:') prefix = 'https:'; + return prefix + '//play.pokemonshowdown.com/'; +})(); + +class PSSubscription { + observable: PSModel | PSStreamModel; + listener: Function; + constructor(observable: PSModel | PSStreamModel, listener: Function) { + 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. + */ +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. + */ +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; + } + update(value: T) { + if (!this.subscriptions.length) { + // save updates for later + this.updates.push(value); + } + for (const subscription of this.subscriptions) { + subscription.listener(value); + } + } +} + +// type JSONObject = {[k: string]: JSONValue}; +// type JSONArray = JSONValue[]; +// type JSONValue = number | string | boolean | null | JSONObject | JSONArray; + +/********************************************************************** + * Background Model + *********************************************************************/ + +declare const ColorThief: any; + +/** + * PS background model. Separate from PSPrefs because unlike prefs, + * backgrounds can be set separately per server, instead of being + * shared among all servers. + * + * Streams the current URL + */ +const PSBackground = new class extends PSStreamModel { + id = ''; + curId = ''; + attrib: {url: string, title: string, artist: string} | null = null; + changeCount = 0; + menuColors: string[] | null = null; + + constructor() { + super(); + try { + let bg = localStorage.getItem('showdown_bg')!.split('\n'); + if (bg.length === 1) { + this.set('', bg[0]); + } else if (bg.length === 2) { + this.set(bg[0], bg[1]); + } else if (bg.length >= 7) { + this.set(bg[0], bg[1], bg.slice(2)); + } + } catch (e) {} + } + save(bgUrl: string) { + if (this.id !== 'custom') { + localStorage.setItem('showdown_bg', this.id); + } else if (this.menuColors) { + localStorage.setItem('showdown_bg', bgUrl + '\n' + this.id + '\n' + this.menuColors.join('\n')); + } + } + + set(bgUrl: string, bgid: string, menuColors: string[] | null = null) { + // id + this.id = bgid; + + // curid + if (!bgid) { + if (location.host === 'smogtours.psim.us') { + bgid = 'shaymin'; + } else if (location.host === 'play.pokemonshowdown.com') { + const bgs = ['horizon', 'ocean', 'waterfall', 'shaymin', 'charizards']; + bgid = bgs[Math.floor(Math.random() * 5)]; + if (bgid === this.curId) bgid = bgs[Math.floor(Math.random() * 5)]; + } + } + this.curId = bgid; + + if (!bgUrl) { + bgUrl = (bgid === 'solidblue' ? '#344b6c' : PSURL + 'fx/client-bg-' + bgid + '.jpg'); + } + + // April Fool's 2016 - Digimon theme + // bgid = 'digimon'; + // bgUrl = PSURL + 'sprites/afd/digimonbg.jpg'; + + this.changeCount++; + + // menuColors, attrib + let attrib = null; + switch (bgid) { + case 'horizon': + menuColors = ["318.87640449438203,35.177865612648226%", "216,46.2962962962963%", "221.25,32.25806451612904%", "197.8021978021978,52.60115606936417%", "232.00000000000003,19.480519480519483%", "228.38709677419354,60.7843137254902%"]; + attrib = {url: 'https://vtas.deviantart.com/art/Pokemon-Horizon-312267168', title: 'Horizon', artist: 'Vivian Zou'}; + break; + case 'ocean': + menuColors = ["82.8169014084507,34.63414634146342%", "216.16438356164383,29.55465587044534%", "212.92682926829266,59.42028985507245%", "209.18918918918916,57.51295336787566%", "199.2857142857143,48.275862068965495%", "213.11999999999998,55.06607929515419%"]; + attrib = {url: 'https://quanyails.deviantart.com/art/Sunrise-Ocean-402667154', title: 'Sunrise Ocean', artist: 'Yijing Chen'}; + break; + case 'waterfall': + menuColors = ["119.31034482758622,37.66233766233767%", "184.36363636363635,23.012552301255226%", "108.92307692307692,37.14285714285714%", "70.34482758620689,20.567375886524818%", "98.39999999999998,36.76470588235296%", "140,38.18181818181818%"]; + attrib = {url: 'https://yilx.deviantart.com/art/Irie-372292729', title: 'Irie', artist: 'Samuel Teo'}; + break; + case 'shaymin': + menuColors = ["39.000000000000064,21.7391304347826%", "170.00000000000003,2.380952380952378%", "157.5,11.88118811881188%", "174.78260869565216,12.041884816753928%", "185.00000000000003,12.76595744680851%", "20,5.660377358490567%"]; + attrib = {url: 'http://cargocollective.com/bluep', title: 'Shaymin', artist: 'Daniel Kong'}; + break; + case 'charizards': + menuColors = ["37.159090909090914,74.57627118644066%", "10.874999999999998,70.79646017699115%", "179.51612903225808,52.10084033613446%", "20.833333333333336,36.73469387755102%", "192.3076923076923,80.41237113402063%", "210,29.629629629629633%"]; + attrib = {url: 'https://seiryuuden.deviantart.com/art/The-Ultimate-Mega-Showdown-Charizards-414587079', title: 'Charizards', artist: 'Jessica Valencia'}; + break; + case 'digimon': + menuColors = ["170.45454545454544,27.500000000000004%", "84.70588235294119,13.821138211382115%", "112.50000000000001,7.8431372549019605%", "217.82608695652175,54.761904761904766%", "0,1.6949152542372816%", ""]; + } + if (!menuColors && bgUrl.charAt(0) === '#') { + const r = parseInt(bgUrl.slice(1, 3), 16) / 255; + const g = parseInt(bgUrl.slice(3, 5), 16) / 255; + const b = parseInt(bgUrl.slice(5, 7), 16) / 255; + const hs = this.getHueSat(r, g, b); + menuColors = [hs, hs, hs, hs, hs, hs]; + } + this.attrib = attrib; + this.menuColors = menuColors; + if (!menuColors) { + this.extractMenuColors(bgUrl); + } else { + this.save(bgUrl); + } + } + extractMenuColors(bgUrl: string) { + const changeCount = this.changeCount; + // We need the image object to load it on a canvas to detect the main color. + const img = new Image(); + img.onload = () => { + if (changeCount === PSBackground.changeCount) return; + // in case ColorThief throws from canvas, + // or localStorage throws + try { + const colorThief = new ColorThief(); + const colors = colorThief.getPalette(img, 5); + + let menuColors = []; + if (!colors) { + menuColors = ['0, 0%', '0, 0%', '0, 0%', '0, 0%', '0, 0%']; + } else { + for (let i = 0; i < 5; i++) { + const color = colors[i]; + const hs = PSBackground.getHueSat(color[0] / 255, color[1] / 255, color[2] / 255); + menuColors.unshift(hs); + } + } + this.menuColors = menuColors; + PSBackground.save(bgUrl); + } catch (e) {} + }; + img.src = bgUrl; + } + getHueSat(r: number, g: number, b: number) { + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + if (max === min) { + return `0,0%`; + } + const l = (max + min) / 2; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h = 0; + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + return `${h * 360},${s * 100}%`; + } +}; + +/********************************************************************** + * Core Views + *********************************************************************/ + +PSBackground.subscribe(bgUrl => { + if (!PSBackground.curId) { + document.body.style.background = ''; + document.body.style.backgroundSize = ''; + const buttonStyleElem = document.getElementById('mainmenubuttoncolors'); + if (buttonStyleElem) buttonStyleElem.textContent = ``; + return; + } + + if (bgUrl !== null) { + let background; + if (bgUrl.charAt(0) === '#') { + background = bgUrl; + } else if (PSBackground.curId !== 'custom') { + background = `#546bac url(${bgUrl}) no-repeat left center fixed`; + } else { + background = `#546bac url(${bgUrl}) no-repeat center center fixed`; + } + document.body.style.background = background; + document.body.style.backgroundSize = 'cover'; + } + + // main menu button colors + let cssBuf = ``; + let n = 0; + if (PSBackground.menuColors) for (const hs of PSBackground.menuColors) { + n++; + cssBuf += `body .button.mainmenu${n} { background: linear-gradient(to bottom, hsl(${hs},72%), hsl(${hs},52%)); border-color: hsl(${hs},40%); }\n`; + cssBuf += `body .button.mainmenu${n}:hover { background: linear-gradient(to bottom, hsl(${hs},62%), hsl(${hs},42%)); border-color: hsl(${hs},21%); }\n`; + cssBuf += `body .button.mainmenu${n}:active { background: linear-gradient(to bottom, hsl(${hs},42%), hsl(${hs},58%)); border-color: hsl(${hs},21%); }\n`; + } + let buttonStyleElem = document.getElementById('mainmenubuttoncolors'); + if (!buttonStyleElem) { + if (cssBuf) { + buttonStyleElem = new HTMLStyleElement(); + buttonStyleElem.id = 'mainmenubuttoncolors'; + buttonStyleElem.textContent = cssBuf; + document.head.appendChild(buttonStyleElem); + } + } else { + buttonStyleElem.textContent = cssBuf; + } +}); +// '"Horizon" background by Vivian Zou'; +// if (attrib) attrib = '' + attrib + ''; diff --git a/src/client-frame.tsx b/src/client-frame.tsx new file mode 100644 index 000000000..46f1f6273 --- /dev/null +++ b/src/client-frame.tsx @@ -0,0 +1,256 @@ + +class PSHeader extends preact.Component<{style: {}}> { + renderRoomTab(id: RoomID) { + const room = PS.rooms[id]; + const closable = (id === '' || id === 'rooms' ? '' : ' closable'); + const cur = (room === PS.leftRoom || room === PS.rightRoom ? ' cur' : ''); + let className = `roomtab button${room.notifying}${closable}${cur}`; + let icon = null; + let title = room.title; + let closeButton = null; + switch (room.type) { + case '': + case 'mainmenu': + icon = ; + break; + case 'teambuilder': + icon = ; + break; + case 'ladder': + icon = ; + break; + case 'battles': + icon = ; + break; + case 'rooms': + icon = ; + title = ''; + break; + case 'battle': + let idChunks = id.substr(7).split('-'); + let formatid; + // TODO: relocate to room implementation + if (idChunks.length <= 1) { + if (idChunks[0] === 'uploadedreplay') formatid = 'Uploaded Replay'; + } else { + formatid = idChunks[idChunks.length - 2]; + } + if (!title) { + let battle = (room as any).battle; + let p1 = (battle && battle.p1 && battle.p1.name) || ''; + let p2 = (battle && battle.p2 && battle.p2.name) || ''; + if (p1 && p2) { + title = '' + p1 + ' v. ' + p2; + } else if (p1 || p2) { + title = '' + p1 + p2; + } else { + title = '(empty room)'; + } + } + icon = {formatid}; + break; + case 'chat': + icon = ; + break; + case 'html': + default: + if (title.charAt(0) === '[') { + let closeBracketIndex = title.indexOf(']'); + if (closeBracketIndex > 0) { + icon = {title.slice(1, closeBracketIndex)}; + title = title.slice(closeBracketIndex + 1); + break; + } + } + icon = ; + break; + } + if (closable) { + closeButton = ; + } + return
  • {icon} {title}{closeButton}
  • ; + } + render() { + return ; + } +} + +class PSRoomPanel extends preact.Component<{style: {}, roomid: RoomID}> { + render() { + return
    +

    Loading...

    +
    ; + } +} + +class PSMain extends preact.Component { + constructor() { + super(); + this.updateLayout(); + } + posStyle(pos: PanelPosition) { + if (!pos) return {display: 'none'}; + + let top: number | null = (pos.top || 0); + let height: number | null = null; + let bottom: number | null = (pos.bottom || 0); + if (bottom > 0 || top < 0) { + height = bottom - top; + if (height < 0) throw new RangeError("Invalid pos range"); + if (top < 0) top = null; + else bottom = null; + } + + let left: number | null = (pos.left || 0); + let width: number | null = null; + let right: number | null = (pos.right || 0); + if (right > 0 || left < 0) { + width = right - left; + if (width < 0) throw new RangeError("Invalid pos range"); + if (left < 0) left = null; + else right = null; + } + + return { + display: 'block', + top: top === null ? `auto` : `${top}px`, + height: height === null ? `auto` : `${height}px`, + bottom: bottom === null ? `auto` : `${-bottom}px`, + left: left === null ? `auto` : `${left}px`, + width: width === null ? `auto` : `${width}px`, + right: right === null ? `auto` : `${-right}px`, + }; + } + /** + * "minWidth" and "maxWidth" are a bit deceptive here - to be clear, + * all PS rooms are expected to responsively support any width from + * 320px up, when in single panel mode. These metrics are used purely + * to calculate the location of the separator in two-panel mode. + * + * - `minWidth` - minimum width as a right-panel + * - `width` - preferred width, minimum width as a left-panel + * - `maxWidth` - maximum width as a left-panel + * + * PS will only show two panels if it can fit `width` in the left, and + * `minWidth` in the right. Extra space will be given to to right panel + * until it reaches `width`, then evenly distributed until both panels + * reach `maxWidth`, and extra space above that will be given to the + * right panel. + */ + getWidthFor(room: PSRoom) { + switch (room.type) { + case 'mainmenu': + return { + minWidth: 340, + width: 628, + maxWidth: 628, + isMainMenu: true, + }; + case 'chat': + case 'rooms': + return { + minWidth: 320, + width: 640, + maxWidth: 640, + }; + case 'battle': + return { + minWidth: 320, + width: 956, + maxWidth: 1180, + }; + } + return { + minWidth: 640, + width: 640, + maxWidth: 640, + }; + } + updateLayout() { + const leftRoomWidth = this.calculateLeftRoomWidth(); + if (PS.leftRoomWidth !== leftRoomWidth) { + PS.leftRoomWidth = leftRoomWidth; + PS.update(); + } + } + calculateLeftRoomWidth() { + // If we don't have both a left room and a right room, obviously + // just show one room + if (!PS.leftRoom || !PS.rightRoom || PS.onePanelMode) { + return 0; + } + + // The rest of this code can assume we have both a left room and a + // right room, and also want to show both if they fit + + const left = this.getWidthFor(PS.leftRoom); + const right = this.getWidthFor(PS.rightRoom); + const available = window.offsetWidth; + + let excess = available - left.width + right.width; + if (excess >= 0) { + // both fit in full size + const leftStretch = left.maxWidth - left.width; + if (!leftStretch) return left.width; + const rightStretch = right.maxWidth - right.width; + if (leftStretch + rightStretch >= excess) return left.maxWidth; + // evenly distribute the excess + return left.width + Math.floor(excess * leftStretch / (leftStretch + rightStretch)); + } + + if (left.isMainMenu) { + if (available >= left.minWidth + right.width) { + return left.minWidth; + } + return 0; + } + + if (available >= left.width + right.minWidth) { + return left.width; + } + return 0; + } + renderRoom(room: PSRoom) { + let pos = null; + if (PS.leftRoomWidth === 0) { + if ((PS.rightRoomFocused && room === PS.leftRoom) || + (!PS.rightRoomFocused && room === PS.rightRoom)) { + pos = {top: 50}; + } + } else { + if (room === PS.leftRoom) pos = {top: 50, right: PS.leftRoomWidth}; + if (room === PS.rightRoom) pos = {top: 50, left: PS.leftRoomWidth}; + } + return ; + } + render() { + let rooms = [] as preact.VNode[]; + for (const k in PS.rooms) { + rooms.push(this.renderRoom(PS.rooms[k])); + } + return
    + + {rooms} +
    ; + } +} + +type PanelPosition = {top?: number, bottom?: number, left?: number, right?: number} | null; + +preact.render(, document.body, document.getElementById('ps-frame')!); diff --git a/src/client-main.ts b/src/client-main.ts new file mode 100644 index 000000000..968d7ba61 --- /dev/null +++ b/src/client-main.ts @@ -0,0 +1,182 @@ +/** + * Client main + * + * Dependencies: client-core + * + * Sets up the main client models: Prefs, Teams, User, and PS. + * + * @author Guangcong Luo + * @license AGPLv3 + */ + +/** + * String that contains only lowercase alphanumeric characters. + */ +type RoomID = string & {__isRoomID: true}; + +const PSPrefsDefaults = {} as {[key: string]: any}; + +/** + * Tracks user preferences, stored in localStorage. Contains most local + * data, with the exception of backgrounds, teams, and session data, + * which get their own models. + */ +const PSPrefs = new class extends PSModel { + /** + * Dark mode! + */ + dark = false; + /** + * Disables animated GIFs, but keeps other animations enabled. + * Workaround for a Chrome 64 bug with GIFs. + * true - Disable GIFs, will be automatically re-enabled if you + * switch away from Chrome 64. + * false - Enable GIFs all the time. + * null - Enable GIFs only on Chrome 64. + */ + nogif: boolean | null = null; + /** + * Show "User joined" and "User left" messages. serverid:roomid + * table. Uses 1 and 0 instead of true/false for JSON packing + * reasons. + */ + showjoins: {[serverid: string]: {[roomid: string]: 1 | 0}} | null = null; + /** + * true = one panel, false = two panels, left and right + */ + onepanel = false; + + storage = {} as {[k: string]: any}; + readonly origin = 'https://play.pokemonshowdown.com'; + constructor() { + super(); + + for (const key in this) { + const value = (this as any)[key]; + if (key === 'storage' || key === 'subscriptions' || key === 'origin') continue; + if (typeof value === 'function') continue; + PSPrefsDefaults[key] = value; + } + + // set up local loading + try { + if (window.localStorage) { + this.save = () => { + localStorage.setItem('showdown_prefs', JSON.stringify(PSPrefs.storage)); + }; + this.load(JSON.parse(localStorage.getItem('showdown_prefs')!) || {}, true); + } + } catch (e) {} + } + /** + * Change a preference. + */ + set(key: string, value: any) { + if (value === null) { + delete this.storage[key]; + (this as any)[key] = PSPrefsDefaults[key]; + } else { + this.storage[key] = value; + (this as any)[key] = value; + } + this.update(); + this.save(); + } + load(newPrefs: object, noSave?: boolean) { + this.fixPrefs(newPrefs); + Object.assign(this, PSPrefsDefaults); + this.storage = newPrefs; + this.update(); + if (!noSave) this.save(); + } + save() { + // noop by default + } + fixPrefs(newPrefs: any) { + const oldShowjoins = newPrefs['showjoins']; + if (oldShowjoins !== undefined && typeof oldShowjoins !== 'object') { + const showjoins: {[serverid: string]: {[roomid: string]: 1 | 0}} = {}; + const serverShowjoins: {[roomid: string]: 1 | 0} = {global: (oldShowjoins ? 1 : 0)}; + const showroomjoins = newPrefs['showroomjoins'] as {[roomid: string]: boolean}; + for (const roomid in showroomjoins) { + serverShowjoins[roomid] = (showroomjoins[roomid] ? 1 : 0); + } + delete newPrefs['showroomjoins']; + showjoins[Config.server.id] = serverShowjoins; + newPrefs['showjoins'] = showjoins; + } + + const isChrome64 = navigator.userAgent.includes(' Chrome/64.'); + if (newPrefs['nogif'] !== undefined) { + if (!isChrome64) { + delete newPrefs['nogif']; + } + } else if (isChrome64) { + newPrefs['nogif'] = true; + alert('Your version of Chrome has a bug that makes animated GIFs freeze games sometimes, so certain animations have been disabled. Only some people have the problem, so you can experiment and enable them in the Options menu setting "Disable GIFs for Chrome 64 bug".'); + } + } +} + +interface Team { + name: string; + format: ID; + /** In packed format */ + team: string; + folder: string; + iconCache: string; +} + +const PSTeams = new class extends PSModel { + list = [] as Team[]; + save() { + // noop by default + } +} + +class PSRoom { + id: RoomID; + title: string; + type = ''; + notifying: '' | ' notifying' | ' subtle-notifying' = ''; + closable = true; + constructor(roomid: RoomID, title: string) { + this.id = roomid; + this.title = title; + } +} + +const PS = new class extends PSModel { + rooms = {} as {[roomid: string]: PSRoom}; + leftRoomList = [] as RoomID[]; + rightRoomList = [] as RoomID[]; + leftRoom: PSRoom | null = null; + rightRoom: PSRoom | null = null; + rightRoomFocused = false; + /** + * Not to be confused with PSPrefs.onepanel, which is permanent. + * PS.onePanelMode will be true if one-panel mode is on, but it will + * also be true if the right panel is temporarily hidden (by opening + * the Rooms panel and clicking "Hide") + * + * Will NOT be true if only one panel fits onto the screen at the + * moment, but resizing will display multiple panels – for that, + * check PS.leftRoomWidth === 0 + */ + onePanelMode = false; + /** 0 = no left room */ + leftRoomWidth = 0; + constructor() { + super(); + + const mainmenu = new PSRoom('' as RoomID, "Home"); + mainmenu.type = 'mainmenu'; + this.rooms[''] = this.leftRoom = mainmenu; + this.leftRoomList.push('' as RoomID); + + const rooms = new PSRoom('rooms' as RoomID, "Rooms"); + rooms.type = 'rooms'; + this.rooms['rooms'] = this.rightRoom = rooms; + this.rightRoomList.push('rooms' as RoomID); + } +}; diff --git a/src/panel-mainmenu.tsx b/src/panel-mainmenu.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/testclient-beta.html b/testclient-beta.html new file mode 100644 index 000000000..29a570c4b --- /dev/null +++ b/testclient-beta.html @@ -0,0 +1,58 @@ + + + + Showdown! + + + + + + + +
    + + +
    + + + + + + +