~vpzom/bracketmonster

3396bd5f0287fc0c59e74af881582ee805f5b9cc — Colin Reeder 5 months ago 7f2ac4d + 74e0141
Merge remote-tracking branch 'origin/more-ssr'
M announcer/src/index.ts => announcer/src/index.ts +1 -1
@@ 44,7 44,7 @@ interface SkipCondition {
	is: number;
}

const markdown = new MarkdownIt("commonmark", {xhtmlOut: true, breaks: true, linkify: true}).enable("linkify");
const markdown = new MarkdownIt("commonmark", {html: false, xhtmlOut: true, breaks: true, linkify: true}).enable("linkify");

function bracketIDToInternal(external: BracketID): BracketIDInternal {
	const buf = bs58.decode(external);

M rosebush/package-lock.json => rosebush/package-lock.json +76 -14
@@ 132,6 132,11 @@
            "lodash": "^4.17.13",
            "to-fast-properties": "^2.0.0"
          }
        },
        "jsesc": {
          "version": "2.5.2",
          "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
          "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA=="
        }
      }
    },


@@ 1143,6 1148,25 @@
      "integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==",
      "dev": true
    },
    "@types/linkify-it": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-2.1.0.tgz",
      "integrity": "sha512-Q7DYAOi9O/+cLLhdaSvKdaumWyHbm7HAk/bFwwyTuU0arR5yyCeW5GOoqt4tJTpDRxhpx9Q8kQL6vMpuw9hDSw=="
    },
    "@types/markdown-it": {
      "version": "10.0.1",
      "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-10.0.1.tgz",
      "integrity": "sha512-L1ibTdA5IUe/cRBlf3N3syAOBQSN1WCMGtAWir6mKxibiRl4LmpZM4jLz+7zAqiMnhQuAP1sqZOF9wXgn2kpEg==",
      "requires": {
        "@types/linkify-it": "*",
        "@types/mdurl": "*"
      }
    },
    "@types/mdurl": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz",
      "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA=="
    },
    "@types/mime": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz",


@@ 1526,7 1550,6 @@
      "version": "1.0.10",
      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
      "dev": true,
      "requires": {
        "sprintf-js": "~1.0.2"
      }


@@ 2715,6 2738,11 @@
        "tapable": "^1.0.0"
      }
    },
    "entities": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.2.tgz",
      "integrity": "sha512-dmD3AvJQBUjKpcNkoqr+x+IF0SdRtPz9Vk0uTy4yWqga9ibB6s4v++QFWNohjiUGoMlF552ZvNyXDxz5iW0qmw=="
    },
    "errno": {
      "version": "0.1.7",
      "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz",


@@ 3023,6 3051,16 @@
        "p-finally": "^1.0.0",
        "signal-exit": "^3.0.0",
        "strip-eof": "^1.0.0"
      },
      "dependencies": {
        "get-stream": {
          "version": "4.1.0",
          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
          "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
          "requires": {
            "pump": "^3.0.0"
          }
        }
      }
    },
    "expand-brackets": {


@@ 3482,9 3520,9 @@
      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
    },
    "get-stream": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
      "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
      "version": "5.1.0",
      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz",
      "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==",
      "requires": {
        "pump": "^3.0.0"
      }


@@ 4155,9 4193,9 @@
      }
    },
    "jsesc": {
      "version": "2.5.2",
      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
      "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA=="
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.1.tgz",
      "integrity": "sha512-w+MMxnByppM4jwskitZotEtvtO3a2C7WOz31NxJToGisHuysCAQQU7umb/pA/6soPFe8LGjXFEFbuPuLEPm7Ag=="
    },
    "json-parse-better-errors": {
      "version": "1.0.2",


@@ 4229,6 4267,14 @@
        "type-check": "~0.3.2"
      }
    },
    "linkify-it": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
      "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
      "requires": {
        "uc.micro": "^1.0.1"
      }
    },
    "linkstate": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/linkstate/-/linkstate-1.1.1.tgz",


@@ 4326,6 4372,18 @@
        "object-visit": "^1.0.0"
      }
    },
    "markdown-it": {
      "version": "10.0.0",
      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz",
      "integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==",
      "requires": {
        "argparse": "^1.0.7",
        "entities": "~2.0.0",
        "linkify-it": "^2.0.0",
        "mdurl": "^1.0.1",
        "uc.micro": "^1.0.5"
      }
    },
    "md5.js": {
      "version": "1.3.5",
      "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",


@@ 4336,6 4394,11 @@
        "safe-buffer": "^5.1.2"
      }
    },
    "mdurl": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
      "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4="
    },
    "mem": {
      "version": "4.3.0",
      "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz",


@@ 5076,11 5139,6 @@
      "resolved": "https://registry.npmjs.org/preact-lazy/-/preact-lazy-0.0.3.tgz",
      "integrity": "sha512-0/UpDxSXpSWdiOix6VJQAoe570ajiTyR5PqOmfRR1ttoZgX9KeCSOHXmThuRAq8AGYMUt/+RBudTsWUhwvZ0yA=="
    },
    "preact-markup": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/preact-markup/-/preact-markup-2.0.0.tgz",
      "integrity": "sha512-1yPS+IuqJI6k9CoOLDj5x68W9rYp6cY4UqRNCXKt2QSRFcWhGrAZhv3fyz8i8urMu2zLuOrMJIxV4lhpVHHF/w=="
    },
    "preact-radio-group": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/preact-radio-group/-/preact-radio-group-4.1.0.tgz",


@@ 5933,8 5991,7 @@
    "sprintf-js": {
      "version": "1.0.3",
      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
      "dev": true
      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
    },
    "ssri": {
      "version": "6.0.1",


@@ 6403,6 6460,11 @@
      "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz",
      "integrity": "sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ=="
    },
    "uc.micro": {
      "version": "1.0.6",
      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
      "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
    },
    "unicode-canonical-property-names-ecmascript": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz",

M rosebush/package.json => rosebush/package.json +4 -1
@@ 6,6 6,7 @@
    "@nerd-coder/webpack-node-externals": "^1.8.2",
    "@pacote/shuffle": "^1.0.4",
    "@types/http-proxy": "^1.17.4",
    "@types/markdown-it": "^10.0.1",
    "@types/node": "^13.13.4",
    "@types/node-fetch": "^2.5.7",
    "@types/qrcode": "^1.3.4",


@@ 16,16 17,18 @@
    "babel-loader": "^8.1.0",
    "css-loader": "^3.5.3",
    "decko": "^1.2.0",
    "get-stream": "^5.1.0",
    "http-proxy": "^1.18.0",
    "isomorphic-cookie": "^1.2.4",
    "jsesc": "^3.0.1",
    "linkstate": "^1.1.1",
    "markdown-it": "^10.0.0",
    "mini-css-extract-plugin": "^0.9.0",
    "node-fetch": "^2.6.0",
    "preact": "^10.4.1",
    "preact-feather": "^4.1.0",
    "preact-jsx-i18n": "^1.0.0",
    "preact-lazy": "0.0.3",
    "preact-markup": "^2.0.0",
    "preact-radio-group": "^4.1.0",
    "preact-render-to-string": "^5.1.6",
    "preact-router": "^3.2.1",

M rosebush/src/client/App.tsx => rosebush/src/client/App.tsx +18 -16
@@ 67,23 67,25 @@ export default class App extends Component<Props, State> {
	}

	public componentDidMount() {
		const hasLogin = !!isomorphicCookie.load("rosebushToken");

		console.log("hasLogin: ", hasLogin);

		if(hasLogin) {
			fetch("/api/unstable/users/~me")
				.then(res => res.json())
				.then(info => {
					if(this.state.userInfo.state === "loading") {
						this.setUserInfo(info);
					}
				})
		if(this.state.userInfo.state !== "done") {
			const hasLogin = !!isomorphicCookie.load("rosebushToken");

			console.log("hasLogin: ", hasLogin);

			if(hasLogin) {
				fetch("/api/unstable/users/~me")
					.then(res => res.json())
					.then(info => {
							if(this.state.userInfo.state === "loading") {
							this.setUserInfo(info);
							}
							})
				.catch(err => {
					this.setState({userInfo: {state: "failed", error: err}});
				});
		} else {
			this.setState({userInfo: {state: "done", value: null}});
						this.setState({userInfo: {state: "failed", error: err}});
						});
			} else {
				this.setState({userInfo: {state: "done", value: null}});
			}
		}
	}


M rosebush/src/client/Data.tsx => rosebush/src/client/Data.tsx +5 -0
@@ 40,6 40,11 @@ export default class Data<T> extends Component<Props<T>, {}> {
		return {state: "failed", error};
	}

	public static unwrap<T>(state: LoadState<T>): T {
		if(state.state === "done") return state.value;
		throw new Error("Tried to unwrap on unloaded data");
	}

	public static load<T, K extends string, S extends {[K_ in K]: LoadState<T>}, C extends Pick<Component<unknown, S>, "setState">>(component: C, key: K, src: Promise<T>): void {
		Data.wrapPromise(src)
			.then(result => {

M rosebush/src/client/Helmet.tsx => rosebush/src/client/Helmet.tsx +33 -17
@@ 9,7 9,7 @@
 *
 */

import { Component, ComponentChildren, JSX, h, toChildArray } from "preact";
import { Component, ComponentChild, ComponentChildren, JSX, h, toChildArray } from "preact";

let refs: Helmet[] = [];



@@ 41,19 41,23 @@ const Wrapper = (props: {children: ComponentChildren}) => {
	return null
}

interface MetaItem {
	name?: string;
	property?: string;
	content?: string;
	httpEquiv?: string;
	noScriptOnly?: boolean;
}

interface HelmetProps {
	title?: string;
	titleTemplate?: string;
	fallbackTitle?: string;
	meta?: Array<{
		name: string;
		property?: string;
		content?: string;
	}>;
	meta?: MetaItem[];
}

export default class Helmet extends Component<HelmetProps, {}> {
	public static renderContent(props: HelmetProps): JSX.Element[] {
	public static renderContent(props: HelmetProps): ComponentChild[] {
		return [
			<title data-helmet='true' key="title">{Helmet._getTitle(props)}</title>,
			...Helmet._getMeta(props),


@@ 65,16 69,28 @@ export default class Helmet extends Component<HelmetProps, {}> {
		return (!title && fallbackTitle) ? fallbackTitle : titleTemplate.replace('%s', title || '');
	}

	private static _getMeta({meta = []}: {meta?: HelmetProps["meta"]}): JSX.Element[] {
		return meta.map(({ name, property, content }) =>
			<meta
				key={name}
				name={name}
				property={property}
				content={content}
				data-helmet
			/>
					)
	private static _getMeta({meta = []}: {meta?: HelmetProps["meta"]}): ComponentChild[] {
		const noScriptOnlyItems = meta.filter(item => item.noScriptOnly);

		const result = [
			...meta.filter(item => !item.noScriptOnly).map(Helmet._renderMetaItem),
			noScriptOnlyItems.length > 0 &&
				<noscript>
					{noScriptOnlyItems.map(Helmet._renderMetaItem)}
				</noscript>
		];

		return result;
	}

	private static _renderMetaItem(meta: MetaItem): JSX.Element {
		return <meta
			name={meta.name}
			property={meta.property}
			content={meta.content}
			http-equiv={meta.httpEquiv}
			data-helmet
		/>;
	}

	constructor() {

M rosebush/src/client/fetchWithError.ts => rosebush/src/client/fetchWithError.ts +1 -1
@@ 1,4 1,4 @@
export default function fetchWithError(input: RequestInfo, init?: RequestInit): Promise<Response> {
export default function fetchWithError(fetch: typeof window.fetch, input: RequestInfo, init?: RequestInit): Promise<Response> {
	return fetch(input, init)
		.then(res => {
			if(res.status < 200 || res.status >= 300) {

M rosebush/src/client/index.tsx => rosebush/src/client/index.tsx +11 -1
@@ 9,6 9,7 @@ if(process.env.NODE_ENV !== "production") {
import App, { UserInfo } from "./App";
import LanguageHandler from "./LanguageHandler";
import Loading from "./Loading";
import { PreloadedDataContext } from "./preload";
import routes from "./routes";

import "./main.scss";


@@ 18,6 19,7 @@ const root = document.getElementById("root")!;
console.log(root);

declare const INIT_USER_INFO: UserInfo | null;
declare let PRELOADED_DATA: unknown;

const routesChildren = routes.map(info => {
	return <Route path={info.path} component={lazy(() => import("./pages/" + info.component), Loading)} />;


@@ 27,7 29,15 @@ LanguageHandler.loadLanguage()
	.then(() => {
		render(
			<LanguageHandler>
				<App initUserInfo={INIT_USER_INFO} routes={routesChildren} />
				<PreloadedDataContext.Provider value={{
					take() {
						const value = PRELOADED_DATA;
						PRELOADED_DATA = undefined;
						return value;
					}
				}}>
					<App initUserInfo={INIT_USER_INFO} routes={routesChildren} />
				</PreloadedDataContext.Provider>
			</LanguageHandler>,
			root,
		 );

A rosebush/src/client/jsesc.d.ts => rosebush/src/client/jsesc.d.ts +102 -0
@@ 0,0 1,102 @@
// Type definitions for jsesc 2.5.2
// Project: https://github.com/mathiasbynens/jsesc
// Definitions by: Bart van der Schoor <https://github.com/Bartvds>
//                 Lyanbin <https://github.com/Lyanbin>
//                 Colin Reeder <https://github.com/vpzomtrrfrt>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped



declare module "jsesc" {
	declare function jsesc(argument: any, opts?: jsesc.Opts): string;

	declare namespace jsesc {
	    var version: string;

	    interface Opts {
		   /**
		    * The default value for the quotes option is 'single'. This means that any occurrences of ' in the input
		    * string are escaped as \', so that the output can be used in a string literal wrapped in single quotes.
		    */
		   quotes?: 'single' | 'double' | 'backtick';

		   /**
		    * The default value for the numbers option is 'decimal'. This means that any numeric values are represented
		    * using decimal integer literals. Other valid options are binary, octal, and hexadecimal, which result in
		    * binary integer literals, octal integer literals, and hexadecimal integer literals, respectively.
		    */
		   numbers?: 'binary' | 'octal' | 'decimal' | 'hexadecimal';

		   /**
		    * The wrap option takes a boolean value (true or false), and defaults to false (disabled). When enabled, the
		    * output is a valid JavaScript string literal wrapped in quotes. The type of quotes can be specified through
		    * the quotes setting.
		    */
		   wrap?: boolean;

		   /**
		    * The es6 option takes a boolean value (true or false), and defaults to false (disabled). When enabled, any
		    * astral Unicode symbols in the input are escaped using ECMAScript 6 Unicode code point escape sequences
		    * instead of using separate escape sequences for each surrogate half. If backwards compatibility with ES5
		    * environments is a concern, don’t enable this setting. If the json setting is enabled, the value for the es6
		    * setting is ignored (as if it was false).
		    */
		   es6?: boolean;

		   /**
		    * The escapeEverything option takes a boolean value (true or false), and defaults to false (disabled). When
		    * enabled, all the symbols in the output are escaped — even printable ASCII symbols.
		    */
		   escapeEverything?: boolean;

		   /**
		    * The minimal option takes a boolean value (true or false), and defaults to false (disabled). When enabled,
		    * only a limited set of symbols in the output are escaped: \0, \b, \t, \n, \f, \r, \\, \u2028, \u2029.
		    */
		   minimal?: boolean;

		   /**
		    * The isScriptContext option takes a boolean value (true or false), and defaults to false (disabled). When
		    * enabled, occurrences of </script and </style in the output are escaped as <\/script and <\/style, and <!--
		    * is escaped as \x3C!-- (or \u003C!-- when the json option is enabled). This setting is useful when jsesc’s
		    * output ends up as part of a <script> or <style> element in an HTML document.
		    */
		   isScriptContext?: boolean;

		   /**
		    * The compact option takes a boolean value (true or false), and defaults to true (enabled). When enabled,
		    * the output for arrays and objects is as compact as possible; it’s not formatted nicely.
		    */
		   compact?: boolean;

		   /**
		    * The indent option takes a string value, and defaults to '\t'. When the compact setting is enabled (true),
		    * the value of the indent option is used to format the output for arrays and objects.
		    */
		   indent?: string;

		   /**
		    * The indentLevel option takes a numeric value, and defaults to 0. It represents the current indentation level,
		    * i.e. the number of times the value of the indent option is repeated.
		    */
		   indentLevel?: number;

		   /**
		    * The json option takes a boolean value (true or false), and defaults to false (disabled). When enabled, the
		    * output is valid JSON. Hexadecimal character escape sequences and the \v or \0 escape sequences are not used.
		    * Setting json: true implies quotes: 'double', wrap: true, es6: false, although these values can still be
		    * overridden if needed — but in such cases, the output won’t be valid JSON anymore.
		    */
		   json?: boolean;

		   /**
		    * The lowercaseHex option takes a boolean value (true or false), and defaults to false (disabled). When enabled,
		    * any alphabetical hexadecimal digits in escape sequences as well as any hexadecimal integer literals (see the
		    * numbers option) in the output are in lowercase.
		    */
		   lowercaseHex?: boolean;
	    }
	}

	export = jsesc;
}

M rosebush/src/client/lang/en.json => rosebush/src/client/lang/en.json +1 -0
@@ 12,6 12,7 @@
	"loading": "Loading…",
	"loggingIn": "Logging in…",
	"login": "Login",
	"matchNotFound": "Failed to find match",
	"myBrackets": "My Brackets",
	"name": "Name",
	"newBracketPage": "Create Bracket",

M rosebush/src/client/main.scss => rosebush/src/client/main.scss +0 -3
@@ 320,9 320,6 @@ button, .button {
	display: flex;
	flex-direction: column;
	align-items: center;
	> * {
		display: inline-block;
	}
	> .options {
		> * {
			margin-right: 0.5em;

A rosebush/src/client/markdown.ts => rosebush/src/client/markdown.ts +5 -0
@@ 0,0 1,5 @@
import * as MarkdownIt from "markdown-it";

const markdown = new MarkdownIt("commonmark", {html: false, xhtmlOut: true, breaks: true, linkify: true}).enable("linkify");

export default markdown;

M rosebush/src/client/modals.tsx => rosebush/src/client/modals.tsx +21 -1
@@ 5,6 5,7 @@ interface ModalContextValue {
	closing: boolean;
	dismiss(): void;
	remove(): void;
	dismissHref?: string;
}

export interface ModalInfo {


@@ 83,6 84,25 @@ Modal.contextType = ModalContext;

export function CancelButton(_props: {}): JSX.Element {
	return <ModalContext.Consumer>
		{ctx => <button type="button" onClick={ctx?.dismiss}><Text id="cancel" /></button>}
		{ctx => {
			const content = <Text id="cancel" />;
			if(ctx && ctx.dismissHref) {
				return <a class="button" href={ctx.dismissHref} onClick={ctx.dismiss}>{content}</a>;
			}
			return <button type="button" onClick={ctx?.dismiss}>{content}</button>;
		}}
	</ModalContext.Consumer>;
}

export function FakeModalContainer(props: {backLink: string; children?: ComponentChildren}) {
	return <ModalContext.Provider
		value={{
			closing: false,
			dismiss: () => undefined,
			remove: () => undefined,
			dismissHref: props.backLink,
		}}
	>
		{props.children}
	</ModalContext.Provider>;
}

M rosebush/src/client/pages/bracket.tsx => rosebush/src/client/pages/bracket.tsx +96 -54
@@ 3,7 3,6 @@ import linkState from "linkstate";
import { Component, JSX, createRef, h } from "preact";
import { Edit } from "preact-feather/dist/icons/edit";
import { Text } from "preact-jsx-i18n";
import Markup from "preact-markup";
import { Radio, RadioGroup } from "preact-radio-group";
import { SteppedLineTo } from "react-lineto";



@@ 12,6 11,7 @@ import Helmet from "../Helmet";
import Loading from "../Loading";
import { CancelButton, Modal, ModalContainer } from "../modals";
import fetchWithError from "../fetchWithError";
import { addData } from "../preload";
import { BracketID } from "../types";

declare const SOCKET_HOST: string;


@@ 26,7 26,7 @@ enum PlayerOutcome {
	Loss = "loss",
}

interface MatchInfo {
export interface MatchInfo {
	id: number;
	players: Array<{
		player: null | {id: number};


@@ 39,7 39,7 @@ interface MatchInfo {
	skipped: boolean;
}

interface BracketInfo {
export interface BracketInfo {
	id: BracketID;
	name: string;
	description_md: string;


@@ 58,8 58,9 @@ interface PageProps {
}

interface PageState {
	bracket: BracketInfo | undefined;
	bracket: LoadState<BracketInfo>;
	yourInfo: LoadState<BracketYourInfo>;
	wasOpen: boolean;
	closed: boolean;
	backoff: number;
}


@@ 73,8 74,9 @@ export default class BracketPage extends Component<PageProps, PageState> {
	public constructor() {
		super();
		this.state = {
			bracket: undefined,
			bracket: Data.loading,
			yourInfo: Data.loading,
			wasOpen: false,
			closed: false,
			backoff: 500,
		};


@@ 83,13 85,10 @@ export default class BracketPage extends Component<PageProps, PageState> {
	render(props: PageProps, state: PageState) {
		return <div>
			<Helmet title="Bracket" />
			{state.closed && !(state.yourInfo.state === "failed") && <div class="warningBox"><Text id={state.bracket ? "pages_bracket_lostConnection" : "pages_bracket_failedToConnect"} /> <Text id="reconnecting" /></div>}
			<Data state={state.yourInfo}>
				{yourInfo => {
					if(state.bracket) {
						return <BracketView bracket={state.bracket} yourInfo={yourInfo} addModal={this.addModal} />;
					}
					return <Loading />;
			{state.closed && !(state.yourInfo.state === "failed") && <div class="warningBox"><Text id={state.wasOpen ? "pages_bracket_lostConnection" : "pages_bracket_failedToConnect"} /> <Text id="reconnecting" /></div>}
			<Data state={Data.and(state.yourInfo, state.bracket)}>
				{([yourInfo, bracket]) => {
					 return <BracketView bracket={bracket} yourInfo={yourInfo} addModal={this.addModal} />;
				}}
			</Data>
			<ModalContainer ref={this.modals} />


@@ 98,9 97,6 @@ export default class BracketPage extends Component<PageProps, PageState> {

	componentDidMount() {
		this.initSocket();

		Data.load(this, "yourInfo", fetchWithError("/api/v1/brackets/" + this.props.id + "/your")
			.then(res => res.json()));
	}

	@bind


@@ 112,31 108,37 @@ export default class BracketPage extends Component<PageProps, PageState> {
			console.log(packet);

			if(packet.type === 2) {
				this.setState({bracket: packet.data.bracket});
				this.setState({bracket: Data.wrapValue(packet.data.bracket)});
			}

			if(packet.type === 3) {
				const data: {match: Partial<MatchInfo> & {id: number}} = packet.data;
				const newMatch = packet.data.match;

				this.setState(state => ({
					bracket: {
						...state.bracket!,
						matches: state.bracket!.matches.map(match => {
							if(match.id === newMatch.id) return {...match, ...newMatch};
							return match;
						}),
					},
				}));
				this.setState(state => {
					const bracket = Data.unwrap(state.bracket);
					return {
						bracket: Data.wrapValue({
						    ...bracket, 
						    matches: bracket.matches.map(match => {
								    if(match.id === newMatch.id) return {...match, ...newMatch};
								    return match;
								    }),
						    }),
					};
				});
			}

			if(packet.type === 4) {
				this.setState(state => ({
					bracket: {
						...state.bracket!,
						...packet.data.info,
					},
				}));
				this.setState(state => {
					const bracket = Data.unwrap(state.bracket);
					return {
						bracket: Data.wrapValue({
						    ...bracket,
						    ...packet.data.info,
					    }),
					};
				});
			}
		});
		this.socket.addEventListener("open", () => {


@@ 155,6 157,29 @@ export default class BracketPage extends Component<PageProps, PageState> {
	}
}

addData<PageProps, PageState, BracketPage>(
	BracketPage,
	(props, fetch) => ({
		yourInfo: fetchWithError(fetch, "/api/v1/brackets/" + props.id + "/your")
			.then(res => res.json()),

		// preload bracket only on server, since we fetch it from the socket anyway
		bracket: (
			typeof window !== "undefined" ?
				undefined :
				Promise.all([
					fetchWithError(fetch, "/api/v1/brackets/" + props.id)
						.then(res => res.json()),
					import("../markdown"),
				])
					.then(([info, mdMod]) => {
						info.description_html = mdMod.default.render(info.description_md);
						return info;
					})
		),
	}),
);

interface PlayerMap {
	[key: string]: PlayerInfo;
}


@@ 173,13 198,7 @@ class BracketView extends Component<BracketViewProps, BracketViewState> {
	public constructor(props: BracketViewProps) {
		super(props);

		const playerMap: PlayerMap = {};

		props.bracket.players.forEach(player => {
			playerMap[player.id] = player;
		});

		this.state = {playerMap};
		this.state = {playerMap: makePlayerMap(props.bracket.players)};
	}

	render(props: BracketViewProps, state: BracketViewState) {


@@ 198,16 217,19 @@ class BracketView extends Component<BracketViewProps, BracketViewState> {
		sections.sort();

		return <div>
			<Helmet title={bracket.name} />
			<Helmet
				title={bracket.name}
				meta={[{httpEquiv: "refresh", content: "5", noScriptOnly: true}]}
			/>
			<div>
				<div class="bracketHeader">
					<div>
						<h1>
							{bracket.name}
							&nbsp;
							{props.yourInfo && props.yourInfo.isAdmin && <button onClick={this.editBracketInfo.bind(this)}><Edit size={16} /></button>}
							{props.yourInfo && props.yourInfo.isAdmin && <a class="button" href={"/dialog/bracketInfoEdit/" + bracket.id} onClick={this.editBracketInfoClick}><Edit size={16} /></a>}
						</h1>
						<p><Markup type="xml" markup={bracket.description_html} trim={false} /></p>
						<p dangerouslySetInnerHTML={{__html: bracket.description_html}}></p>
						<LeaderboardView bracket={bracket} />
					</div>
					<div class="qrArea">


@@ 235,7 257,7 @@ class BracketView extends Component<BracketViewProps, BracketViewState> {
													})}
												</ul>
												{showButtons && <div class="buttons">
													<button onClick={this.edit.bind(this, match)}><Edit size={16} /></button>
													<a href={"/dialog/matchEdit/" + bracket.id + "/" + match.id} class="button" onClick={this.editClick.bind(this, match)}><Edit size={16} /></a>
												</div>}
											</div>
										</div>;


@@ 244,7 266,7 @@ class BracketView extends Component<BracketViewProps, BracketViewState> {
							})}
						</div>
					})}
					{bracket.matches.map(match => {
					{typeof window !== "undefined" && bracket.matches.map(match => {
						return match.players.map((matchPlayer, idx) => {
							if(matchPlayer.source && matchPlayer.source.outcome === PlayerOutcome.Win) {
								return <SteppedLineTo from={"matchMagic_" + matchPlayer.source.match} to={"matchMagic_" + match.id + "_player_" + idx} orientation="h" delay fromAnchor="right" toAnchor="left" borderColor="gray" within={"bracketMagic_" + bracket.id} />;


@@ 256,11 278,17 @@ class BracketView extends Component<BracketViewProps, BracketViewState> {
		</div>;
	}

	editBracketInfo(): void {
	@bind
	editBracketInfoClick(evt: Event): void {
		evt.preventDefault();
		evt.stopPropagation();
		this.props.addModal(<BracketInfoEditDialog bracket={this.props.bracket} />);
	}

	edit(match: MatchInfo): void {
	@bind
	editClick(match: MatchInfo, evt: Event): void {
		evt.preventDefault();
		evt.stopPropagation();
		this.props.addModal(<MatchEditDialog match={match} playerMap={this.state.playerMap} bracket={this.props.bracket} />);
	}
}


@@ 295,7 323,7 @@ function LeaderboardView(props: {bracket: BracketInfo}): JSX.Element {
}

interface MatchEditProps {
	bracket: BracketInfo;
	bracket: Pick<BracketInfo, "id" | "use_scores">;
	match: MatchInfo;
	playerMap: PlayerMap;
}


@@ 307,7 335,7 @@ interface MatchEditState {
	submitting: boolean;
}

class MatchEditDialog extends Component<MatchEditProps, MatchEditState> {
export class MatchEditDialog extends Component<MatchEditProps, MatchEditState> {
	private modal = createRef();

	public constructor(props: MatchEditProps) {


@@ 322,8 350,10 @@ class MatchEditDialog extends Component<MatchEditProps, MatchEditState> {

	public render(props: MatchEditProps, state: MatchEditState) {
		return <Modal title={<Text id="editMatch" />} ref={this.modal}>
			<form onSubmit={this.submit.bind(this)}>
			<form onSubmit={this.submit.bind(this)} method="POST" action="/formSubmit/matchEdit">
				<div class="modal-body">
					<input type="hidden" name="bracket" value={props.bracket.id} />
					<input type="hidden" name="match" value={props.match.id} />
					<RadioGroup name="winner" selectedValue={String(state.winner)} onChange={this.onChangeWinner.bind(this)}>
						<table>
							<tr>


@@ 338,7 368,7 @@ class MatchEditDialog extends Component<MatchEditProps, MatchEditState> {
										{player.player === null ? ["[", <Text id="unknown" />, "]"] : props.playerMap[player.player.id].name}
									</td>
									{props.bracket.use_scores && <td>
										<input type="number" value={state.scores[idx]} onChange={this.onChangeScore.bind(this, idx)} />
										<input type="number" value={state.scores[idx]} onChange={this.onChangeScore.bind(this, idx)} name="score[]" />
									</td>}
									<td>
										<Radio value={String(idx)} />


@@ 404,7 434,7 @@ class MatchEditDialog extends Component<MatchEditProps, MatchEditState> {
}

interface BracketInfoEditProps {
	bracket: BracketInfo;
	bracket: Pick<BracketInfo, "name" | "description_md" | "id">;
}

interface BracketInfoEditState {


@@ 413,23 443,24 @@ interface BracketInfoEditState {
	submitting: boolean;
}

class BracketInfoEditDialog extends Component<BracketInfoEditProps, BracketInfoEditState> {
export class BracketInfoEditDialog extends Component<BracketInfoEditProps, BracketInfoEditState> {
	private modal = createRef();

	public render(props: BracketInfoEditProps, state: BracketInfoEditState) {
		const { bracket } = props;

		return <Modal title={<Text id="editBracketInfo" />} ref={this.modal}>
			<form onSubmit={this.submit.bind(this)}>
			<form method="POST" action="/formSubmit/bracketInfoEdit" onSubmit={this.submit}>
				<div class="modal-body">
					<input type="hidden" name="bracket" value={bracket.id} />
					<label class="form-control-label">
						<Text id="name" />
						<input type="text" value={typeof state.name === "undefined" ? bracket.name : state.name} onChange={linkState(this, "name")} />
						<input type="text" value={typeof state.name === "undefined" ? bracket.name : state.name} onChange={linkState(this, "name")} name="bracketName" />
					</label>

					<label class="form-control-label">
						<Text id="description" />
						<textarea value={typeof state.description === "undefined" ? bracket.description_md : state.description} onChange={linkState(this, "description")} />
						<textarea value={typeof state.description === "undefined" ? bracket.description_md : state.description} onChange={linkState(this, "description")} name="description_md" />
					</label>
				</div>
				<div class="modal-footer modal-footer-buttons">


@@ 442,6 473,7 @@ class BracketInfoEditDialog extends Component<BracketInfoEditProps, BracketInfoE
		</Modal>;
	}

	@bind
	private submit(evt: Event): void {
		evt.preventDefault();



@@ 456,3 488,13 @@ class BracketInfoEditDialog extends Component<BracketInfoEditProps, BracketInfoE
		});
	}
}

export function makePlayerMap(players: PlayerInfo[]) {
	const playerMap: PlayerMap = {};

	players.forEach(player => {
		playerMap[player.id] = player;
	});

	return playerMap;
}

A rosebush/src/client/pages/dialog/bracketInfoEdit.tsx => rosebush/src/client/pages/dialog/bracketInfoEdit.tsx +43 -0
@@ 0,0 1,43 @@
import { bind } from "decko";
import { Component, h } from "preact";

import Data, { LoadState } from "../../Data";
import fetchWithError from "../../fetchWithError";
import { FakeModalContainer } from "../../modals";
import { addData } from "../../preload";

import { BracketInfo, BracketInfoEditDialog } from "../bracket";

interface PageProps {
	bracketID: string;
}

interface PageState {
	bracket: LoadState<Omit<BracketInfo, "description_html">>;
}

export default class BracketInfoEditPage extends Component<PageProps, PageState> {
	public constructor() {
		super();
		this.state = {bracket: Data.loading};
	}
	
	public render(props: PageProps, state: PageState) {
		return <Data state={state.bracket}>{this.renderContent}</Data>;
	}

	@bind
	private renderContent(info: Omit<BracketInfo, "description_html">) {
		return <FakeModalContainer backLink={"/bracket/" + info.id}>
			<BracketInfoEditDialog bracket={info} />
		</FakeModalContainer>;
	}
}

addData<PageProps, PageState, BracketInfoEditPage>(
	BracketInfoEditPage,
	(props, fetch) => ({
		bracket: fetchWithError(fetch, "/api/v1/brackets/" + props.bracketID)
			.then(res => res.json()),
	}),
);

A rosebush/src/client/pages/dialog/matchEdit.tsx => rosebush/src/client/pages/dialog/matchEdit.tsx +61 -0
@@ 0,0 1,61 @@
import { bind } from "decko";
import { Component, h } from "preact";
import { Text } from "preact-jsx-i18n";

import Data, { LoadState } from "../../Data";
import fetchWithError from "../../fetchWithError";
import { FakeModalContainer } from "../../modals";
import { addData } from "../../preload";

import { BracketInfo, MatchEditDialog, MatchInfo, makePlayerMap } from "../bracket";

interface PageProps {
	bracketID: string;
	matchID: string;
}

interface PageState {
	bracket: LoadState<Omit<BracketInfo, "description_html">>;
}

export default class BracketInfoEditPage extends Component<PageProps, PageState> {
	public constructor() {
		super();
		this.state = {bracket: Data.loading};
	}

	public render(props: PageProps, state: PageState) {
		return <Data state={state.bracket}>{this.renderContent}</Data>;
	}

	@bind
	private renderContent(bracket: Omit<BracketInfo, "description_html">) {
		let match: MatchInfo | null = null;

		for(let i = 0; i < bracket.matches.length; i++) {
			const current = bracket.matches[i];
			if(String(current.id) === this.props.matchID) {
				match = current;
				break;
			}
		}

		if(match === null) {
			return <div class="errorBox">
				<Text id="errorPrefix" /> <Text id="matchNotFound" />
			</div>;
		}

		return <FakeModalContainer backLink={"/bracket/" + bracket.id}>
			<MatchEditDialog bracket={bracket} playerMap={makePlayerMap(bracket.players)} match={match} />
		</FakeModalContainer>;
	}
}

addData<PageProps, PageState, BracketInfoEditPage>(
	BracketInfoEditPage,
	(props, fetch) => ({
		bracket: fetchWithError(fetch, "/api/v1/brackets/" + props.bracketID)
			.then(res => res.json()),
	}),
);

M rosebush/src/client/pages/login.tsx => rosebush/src/client/pages/login.tsx +1 -1
@@ 23,7 23,7 @@ export default class LoginPage extends Component<Props, State> {

			<h1><Text id="login" /></h1>

			<form onSubmit={this.submit.bind(this)}>
			<form action="/formSubmit/login" method="POST" onSubmit={this.submit.bind(this)}>
				<p>
					<label>
						<Text id="username" />: <input type="text" name="username" />

M rosebush/src/client/pages/myBrackets.tsx => rosebush/src/client/pages/myBrackets.tsx +8 -5
@@ 4,6 4,7 @@ import { Text } from "preact-jsx-i18n";
import Data, { LoadState } from "../Data";
import Helmet from "../Helmet";
import fetchWithError from "../fetchWithError";
import { addData } from "../preload";
import { BracketID } from "../types";

interface BracketListBracket {


@@ 30,11 31,6 @@ export default class MyBracketsPage extends Component<{}, PageState> {
		</div>;
	}

	public componentDidMount() {
		Data.load(this, "myBrackets", fetchWithError("/api/unstable/users/me/brackets")
			.then(res => res.json()));
	}

	private renderContent(myBrackets: BracketListBracket[]): JSX.Element {
		return <ul>
			{myBrackets.map(bracket => {


@@ 43,3 39,10 @@ export default class MyBracketsPage extends Component<{}, PageState> {
		</ul>;
	}
}

addData<{}, PageState, MyBracketsPage>(
	MyBracketsPage,
	(_, fetch) => ({
		myBrackets: fetchWithError(fetch, "/api/unstable/users/me/brackets").then(res => res.json()),
	}),
);

M rosebush/src/client/pages/newBracket.tsx => rosebush/src/client/pages/newBracket.tsx +4 -3
@@ 68,7 68,8 @@ class PageContent extends Component<Props, State> {

			return <Fragment>
				<h1><Text id="newBracketPage" /></h1>
				<form onSubmit={this.submit.bind(this)}>
				<form onSubmit={this.submit.bind(this)} method="POST" action="/formSubmit/newBracket">
					{props.app.userInfo.value === null && <input type="hidden" name="newUser" value="true" />}
					<p>
						<label>
							<Text id="name" />: <input type="text" name="bracketName" />


@@ 87,7 88,7 @@ class PageContent extends Component<Props, State> {
					</p>
					<p>
						<label>
							<input type="checkbox" checked={state.useScores} onChange={this.onChangeUseScores} />
							<input type="checkbox" checked={state.useScores} onChange={this.onChangeUseScores} name="use_scores" />
							{" "}<Text id="useScoring" />
						</label>
					</p>


@@ 99,7 100,7 @@ class PageContent extends Component<Props, State> {
					</p>
					<p>
						<label>
							<input type="checkbox" checked={state.shufflePlayers} onChange={this.onChangeShufflePlayers} />
							<input type="checkbox" checked={state.shufflePlayers} onChange={this.onChangeShufflePlayers} name="shufflePlayers" />
							{" "}<Text id="shufflePlayers" />
						</label>
					</p>

M rosebush/src/client/pages/register.tsx => rosebush/src/client/pages/register.tsx +1 -1
@@ 37,7 37,7 @@ export default class RegisterPage extends Component<Props, State> {

					<h1><Text id="register" /></h1>
					{userInfo !== null && <p><Text id="pages_register_anonymousUpgrade" /></p>}
					<form onSubmit={this.submit.bind(this)}>
					<form method="POST" action="/formSubmit/register" onSubmit={this.submit.bind(this)}>
						<p>
							<label>
								<Text id="username" />: <input type="text" name="username" />

A rosebush/src/client/preload.ts => rosebush/src/client/preload.ts +46 -0
@@ 0,0 1,46 @@
import { Component, createContext } from "preact";
import { useContext } from "preact/hooks";

import Data, { LoadState } from "./Data";

export const PreloadedDataContext = createContext<{take(): unknown} | undefined>(undefined);

export function addData<P, S, T extends Component<P, S>>(
	clazz: new () => T,
	fn: (props: P, fetch: typeof window.fetch) => Partial<{
		[K in keyof S]: S[K] extends LoadState<infer X> ? Promise<X> : never;
	}>
) {
	(clazz as any).__loadData = fn;

	const oldCWM = clazz.prototype.componentWillMount;
	clazz.prototype.componentWillMount = function() {
		const dataCtx = useContext(PreloadedDataContext);
		const dataMaybe = dataCtx && dataCtx.take();
		if(typeof dataMaybe !== "undefined") {
			const data = dataMaybe as Partial<S>;
			(data as any).__preloaded = true;

			this.setState(data);
		}

		if(oldCWM) oldCWM.apply(this, arguments);
	};

	const oldCDM = clazz.prototype.componentDidMount;
	clazz.prototype.componentDidMount = function() {
		if(!(this.state as any).__preloaded) {
			const content = fn(this.props, fetch);
			for(const key in content) {
				const req = content[key as keyof typeof content] as (Promise<unknown> | undefined);
				if(typeof req !== "undefined") {
					Data.wrapPromise(req)
						.then(result => {
							this.setState({[key]: result});
						});
				}
			}
		}
		if(oldCDM) oldCDM.apply(this, arguments);
	}
}

M rosebush/src/client/routes.ts => rosebush/src/client/routes.ts +2 -0
@@ 5,6 5,8 @@ const routes: Array<{path: string; component: string}> = [
	{path: "/myBrackets", component: "myBrackets"},
	{path: "/register", component: "register"},
	{path: "/login", component: "login"},
	{path: "/dialog/bracketInfoEdit/:bracketID", component: "dialog/bracketInfoEdit"},
	{path: "/dialog/matchEdit/:bracketID/:matchID", component: "dialog/matchEdit"},
];

export default routes;

M rosebush/src/server/index.tsx => rosebush/src/server/index.tsx +251 -37
@@ 1,18 1,25 @@
import * as acceptLanguageNegotiator from "accept-language-negotiator";
import * as fs from "fs";
import getStream from "get-stream";
import * as http from "http";
import * as httpProxy from "http-proxy";
import isomorphicCookie from "isomorphic-cookie";
import fetch from "node-fetch";
import jsesc from "jsesc";
import fetch, { HeadersInit, RequestInit } from "node-fetch";
import * as path from "path";
import { Fragment, h } from "preact";
import { Fragment, JSX, h } from "preact";
import { Dictionary, IntlProvider } from "preact-jsx-i18n";
import { render } from "preact-render-to-string";
import { Route, RouteProps } from "preact-router";
import { Route, RouteProps, Router } from "preact-router";
import * as serveStatic from "serve-static";
import * as querystring from "querystring";
import { shuffle } from "@pacote/shuffle";

import App from "../client/App";
import App, { UserInfo } from "../client/App";
import { COOKIE_AGE } from "../client/constants";
import Data, { LoadState } from "../client/Data";
import Helmet, { rewindClear } from "../client/Helmet";
import { PreloadedDataContext } from "../client/preload";
import routes from "../client/routes";

const API_HOST = process.env.API_HOST || "http://localhost:5000";


@@ 33,22 40,8 @@ interface RouteInfo {
	component: string;
}

let lastRoute: null | RouteInfo = null;

function rewindRouteClear() {
	const result = lastRoute;
	lastRoute = null;

	return result;
}

function TracingRoute(props: RouteProps<any> & {routeInfo: RouteInfo}) {
	lastRoute = props.routeInfo;
	return <Route {...props} />;
}

const routesChildren = routes.map(info => {
	return <TracingRoute path={info.path} component={require("../client/pages/" + info.component).default} routeInfo={info} />;
	return <Route path={info.path} component={require("../client/pages/" + info.component).default} routeInfo={info} />;
});

const routePreloads: {[key: string]: string[]} = {};


@@ 77,16 70,21 @@ apiServer.on("proxyReq", function(proxyReq, req) {
	}
});

function apiFetch(srcReq: http.IncomingMessage, url: string): ReturnType<typeof fetch> {
function apiFetchRaw(srcReq: http.IncomingMessage, url: string, init?: RequestInit): ReturnType<typeof fetch> {
	const token = isomorphicCookie.load("rosebushToken", srcReq);

	const headers: {[key: string]: string} = {};
	const headers: HeadersInit = (init && init.headers) ? init.headers : {};

	if(token) {
		headers["Authorization"] = "Bearer " + token;
	if(token && !("Authorization" in headers)) {
		// probably shouldn't cast this, but it's fine for now
		(headers as any).Authorization = "Bearer " + token;
	}

	return fetch(API_HOST + "/" + url, {headers})
	return fetch(API_HOST + "/" + url, {...init, headers});
}

function apiFetch(srcReq: http.IncomingMessage, url: string, init?: RequestInit): ReturnType<typeof fetch> {
	return apiFetchRaw(srcReq, url, init)
		.then(res => {
			if(res.status < 200 || res.status >= 300) {
				return res.text()


@@ 96,10 94,36 @@ function apiFetch(srcReq: http.IncomingMessage, url: string): ReturnType<typeof 
		});
}

const API_PREFIX = "/api/";

async function prepareData(srcReq: http.IncomingMessage, node: JSX.Element): Promise<unknown> {
	function selfFetch(url: string, init?: RequestInit) {
		if(url.startsWith(API_PREFIX)) {
			return apiFetchRaw(srcReq, url.substring(API_PREFIX.length), init);
		}
		else {
			throw new Error("Cannot preload data from outside of API");
		}
	}

	if(node && node.type === Route && "__loadData" in node.props.component) {
		const content = (node.props.component as any).__loadData(node.props, selfFetch);
		const data: {[K in keyof typeof content]: LoadState<unknown>} = {};
		await Promise.all(Object.keys(content).map(key => {
			return Data.wrapPromise(content[key])
			.then(result => {
					data[key] = result;
			});
		}));

		return data;
	}
}

http.createServer(async (req_, res) => {
	const req = req_ as (typeof req_ & {url: string});
	if(req.url.startsWith("/api/")) {
		req.url = req.url.substring(4);
	if(req.url.startsWith(API_PREFIX)) {
		req.url = req.url.substring(API_PREFIX.length - 1);
		apiServer.web(req, res, {target: API_HOST}, err => {
			console.error(err);
			try {


@@ 124,6 148,187 @@ http.createServer(async (req_, res) => {
			res.end();
		});
	}
	else if(req.url.startsWith("/formSubmit/")) {
		if(req.method !== "POST") {
			res.writeHead(405);
			res.write("formSubmit can only be used with POST");
			res.end();
			return;
		}
		const rest = req.url.substring(12);
		try {
			const fields = querystring.decode(await getStream(req));
			if(rest === "login") {
				const resp = await apiFetchRaw(req, "unstable/loginSessions", {method: "POST", body: JSON.stringify({
					username: fields.username,
					password: fields.password,
				})});

				if(resp.status < 200 || resp.status >= 300) {
					const text = await resp.text();
					res.writeHead(resp.status);
					res.write(text);
					res.end();
					return;
				}

				const result = await resp.json();

				const token = result.token;

				res.writeHead(
					303,
					{"Set-Cookie": "rosebushToken=" + token + "; Path=/; Max-Age=" + COOKIE_AGE, Location: fields.continue || "/myBrackets"}
				);
				res.write("Successfully logged in.");
				res.end();
			}
			else if(rest === "register") {
				const resp = await apiFetchRaw(req, "v1/users", {method: "POST", body: JSON.stringify({
					username: fields.username,
					password: fields.password,
				})});

				if(resp.status < 200 || resp.status >= 300) {
					const text = await resp.text();
					res.writeHead(resp.status);
					res.write(text);
					res.end();
					return;
				}

				const result = await resp.json();

				const token = result.token;

				res.writeHead(
					303,
					{"Set-Cookie": "rosebushToken=" + token + "; Path=/; Max-Age=" + COOKIE_AGE, Location: fields.continue || "/myBrackets"}
				);
				res.write("Successfully logged in.");
				res.end();
			}
			else if(rest === "newBracket") {
				let newUserToken = null;
				const newHeaders: {[key: string]: string} = {};
				if(fields.newUser) {
					const resp = await apiFetch(req, "v1/users", {method: "POST"})
						.then(resp => resp.json());

					newUserToken = resp.token;
				}

				if(newUserToken !== null) {
					newHeaders.Authorization = "Bearer " + newUserToken;
				}	

				let players = String(fields.playerList).split("\n");

				if(fields.shufflePlayers) {
					players = shuffle(players);
				}

				const resp = await apiFetchRaw(req, "v1/brackets", {method: "POST", body: JSON.stringify({
					name: fields.bracketName,
					players,
					type: fields.bracketType,
					use_scores: !!fields.use_scores,
				}), headers: newHeaders});

				if(resp.status < 200 || resp.status >= 300) {
					const text = await resp.text();
					res.writeHead(resp.status);
					res.write(text);
					res.end();
					return;
				}

				const result = await resp.json();

				const newID = result.id;

				const outHeaders: {[key: string]: string} = {
					Location: "/bracket/" + newID,
				};

				if(newUserToken !== null) {
					outHeaders["Set-Cookie"] = "rosebushToken=" + newUserToken + "; Path=/; Max-Age=" + COOKIE_AGE;
				}

				res.writeHead(
					303,
					outHeaders,
				);
				res.write("Created bracket " + newID);
				res.end();
			}
			else if(rest === "bracketInfoEdit") {
				const resp = await apiFetchRaw(
					req,
					"v1/brackets/" + fields.bracket,
					{
						method: "PATCH",
						body: JSON.stringify({
							name: fields.bracketName,
							description_md: fields.description_md,
						}),
					},
				);

				if(resp.status < 200 || resp.status >= 300) {
					const text = await resp.text();
					res.writeHead(resp.status);
					res.write(text);
					res.end();
					return;
				}

				res.writeHead(303, {Location: "/bracket/" + fields.bracket});
				res.write("Successfully edited.");
				res.end();
			}
			else if(rest === "matchEdit") {
				let winner;

				if(fields.winner === "null") winner = null;
				else winner = parseInt(String(fields.winner), 10);

				const resp = await apiFetchRaw(
					req,
					"v1/brackets/" + fields.bracket + "/matches/" + fields.match,
					{
						method: "PATCH",
						body: JSON.stringify({
							scores: (fields["score[]"] as string[]).map(str => parseInt(str, 10)),
							winner,
						}),
					},
				);

				if(resp.status < 200 || resp.status >= 300) {
					const text = await resp.text();
					res.writeHead(resp.status);
					res.write(text);
					res.end();
					return;
				}

				res.writeHead(303, {Location: "/bracket/" + fields.bracket});
				res.write("Successfully edited.");
				res.end();
			}
			else {
				res.writeHead(404);
				res.write("Not Found");
				res.end();
			}
		} catch(e) {
			console.error(e);
			res.writeHead(500);
			res.write("Internal Server Error");
			res.end();
		}
	}
	else {
		const acceptLanguage = req.headers["accept-language"];
		const language = acceptLanguage ?


@@ 131,22 336,30 @@ http.createServer(async (req_, res) => {
			"en";
		const dictionary = lang[language];
		try {
			const userInfo = isomorphicCookie.load("rosebushToken", req) ?
				await apiFetch(req, "unstable/users/~me")
					.then(res => res.json()) :
				null;
			const currentRoute: JSX.Element | undefined = Router.prototype.getMatchingChildren(routesChildren, req.url, true)[0];

			const app = <IntlProvider dictionary={dictionary}>
				<App url={req.url} initUserInfo={userInfo} routes={routesChildren} />
			</IntlProvider>;
			const [userInfo, preloadedData]: [UserInfo | null | undefined, unknown] = await Promise.all([
					isomorphicCookie.load("rosebushToken", req) ?
						apiFetch(req, "unstable/users/~me")
							.then(res => res.json()) :
						null,
					prepareData(req, currentRoute),
			]);

			const app = <PreloadedDataContext.Provider value={{take: () => preloadedData}}>
				<IntlProvider dictionary={dictionary}>
					<App url={req.url} initUserInfo={userInfo} routes={routesChildren} />
				</IntlProvider>
			</PreloadedDataContext.Provider>;

			const content = render(app, {}, {pretty: true});
			const helmetInfo = rewindClear();

			const routeInfo: RouteInfo | null = rewindRouteClear();
			const preloads = routeInfo === null ?
				[] :
				routePreloads[routeInfo.component];
			const routeInfo: RouteInfo | undefined = currentRoute?.props?.routeInfo;

			const preloads = routeInfo ?
				routePreloads[routeInfo.component] :
				[];

			const headContent = render(<Fragment>{Helmet.renderContent(helmetInfo)}</Fragment>, {}, {pretty: true});



@@ 160,6 373,7 @@ http.createServer(async (req_, res) => {
			var SOCKET_HOST = ${JSON.stringify(SOCKET_HOST)};
			var INIT_USER_INFO = ${JSON.stringify(userInfo)};
			var LANGUAGE = ${JSON.stringify(language)};
			var PRELOADED_DATA = ${jsesc(preloadedData, {isScriptContext: true})};
		</script>
		${preloads.map(file => {
			return `<link rel="preload" href="/static/${file}" as="script">`;