= ({ }) => {
8 | return hello {{pascalCase name}}
;
9 | }
--------------------------------------------------------------------------------
/blueprint-templates/component/__pascalCase_name__.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | interface Props {
4 |
5 | }
6 |
7 | export const {{pascalCase name}}: React.FC = ({ }) => {
8 | return hello {{pascalCase name}}
;
9 | }
--------------------------------------------------------------------------------
/blueprint-templates/context-hook/use__pascalCase_name__.tsx:
--------------------------------------------------------------------------------
1 | import createContainer from "constate";
2 | import { useContext } from "react";
3 |
4 | const {log, error, warn} = logger("use{{pascalCase name}}");
5 |
6 | function hook() {}
7 |
8 | export function use{{pascalCase name}}() {
9 | return useContext({{pascalCase name}}ContextContainer.Context);
10 | }
11 |
12 | export const {{pascalCase name}}ContextContainer = createContainer(hook);
13 |
--------------------------------------------------------------------------------
/blueprint-templates/function/__name__.ts:
--------------------------------------------------------------------------------
1 | interface Options {
2 |
3 | }
4 |
5 | export const {{name}} = ({}: Options): string => {
6 | return "foo";
7 | }
--------------------------------------------------------------------------------
/blueprint-templates/modal/Connected__pascalCase_name__Modal.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { {{pascalCase name}}Modal } from "./{{pascalCase name}}Modal";
3 | import { observer } from "mobx-react-lite";
4 |
5 | interface Props {
6 |
7 | }
8 |
9 | export const Connected{{pascalCase name}}Modal: React.FC = observer(({ }) => {
10 |
11 | const onClose = () => {};
12 | const visible = true;
13 |
14 | return <{{pascalCase name}}Modal visible={visible} onClose={onClose} />
15 | });
16 |
--------------------------------------------------------------------------------
/blueprint-templates/modal/__pascalCase_name__Modal.stories.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { storiesOf } from "@storybook/react";
3 | import { {{pascalCase name}}Modal } from "./{{pascalCase name}}Modal";
4 |
5 | const props: React.ComponentProps = {
6 | visible: false,
7 | onClose: storybookActionHandler(`onClose`)
8 | };
9 |
10 | storiesOf("{{pascalCase name}}Modal", module)
11 | .add("default", () => <{{pascalCase name}}Modal {...props} />)
12 | .add("visible", () => <{{pascalCase name}}Modal {...props} visible />)
13 |
--------------------------------------------------------------------------------
/blueprint-templates/modal/__pascalCase_name__Modal.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Vertical } from "gls";
3 |
4 | interface Props {
5 | visible: boolean;
6 | onClose: () => any;
7 | }
8 |
9 | export const {{pascalCase name}}Modal: React.FC = ({ visible, onClose }) => {
10 | return
17 |
18 | {{pascalCase name}}
19 |
20 |
21 |
22 | ;
23 | }
24 |
--------------------------------------------------------------------------------
/blueprint-templates/redux-store/__name__/actions.ts:
--------------------------------------------------------------------------------
1 | import { createAction } from "typesafe-actions";
2 |
3 | export const set{{pascalCase name}} = createAction(`{{name}}/set{{pascalCase name}}`)>();
4 |
--------------------------------------------------------------------------------
/blueprint-templates/redux-store/__name__/epics.ts:
--------------------------------------------------------------------------------
1 | import { logger } from "@project/essentials";
2 | import { ThunkAC } from "../types";
3 |
4 | const { log } = logger(`{{name}}`);
5 |
6 | export const someEpic = (): ThunkAC => (dispatch) => {};
7 |
--------------------------------------------------------------------------------
/blueprint-templates/redux-store/__name__/reducer.ts:
--------------------------------------------------------------------------------
1 | import { createReducer } from "typesafe-actions";
2 | import { defaultState, {{pascalCase name}}State, {{pascalCase name}}Actions } from "./types";
3 | import { set{{pascalCase name}} } from "./actions";
4 | import { update } from "typescript-immutable-utils";
5 |
6 | export const reducer = createReducer<{{pascalCase name}}State, {{pascalCase name}}Actions>(defaultState()).handleAction(
7 | set{{pascalCase name}},
8 | (state, {payload}) => update(state, { {{name}}: payload })
9 | );
10 |
--------------------------------------------------------------------------------
/blueprint-templates/redux-store/__name__/selectors.ts:
--------------------------------------------------------------------------------
1 | import { AppState } from "../types";
2 |
3 | export const select{{pascalCase name}} = (state: AppState) => state.{{name}}.{{name}};
4 |
--------------------------------------------------------------------------------
/blueprint-templates/redux-store/__name__/types.ts:
--------------------------------------------------------------------------------
1 | import { ActionType } from "typesafe-actions";
2 | import * as actions from "./actions";
3 | import { validate as _validate } from "@project/essentials";
4 | import * as t from "io-ts";
5 |
6 | export type {{pascalCase name}}Actions = ActionType;
7 |
8 | export const {{name}}Codec = t.strict({
9 | {{name}}: t.record(t.string, t.any)
10 | });
11 |
12 | export interface {{pascalCase name}}State extends t.TypeOf {}
13 |
14 | const encode = (s: {{pascalCase name}}State) => {{name}}Codec.encode(s);
15 |
16 | export const validate = (s?: Partial<{{pascalCase name}}State>): {{pascalCase name}}State =>
17 | _validate<{{pascalCase name}}State>(
18 | {{name}}Codec,
19 | encode({
20 | {{name}}: s?.{{name}} ?? {},
21 | })
22 | );
23 |
24 | export const defaultState = () => validate();
25 |
--------------------------------------------------------------------------------
/blueprint-templates/story/__pascalCase_name__.stories.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { storiesOf } from "@storybook/react";
3 |
4 | const props: React.ComponentProps = {
5 | };
6 |
7 | storiesOf("{{pascalCase name}}", module)
8 | .add("default", () => <{{pascalCase name}} {...props} />)
9 |
--------------------------------------------------------------------------------
/blueprint-templates/type/__pascalCase_name__.ts:
--------------------------------------------------------------------------------
1 | import * as t from "io-ts";
2 |
3 | export const {{pascalCase name}} = t.intersection([
4 | t.strict({
5 |
6 | }),
7 | t.partial({
8 |
9 | })
10 | ]);
11 |
12 | export interface {{pascalCase name}} extends t.TypeOf {}
13 |
14 | export const produce{{pascalCase name}} = (overrides?: Partial<{{pascalCase name}}> & {}): {{pascalCase name}} =>
15 | {{pascalCase name}}.encode({
16 | ...overrides,
17 | });
18 |
--------------------------------------------------------------------------------
/build-and-deploy-dev.ps1:
--------------------------------------------------------------------------------
1 | # First lets build the server
2 | yarn server build
3 |
4 | # Now we can publish the server
5 | yarn server deploy
6 |
7 | # Now lets build the site ensuring that its going to point to the corrcet location
8 | yarn cross-env VITE_SERVER_ROOT="https://clouds-and-edges-server-dev.mikeysee.workers.dev" yarn build
9 |
10 | # Now we can deploy the site to the worker
11 | yarn site-worker deploy
12 |
--------------------------------------------------------------------------------
/docs/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikecann/clouds-and-edges/e144ca463702660bd2047857af9fa3a6e702b5e6/docs/images/logo.png
--------------------------------------------------------------------------------
/docs/images/youtube-thumb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikecann/clouds-and-edges/e144ca463702660bd2047857af9fa3a6e702b5e6/docs/images/youtube-thumb.png
--------------------------------------------------------------------------------
/jest.config.base.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: ["/src"],
3 | clearMocks: true,
4 | transform: {
5 | "^.+\\.[tj]sx?$": "ts-jest",
6 | },
7 | moduleFileExtensions: ["ts", "tsx", "js", "json"],
8 | verbose: true,
9 | globals: {
10 | "ts-jest": {
11 | isolatedModules: true,
12 | },
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "packages": [
3 | "packages/*"
4 | ],
5 | "npmClient": "yarn",
6 | "useWorkspaces": true,
7 | "version": "0.0.1"
8 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "clouds-and-edges",
4 | "version": "1.0.0",
5 | "workspaces": [
6 | "packages/*"
7 | ],
8 | "description": "A Serverless Databaseless Event-Sourced Game",
9 | "module": "./dist/index.mjs",
10 | "scripts": {
11 | "dev": "yarn compile --watch",
12 | "compile": "tsc --build",
13 | "build": "yarn compile && lerna run build --stream",
14 | "deploy:dev": "yarn cross-env VITE_SERVER_ROOT=\"https://clouds-and-edges-server-dev.mikeysee.workers.dev\" yarn site build && yarn site-worker deploy && yarn server deploy",
15 | "clean:ts": "rimraf **/dist **/.mf **/tsconfig.tsbuildinfo",
16 | "clean:deps": "rimraf **/node_modules **/yarn-error.log **/yarn.lock",
17 | "clean:full": "yarn clean:deps && yarn clean:ts",
18 | "lint": "lerna run lint --stream",
19 | "test": "lerna run test --stream",
20 | "barrel": "lerna run barrel --stream",
21 | "site": "cd packages/site && yarn run",
22 | "server": "cd packages/server && yarn run",
23 | "shared": "cd packages/shared && yarn run",
24 | "workers-es": "cd packages/workers-es && yarn run",
25 | "essentials": "cd packages/essentials && yarn run",
26 | "site-worker": "cd packages/site-worker && yarn run"
27 | },
28 | "devDependencies": {
29 | "@types/jest": "^26.0.24",
30 | "@types/node": "^16.4.10",
31 | "@typescript-eslint/eslint-plugin": "^4.29.3",
32 | "@typescript-eslint/parser": "^4.29.3",
33 | "cross-env": "^7.0.3",
34 | "esbuild": "^0.12.18",
35 | "eslint": "^7.32.0",
36 | "eslint-config-prettier": "^8.3.0",
37 | "eslint-config-standard": "^16.0.3",
38 | "eslint-plugin-import": "^2.24.2",
39 | "eslint-plugin-node": "^11.1.0",
40 | "eslint-plugin-promise": "^5.1.0",
41 | "jest": "^27.0.6",
42 | "jest-extended": "^0.11.5",
43 | "lerna": "^4.0.0",
44 | "prettier": "^2.3.2",
45 | "rimraf": "^3.0.2",
46 | "ts-jest": "^27.0.4",
47 | "ts-node": "^10.1.0",
48 | "tsconfig-paths": "^3.10.1",
49 | "typescript": "^4.4.2"
50 | },
51 | "author": "mike.cann@gmail.com",
52 | "license": "MIT"
53 | }
54 |
--------------------------------------------------------------------------------
/packages/essentials/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "no-restricted-imports": ["error", "@project/essentials"],
4 | "no-use-before-define": "off"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/packages/essentials/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | ...require(`../../jest.config.base.js`),
3 | displayName: "essentials",
4 | };
5 |
--------------------------------------------------------------------------------
/packages/essentials/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "@project/essentials",
4 | "version": "1.0.0",
5 | "description": "the essential util, no other project deps",
6 | "main": "src/index.ts",
7 | "scripts": {
8 | "test": "jest",
9 | "barrel": "barrelsby -d src -c ../../barrelsby.json",
10 | "lint": "eslint ./src"
11 | },
12 | "devDependencies": {
13 | "barrelsby": "^2.2.0"
14 | },
15 | "dependencies": {
16 | "variant": "^2.1.0",
17 | "zod": "^3.7.2"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/essentials/src/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Automatically generated by barrelsby.
3 | */
4 |
5 | export * from "./logging/logging";
6 | export * from "./match/experimental";
7 | export * from "./match/kind";
8 | export * from "./match/nonDiscriminatedMatch";
9 | export * from "./rpc/rpc";
10 | export * from "./utils/colors";
11 | export * from "./utils/direction";
12 | export * from "./utils/emojis";
13 | export * from "./utils/ensure";
14 | export * from "./utils/id";
15 | export * from "./utils/misc";
16 | export * from "./utils/object";
17 | export * from "./utils/point2D";
18 | export * from "./utils/prng";
19 | export * from "./utils/rand";
20 | export * from "./utils/random";
21 | export * from "./utils/response";
22 |
--------------------------------------------------------------------------------
/packages/essentials/src/logging/logging.ts:
--------------------------------------------------------------------------------
1 | //import { Logger, ISettingsParam } from "tslog";
2 |
3 | export const getLogger = (name: string): Logger => ({
4 | log: (...args: any[]) => console.log(`[${name}]`, ...args),
5 | debug: (...args: any[]) => console.debug(`[${name}]`, ...args),
6 | info: (...args: any[]) => console.info(`[${name}]`, ...args),
7 | error: (...args: any[]) => console.error(`[${name}]`, ...args),
8 | warn: (...args: any[]) => console.warn(`[${name}]`, ...args),
9 | });
10 |
11 | export interface Logger {
12 | log: (...args: any[]) => void;
13 | debug: (...args: any[]) => void;
14 | info: (...args: any[]) => void;
15 | error: (...args: any[]) => void;
16 | warn: (...args: any[]) => void;
17 | }
18 |
19 | export const nullLogger: Logger = {
20 | log: () => {},
21 | debug: () => {},
22 | info: () => {},
23 | error: () => {},
24 | warn: () => {},
25 | };
26 |
--------------------------------------------------------------------------------
/packages/essentials/src/match/kind.ts:
--------------------------------------------------------------------------------
1 | import { matchImpl } from "./experimental";
2 |
3 | const { match } = matchImpl(`kind`);
4 |
5 | export const matchKind = match;
6 |
--------------------------------------------------------------------------------
/packages/essentials/src/rpc/rpc.ts:
--------------------------------------------------------------------------------
1 | import { z, ZodObject } from "zod";
2 |
3 | export interface RPCOperation {
4 | input: ZodObject;
5 | output: ZodObject;
6 | }
7 |
8 | export type RPCOperations = Record;
9 |
10 | export type OperationNames = keyof RPCOperations;
11 |
12 | export type OperationInput<
13 | TApi extends RPCOperations,
14 | TOperation extends keyof TApi = string
15 | > = z.infer;
16 |
17 | export type OperationOutput<
18 | TApi extends RPCOperations,
19 | TOperation extends keyof TApi = string
20 | > = z.infer;
21 |
22 | // export const RPCRequest = z.object({
23 | // endpoint: z.string(),
24 | // payload: z.unknown().optional(),
25 | // });
26 | //
27 | // export interface RPCRequest extends z.infer {}
28 | //
29 | // export const RPCSuccessResponse = z.object({
30 | // kind: z.literal("success"),
31 | // payload: z.unknown().optional(),
32 | // });
33 | //
34 | // export type RPCSuccessResponse = z.infer;
35 | //
36 | // export const RPCFailResponse = z.object({
37 | // kind: z.literal("fail"),
38 | // message: z.string(),
39 | // });
40 | // export type RPCFailResponse = z.infer;
41 | //
42 | // export const RPCResponse = z.union([RPCSuccessResponse, RPCFailResponse]);
43 | //
44 | // export type RPCResponse = z.infer;
45 |
--------------------------------------------------------------------------------
/packages/essentials/src/utils/colors.ts:
--------------------------------------------------------------------------------
1 | export const someNiceColors = [
2 | "#F692BC",
3 | "#F4ADC6",
4 | "#FDFD95",
5 | "#AAC5E2",
6 | "#6891C3",
7 | "#C9C1E7",
8 | "#BDD5EF",
9 | "#C7E3D0",
10 | "#E7E6CE",
11 | "#F2D8CC",
12 | "#E9CCCE",
13 | "#C6D5D8",
14 | "#E8DCD5",
15 | "#DCD2D3",
16 | "#FCDABE",
17 | "#F2C7CC",
18 | "#C0C2E2",
19 | "#C2E7F1",
20 | "#D7F2CE",
21 | "#F3FBD2",
22 | ];
23 |
--------------------------------------------------------------------------------
/packages/essentials/src/utils/direction.ts:
--------------------------------------------------------------------------------
1 | export type Direction = "up" | "down" | "left" | "right";
2 |
--------------------------------------------------------------------------------
/packages/essentials/src/utils/ensure.ts:
--------------------------------------------------------------------------------
1 | export const ensureNotUndefined = (
2 | obj: T | undefined,
3 | err = `variable was undefined when it shouldnt have been.`
4 | ): T => {
5 | if (obj === undefined) throw new Error(err);
6 | return obj;
7 | };
8 |
9 | export const ensureNotNull = (
10 | obj: T | null,
11 | err = `variable was null when it shouldnt have been.`
12 | ): T => {
13 | if (obj === null) throw new Error(err);
14 | return obj;
15 | };
16 |
17 | export const ensure = (
18 | obj: T | undefined | null,
19 | err = `variable was undefined or null when it shouldnt have been.`
20 | ): T => {
21 | obj = ensureNotUndefined(obj, err);
22 | obj = ensureNotNull(obj, err);
23 | return obj;
24 | };
25 |
26 | export const ensureNotUndefinedFP = (
27 | err = `variable was undefined when it shouldnt have been.`
28 | ) => (obj: T | undefined): T => {
29 | if (obj === undefined) throw new Error(err);
30 | return obj;
31 | };
32 |
33 | export const ensureFP = ensureNotUndefinedFP;
34 |
--------------------------------------------------------------------------------
/packages/essentials/src/utils/id.ts:
--------------------------------------------------------------------------------
1 | import { getRandom } from "./random";
2 |
3 | /**
4 | * Note this was borrowed from: https://github.com/ai/nanoid/blob/main/non-secure/index.js
5 | */
6 |
7 | // This alphabet uses `A-Za-z0-9_-` symbols. The genetic algorithm helped
8 | // optimize the gzip compression for this alphabet.
9 | const urlAlphabet = "ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW";
10 |
11 | export const generateId = (rng = getRandom(), size = 21) => {
12 | let id = "";
13 | // A compact alternative for `for (var i = 0; i < step; i++)`.
14 | let i = size;
15 | while (i--) {
16 | // `| 0` is more compact and faster than `Math.floor()`.
17 | id += urlAlphabet[(rng * 64) | 0];
18 | }
19 | return id;
20 | };
21 |
--------------------------------------------------------------------------------
/packages/essentials/src/utils/misc.ts:
--------------------------------------------------------------------------------
1 | export function wait(ms: number) {
2 | return new Promise((resolve, reject) => setTimeout(resolve, ms));
3 | }
4 |
5 | export const iife = (fn: () => T): T => fn();
6 |
7 | export function leftFillNum(num: number, targetLength: number) {
8 | return num.toString().padStart(targetLength, "0");
9 | }
10 |
11 | export function narray(count: number) {
12 | return [...Array(Math.floor(count)).keys()];
13 | }
14 |
--------------------------------------------------------------------------------
/packages/essentials/src/utils/object.ts:
--------------------------------------------------------------------------------
1 | export const getInObj = function , U extends string>(
2 | obj: T,
3 | key: U
4 | ): any {
5 | if (key in obj == false) {
6 | const keys = Object.keys(obj);
7 | throw new Error(
8 | `Cannot get '${key}' from the given object, the property doesnt exist in the given object. Other properties that do exist are: '${
9 | keys.length > 20
10 | ? keys.slice(0, 20).join(", ") + ` ... <${keys.length - 20} more>`
11 | : keys.slice(0, 20).join(", ")
12 | }'`
13 | );
14 | }
15 |
16 | return (obj as any)[key];
17 | };
18 |
19 | export const findInObj = function , U extends string>(
20 | obj: T,
21 | key: U
22 | ): any {
23 | return (obj as any)[key];
24 | };
25 |
26 | // export const recordFromUnion = (kindables: T[]): Record => {
27 | // const obj: any = {};
28 | // for(let kindable of kindables)
29 | // obj[kindable.kind] =
30 | // }
31 |
32 | export const clone = (obj: T): T => JSON.parse(JSON.stringify(obj));
33 |
--------------------------------------------------------------------------------
/packages/essentials/src/utils/point2D.ts:
--------------------------------------------------------------------------------
1 | export interface Point2D {
2 | x: number;
3 | y: number;
4 | }
5 |
6 | export const equals = (p1: Point2D, p2: Point2D): boolean => p1.x == p2.x && p1.y == p2.y;
7 |
--------------------------------------------------------------------------------
/packages/essentials/src/utils/rand.ts:
--------------------------------------------------------------------------------
1 | import { getRandom } from "./random";
2 |
3 | export const randomIndex = (items: T[]): number => Math.floor(getRandom() * items.length);
4 |
5 | export const randomOne = (items: T[]): T => items[randomIndex(items)];
6 |
--------------------------------------------------------------------------------
/packages/essentials/src/utils/random.test.ts:
--------------------------------------------------------------------------------
1 | import { getRandom, setRandomSeed } from "./random";
2 |
3 | it(`works`, () => {
4 | setRandomSeed(1);
5 | expect(getRandom()).toBe(0.8678360471967608);
6 | expect(getRandom()).toBe(0.6197745203971863);
7 | expect(getRandom()).toBe(0.39490225445479155);
8 | });
9 |
--------------------------------------------------------------------------------
/packages/essentials/src/utils/random.ts:
--------------------------------------------------------------------------------
1 | import { PRNG } from "./prng";
2 |
3 | let random = new PRNG();
4 |
5 | export const getRandom = () => {
6 | const rng = random.next();
7 | return rng;
8 | };
9 |
10 | export const setRandomSeed = (seed: number) => {
11 | random = new PRNG([seed, seed, seed, seed]);
12 | };
13 |
--------------------------------------------------------------------------------
/packages/essentials/src/utils/response.ts:
--------------------------------------------------------------------------------
1 | export interface Success {
2 | kind: `success`;
3 | payload: T;
4 | }
5 |
6 | export interface Fail {
7 | kind: `fail`;
8 | message: string;
9 | }
10 |
11 | export type Result = Success | Fail;
12 |
--------------------------------------------------------------------------------
/packages/essentials/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "outDir": "./dist",
6 | "rootDir": "./src",
7 | "baseUrl": ".",
8 | "lib": ["es2019", "dom"],
9 | "types": ["jest", "node"]
10 | },
11 | "include": ["src/**/*"]
12 | }
13 |
--------------------------------------------------------------------------------
/packages/server/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "no-restricted-imports": ["error", "@project/server"],
4 | "no-use-before-define": "off"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/packages/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "@project/server",
4 | "version": "1.0.0",
5 | "module": "./dist/main.mjs",
6 | "scripts": {
7 | "script": "ts-node --transpile-only --require tsconfig-paths/register --project ./scripts/tsconfig.json",
8 | "build": "yarn script ./scripts/build.ts",
9 | "dev": "miniflare dist/main.mjs --watch --debug --do-persist --port 8777",
10 | "deploy": "wrangler publish",
11 | "tail": "wrangler tail --format pretty",
12 | "lint": "eslint ./src"
13 | },
14 | "author": "mike.cann@gmail.com",
15 | "license": "MIT",
16 | "devDependencies": {
17 | "@cloudflare/workers-types": "^2.2.1",
18 | "miniflare": "^1.4.0"
19 | },
20 | "dependencies": {
21 | "@project/shared": "*",
22 | "@project/workers-es": "*",
23 | "itty-router": "^2.4.2"
24 | }
25 | }
26 |
27 |
28 |
--------------------------------------------------------------------------------
/packages/server/scripts/build.ts:
--------------------------------------------------------------------------------
1 | import { build } from "esbuild";
2 |
3 | const isWatchMode = () => false;
4 | const config = {};
5 |
6 | async function bootstrap() {
7 | console.log(`starting`, config);
8 |
9 | await build({
10 | entryPoints: ["./src/main.ts"],
11 | bundle: true,
12 | format: "esm",
13 | outdir: `dist`,
14 | outExtension: { ".js": ".mjs" },
15 | sourcemap: "external",
16 | platform: "node",
17 | target: [`node14`],
18 | external: [],
19 | watch: isWatchMode()
20 | ? {
21 | onRebuild(err, result) {
22 | if (err) console.error("watch build failed:", err);
23 | else console.log("watch build succeeded");
24 | },
25 | }
26 | : undefined,
27 | banner: {
28 | js: `const process = { env: { SEED_RNG: "true" } };`,
29 | },
30 | });
31 |
32 | console.log(`build complete`);
33 | }
34 |
35 | bootstrap()
36 | .then(() => {
37 | if (!isWatchMode()) {
38 | console.log(`build done, exiting now.`);
39 | process.exit(0);
40 | }
41 | })
42 | .catch((e) => {
43 | console.error(e);
44 | process.exit(1);
45 | });
46 |
--------------------------------------------------------------------------------
/packages/server/scripts/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "module": "CommonJS"
6 | },
7 | "include": ["**/*.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/server/src/aggregates/aggregates.ts:
--------------------------------------------------------------------------------
1 | import { Env } from "../env";
2 | import { AggregateKinds } from "@project/shared";
3 |
4 | export const aggregates: Record = {
5 | user: `UserAggregate`,
6 | match: `MatchAggregate`,
7 | };
8 |
--------------------------------------------------------------------------------
/packages/server/src/aggregates/match/MatchAggregate.ts:
--------------------------------------------------------------------------------
1 | import { commands } from "./commands";
2 | import { reducers } from "./reducers";
3 | import { Env } from "../../env";
4 | import { AggreateDurableObject } from "@project/workers-es";
5 | import { system } from "../../system";
6 |
7 | export class MatchAggregate extends AggreateDurableObject {
8 | constructor(objectState: DurableObjectState, env: Env) {
9 | super(objectState, env, system, "match", commands, reducers as any);
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/server/src/aggregates/match/events.ts:
--------------------------------------------------------------------------------
1 | import { Point2D } from "@project/essentials";
2 | import { Line, LineDirection, MatchSettings, Player, PlayerId } from "@project/shared";
3 |
4 | export interface MatchCreated {
5 | kind: "match-created";
6 | payload: {
7 | createdByUserId: string;
8 | settings: MatchSettings;
9 | };
10 | }
11 | export interface MatchJoinRequested {
12 | kind: "match-join-requested";
13 | payload: {
14 | userId: string;
15 | };
16 | }
17 |
18 | export interface MatchJoined {
19 | kind: "match-joined";
20 | payload: {
21 | player: Player;
22 | };
23 | }
24 |
25 | export interface MatchStarted {
26 | kind: "match-started";
27 | payload: {
28 | firstPlayerToTakeATurn: string;
29 | };
30 | }
31 |
32 | export interface MatchCancelled {
33 | kind: "match-cancelled";
34 | payload: Record;
35 | }
36 |
37 | export interface MatchTurnTaken {
38 | kind: "match-turn-taken";
39 | payload: {
40 | line: Line;
41 | nextPlayerToTakeTurn: PlayerId;
42 | };
43 | }
44 |
45 | export interface MatchFinished {
46 | kind: "match-finished";
47 | payload: {
48 | winner: string;
49 | };
50 | }
51 |
52 | export type MatchEvent =
53 | | MatchCreated
54 | | MatchJoinRequested
55 | | MatchJoined
56 | | MatchStarted
57 | | MatchCancelled
58 | | MatchTurnTaken
59 | | MatchFinished;
60 |
--------------------------------------------------------------------------------
/packages/server/src/aggregates/match/reducers.ts:
--------------------------------------------------------------------------------
1 | import { MatchAggregateState } from "./state";
2 | import { AggregateReducers } from "@project/workers-es";
3 | import { MatchEvent } from "./events";
4 |
5 | export const reducers: AggregateReducers = {
6 | "match-created": ({ state, aggregateId, timestamp, payload }) => ({
7 | ...state,
8 | id: aggregateId,
9 | createdAt: timestamp,
10 | createdByUserId: payload.createdByUserId,
11 | settings: payload.settings,
12 | status: "not-started",
13 | players: [],
14 | lines: [],
15 | }),
16 | "match-cancelled": ({ state, timestamp }) => ({
17 | ...state,
18 | cancelledAt: timestamp,
19 | status: "cancelled",
20 | }),
21 | "match-join-requested": ({ state, payload }) => ({
22 | ...state,
23 | }),
24 | "match-joined": ({ state, payload }) => ({
25 | ...state,
26 | players: [...state.players, payload.player],
27 | }),
28 | "match-started": ({ state, payload }) => ({
29 | ...state,
30 | status: "playing",
31 | nextPlayerToTakeTurn: payload.firstPlayerToTakeATurn,
32 | lines: [],
33 | }),
34 | "match-turn-taken": ({ state, payload }) => ({
35 | ...state,
36 | nextPlayerToTakeTurn: payload.nextPlayerToTakeTurn,
37 | lines: [...state.lines, payload.line],
38 | }),
39 | "match-finished": ({ state, payload }) => ({
40 | ...state,
41 | status: "finished",
42 | }),
43 | };
44 |
--------------------------------------------------------------------------------
/packages/server/src/aggregates/match/state.ts:
--------------------------------------------------------------------------------
1 | import { Line, MatchSettings, PlayerId, Player, MatchStatus } from "@project/shared";
2 |
3 | export interface MatchAggregateState {
4 | id?: string;
5 | createdAt?: number;
6 | cancelledAt?: number;
7 | createdByUserId?: string;
8 | players: Player[];
9 | settings?: MatchSettings;
10 | lines: Line[];
11 | status: MatchStatus;
12 | nextPlayerToTakeTurn?: PlayerId;
13 | winner?: PlayerId;
14 | }
15 |
--------------------------------------------------------------------------------
/packages/server/src/aggregates/user/UserAggregate.ts:
--------------------------------------------------------------------------------
1 | import { commands } from "./commands";
2 | import { reducers } from "./reducers";
3 | import { Env } from "../../env";
4 | import { AggreateDurableObject } from "@project/workers-es";
5 | import { system } from "../../system";
6 |
7 | export class UserAggregate extends AggreateDurableObject {
8 | constructor(objectState: DurableObjectState, env: Env) {
9 | super(objectState, env, system, "user", commands, reducers as any);
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/server/src/aggregates/user/commands.ts:
--------------------------------------------------------------------------------
1 | import { UserAggregateState } from "./state";
2 | import { UserEvent } from "./events";
3 | import { AggregateCommandHandlers } from "@project/workers-es";
4 | import { UserCommands } from "@project/shared";
5 | import { emojis, randomOne, someNiceColors } from "@project/essentials";
6 |
7 | export const commands: AggregateCommandHandlers = {
8 | create: ({ state, payload }) => {
9 | if (state.createdAt) throw new Error(`user already created`);
10 | return {
11 | kind: `user-created`,
12 | payload: {
13 | name: payload.name,
14 | avatar: randomOne(emojis),
15 | color: randomOne(someNiceColors),
16 | },
17 | };
18 | },
19 | "set-name": ({ state, payload }) => {
20 | if (!state.createdAt) throw new Error(`user not created`);
21 | return {
22 | kind: `user-name-set`,
23 | payload: {
24 | name: payload.name,
25 | },
26 | };
27 | },
28 | "create-match-request": ({ state, payload, userId }) => {
29 | if (!state.createdAt) throw new Error(`user not created`);
30 | return {
31 | kind: `user-create-match-requested`,
32 | payload: {
33 | size: payload.size,
34 | userId,
35 | },
36 | };
37 | },
38 | };
39 |
--------------------------------------------------------------------------------
/packages/server/src/aggregates/user/events.ts:
--------------------------------------------------------------------------------
1 | import { CreateMatchSize } from "@project/shared";
2 |
3 | export interface UserCreated {
4 | kind: "user-created";
5 | payload: {
6 | name: string;
7 | avatar: string;
8 | };
9 | }
10 |
11 | export interface UserNameSet {
12 | kind: "user-name-set";
13 | payload: {
14 | name: string;
15 | };
16 | }
17 |
18 | export interface UserCreateMatchRequested {
19 | kind: "user-create-match-requested";
20 | payload: {
21 | userId: string;
22 | size: CreateMatchSize;
23 | };
24 | }
25 |
26 | export type UserEvent = UserCreated | UserNameSet | UserCreateMatchRequested;
27 |
--------------------------------------------------------------------------------
/packages/server/src/aggregates/user/reducers.ts:
--------------------------------------------------------------------------------
1 | import { UserAggregateState } from "./state";
2 | import { AggregateReducers } from "@project/workers-es";
3 | import { UserEvent } from "./events";
4 |
5 | export const reducers: AggregateReducers = {
6 | "user-created": ({ state, aggregateId, timestamp, payload }) => ({
7 | ...state,
8 | id: aggregateId,
9 | createdAt: timestamp,
10 | name: payload.name,
11 | avatar: payload.avatar,
12 | }),
13 | "user-name-set": ({ state }) => ({
14 | ...state,
15 | name: state.name,
16 | }),
17 | "user-create-match-requested": ({ state }) => state,
18 | };
19 |
--------------------------------------------------------------------------------
/packages/server/src/aggregates/user/state.ts:
--------------------------------------------------------------------------------
1 | export interface UserAggregateState {
2 | id?: string;
3 | createdAt?: number;
4 | name?: string;
5 | avatar?: string;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/server/src/env.ts:
--------------------------------------------------------------------------------
1 | export interface Env {
2 | [key: string]: DurableObjectNamespace;
3 | UserAggregate: DurableObjectNamespace;
4 | MatchAggregate: DurableObjectNamespace;
5 |
6 | EventStore: DurableObjectNamespace;
7 |
8 | UsersProjection: DurableObjectNamespace;
9 | MatchesProjection: DurableObjectNamespace;
10 |
11 | MatchCreationProcess: DurableObjectNamespace;
12 | MatchJoiningProcess: DurableObjectNamespace;
13 | }
14 |
--------------------------------------------------------------------------------
/packages/server/src/events/EventStore.ts:
--------------------------------------------------------------------------------
1 | import { Env } from "../env";
2 | import { BaseEventStore } from "@project/workers-es";
3 | import { system } from "../system";
4 |
5 | export class EventStore extends BaseEventStore {
6 | constructor(objectState: DurableObjectState, env: Env) {
7 | super(objectState, env, system);
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/server/src/events/events.ts:
--------------------------------------------------------------------------------
1 | import { UserEvent } from "../aggregates/user/events";
2 | import { MatchEvent } from "../aggregates/match/events";
3 |
4 | export type Events = UserEvent | MatchEvent;
5 |
--------------------------------------------------------------------------------
/packages/server/src/main.ts:
--------------------------------------------------------------------------------
1 | import { Env } from "./env";
2 | import { router } from "./routes";
3 |
4 | // Todo: theres a better way of doing this by exporting these from the system but
5 | // I cant quite work it out right now
6 | export { UserAggregate } from "./aggregates/user/UserAggregate";
7 | export { MatchAggregate } from "./aggregates/match/MatchAggregate";
8 |
9 | export { UsersProjection } from "./projections/users/UsersProjection";
10 | export { MatchesProjection } from "./projections/matches/MatchesProjection";
11 |
12 | export { MatchCreationProcess } from "./processes/matchCreation/MatchCreationProcess";
13 | export { MatchJoiningProcess } from "./processes/matchJoining/MatchJoiningProcess";
14 |
15 | export { EventStore } from "./events/EventStore";
16 |
17 | // We want to randomise the seed
18 |
19 | export default {
20 | async fetch(request: Request, env: Env): Promise {
21 | try {
22 | const response = await router.handle(request, env);
23 | if (response) {
24 | response.headers.set("Access-Control-Allow-Origin", "*");
25 | response.headers.set(`Access-Control-Allow-Headers`, "*");
26 | }
27 | return response;
28 | } catch (e) {
29 | console.error(`main fetch caught error`, e);
30 | const errorMessage = e instanceof Error ? e.message : e + "";
31 | const response = new Response(errorMessage, {
32 | status: 500,
33 | });
34 | response.headers.set("Access-Control-Allow-Origin", "*");
35 | return response;
36 | }
37 | },
38 | };
39 |
--------------------------------------------------------------------------------
/packages/server/src/processes/matchCreation/MatchCreationProcess.ts:
--------------------------------------------------------------------------------
1 | import { Env } from "../../env";
2 | import { ProcessDurableObject } from "@project/workers-es";
3 | import { system } from "../../system";
4 | import { getHandlers } from "./eventHandlers";
5 | import { createDb } from "./db";
6 |
7 | export class MatchCreationProcess extends ProcessDurableObject {
8 | constructor(objectState: DurableObjectState, env: any) {
9 | super(objectState, getHandlers(createDb(objectState.storage)) as any, env, system);
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/server/src/processes/matchCreation/db.ts:
--------------------------------------------------------------------------------
1 | import { createSimpleDb } from "@project/workers-es";
2 |
3 | type Entities = {
4 | user: {
5 | id: string;
6 | name: string;
7 | activeMatches: number;
8 | avatar: string;
9 | };
10 | };
11 |
12 | export const createDb = (storage: DurableObjectStorage) => createSimpleDb({ storage });
13 |
14 | export type Db = ReturnType;
15 |
--------------------------------------------------------------------------------
/packages/server/src/processes/matchCreation/eventHandlers.ts:
--------------------------------------------------------------------------------
1 | import { ProcessEventHandlers } from "@project/workers-es";
2 | import { Events } from "../../events/events";
3 | import { getLogger } from "@project/essentials";
4 | import { Commands } from "@project/shared";
5 | import { Db } from "./db";
6 |
7 | const logger = getLogger(`UsersProjection-handlers`);
8 |
9 | // Todo: handle active match incrementing and decrementing in here
10 | // Todo: handle match join rejection too
11 |
12 | export const getHandlers = (db: Db): ProcessEventHandlers => ({
13 | handlers: {
14 | // Remember this user
15 | "user-created": async ({ event }) => {
16 | await db.put("user", {
17 | id: event.aggregateId,
18 | name: event.payload.name,
19 | activeMatches: 0,
20 | avatar: event.payload.avatar,
21 | });
22 | },
23 |
24 | // Remember if they change their name
25 | "user-name-set": async ({ event }) => {
26 | await db.put("user", {
27 | ...(await db.get("user", event.aggregateId)),
28 | name: event.payload.name,
29 | });
30 | },
31 |
32 | // The main event
33 | "user-create-match-requested": async ({ event, effects }) => {
34 | const user = await db.get("user", event.payload.userId);
35 |
36 | // User may only join if they have a max of 3 matches
37 | if (user.activeMatches > 3) return;
38 |
39 | // First we create the match
40 | const response = await effects.executeCommand({
41 | aggregate: "match",
42 | kind: "create",
43 | payload: {
44 | size: event.payload.size,
45 | createdByUserId: event.payload.userId,
46 | },
47 | });
48 |
49 | // Its possible that we are being rebuilt. In which case the command wont actually
50 | // get executed and thus there will be no response so we can just end here
51 | if (!response) return;
52 |
53 | // Then we automatically join the first player as the creator
54 | await effects.executeCommand({
55 | aggregate: "match",
56 | kind: "join",
57 | aggregateId: response.aggregateId,
58 | payload: {
59 | userId: user.id,
60 | name: user.name,
61 | color: `#d5aae9`, // user.color,
62 | avatar: user.avatar,
63 | },
64 | });
65 | },
66 | },
67 | effects: {
68 | sendEmail: async (toUserId: string) => {
69 | // todo
70 | },
71 | },
72 | });
73 |
--------------------------------------------------------------------------------
/packages/server/src/processes/matchJoining/MatchJoiningProcess.ts:
--------------------------------------------------------------------------------
1 | import { Env } from "../../env";
2 | import { Processes } from "@project/shared";
3 | import { ProcessDurableObject } from "@project/workers-es";
4 | import { system } from "../../system";
5 | import { getHandlers } from "./eventHandlers";
6 | import { createDb } from "./db";
7 |
8 | type API = Processes["matchJoining"];
9 |
10 | export class MatchJoiningProcess extends ProcessDurableObject {
11 | constructor(objectState: DurableObjectState, env: any) {
12 | super(objectState, getHandlers(createDb(objectState.storage)) as any, env, system);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/packages/server/src/processes/matchJoining/db.ts:
--------------------------------------------------------------------------------
1 | import { createSimpleDb } from "@project/workers-es";
2 |
3 | type Entities = {
4 | user: {
5 | id: string;
6 | name: string;
7 | activeMatches: number;
8 | avatar: string;
9 | };
10 | match: {
11 | id: string;
12 | };
13 | };
14 |
15 | export const createDb = (storage: DurableObjectStorage) => createSimpleDb({ storage });
16 |
17 | export type Db = ReturnType;
18 |
--------------------------------------------------------------------------------
/packages/server/src/processes/matchJoining/eventHandlers.ts:
--------------------------------------------------------------------------------
1 | import { ProcessEventHandlers } from "@project/workers-es";
2 | import { Events } from "../../events/events";
3 | import { getLogger } from "@project/essentials";
4 | import { Commands } from "@project/shared";
5 | import { Db } from "./db";
6 |
7 | const logger = getLogger(`UsersProjection-handlers`);
8 |
9 | // Todo: handle active match incrementing and decrementing in here
10 | // Todo: handle match join rejection too
11 |
12 | export const getHandlers = (db: Db): ProcessEventHandlers => ({
13 | handlers: {
14 | // Remember this user
15 | "user-created": async ({ event }) => {
16 | await db.put("user", {
17 | id: event.aggregateId,
18 | name: event.payload.name,
19 | activeMatches: 0,
20 | avatar: event.payload.avatar,
21 | });
22 | },
23 |
24 | // Remember if they change their name
25 | "user-name-set": async ({ event }) => {
26 | await db.put("user", {
27 | ...(await db.get("user", event.aggregateId)),
28 | name: event.payload.name,
29 | });
30 | },
31 |
32 | "match-join-requested": async ({ event, effects }) => {
33 | // Lets grab the user that wants to join from storage
34 | const user = await db.get("user", event.payload.userId);
35 |
36 | // User may only join if they have a max of 3 matches
37 | if (user.activeMatches > 3) return;
38 |
39 | // First we join the player to the match
40 | await effects.executeCommand({
41 | aggregate: "match",
42 | kind: "join",
43 | aggregateId: event.aggregateId,
44 | payload: {
45 | userId: user.id,
46 | name: user.name,
47 | color: `#D7F2CE`,
48 | avatar: user.avatar,
49 | },
50 | });
51 | },
52 | },
53 | effects: {
54 | sendEmail: (toUserId: string) => {
55 | // todo
56 | },
57 | },
58 | });
59 |
--------------------------------------------------------------------------------
/packages/server/src/projections/matches/MatchesProjection.ts:
--------------------------------------------------------------------------------
1 | import { Env } from "../../env";
2 | import { Projections } from "@project/shared";
3 | import { RPCApiHandler, RPCHandler, ProjectionDurableObject } from "@project/workers-es";
4 | import { getHandlers } from "./eventHandlers";
5 | import { system } from "../../system";
6 | import { createDb, Db } from "./db";
7 |
8 | type API = Projections["matches"];
9 |
10 | export class MatchesProjection extends ProjectionDurableObject implements RPCApiHandler {
11 | private db: Db;
12 |
13 | constructor(objectState: DurableObjectState, env: Env) {
14 | super(objectState, getHandlers(createDb(objectState.storage)) as any, env, system);
15 | this.db = createDb(objectState.storage);
16 | }
17 |
18 | getMatch: RPCHandler = async ({ id }) => {
19 | return await this.db.get("match", id);
20 | };
21 |
22 | getOpen: RPCHandler = async ({ excludePlayer }) => {
23 | const ids = await this.db
24 | .list("open", { prefix: "" })
25 | .then((map) => [...map.keys()].map((k) => k.replace(`open:`, ``)));
26 |
27 | const matches = await Promise.all(ids.map((id) => this.db.get("match", id)));
28 |
29 | return matches.filter((m) => m.players.some((p) => p.id == excludePlayer) == false);
30 | };
31 |
32 | getForPlayer: RPCHandler = async ({ playerId }) => {
33 | const player = await this.db.find("player", playerId);
34 | const matches = player?.matches ?? [];
35 | return await Promise.all(matches.map((id) => this.db.get("match", id)));
36 | };
37 | }
38 |
--------------------------------------------------------------------------------
/packages/server/src/projections/matches/db.ts:
--------------------------------------------------------------------------------
1 | import { Id, MatchProjection } from "@project/shared";
2 | import { createSimpleDb } from "@project/workers-es";
3 |
4 | type Entities = {
5 | match: MatchProjection;
6 | open: {
7 | id: Id;
8 | };
9 | player: {
10 | id: Id;
11 | matches: Id[];
12 | }
13 | };
14 |
15 | export const createDb = (storage: DurableObjectStorage) => createSimpleDb({ storage });
16 |
17 | export type Db = ReturnType;
18 |
--------------------------------------------------------------------------------
/packages/server/src/projections/matches/eventHandlers.ts:
--------------------------------------------------------------------------------
1 | import { ProjectionEventHandlers } from "@project/workers-es";
2 | import { Events } from "../../events/events";
3 | import { Db } from "./db";
4 |
5 | export const getHandlers = (db: Db): ProjectionEventHandlers => ({
6 | "match-created": async ({ event: { aggregateId, payload, timestamp } }) => {
7 | await db.put("open", { id: aggregateId });
8 | await db.put("match", {
9 | id: aggregateId,
10 | settings: payload.settings,
11 | createdByUserId: payload.createdByUserId,
12 | createdAt: timestamp,
13 | players: [],
14 | status: "not-started",
15 | turns: [],
16 | nextPlayerToTakeTurn: payload.createdByUserId,
17 | });
18 | },
19 |
20 | "match-cancelled": async ({
21 | event: {
22 | aggregateId,
23 | payload: {},
24 | },
25 | }) => {
26 | await db.remove("open", aggregateId);
27 | await db.update("match", aggregateId, (match) => ({
28 | ...match,
29 | status: "cancelled",
30 | }));
31 | },
32 |
33 | "match-joined": async ({ event: { aggregateId, payload } }) => {
34 | await db.update("match", aggregateId, (match) => ({
35 | ...match,
36 | players: [...match.players, payload.player],
37 | }));
38 |
39 | const player = await db.find("player", payload.player.id);
40 | // Limit the player to 10 matches stored for performance
41 | const matches = [aggregateId, ...(player?.matches ?? [])].slice(0, 10);
42 | await db.put(`player`, { id: payload.player.id, matches });
43 | },
44 |
45 | "match-started": async ({ event: { aggregateId, payload } }) => {
46 | await db.remove("open", aggregateId);
47 | await db.update("match", aggregateId, (match) => ({
48 | ...match,
49 | status: "playing",
50 | nextPlayerToTakeTurn: payload.firstPlayerToTakeATurn,
51 | }));
52 | },
53 |
54 | "match-turn-taken": async ({ event: { aggregateId, payload, timestamp } }) => {
55 | await db.update("match", aggregateId, (match) => ({
56 | ...match,
57 | status: "playing",
58 | nextPlayerToTakeTurn: payload.nextPlayerToTakeTurn,
59 | turns: [
60 | ...match.turns,
61 | {
62 | line: payload.line,
63 | timestamp,
64 | },
65 | ],
66 | }));
67 | },
68 |
69 | "match-finished": async ({ event: { aggregateId, payload } }) => {
70 | await db.update("match", aggregateId, (match) => ({
71 | ...match,
72 | status: "finished",
73 | winner: payload.winner,
74 | }));
75 | },
76 | });
77 |
--------------------------------------------------------------------------------
/packages/server/src/projections/users/UsersProjection.ts:
--------------------------------------------------------------------------------
1 | import { Env } from "../../env";
2 | import { Projections, UserProjection } from "@project/shared";
3 | import { RPCApiHandler, RPCHandler, ProjectionDurableObject } from "@project/workers-es";
4 | import { getHandlers } from "./eventHandlers";
5 | import { system } from "../../system";
6 |
7 | type API = Projections["users"];
8 |
9 | export class UsersProjection extends ProjectionDurableObject implements RPCApiHandler {
10 | constructor(objectState: DurableObjectState, env: Env) {
11 | super(objectState, getHandlers(objectState.storage) as any, env, system);
12 | }
13 |
14 | findUserById: RPCHandler = async ({ id }) => {
15 | const val = await this.storage.get(`user:${id}`);
16 | return {
17 | user: val ? (val as UserProjection) : null,
18 | };
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/packages/server/src/projections/users/eventHandlers.ts:
--------------------------------------------------------------------------------
1 | import { ProjectionEventHandlers } from "@project/workers-es";
2 | import { Events } from "../../events/events";
3 | import { getLogger } from "@project/essentials";
4 |
5 | const logger = getLogger(`UsersProjection-handlers`);
6 |
7 | export const getHandlers = (storage: DurableObjectStorage): ProjectionEventHandlers => ({
8 | "user-created": async ({ event: { aggregateId, payload } }) => {
9 | logger.debug(`UsersProjection user-created`, aggregateId, payload);
10 | await storage.put(`user:${aggregateId}`, {
11 | id: aggregateId,
12 | name: (payload as any).name,
13 | avatar: payload.avatar,
14 | });
15 | logger.debug(`UsersProjection stored`);
16 | },
17 | "user-name-set": async ({ event: { payload, aggregateId } }) => {
18 | logger.debug(`UsersProjection user-name-set`, aggregateId, payload);
19 | const user = await storage.get(`user:${aggregateId}`);
20 | await storage.put(`user:${aggregateId}`, {
21 | ...(user as any),
22 | name: (payload as any).name,
23 | });
24 | logger.debug(`UsersProjection updated user`);
25 | },
26 | });
27 |
--------------------------------------------------------------------------------
/packages/server/src/system.ts:
--------------------------------------------------------------------------------
1 | import { createSystem } from "@project/workers-es";
2 | import { UserAggregate } from "./aggregates/user/UserAggregate";
3 | import { MatchAggregate } from "./aggregates/match/MatchAggregate";
4 | import { UsersProjection } from "./projections/users/UsersProjection";
5 | import { EventStore } from "./events/EventStore";
6 | import { MatchesProjection } from "./projections/matches/MatchesProjection";
7 | import { MatchJoiningProcess } from "./processes/matchJoining/MatchJoiningProcess";
8 | import { MatchCreationProcess } from "./processes/matchCreation/MatchCreationProcess";
9 |
10 | export const system = createSystem({
11 | namespaces: {
12 | aggregates: {
13 | user: UserAggregate,
14 | match: MatchAggregate,
15 | },
16 |
17 | projections: {
18 | users: UsersProjection,
19 | matches: MatchesProjection,
20 | },
21 |
22 | processes: {
23 | matchJoining: MatchJoiningProcess,
24 | matchCreation: MatchCreationProcess,
25 | },
26 |
27 | events: EventStore,
28 | },
29 | });
30 |
--------------------------------------------------------------------------------
/packages/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "outDir": "./dist",
6 | "rootDir": "./src",
7 | "baseUrl": ".",
8 | "lib": ["esnext", "webworker"],
9 | "noEmit": true,
10 | "experimentalDecorators": true,
11 | "emitDecoratorMetadata": true,
12 | "types": ["jest", "node", "@cloudflare/workers-types"],
13 | "paths": {
14 | "@project/essentials": ["../essentials/src/index.ts"],
15 | "@project/shared": ["../shared/src/index.ts"],
16 | "@project/workers-es": ["../workers-es/src/index.ts"]
17 | }
18 | },
19 | "include": ["src/**/*"],
20 | "references": [
21 | { "path": "../essentials/tsconfig.json" },
22 | { "path": "../shared/tsconfig.json" },
23 | { "path": "../workers-es/tsconfig.json" }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/packages/server/wrangler.toml:
--------------------------------------------------------------------------------
1 | compatibility_date = "2021-10-10"
2 |
3 | name = "clouds-and-edges-server-dev"
4 | type = "javascript"
5 | account_id = ""
6 | workers_dev = true
7 | route = ""
8 | zone_id = ""
9 |
10 | [build.upload]
11 | format = "modules"
12 | dir = "dist"
13 | main = "./main.mjs"
14 | rules = [{type = "Data", globs = ["**/*.html"]}]
15 |
16 | [build]
17 | command = "yarn build"
18 |
19 | [durable_objects]
20 | bindings = [
21 | {name = "UserAggregate", class_name = "UserAggregate"},
22 | {name = "MatchAggregate", class_name = "MatchAggregate"},
23 |
24 | {name = "EventStore", class_name = "EventStore"},
25 |
26 | {name = "UsersProjection", class_name = "UsersProjection"},
27 | {name = "MatchesProjection", class_name = "MatchesProjection"},
28 |
29 | {name = "MatchCreationProcess", class_name = "MatchCreationProcess"},
30 | {name = "MatchJoiningProcess", class_name = "MatchJoiningProcess"},
31 | ]
32 |
33 | [env.main.durable_objects]
34 | bindings = [
35 | {name = "UserAggregate", class_name = "UserAggregate"},
36 | {name = "MatchAggregate", class_name = "MatchAggregate"},
37 |
38 | {name = "EventStore", class_name = "EventStore"},
39 |
40 | {name = "UsersProjection", class_name = "UsersProjection"},
41 | {name = "MatchesProjection", class_name = "MatchesProjection"},
42 |
43 | {name = "MatchCreationProcess", class_name = "MatchCreationProcess"},
44 | {name = "MatchJoiningProcess", class_name = "MatchJoiningProcess"},
45 | ]
46 |
47 | [[migrations]]
48 | tag = "v1"
49 | new_classes = [
50 | "UserAggregate",
51 | "MatchAggregate",
52 |
53 | "EventStore",
54 |
55 | "UsersProjection",
56 | "MatchesProjection",
57 |
58 | "MatchCreationProcess",
59 | "MatchJoiningProcess"
60 | ]
61 |
62 |
63 | [env.main]
64 | name = "clouds-and-edges-server"
--------------------------------------------------------------------------------
/packages/shared/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "no-restricted-imports": ["error", "@project/shared"],
4 | "no-use-before-define": "off"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/packages/shared/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | ...require(`../../jest.config.base.js`),
3 | displayName: "shared",
4 | };
5 |
--------------------------------------------------------------------------------
/packages/shared/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "@project/shared",
4 | "version": "1.0.0",
5 | "description": "",
6 | "main": "src/index.ts",
7 | "scripts": {
8 | "test": "jest",
9 | "barrel": "barrelsby -d src -c ../../barrelsby.json",
10 | "lint": "eslint ./src"
11 | },
12 | "devDependencies": {
13 | "barrelsby": "^2.2.0"
14 | },
15 | "dependencies": {
16 | "@project/essentials": "*",
17 | "@project/workers-es": "*",
18 | "tslog": "^3.2.1",
19 | "zod": "^3.7.2"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/shared/src/aggregates.ts:
--------------------------------------------------------------------------------
1 | import { UserCommands } from "./user/commands";
2 | import { MatchCommands } from "./match/commands";
3 |
4 | export interface Aggregates {
5 | user: UserCommands;
6 | match: MatchCommands;
7 | }
8 |
9 | export type AggregateKinds = keyof Aggregates;
10 |
--------------------------------------------------------------------------------
/packages/shared/src/api.ts:
--------------------------------------------------------------------------------
1 | import { Result } from "@project/essentials";
2 | import { Projections } from "./projections";
3 | import { DurableObjectIdentifier, QueryStorageAPI, StoredEvent } from "@project/workers-es";
4 | import { AggregateKinds } from "./aggregates";
5 | import { MatchProjection } from "./match/projections";
6 |
7 | export type API = {
8 | "projections.users.findUserById": Projections["users"]["findUserById"];
9 | "projections.matches.getMine": {
10 | input: Record;
11 | output: MatchProjection[];
12 | };
13 | "projections.matches.getMatch": Projections["matches"]["getMatch"];
14 | "projections.matches.getOpen": Projections["matches"]["getOpen"];
15 | "admin.queryStorage": {
16 | input: {
17 | identifier: DurableObjectIdentifier;
18 | input: QueryStorageAPI["input"];
19 | };
20 | output: QueryStorageAPI["output"];
21 | };
22 | "admin.rebuild": {
23 | input: {
24 | identifier: DurableObjectIdentifier;
25 | input: Record;
26 | };
27 | output: Record;
28 | };
29 | "auth.signup": {
30 | input: {
31 | name: string;
32 | };
33 | output: {
34 | userId: string;
35 | };
36 | };
37 | command: {
38 | input: {
39 | aggregate: AggregateKinds;
40 | aggregateId?: string;
41 | command: string;
42 | payload: unknown;
43 | };
44 | output: Result<{
45 | aggregateId: string;
46 | }>;
47 | };
48 | };
49 |
50 | export type APIOperations = keyof API;
51 |
52 | export type APIOperationInput = API[TOperation]["input"];
53 | export type APIOperationOutput = API[TOperation]["output"];
54 |
--------------------------------------------------------------------------------
/packages/shared/src/commands.ts:
--------------------------------------------------------------------------------
1 | import { UserCommands } from "./user/commands";
2 | import { MatchCommands } from "./match/commands";
3 |
4 | export type Commands = UserCommands | MatchCommands;
5 |
--------------------------------------------------------------------------------
/packages/shared/src/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Automatically generated by barrelsby.
3 | */
4 |
5 | export * from "./aggregates";
6 | export * from "./api";
7 | export * from "./commands";
8 | export * from "./processes";
9 | export * from "./projections";
10 | export * from "./match/commands";
11 | export * from "./match/match";
12 | export * from "./match/projections";
13 | export * from "./modal/cell";
14 | export * from "./modal/computeCellStates";
15 | export * from "./modal/doesAddingLineFinishACell";
16 | export * from "./modal/dot";
17 | export * from "./modal/id";
18 | export * from "./modal/line";
19 | export * from "./modal/player";
20 | export * from "./modal/score";
21 | export * from "./user/commands";
22 | export * from "./user/projections";
23 |
--------------------------------------------------------------------------------
/packages/shared/src/match/commands.ts:
--------------------------------------------------------------------------------
1 | import { CreateMatchSize } from "./match";
2 | import { Dot } from "../modal/dot";
3 | import { LineDirection } from "../modal/line";
4 | import { PlayerId } from "../modal/player";
5 |
6 | interface Base {
7 | aggregate: `match`;
8 | }
9 |
10 | interface Create extends Base {
11 | kind: `create`;
12 | payload: {
13 | createdByUserId: string;
14 | size: CreateMatchSize;
15 | };
16 | }
17 |
18 | interface Cancel extends Base {
19 | kind: `cancel`;
20 | payload: Record;
21 | }
22 |
23 | interface JoinRequest extends Base {
24 | kind: `join-request`;
25 | payload: Record;
26 | }
27 |
28 | interface Join extends Base {
29 | kind: `join`;
30 | payload: {
31 | userId: string;
32 | name: string;
33 | avatar: string;
34 | color: string;
35 | };
36 | }
37 |
38 | interface Start extends Base {
39 | kind: `start`;
40 | payload: {
41 | firstPlayerToTakeATurn: PlayerId;
42 | };
43 | }
44 |
45 | interface TakeTurn extends Base {
46 | kind: `take-turn`;
47 | payload: {
48 | from: Dot;
49 | direction: LineDirection;
50 | };
51 | }
52 |
53 | export type MatchCommands = Create | Cancel | JoinRequest | Join | Start | TakeTurn;
54 |
--------------------------------------------------------------------------------
/packages/shared/src/match/match.ts:
--------------------------------------------------------------------------------
1 | import { matchLiteral } from "variant";
2 |
3 | export interface Dimensions2d {
4 | width: number;
5 | height: number;
6 | }
7 |
8 | export interface MatchSettings {
9 | gridSize: Dimensions2d;
10 | maxPlayers: 2;
11 | }
12 |
13 | export type CreateMatchSize = "small" | "medium" | "large";
14 |
15 | export const createMatchSizeToDimensions = (size: CreateMatchSize): Dimensions2d =>
16 | matchLiteral(size, {
17 | small: () => ({ width: 3, height: 3 }),
18 | medium: () => ({ width: 5, height: 5 }),
19 | large: () => ({ width: 7, height: 7 }),
20 | });
21 |
--------------------------------------------------------------------------------
/packages/shared/src/match/projections.ts:
--------------------------------------------------------------------------------
1 | import { Id } from "../modal/id";
2 | import { MatchSettings } from "./match";
3 | import { Line, LineDirection } from "../modal/line";
4 | import { PlayerId, Player } from "../modal/player";
5 | import { Dot } from "../modal/dot";
6 |
7 | export type MatchStatus = "not-started" | "cancelled" | "playing" | "finished";
8 |
9 | export interface PlayerTurn {
10 | line: Line;
11 | timestamp: number;
12 | }
13 |
14 | export interface MatchProjection {
15 | id: Id;
16 | settings: MatchSettings;
17 | createdAt: number;
18 | createdByUserId: PlayerId;
19 | nextPlayerToTakeTurn: PlayerId;
20 | turns: PlayerTurn[];
21 | players: Player[];
22 | status: MatchStatus;
23 | winner?: PlayerId;
24 | }
25 |
26 | export interface MatchesProjections {
27 | matches: {
28 | getOpen: {
29 | input: {
30 | excludePlayer?: PlayerId;
31 | };
32 | output: MatchProjection[];
33 | };
34 | getForPlayer: {
35 | input: { playerId: string };
36 | output: MatchProjection[];
37 | };
38 | getMatch: {
39 | input: {
40 | id: string;
41 | };
42 | output: MatchProjection;
43 | };
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/packages/shared/src/modal/cell.ts:
--------------------------------------------------------------------------------
1 | import { ensure, equals, narray, Point2D } from "@project/essentials";
2 | import { Dimensions2d } from "../match/match";
3 | import { PlayerId } from "./player";
4 | import { Dot, getDotInDirection } from "./dot";
5 | import { matchLiteral } from "variant";
6 |
7 | export type CellPosition = Point2D;
8 |
9 | export interface CellState {
10 | position: CellPosition;
11 | owner?: PlayerId;
12 | }
13 |
14 | export const produceCellStates = (
15 | dimensions: Dimensions2d = { width: 3, height: 3 }
16 | ): CellState[] =>
17 | narray(dimensions.height)
18 | .map((y) => narray(dimensions.width).map((x) => ({ position: { x, y } })))
19 | .flat();
20 |
21 | export const isCellAt =
22 | (pos: CellPosition) =>
23 | (cell: CellState): boolean =>
24 | equals(cell.position, pos);
25 |
26 | export const isCellOwnedBy =
27 | (player: PlayerId) =>
28 | (cell: CellState): boolean =>
29 | cell.owner == player;
30 |
31 | export const findCellAt = (cells: CellState[], pos: CellPosition): CellState | undefined =>
32 | cells.find(isCellAt(pos));
33 |
34 | export const getCellAt = (cells: CellState[], pos: CellPosition): CellState =>
35 | ensure(findCellAt(cells, pos));
36 |
37 | export const getCell = (cells: CellState[], pos: CellPosition, owner: PlayerId): CellState =>
38 | ensure(cells.find((c) => isCellAt(pos)(c) && isCellOwnedBy(owner)(c)));
39 |
40 | export const areAllCellsOwned = (cells: CellState[]): boolean =>
41 | cells.every((cell) => cell.owner != undefined);
42 |
43 | export type CellCorner = "top-left" | "top-right" | "bottom-right" | "bottom-left";
44 |
45 | export const getDotForCellCorner = ({ x, y }: CellPosition, corner: CellCorner): Dot =>
46 | matchLiteral(corner, {
47 | "top-left": () => ({ x, y }),
48 | "top-right": () => ({ x: x + 1, y }),
49 | "bottom-left": () => ({ x, y: y + 1 }),
50 | "bottom-right": () => ({ x: x + 1, y: y + 1 }),
51 | });
52 |
53 | export const findCellCornerForDot = (dot: Dot, cellPos: CellPosition): CellCorner | undefined => {
54 | if (equals(dot, getDotForCellCorner(cellPos, "top-left"))) return "top-left";
55 | if (equals(dot, getDotForCellCorner(cellPos, "top-right"))) return "top-right";
56 | if (equals(dot, getDotForCellCorner(cellPos, "bottom-left"))) return "bottom-left";
57 | if (equals(dot, getDotForCellCorner(cellPos, "bottom-right"))) return "bottom-right";
58 | return undefined;
59 | };
60 |
--------------------------------------------------------------------------------
/packages/shared/src/modal/computeCellStates.test.ts:
--------------------------------------------------------------------------------
1 | import { computeCellStates } from "./computeCellStates";
2 | import { MatchSettings } from "../match/match";
3 | import { Line } from "./line";
4 |
5 | const settings: MatchSettings = {
6 | maxPlayers: 2,
7 | gridSize: {
8 | width: 3,
9 | height: 3,
10 | },
11 | };
12 |
13 | it(`works`, () => {
14 | const lines: Line[] = [
15 | { owner: "a", direction: "right", from: { x: 1, y: 1 } },
16 | { owner: "b", direction: "down", from: { x: 2, y: 1 } },
17 | { owner: "c", direction: "right", from: { x: 1, y: 1 } },
18 | { owner: "d", direction: "right", from: { x: 2, y: 2 } },
19 | { owner: "e", direction: "down", from: { x: 1, y: 1 } },
20 | ];
21 |
22 | const cells = computeCellStates({ settings, lines });
23 |
24 | expect(cells.map((c) => c.owner)).toEqual([
25 | undefined,
26 | undefined,
27 | undefined,
28 | undefined,
29 | "e",
30 | undefined,
31 | undefined,
32 | undefined,
33 | undefined,
34 | ]);
35 | });
36 |
--------------------------------------------------------------------------------
/packages/shared/src/modal/computeCellStates.ts:
--------------------------------------------------------------------------------
1 | import { MatchSettings } from "../match/match";
2 | import { getLinesAroundCell, Line } from "./line";
3 | import { CellState, produceCellStates } from "./cell";
4 |
5 | interface Options {
6 | settings: MatchSettings;
7 | lines: Line[];
8 | }
9 |
10 | export const computeCellStates = ({ settings, lines }: Options): CellState[] => {
11 | const cells = produceCellStates(settings.gridSize);
12 |
13 | for (const cell of cells) {
14 | const cellLines = getLinesAroundCell(lines, cell.position);
15 | if (cellLines.length == 4) cell.owner = cellLines[3].owner;
16 | }
17 |
18 | return cells;
19 | };
20 |
--------------------------------------------------------------------------------
/packages/shared/src/modal/doesAddingLineFinishACell.ts:
--------------------------------------------------------------------------------
1 | import { Line } from "./line";
2 | import { MatchSettings } from "../match/match";
3 | import { calculateTotalScore } from "./score";
4 | import { computeCellStates } from "./computeCellStates";
5 |
6 | interface Options {
7 | lines: Line[];
8 | newLine: Line;
9 | settings: MatchSettings;
10 | }
11 |
12 | export const doesAddingLineFinishACell = ({ lines, newLine, settings }: Options): boolean => {
13 | const scoreBefore = calculateTotalScore(computeCellStates({ lines, settings }));
14 | const scoreAfter = calculateTotalScore(computeCellStates({ lines, settings }));
15 | return scoreAfter > scoreBefore;
16 | };
17 |
--------------------------------------------------------------------------------
/packages/shared/src/modal/dot.ts:
--------------------------------------------------------------------------------
1 | import { Point2D, Direction } from "@project/essentials";
2 | import { matchLiteral } from "variant";
3 |
4 | export type Dot = Point2D;
5 |
6 | export const getDotInDirection = ({ x, y }: Dot, direction: Direction): Dot =>
7 | matchLiteral(direction, {
8 | up: () => ({ x, y: y - 1 }),
9 | down: () => ({ x, y: y + 1 }),
10 | left: () => ({ x: x - 1, y }),
11 | right: () => ({ x: x + 1, y }),
12 | });
13 |
--------------------------------------------------------------------------------
/packages/shared/src/modal/id.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const Id = z.string();
4 | export type Id = z.infer;
5 |
--------------------------------------------------------------------------------
/packages/shared/src/modal/line.test.ts:
--------------------------------------------------------------------------------
1 | import { isLineAroundCell } from "./line";
2 |
3 | describe(`isLineAroundCell`, () => {
4 | it(`works`, () => {
5 | expect(
6 | isLineAroundCell(
7 | {
8 | from: { x: 1, y: 1 },
9 | direction: "right",
10 | owner: "bob",
11 | },
12 | { x: 1, y: 1 }
13 | )
14 | ).toBe(true);
15 |
16 | expect(
17 | isLineAroundCell(
18 | {
19 | from: { x: 1, y: 1 },
20 | direction: "down",
21 | owner: "bob",
22 | },
23 | { x: 1, y: 1 }
24 | )
25 | ).toBe(true);
26 |
27 | expect(
28 | isLineAroundCell(
29 | {
30 | from: { x: 2, y: 1 },
31 | direction: "down",
32 | owner: "bob",
33 | },
34 | { x: 1, y: 1 }
35 | )
36 | ).toBe(true);
37 |
38 | expect(
39 | isLineAroundCell(
40 | {
41 | from: { x: 2, y: 1 },
42 | direction: "right",
43 | owner: "bob",
44 | },
45 | { x: 1, y: 1 }
46 | )
47 | ).toBe(false);
48 |
49 | expect(
50 | isLineAroundCell(
51 | {
52 | from: { x: 1, y: 2 },
53 | direction: "down",
54 | owner: "bob",
55 | },
56 | { x: 1, y: 1 }
57 | )
58 | ).toBe(false);
59 |
60 | expect(
61 | isLineAroundCell(
62 | {
63 | from: { x: 1, y: 2 },
64 | direction: "right",
65 | owner: "bob",
66 | },
67 | { x: 1, y: 1 }
68 | )
69 | ).toBe(true);
70 |
71 | expect(
72 | isLineAroundCell(
73 | {
74 | from: { x: 2, y: 2 },
75 | direction: "right",
76 | owner: "bob",
77 | },
78 | { x: 1, y: 1 }
79 | )
80 | ).toBe(false);
81 |
82 | expect(
83 | isLineAroundCell(
84 | {
85 | from: { x: 0, y: 1 },
86 | direction: "right",
87 | owner: "bob",
88 | },
89 | { x: 1, y: 1 }
90 | )
91 | ).toBe(false);
92 | });
93 | });
94 |
--------------------------------------------------------------------------------
/packages/shared/src/modal/line.ts:
--------------------------------------------------------------------------------
1 | import { Dot } from "./dot";
2 | import { PlayerId } from "./player";
3 | import { equals, Point2D } from "@project/essentials";
4 | import { findCellCornerForDot } from "./cell";
5 |
6 | export type LineDirection = "right" | "down";
7 |
8 | export interface Line {
9 | from: Dot;
10 | direction: LineDirection;
11 | owner: PlayerId;
12 | }
13 |
14 | export const isLineAroundCell = (line: Line, cellPos: Point2D) => {
15 | const from = findCellCornerForDot(line.from, cellPos);
16 |
17 | if (from == "top-left") return true;
18 | if (from == "top-right" && line.direction == "down") return true;
19 | if (from == "bottom-left" && line.direction == "right") return true;
20 |
21 | return false;
22 | };
23 |
24 | export const getLinesAroundCell = (lines: Line[], cellPos: Point2D) =>
25 | lines.filter((l) => isLineAroundCell(l, cellPos));
26 |
27 | export const findLineOwner = (
28 | lines: Line[],
29 | from: Dot,
30 | direction: LineDirection
31 | ): PlayerId | undefined =>
32 | lines.find((l) => equals(l.from, from) && l.direction == direction)?.owner;
33 |
--------------------------------------------------------------------------------
/packages/shared/src/modal/player.ts:
--------------------------------------------------------------------------------
1 | import { ensure, iife } from "@project/essentials";
2 |
3 | export type PlayerId = string;
4 |
5 | export interface Player {
6 | id: PlayerId;
7 | name: string;
8 | color: string;
9 | avatar: string;
10 | }
11 |
12 | export const producePlayerState = (options: { id: string; color?: string }): Player => ({
13 | color: "red",
14 | name: "",
15 | avatar: ":)",
16 | ...options,
17 | });
18 |
19 | export const getPlayer = (players: Player[], id: string): Player =>
20 | ensure(players.find((p) => p.id == id));
21 |
22 | export const getNextPlayer = (players: Player[], currentPlayer: PlayerId): Player => {
23 | const index = players.findIndex((p) => p.id == currentPlayer);
24 | if (index == -1) throw new Error(`player must be part of the given players`);
25 | const nextIndex = iife(() => {
26 | if (index + 1 > players.length - 1) return 0;
27 | return index + 1;
28 | });
29 | return players[nextIndex];
30 | };
31 |
--------------------------------------------------------------------------------
/packages/shared/src/modal/score.test.ts:
--------------------------------------------------------------------------------
1 | import { calculateScores, calculateWinner } from "./score";
2 | import { Point2D } from "@project/essentials";
3 | import { CellState } from "./cell";
4 | import { PlayerId } from "./player";
5 |
6 | const produceCellState = (position: Point2D, owner?: PlayerId): CellState => ({
7 | position,
8 | owner,
9 | });
10 |
11 | describe("calculateScore", () => {
12 | it(`if no owners no score`, () => {
13 | const cell1 = produceCellState({ x: 0, y: 0 });
14 | expect(calculateScores([cell1])).toEqual({});
15 | });
16 | it(`if owner then scorer`, () => {
17 | const cell1 = produceCellState({ x: 0, y: 0 }, "dave");
18 | expect(calculateScores([cell1])).toEqual({ dave: 1 });
19 | const cell2 = produceCellState({ x: 1, y: 0 }, "dave");
20 | expect(calculateScores([cell1, cell2])).toEqual({ dave: 2 });
21 | });
22 | it(`if different owners then scores correctly`, () => {
23 | const cell1 = produceCellState({ x: 0, y: 0 }, "dave");
24 | const cell2 = produceCellState({ x: 1, y: 0 }, "bob");
25 | expect(calculateScores([cell1, cell2])).toEqual({ dave: 1, bob: 1 });
26 | const cell3 = produceCellState({ x: 2, y: 0 }, "dave");
27 | expect(calculateScores([cell1, cell2, cell3])).toEqual({ dave: 2, bob: 1 });
28 | });
29 | });
30 |
31 | describe("calculateWinner", () => {
32 | it(`when not finished there can be no winne`, () => {
33 | const cell1 = produceCellState({ x: 0, y: 0 });
34 | const cell2 = produceCellState({ x: 0, y: 0 }, "dave");
35 | expect(calculateWinner([cell1, cell2])).toEqual(undefined);
36 | });
37 |
38 | it(`returns winner if finished`, () => {
39 | const cell1 = produceCellState({ x: 0, y: 0 }, "dave");
40 | const cell2 = produceCellState({ x: 0, y: 0 }, "dave");
41 | expect(calculateWinner([cell1, cell2])).toEqual("dave");
42 | });
43 |
44 | it(`returns winner as the highest scoring player`, () => {
45 | const cell1 = produceCellState({ x: 0, y: 0 }, "bob");
46 | const cell2 = produceCellState({ x: 0, y: 0 }, "dave");
47 | const cell3 = produceCellState({ x: 0, y: 0 }, "bob");
48 | expect(calculateWinner([cell1, cell2, cell3])).toEqual("bob");
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/packages/shared/src/modal/score.ts:
--------------------------------------------------------------------------------
1 | import { areAllCellsOwned, CellState } from "./cell";
2 | import { PlayerId } from "./player";
3 |
4 | export type Score = number;
5 |
6 | export type Scores = Record;
7 |
8 | export const calculateScores = (cells: CellState[]): Scores => {
9 | const scores: Scores = {};
10 | for (const cell of cells) {
11 | if (!cell.owner) continue;
12 | const score = scores[cell.owner] ?? 0;
13 | scores[cell.owner] = score + 1;
14 | }
15 | return scores;
16 | };
17 |
18 | export const calculateTotalScore = (cells: CellState[]): number =>
19 | Object.values(calculateScores(cells)).reduce((accum, curr) => accum + curr, 0);
20 |
21 | export const calculateWinner = (cells: CellState[]): PlayerId | undefined => {
22 | if (!areAllCellsOwned(cells)) return undefined;
23 | const scores = Object.entries(calculateScores(cells)).sort(([, a], [, b]) => b - a);
24 | const first = scores[0];
25 | if (!first) return undefined;
26 | return first[0];
27 | };
28 |
--------------------------------------------------------------------------------
/packages/shared/src/processes.ts:
--------------------------------------------------------------------------------
1 | export type Processes = {
2 | matchJoining: Record;
3 | matchCreation: Record;
4 | };
5 |
6 | export type ProcessKinds = keyof Processes;
7 |
--------------------------------------------------------------------------------
/packages/shared/src/projections.ts:
--------------------------------------------------------------------------------
1 | import { MatchesProjections } from "./match/projections";
2 | import { UsersProjections } from "./user/projections";
3 |
4 | export type Projections = UsersProjections & MatchesProjections;
5 | export type ProjectionKinds = keyof Projections;
6 |
--------------------------------------------------------------------------------
/packages/shared/src/user/commands.ts:
--------------------------------------------------------------------------------
1 | import { CreateMatchSize } from "../match/match";
2 |
3 | interface Base {
4 | aggregate: `user`;
5 | }
6 |
7 | interface Create extends Base {
8 | kind: `create`;
9 | payload: {
10 | name: string;
11 | };
12 | }
13 |
14 | interface SetName extends Base {
15 | kind: `set-name`;
16 | payload: {
17 | name: string;
18 | };
19 | }
20 |
21 | interface CreateMatchRequest extends Base {
22 | kind: `create-match-request`;
23 | payload: {
24 | size: CreateMatchSize;
25 | };
26 | }
27 |
28 | export type UserCommands = Create | SetName | CreateMatchRequest;
29 |
--------------------------------------------------------------------------------
/packages/shared/src/user/projections.ts:
--------------------------------------------------------------------------------
1 | import { Id } from "../modal/id";
2 |
3 | export interface UserProjection {
4 | id: Id;
5 | name: string;
6 | avatar: string;
7 | }
8 |
9 | export interface UsersProjections {
10 | users: {
11 | findUserById: {
12 | input: {
13 | id: string;
14 | };
15 | output: {
16 | user?: UserProjection | null;
17 | };
18 | };
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/packages/shared/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "outDir": "./dist",
6 | "rootDir": "./src",
7 | "baseUrl": ".",
8 | "lib": ["DOM", "ESNext"],
9 | "types": ["jest", "node"],
10 | "paths": {
11 | "@project/essentials": ["../essentials/src/index.ts"],
12 | "@project/workers-es": ["../workers-es/src/index.ts"]
13 | }
14 | },
15 | "include": ["src/**/*"],
16 | "references": [
17 | { "path": "../essentials/tsconfig.json" },
18 | { "path": "../workers-es/tsconfig.json" }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/packages/site-worker/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "no-restricted-imports": ["error", "@project/site-worker"],
4 | "no-use-before-define": "off"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/packages/site-worker/index.js:
--------------------------------------------------------------------------------
1 | import { getAssetFromKV, serveSinglePageApp } from "@cloudflare/kv-asset-handler";
2 |
3 | /**
4 | * The DEBUG flag will do two things that help during development:
5 | * 1. we will skip caching on the edge, which makes it easier to
6 | * debug.
7 | * 2. we will return an error message on exception in your Response rather
8 | * than the default 404.html page.
9 | */
10 | const DEBUG = true;
11 |
12 | addEventListener("fetch", (event) => {
13 | try {
14 | event.respondWith(handleEvent(event));
15 | } catch (e) {
16 | if (DEBUG) {
17 | return event.respondWith(
18 | new Response(e.message || e.toString(), {
19 | status: 500,
20 | })
21 | );
22 | }
23 | event.respondWith(new Response("Internal Error", { status: 500 }));
24 | }
25 | });
26 |
27 | async function handleEvent(event) {
28 | let options = {
29 | mapRequestToAsset: serveSinglePageApp,
30 | };
31 |
32 | /**
33 | * You can add custom logic to how we fetch your assets
34 | * by configuring the function `mapRequestToAsset`
35 | */
36 | // options.mapRequestToAsset = (request) => {
37 | // const url = new URL(request.url);
38 | // url.pathname = `/`;
39 | // return mapRequestToAsset(new Request(url, request));
40 | // };
41 |
42 | try {
43 | if (DEBUG) {
44 | // customize caching
45 | options.cacheControl = {
46 | bypassCache: true,
47 | };
48 | }
49 | const page = await getAssetFromKV(event, options);
50 |
51 | // allow headers to be altered
52 | const response = new Response(page.body, page);
53 |
54 | response.headers.set("X-XSS-Protection", "1; mode=block");
55 | response.headers.set("X-Content-Type-Options", "nosniff");
56 | response.headers.set("X-Frame-Options", "DENY");
57 | response.headers.set("Referrer-Policy", "unsafe-url");
58 | response.headers.set("Feature-Policy", "none");
59 |
60 | return response;
61 | } catch (e) {
62 | // if an error is thrown try to serve the asset at 404.html
63 | if (!DEBUG) {
64 | try {
65 | let notFoundResponse = await getAssetFromKV(event, {
66 | mapRequestToAsset: (req) => new Request(`${new URL(req.url).origin}/404.html`, req),
67 | });
68 |
69 | return new Response(notFoundResponse.body, {
70 | ...notFoundResponse,
71 | status: 404,
72 | });
73 | } catch (e) {}
74 | }
75 |
76 | return new Response(e.message || e.toString(), { status: 500 });
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/packages/site-worker/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "@project/site-worker",
4 | "type": "module",
5 | "version": "1.0.0",
6 | "description": "The worker that hosts the site",
7 | "main": "index.js",
8 | "author": "Mike Cann ",
9 | "license": "MIT",
10 | "scripts": {
11 | "deploy": "wrangler publish"
12 | },
13 | "dependencies": {
14 | "@cloudflare/kv-asset-handler": "~0.1.2",
15 | "miniflare": "^1.3.3"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/site-worker/wrangler.toml:
--------------------------------------------------------------------------------
1 | compatibility_date = "2021-10-10"
2 |
3 | name = "clouds-and-edges-site-dev"
4 | type = "webpack"
5 | route = ''
6 | zone_id = ''
7 | usage_model = ''
8 | workers_dev = true
9 |
10 | [site]
11 | bucket = "../site/dist"
12 | entry-point = "."
13 |
14 | [env.main]
15 | name = "clouds-and-edges-site"
--------------------------------------------------------------------------------
/packages/site/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "no-restricted-imports": ["error", "@project/site"],
4 | "no-use-before-define": "off"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/packages/site/.storybook/main.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "stories": [
3 | "../src/**/*.stories.mdx",
4 | "../src/**/*.stories.@(js|jsx|ts|tsx)"
5 | ],
6 | "addons": [
7 | "@storybook/addon-links",
8 | "@storybook/addon-essentials"
9 | ],
10 | "core": {
11 | "builder": "storybook-builder-vite"
12 | }
13 | }
--------------------------------------------------------------------------------
/packages/site/.storybook/preview.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ProjectChakraProvider } from "../src/features/theme/ProjectChakraProvider";
3 | import { ProjectGLSProvider } from "../src/features/theme/ProjectGLSProvider";
4 |
5 | export const decorators = [
6 | (Story, params) => {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 | );
14 | },
15 | ];
16 |
17 | export const parameters = {
18 | actions: { argTypesRegex: "^on[A-Z].*" },
19 | };
20 |
--------------------------------------------------------------------------------
/packages/site/.storybook/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "react",
5 | "noEmit": true
6 | },
7 | "include": ["./"],
8 | }
9 |
--------------------------------------------------------------------------------
/packages/site/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Clouds & Edges
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/packages/site/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "@project/site",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "tsc && vite build",
8 | "deploy": "wrangler publish",
9 | "serve": "vite preview",
10 | "storybook": "start-storybook -p 6006",
11 | "build-storybook": "build-storybook",
12 | "lint": "eslint ./src"
13 | },
14 | "dependencies": {
15 | "@chakra-ui/react": "^1.6.6",
16 | "@cloudflare/kv-asset-handler": "^0.1.3",
17 | "@emotion/react": "^11",
18 | "@emotion/styled": "^11",
19 | "@project/shared": "*",
20 | "@types/react-table": "^7.7.5",
21 | "constate": "^3.3.0",
22 | "framer-motion": "^4",
23 | "immer": "^9.0.6",
24 | "react": "^17.0.0",
25 | "react-dom": "^17.0.0",
26 | "react-icons": "^4.2.0",
27 | "react-json-view": "^1.21.3",
28 | "react-query": "^3.19.6",
29 | "react-router-dom": "^5.2.0",
30 | "typestyle": "^2.1.0"
31 | },
32 | "devDependencies": {
33 | "@babel/core": "^7.15.0",
34 | "@storybook/addon-actions": "^6.4.0-alpha.29",
35 | "@storybook/addon-essentials": "^6.4.0-alpha.29",
36 | "@storybook/addon-links": "^6.4.0-alpha.29",
37 | "@storybook/react": "^6.4.0-alpha.29",
38 | "@types/faker": "^5.5.8",
39 | "@types/react": "^17.0.0",
40 | "@types/react-dom": "^17.0.0",
41 | "@types/react-router-dom": "^5.1.8",
42 | "@vitejs/plugin-react-refresh": "^1.3.1",
43 | "babel-loader": "^8.2.2",
44 | "faker": "^5.5.3",
45 | "storybook-builder-vite": "^0.0.12",
46 | "typescript": "^4.3.2",
47 | "vite": "^2.4.2"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/packages/site/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "./index.css";
3 | import { QueryClient, QueryClientProvider } from "react-query";
4 | import { Router } from "./features/router/Router";
5 | import { AppStateProvider } from "./features/state/appState";
6 | import { ProjectChakraProvider } from "./features/theme/ProjectChakraProvider";
7 | import { ReactQueryDevtools } from "react-query/devtools";
8 |
9 | const queryClient = new QueryClient();
10 |
11 | function App() {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
24 | export default App;
25 |
--------------------------------------------------------------------------------
/packages/site/src/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikecann/clouds-and-edges/e144ca463702660bd2047857af9fa3a6e702b5e6/packages/site/src/favicon.ico
--------------------------------------------------------------------------------
/packages/site/src/features/admin/AdminPage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { SidebarPage } from "../page/SidebarPage";
3 | import {
4 | Center,
5 | Heading,
6 | TabList,
7 | TabPanel,
8 | TabPanels,
9 | Tabs,
10 | Text,
11 | VStack,
12 | Tab,
13 | Wrap,
14 | WrapItem,
15 | HStack,
16 | } from "@chakra-ui/react";
17 | import { SectionContainer } from "./SectionContainer";
18 | import { ConnectedEventsAdminLog } from "./events/ConnectedEventsAdminLog";
19 | import { ConnectedProjectionAdmin } from "./projections/ConnectedProjectionAdmin";
20 | import { ConnectedProcessAdmin } from "./processes/ConnectedProcessAdmin";
21 | import { ProcessesAdmin } from "./processes/ProcessesAdmin";
22 | import { AggregatesAdmin } from "./aggregates/AggregatesAdmin";
23 | import { ProjectionsAdmin } from "./projections/ProjectionsAdmin";
24 |
25 | interface Props {}
26 |
27 | export const AdminPage: React.FC = ({}) => {
28 | return (
29 |
30 |
31 | Admin Page
32 | Some information on the state of the system
33 |
34 |
35 |
36 | Events
37 | Aggregates
38 | Processes
39 | Projections
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/SectionContainer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Box, BoxProps, Heading, VStack } from "@chakra-ui/react";
3 |
4 | interface Props extends BoxProps {
5 | title: string;
6 | }
7 |
8 | export const SectionContainer: React.FC = ({ title, children, ...rest }) => {
9 | return (
10 |
19 | {title}
20 |
28 | {children}
29 |
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/aggregates/AggregatesAdmin.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
3 | import { SectionContainer } from "../SectionContainer";
4 |
5 | interface Props {}
6 |
7 | export const AggregatesAdmin: React.FC = ({}) => {
8 | return (
9 |
10 |
11 | User
12 | Match
13 |
14 |
15 |
16 |
17 | Todo: Implement me :)
18 |
19 |
20 | Todo: Implement me :)
21 |
22 |
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/events/ConnectedEventsAdminLog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ConnectedInspectableStorage } from "../storage/ConnectedInspectableStorage";
3 |
4 | interface Props {}
5 |
6 | export const ConnectedEventsAdminLog: React.FC = ({}) => {
7 | return ;
8 | };
9 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/events/EventItem.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { HStack, Text } from "@chakra-ui/react";
3 |
4 | interface Props {
5 | label: React.ReactNode;
6 | value: React.ReactNode;
7 | }
8 |
9 | export const EventItem: React.FC = ({ label, value }) => {
10 | return (
11 |
12 |
13 | {label}:{" "}
14 |
15 | {value}
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/events/EventsAdminLog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { StoredEvent } from "@project/workers-es";
3 | import { KeyValueTable } from "../storage/KeyValueTable";
4 |
5 | export interface EventsAdminLogProps {
6 | events: StoredEvent[];
7 | }
8 |
9 | export const EventsAdminLog: React.FC = ({ events }) => {
10 | const kv = events
11 | .sort((a, b) => a.timestamp - b.timestamp)
12 | .map((event) => [event.id, event] as const);
13 |
14 | return ;
15 | };
16 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/processes/ConnectedProcessAdmin.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ProcessKinds, ProjectionKinds } from "@project/shared";
3 | import { Button, VStack } from "@chakra-ui/react";
4 | import { ConnectedInspectableStorage } from "../storage/ConnectedInspectableStorage";
5 | import { SectionContainer } from "../SectionContainer";
6 |
7 | interface Props {
8 | process: ProcessKinds;
9 | }
10 |
11 | export const ConnectedProcessAdmin: React.FC = ({ process }) => {
12 | //const { rebuild, state, storageContents } = useProjectionAdmin(projection);
13 |
14 | //if (!state || !storageContents) return null;
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/processes/ProcessesAdmin.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { HStack, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
3 | import { SectionContainer } from "../SectionContainer";
4 | import { ConnectedProcessAdmin } from "./ConnectedProcessAdmin";
5 |
6 | interface Props {}
7 |
8 | export const ProcessesAdmin: React.FC = ({}) => {
9 | return (
10 |
11 |
12 | MatchCreation
13 | MatchJoining
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/projections/ConnectedProjectionAdmin.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ProjectionKinds } from "@project/shared";
3 | import { Button, VStack } from "@chakra-ui/react";
4 | import { ConnectedInspectableStorage } from "../storage/ConnectedInspectableStorage";
5 | import { useAdminRebuild } from "../useAdminRebuild";
6 | import { SectionContainer } from "../SectionContainer";
7 |
8 | interface Props {
9 | projection: ProjectionKinds;
10 | }
11 |
12 | export const ConnectedProjectionAdmin: React.FC = ({ projection }) => {
13 | const { mutate } = useAdminRebuild();
14 |
15 | return (
16 |
17 |
18 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/projections/ProjectionsAdmin.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
3 | import { SectionContainer } from "../SectionContainer";
4 | import { ConnectedProjectionAdmin } from "./ConnectedProjectionAdmin";
5 |
6 | interface Props {}
7 |
8 | export const ProjectionsAdmin: React.FC = ({}) => {
9 | return (
10 |
11 |
12 | Users
13 | Matches
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/storage/ConnectedInspectableStorage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useQueryStorage } from "./useQueryStorage";
3 | import { InspectableStorage } from "./InspectableStorage";
4 | import { DurableObjectIdentifier } from "@project/workers-es";
5 |
6 | interface Props {
7 | identifier: DurableObjectIdentifier;
8 | }
9 |
10 | export const ConnectedInspectableStorage: React.FC = ({ identifier }) => {
11 | const [prefix, setPrefix] = React.useState("");
12 | const [limit, setLimit] = React.useState();
13 | const [start, setStart] = React.useState("");
14 | const [reverse, setReverse] = React.useState(false);
15 |
16 | const {
17 | data: contents,
18 | refetch,
19 | isLoading,
20 | } = useQueryStorage({ input: { prefix, limit, start, reverse }, identifier });
21 |
22 | return (
23 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/storage/InspectableStorage.stories.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ComponentMeta, ComponentStory } from "@storybook/react";
3 | import { InspectableStorage } from "./InspectableStorage";
4 |
5 | export default {
6 | title: "InspectableStorage",
7 | component: InspectableStorage,
8 | } as ComponentMeta;
9 |
10 | const Template: ComponentStory = (args) => (
11 |
12 | );
13 |
14 | export const Primary = Template.bind({});
15 | Primary.args = {
16 | contents: {},
17 | };
18 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/storage/KeyValueTable.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Table, Tbody, Td, Th, Thead, Tr, Text } from "@chakra-ui/react";
3 | import ReactJson from "react-json-view";
4 |
5 | interface Props {
6 | data: [key: string, value: any][];
7 | }
8 |
9 | export const KeyValueTable: React.FC = ({ data }) => {
10 | return (
11 |
18 |
19 |
20 | Key |
21 | Value |
22 |
23 |
24 |
25 | {data.map(([key, value]) => (
26 |
27 |
28 | {key}
29 | |
30 |
31 | {typeof value == "object" ? (
32 |
40 | ) : (
41 | {value}
42 | )}
43 | |
44 |
45 | ))}
46 |
47 |
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/storage/useQueryStorage.ts:
--------------------------------------------------------------------------------
1 | import { QueryStorageAPI, DurableObjectIdentifier } from "@project/workers-es";
2 | import { useApiQuery } from "../../api/useApiQuery";
3 |
4 | interface Options {
5 | identifier: DurableObjectIdentifier;
6 | input: QueryStorageAPI["input"];
7 | }
8 |
9 | export const useQueryStorage = ({ identifier, input }: Options) => {
10 | return useApiQuery({
11 | endpoint: "admin.queryStorage",
12 | key: [`storageQuery`, identifier, input],
13 | input: {
14 | input,
15 | identifier,
16 | },
17 | });
18 | };
19 |
--------------------------------------------------------------------------------
/packages/site/src/features/admin/useAdminRebuild.ts:
--------------------------------------------------------------------------------
1 | import { useApiMutation } from "../api/useApiMutation";
2 |
3 | export const useAdminRebuild = () => {
4 | return useApiMutation("admin.rebuild");
5 | };
6 |
--------------------------------------------------------------------------------
/packages/site/src/features/api/performRPCOperation.ts:
--------------------------------------------------------------------------------
1 | import { config } from "../config/config";
2 | import { APIOperations, APIOperationInput, APIOperationOutput } from "@project/shared";
3 | import { getLogger } from "@project/essentials";
4 |
5 | const { log } = getLogger(`RPC`);
6 |
7 | export const performRPCOperation =
8 | (operation: TOperation, authToken?: string) =>
9 | async (input: APIOperationInput): Promise> => {
10 | log(`fetching..`, { operation, input, authToken });
11 | const beforeTimestamp = Date.now();
12 | const response = await fetch(`${config.SERVER_ROOT}/api/v1/${operation}`, {
13 | method: "POST",
14 | body: JSON.stringify(input),
15 | headers: {
16 | ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
17 | },
18 | });
19 | if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
20 | const json = await response.json();
21 | log(`fetched..`, { operation, json, authToken, deltaMs: Date.now() - beforeTimestamp });
22 | return json;
23 | };
24 |
--------------------------------------------------------------------------------
/packages/site/src/features/api/useApiMutation.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, UseMutationOptions } from "react-query";
2 | import { useRPCOperation } from "./useRPCOperation";
3 | import { useGenericErrorHandler } from "../errors/useGenericErrorHandler";
4 | import { APIOperationInput, APIOperationOutput, APIOperations } from "@project/shared";
5 |
6 | export const useApiMutation = (
7 | operation: TOperation,
8 | options?: UseMutationOptions, Error, APIOperationInput>
9 | ) => {
10 | const onError = useGenericErrorHandler();
11 | return useMutation(useRPCOperation(operation), {
12 | onError,
13 | ...options,
14 | });
15 | };
16 |
--------------------------------------------------------------------------------
/packages/site/src/features/api/useApiQuery.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery, QueryKey, UseQueryOptions } from "react-query";
2 | import { useGenericErrorHandler } from "../errors/useGenericErrorHandler";
3 | import { APIOperationInput, APIOperationOutput, APIOperations } from "@project/shared";
4 | import { useRPCOperation } from "./useRPCOperation";
5 |
6 | export type ApiQueryOptions = UseQueryOptions<
7 | APIOperationOutput,
8 | Error
9 | >;
10 |
11 | interface Options {
12 | key: QueryKey;
13 | endpoint: TOperation;
14 | input: APIOperationInput;
15 | options?: ApiQueryOptions;
16 | }
17 |
18 | export const useApiQuery = ({
19 | key,
20 | options,
21 | endpoint,
22 | input,
23 | }: Options) => {
24 | const onError = useGenericErrorHandler();
25 | const operation = useRPCOperation(endpoint);
26 |
27 | return useQuery, Error>(key, () => operation(input), {
28 | onError,
29 | ...options,
30 | });
31 | };
32 |
--------------------------------------------------------------------------------
/packages/site/src/features/api/useCommand.ts:
--------------------------------------------------------------------------------
1 | import { useRPCOperation } from "./useRPCOperation";
2 | import { AggregateKinds, Aggregates } from "@project/shared";
3 | import { useGenericErrorHandler } from "../errors/useGenericErrorHandler";
4 | import { useMutation, UseMutationOptions } from "react-query";
5 | import { CommandExecutionResponse } from "@project/workers-es";
6 |
7 | interface Options<
8 | TAggreate extends AggregateKinds,
9 | TOperation extends Aggregates[TAggreate]["kind"]
10 | > {
11 | aggregate: TAggreate;
12 | command: TOperation;
13 | aggregateId?: string;
14 | options?: UseMutationOptions<
15 | CommandExecutionResponse,
16 | Error,
17 | Extract["payload"]
18 | >;
19 | }
20 |
21 | export const useCommand = <
22 | TAggreate extends AggregateKinds,
23 | TOperation extends Aggregates[TAggreate]["kind"]
24 | >({
25 | command,
26 | aggregateId,
27 | aggregate,
28 | options,
29 | }: Options) => {
30 | type Payload = Extract["payload"];
31 |
32 | const _command = useRPCOperation("command");
33 | const onError = useGenericErrorHandler();
34 | return useMutation(
35 | (payload: Payload) => _command({ aggregate, command, payload, aggregateId }) as any,
36 | {
37 | onError,
38 | ...options,
39 | }
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/packages/site/src/features/api/useRPCOperation.ts:
--------------------------------------------------------------------------------
1 | import { APIOperations } from "@project/shared";
2 | import { performRPCOperation } from "./performRPCOperation";
3 | import { useAuthToken } from "../auth/useAuthToken";
4 |
5 | export const useRPCOperation = (operation: TOperation) => {
6 | const token = useAuthToken();
7 | return performRPCOperation(operation, token);
8 | };
9 |
--------------------------------------------------------------------------------
/packages/site/src/features/auth/useAuthToken.ts:
--------------------------------------------------------------------------------
1 | import { useAppState } from "../state/appState";
2 |
3 | // For now the userId is the auth token, security FTW!
4 | export const useAuthToken = () => useAppState()[0].userId;
5 |
--------------------------------------------------------------------------------
/packages/site/src/features/auth/useSignout.ts:
--------------------------------------------------------------------------------
1 | import { useAppState } from "../state/appState";
2 | import { useQueryClient } from "react-query";
3 |
4 | export const useSignout = () => {
5 | const [, setAppState] = useAppState();
6 | const queryClient = useQueryClient();
7 | return () => {
8 | queryClient.clear();
9 | setAppState((p) => ({ ...p, userId: "" }));
10 | };
11 | };
12 |
--------------------------------------------------------------------------------
/packages/site/src/features/auth/useSignup.ts:
--------------------------------------------------------------------------------
1 | import { useAppState } from "../state/appState";
2 | import { useApiMutation } from "../api/useApiMutation";
3 |
4 | export const useSignup = () => {
5 | const [, setState] = useAppState();
6 | return useApiMutation("auth.signup", {
7 | onSuccess: async ({ userId }) => {
8 | setState((p) => ({ ...p, userId }));
9 | },
10 | });
11 | };
12 |
--------------------------------------------------------------------------------
/packages/site/src/features/buttons/MyButton.stories.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ComponentMeta, ComponentStory } from "@storybook/react";
3 | import { MyButton } from "./MyButton";
4 |
5 | export default {
6 | title: "MyButton",
7 | component: MyButton,
8 | } as ComponentMeta;
9 |
10 | const Template: ComponentStory = args => (
11 | hello world
12 | );
13 |
14 | export const Primary = Template.bind({});
15 | Primary.args = {};
16 |
--------------------------------------------------------------------------------
/packages/site/src/features/buttons/MyButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@chakra-ui/react";
2 | import * as React from "react";
3 |
4 | interface Props {}
5 |
6 | export const MyButton: React.FC = ({ children }) => {
7 | return ;
8 | };
9 |
--------------------------------------------------------------------------------
/packages/site/src/features/config/config.ts:
--------------------------------------------------------------------------------
1 | export const config = {
2 | SERVER_ROOT: import.meta.env.VITE_SERVER_ROOT ?? `http://localhost:8777`,
3 | MODE: import.meta.env.MODE,
4 | DEV: import.meta.env.DEV,
5 | SSR: import.meta.env.SSR,
6 | PROD: import.meta.env.PROD,
7 | BASE_URL: import.meta.env.BASE_URL,
8 | };
9 |
--------------------------------------------------------------------------------
/packages/site/src/features/editable/EditableControls.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ButtonGroup, Flex, IconButton, useEditableControls } from "@chakra-ui/react";
3 | import { IoCheckmark, IoMdClose, RiEditFill } from "react-icons/all";
4 |
5 | interface Props {}
6 |
7 | export const EditableControls: React.FC = ({}) => {
8 | const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } =
9 | useEditableControls();
10 |
11 | return isEditing ? (
12 |
13 | }
16 | {...getSubmitButtonProps()}
17 | />
18 | }
21 | {...getCancelButtonProps()}
22 | />
23 |
24 | ) : (
25 |
26 | }
30 | {...getEditButtonProps()}
31 | />
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/packages/site/src/features/editable/EditableText.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { EditableControls } from "./EditableControls";
3 | import { Editable, EditableInput, EditablePreview, EditableProps, HStack } from "@chakra-ui/react";
4 |
5 | interface Props extends EditableProps {}
6 |
7 | export const EditableText: React.FC = ({ ...rest }) => {
8 | return (
9 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/packages/site/src/features/errors/useGenericErrorHandler.ts:
--------------------------------------------------------------------------------
1 | import { useToast } from "@chakra-ui/react";
2 |
3 | export const useGenericErrorHandler = () => {
4 | const toast = useToast();
5 | return (err: unknown) => {
6 | toast({
7 | title: `API Error`,
8 | description: err + "",
9 | status: `error`,
10 | isClosable: true,
11 | });
12 | };
13 | };
14 |
--------------------------------------------------------------------------------
/packages/site/src/features/loading/LoadingPage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Center, Spinner } from "@chakra-ui/react";
3 |
4 | interface Props {}
5 |
6 | export const LoadingPage: React.FC = ({}) => {
7 | return (
8 |
9 |
10 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/packages/site/src/features/logo/CloudflareLogo.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import logo from "./cloudflare-icon.svg";
3 | import "./Logo.css";
4 | import { Image, ImageProps } from "@chakra-ui/react";
5 |
6 | interface Props extends ImageProps {}
7 |
8 | export const CloudflareLogo: React.FC = ({ ...rest }) => {
9 | return ;
10 | };
11 |
--------------------------------------------------------------------------------
/packages/site/src/features/logo/Logo.css:
--------------------------------------------------------------------------------
1 | @keyframes App-logo-spin {
2 | 0% {
3 | transform: scale(1);
4 | }
5 | 50% {
6 | transform: scale(1.1);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/site/src/features/logo/Logo.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | //import logo from "./cloudflare-icon.svg";
3 | import logo from "./logo.png";
4 | import "./Logo.css";
5 | import { Image, ImageProps } from "@chakra-ui/react";
6 |
7 | interface Props extends ImageProps {}
8 |
9 | export const Logo: React.FC = ({ ...rest }) => {
10 | return ;
11 | };
12 |
--------------------------------------------------------------------------------
/packages/site/src/features/logo/cloudflare-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/site/src/features/logo/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikecann/clouds-and-edges/e144ca463702660bd2047857af9fa3a6e702b5e6/packages/site/src/features/logo/logo.png
--------------------------------------------------------------------------------
/packages/site/src/features/match/Announcement.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Box, Text } from "@chakra-ui/react";
3 | import { MatchProjection, PlayerId } from "@project/shared";
4 | import { iife } from "@project/essentials";
5 |
6 | interface Props {
7 | match: MatchProjection;
8 | meId: PlayerId;
9 | }
10 |
11 | export const Announcement: React.FC = ({ match, meId }) => {
12 | const isMyTurn = match.nextPlayerToTakeTurn == meId;
13 |
14 | const text = iife(() => {
15 | if (match.winner) {
16 | const isMe = match.winner == meId;
17 | if (isMe)
18 | return (
19 |
20 | YOU WON!
21 |
22 | );
23 | return (
24 |
25 | YOU LOST!
26 |
27 | );
28 | }
29 | return (
30 |
31 | {" "}
32 | {isMyTurn ? `Your Turn` : `Their Turn`}
33 |
34 | );
35 | });
36 |
37 | return {text};
38 | };
39 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/Cancelled.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Box, Heading, VStack } from "@chakra-ui/react";
3 |
4 | interface Props {}
5 |
6 | export const NotStarted: React.FC = ({}) => {
7 | return (
8 |
9 | Match Cancelled
10 | Nothing to see here
11 |
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/Finished.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { MatchProjection } from "@project/shared";
3 | import { GameBoard } from "./game/GameBoard";
4 | import { constructGameState } from "./game/GameState";
5 | import { HStack, VStack } from "@chakra-ui/react";
6 | import { useMe } from "../me/useMe";
7 | import { PlayerPanel } from "./PlayerPanel";
8 | import { Announcement } from "./Announcement";
9 |
10 | interface Props {
11 | match: MatchProjection;
12 | }
13 |
14 | export const Finished: React.FC = ({ match }) => {
15 | const { me } = useMe();
16 |
17 | if (!me) return null;
18 |
19 | const game = constructGameState(match);
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/MatchPage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { SidebarPage } from "../page/SidebarPage";
3 | import { Center, VStack } from "@chakra-ui/react";
4 | import { useParams } from "react-router-dom";
5 | import { PlayingGame } from "./PlayingGame";
6 | import { NotStarted } from "./NotStarted";
7 | import { useMatch } from "../matches/matches/useMatch";
8 | import { matchLiteral } from "variant";
9 | import { Finished } from "./Finished";
10 |
11 | interface Props {}
12 |
13 | export const MatchPage: React.FC = ({}) => {
14 | const { matchId } = useParams<{ matchId: string }>();
15 | if (!matchId) throw new Error(`Match must be supplied`);
16 |
17 | const { data: match, isLoading, refetch } = useMatch(matchId);
18 |
19 | if (!match) return null;
20 |
21 | return (
22 |
23 |
24 |
25 | {matchLiteral(match.status, {
26 | playing: () => ,
27 | cancelled: () => ,
28 | "not-started": () => ,
29 | finished: () => ,
30 | })}
31 |
32 |
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/NotStarted.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Box, Heading, VStack } from "@chakra-ui/react";
3 |
4 | interface Props {}
5 |
6 | export const NotStarted: React.FC = ({}) => {
7 | return (
8 |
9 | Match Not Started
10 | Waiting for an opponent to join you..
11 |
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/PlayerPanel.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Box, BoxProps, Heading, Text, VStack } from "@chakra-ui/react";
3 | import { calculateScores, getPlayer, PlayerId, Player } from "@project/shared";
4 | import { GameState } from "./game/GameState";
5 | import { IdIcon } from "../misc/IdIcon";
6 |
7 | interface Props extends BoxProps {
8 | playerId: PlayerId;
9 | game: GameState;
10 | }
11 |
12 | export const PlayerPanel: React.FC = ({ playerId, game, ...rest }) => {
13 | const player: Player = getPlayer(game.players, playerId);
14 | const score = calculateScores(game.cells)[playerId] ?? 0;
15 |
16 | return (
17 |
18 |
19 | {player.avatar}
20 |
21 | {player.name}
22 |
23 | {/*{player.id}*/}
24 | Score: {score}
25 |
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/PlayingGame.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { MatchProjection } from "@project/shared";
3 | import { GameBoard } from "./game/GameBoard";
4 | import { constructGameState } from "./game/GameState";
5 | import { Button, HStack, VStack } from "@chakra-ui/react";
6 | import { useTakeTurn } from "../matches/matches/useTakeTurn";
7 | import { useMe } from "../me/useMe";
8 | import { PlayerPanel } from "./PlayerPanel";
9 | import { Announcement } from "./Announcement";
10 | import { IoReloadOutline } from "react-icons/all";
11 |
12 | interface Props {
13 | match: MatchProjection;
14 | isLoading: boolean;
15 | onRefresh: () => unknown;
16 | }
17 |
18 | export const PlayingGame: React.FC = ({ match, isLoading, onRefresh }) => {
19 | const { mutate: takeTurn } = useTakeTurn(match.id);
20 | const { me } = useMe();
21 |
22 | if (!me) return null;
23 |
24 | const isMyTurn = match.nextPlayerToTakeTurn == me.id;
25 | const isFinished = match.winner != undefined;
26 | const canTakeTurn = isMyTurn && !isFinished;
27 |
28 | const game = constructGameState(match);
29 |
30 | return (
31 |
32 |
33 |
34 | }
39 | onClick={onRefresh}
40 | >
41 | Refresh
42 |
43 |
44 |
45 |
46 | takeTurn({ direction, from }) : undefined}
49 | />
50 |
51 |
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/game/CellView.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Box, BoxProps, Center } from "@chakra-ui/react";
3 | import { CellState, Dimensions2d, getPlayer, LineDirection } from "@project/shared";
4 | import { GameState } from "./GameState";
5 |
6 | interface Props extends BoxProps {
7 | cell: CellState;
8 | game: GameState;
9 | }
10 |
11 | export const cellSize: Dimensions2d = {
12 | width: 100,
13 | height: 100,
14 | };
15 |
16 | export const CellView: React.FC = ({ cell, game, ...rest }) => {
17 | const { owner } = cell;
18 | const ownerPlayer = owner ? getPlayer(game.players, owner) : undefined;
19 |
20 | const isBottomRow = cell.position.y == game.settings.gridSize.height - 1;
21 | const isRightCol = cell.position.x == game.settings.gridSize.width - 1;
22 |
23 | const border = `1px dashed rgba(255,255,255,0.5)`;
24 |
25 | return (
26 |
36 | {ownerPlayer && (
37 |
38 |
44 |
53 | {ownerPlayer.avatar}
54 |
55 |
56 | )}
57 |
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/game/DotView.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Box, BoxProps, Center } from "@chakra-ui/react";
3 | import { getPlayer, LineDirection, Dot, findLineOwner } from "@project/shared";
4 | import { LineView } from "./LineView";
5 | import { cellSize } from "./CellView";
6 | import { GameState } from "./GameState";
7 | import { Logo } from "../../logo/Logo";
8 | import { CloudflareLogo } from "../../logo/CloudflareLogo";
9 |
10 | interface Props extends BoxProps {
11 | game: GameState;
12 | dot: Dot;
13 | onFillLine?: (direction: LineDirection) => unknown;
14 | }
15 |
16 | const lineSize = 15;
17 |
18 | export const DotView: React.FC = ({ dot, game, onFillLine, ...rest }) => {
19 | const isBottomRow = dot.y == game.settings.gridSize.height;
20 | const isRightCol = dot.x == game.settings.gridSize.width;
21 |
22 | return (
23 |
24 | {/* Down */}
25 | {isBottomRow ? null : (
26 | onFillLine("down") : undefined}
37 | />
38 | )}
39 |
40 | {/* Right */}
41 | {isRightCol ? null : (
42 | onFillLine("right") : undefined}
53 | />
54 | )}
55 |
56 |
65 |
66 |
67 |
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/game/GameBoard.stories.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Meta } from "@storybook/react";
3 | import { GameBoard } from "./GameBoard";
4 | import { produceCellStates, producePlayerState } from "@project/shared";
5 | import { produce } from "immer";
6 | import { Box } from "@chakra-ui/react";
7 |
8 | export default {
9 | title: "GameBoard",
10 | component: GameBoard,
11 | } as Meta;
12 |
13 | const playerA = producePlayerState({ id: `playerA`, color: "red" });
14 | const playerB = producePlayerState({ id: `playerB`, color: "blue" });
15 |
16 | const props: React.ComponentProps = {
17 | game: {
18 | lines: [],
19 | cells: produceCellStates({ width: 3, height: 3 }),
20 | players: [playerA, playerB],
21 | settings: {
22 | maxPlayers: 2,
23 | gridSize: {
24 | width: 3,
25 | height: 3,
26 | },
27 | },
28 | },
29 | onTakeTurn: (cell, line) => alert(`onTakeTurn ${JSON.stringify({ cell, line })}`),
30 | };
31 |
32 | export const Primary = () => (
33 | {
36 | draft.lines.push({ from: { x: 0, y: 0 }, owner: playerA.id, direction: "right" });
37 | draft.lines.push({ from: { x: 1, y: 1 }, owner: playerB.id, direction: "down" });
38 | draft.lines.push({ from: { x: 2, y: 1 }, owner: playerA.id, direction: "right" });
39 |
40 | draft.lines.push({ from: { x: 2, y: 2 }, owner: playerB.id, direction: "right" });
41 | draft.lines.push({ from: { x: 2, y: 2 }, owner: playerB.id, direction: "down" });
42 | draft.lines.push({ from: { x: 3, y: 2 }, owner: playerB.id, direction: "down" });
43 | draft.lines.push({ from: { x: 2, y: 3 }, owner: playerA.id, direction: "right" });
44 | draft.cells[8].owner = playerB.id;
45 | })}
46 | />
47 | );
48 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/game/GameBoard.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Box } from "@chakra-ui/react";
3 | import { narray } from "@project/essentials";
4 | import { CellView, cellSize } from "./CellView";
5 | import { Dot, getCellAt, LineDirection } from "@project/shared";
6 | import { DotView } from "./DotView";
7 | import { GameState } from "./GameState";
8 |
9 | interface Props {
10 | game: GameState;
11 | onTakeTurn?: (from: Dot, direction: LineDirection) => unknown;
12 | }
13 |
14 | export const GameBoard: React.FC = ({ game, onTakeTurn }) => {
15 | const { settings } = game;
16 |
17 | return (
18 |
24 |
25 | {narray(settings.gridSize.height)
26 | .map((y) =>
27 | narray(settings.gridSize.width).map((x) => (
28 |
36 | ))
37 | )
38 | .flat()}
39 |
40 |
41 |
42 | {narray(settings.gridSize.height + 1)
43 | .map((y) =>
44 | narray(settings.gridSize.width + 1).map((x) => (
45 | onTakeTurn({ x, y }, line) : undefined}
53 | />
54 | ))
55 | )
56 | .flat()}
57 |
58 |
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/game/GameState.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CellState,
3 | computeCellStates,
4 | Line,
5 | MatchProjection,
6 | MatchSettings,
7 | Player,
8 | } from "@project/shared";
9 |
10 | export interface GameState {
11 | cells: CellState[];
12 | lines: Line[];
13 | players: Player[];
14 | settings: MatchSettings;
15 | }
16 |
17 | export const constructGameState = (match: MatchProjection): GameState => {
18 | const lines = match.turns.map((t) => t.line);
19 | return {
20 | lines,
21 | cells: computeCellStates({ lines, settings: match.settings }),
22 | settings: match.settings,
23 | players: match.players,
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/packages/site/src/features/match/game/LineView.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Box, BoxProps } from "@chakra-ui/react";
3 | import { getPlayer, LineDirection, PlayerId, Player } from "@project/shared";
4 | import { matchKind, Point2D } from "@project/essentials";
5 | import { GameState } from "./GameState";
6 |
7 | interface Props extends BoxProps {
8 | from: Point2D;
9 | direction: LineDirection;
10 | owner?: PlayerId;
11 | game: GameState;
12 | onFill?: () => unknown;
13 | }
14 |
15 | export const LineView: React.FC = ({ from, direction, owner, game, onFill, ...rest }) => {
16 | if (owner)
17 | return (
18 |
23 | );
24 |
25 | return (
26 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/MatchesPage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { SidebarPage } from "../page/SidebarPage";
3 | import { Button, Heading, VStack, Text, Box, Divider } from "@chakra-ui/react";
4 | import { useState } from "react";
5 | import { ConnectedMatchCards } from "./matches/ConnectedMatchCards";
6 | import { ConnectedCreateNewMatchModal } from "./matches/ConnectedCreateNewMatchModal";
7 | import { ConnectedOpenMatches } from "./openMatches/ConnectedOpenMatches";
8 | import { BsPlusSquareFill } from "react-icons/bs";
9 |
10 | interface Props {}
11 |
12 | export const MatchesPage: React.FC = ({}) => {
13 | const [isCreateMatchModalOpen, setIsCreateMatchModalOpen] = useState(false);
14 |
15 | return (
16 |
17 |
18 |
19 |
27 |
28 |
29 |
30 | My Matches
31 | My Latest Matches
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | Open Matches
41 | Matches Available to Join
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | setIsCreateMatchModalOpen(false)}
52 | />
53 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/ConnectedCreateNewMatchModal.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { CreateNewMatchModal } from "./CreateNewMatchModal";
3 | import { useCreateNewMatch } from "./useCreateNewMatch";
4 |
5 | interface Props {
6 | isOpen: boolean;
7 | onClose: () => any;
8 | }
9 |
10 | export const ConnectedCreateNewMatchModal: React.FC = ({ isOpen, onClose }) => {
11 | const { mutateAsync, isLoading } = useCreateNewMatch();
12 | return (
13 | mutateAsync({ size }).then(onClose)}
17 | isLoading={isLoading}
18 | />
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/ConnectedMatchCard.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useAppState } from "../../state/appState";
3 | import { useCancelMatch } from "./useCancelMatch";
4 | import { useRequestJoinMatch } from "./useJoinMatch";
5 | import { MatchProjection } from "@project/shared";
6 | import { MatchCard } from "./MatchCard";
7 | import { useHistory } from "react-router-dom";
8 | import { MyMatchCard } from "./MyMatchCard";
9 |
10 | interface Props {
11 | match: MatchProjection;
12 | }
13 |
14 | export const ConnectedMatchCard: React.FC = ({ match }) => {
15 | const { mutate: onCancel, isLoading: isCancelling } = useCancelMatch(match.id);
16 | const { mutate: onJoin, isLoading: isJoining } = useRequestJoinMatch(match.id);
17 | const [{ userId }] = useAppState();
18 | const history = useHistory();
19 |
20 | if (!userId) return null;
21 |
22 | const isCreatedByMe = match.createdByUserId == userId;
23 | const isJoinedByMe = match.players.some((p) => p.id == userId);
24 |
25 | const canJoin = !isCreatedByMe && !isJoinedByMe && match.status == "not-started";
26 | const canCancel = isCreatedByMe && match.status == "not-started";
27 | const canOpen =
28 | ((isCreatedByMe || isJoinedByMe) && match.status == "playing") || match.status == "finished";
29 |
30 | return (
31 | onCancel({}) : undefined}
36 | onJoin={canJoin ? () => onJoin({}) : undefined}
37 | onOpen={canOpen ? () => history.push(`/match/${match.id}`) : undefined}
38 | />
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/ConnectedMatchCards.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Wrap, WrapItem } from "@chakra-ui/react";
3 | import { useMyMatches } from "./useMyMatches";
4 | import { ConnectedMatchCard } from "./ConnectedMatchCard";
5 |
6 | interface Props {}
7 |
8 | export const ConnectedMatchCards: React.FC = ({}) => {
9 | const { data: matches } = useMyMatches();
10 |
11 | return (
12 |
13 | {matches?.map((m) => (
14 |
15 |
16 |
17 | ))}
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/CreateNewMatchModal.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | Button,
4 | Modal,
5 | ModalBody,
6 | ModalCloseButton,
7 | ModalContent,
8 | ModalFooter,
9 | ModalHeader,
10 | ModalOverlay,
11 | Select,
12 | Text,
13 | VStack,
14 | } from "@chakra-ui/react";
15 | import { useState } from "react";
16 | import { CreateMatchSize } from "@project/shared";
17 |
18 | interface Props {
19 | isOpen: boolean;
20 | isLoading: boolean;
21 | onClose: () => any;
22 | onPropose: (size: CreateMatchSize) => any;
23 | }
24 |
25 | export const CreateNewMatchModal: React.FC = ({ isOpen, onClose, onPropose, isLoading }) => {
26 | const [size, setSize] = useState(`small`);
27 |
28 | return (
29 |
30 |
31 |
32 | Create New Match
33 |
34 |
35 |
36 | Map Size
37 |
42 |
43 |
44 |
45 |
46 |
55 |
56 |
57 |
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/MatchCard.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Badge, Button, Text, VStack, Box, Avatar, HStack, Tooltip } from "@chakra-ui/react";
3 | import { MatchProjection, MatchSettings, MatchStatus, Player } from "@project/shared";
4 | import { matchLiteral } from "variant";
5 |
6 | interface Props {
7 | match: MatchProjection;
8 | meId: string;
9 | actions?: React.ReactNode;
10 | }
11 |
12 | export const MatchCard: React.FC = ({ match, meId, actions }) => {
13 | return (
14 |
15 |
16 | {match.players.map((p) => (
17 |
18 | {p.avatar}}
20 | border={`2px solid rgba(255,255,255,0.5)`}
21 | >
22 |
23 | ))}
24 |
25 |
26 | {match.settings.gridSize.width}x{match.settings.gridSize.height}
27 |
28 |
29 | {matchLiteral(match.status, {
30 | "not-started": () => Not Started,
31 | playing: () => {
32 | const meIsPlayer = match.players.some((p) => p.id == meId);
33 | if (!meIsPlayer) return Playing;
34 |
35 | const isMyTurn = match.nextPlayerToTakeTurn == meId;
36 | if (isMyTurn) return Your Turn;
37 |
38 | return Their Turn;
39 | },
40 | cancelled: () => Cancelled,
41 | finished: () => Finished,
42 | })}
43 |
44 | {actions}
45 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/MatchesTable.stories.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Meta } from "@storybook/react";
3 |
4 | import { produceCellStates, producePlayerState } from "@project/shared";
5 | import { produce } from "immer";
6 | import { MatchesTable } from "./MatchesTable";
7 |
8 | export default {
9 | title: "MatchesTable",
10 | component: MatchesTable,
11 | } as Meta;
12 |
13 | const props: React.ComponentProps = {
14 | matches: [],
15 | onJoin: () => alert(`onJoin`),
16 | onCancel: () => alert(`onCancel`),
17 | onOpen: () => alert(`onOpen`),
18 | isLoading: false,
19 | };
20 |
21 | export const Primary = () => ;
22 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/MatchesTable.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { MatchProjection } from "@project/shared";
3 | import { Table, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react";
4 |
5 | interface Props {
6 | matches: MatchProjection[];
7 | onCancel?: () => any;
8 | onJoin?: () => any;
9 | onOpen?: () => any;
10 | isLoading: boolean;
11 | }
12 |
13 | export const MatchesTable: React.FC = ({ matches }) => {
14 | return (
15 |
16 |
17 |
18 | Created At |
19 | Size |
20 | multiply by |
21 |
22 |
23 |
24 | {matches.map((match) => (
25 |
26 | inches |
27 | millimetres (mm) |
28 | 25.4 |
29 |
30 | ))}
31 |
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/MyMatchCard.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Badge, Button } from "@chakra-ui/react";
3 | import { MatchProjection } from "@project/shared";
4 | import { matchLiteral } from "variant";
5 | import { MatchCard } from "./MatchCard";
6 |
7 | interface Props {
8 | match: MatchProjection;
9 | onCancel?: () => any;
10 | onJoin?: () => any;
11 | onOpen?: () => any;
12 | isLoading: boolean;
13 | meId: string;
14 | }
15 |
16 | export const MyMatchCard: React.FC = ({
17 | match,
18 | meId,
19 | onCancel,
20 | onJoin,
21 | onOpen,
22 | isLoading,
23 | }) => {
24 | return (
25 |
30 | {onOpen ? (
31 |
39 | ) : null}
40 | {onJoin ? (
41 |
49 | ) : null}
50 | {onCancel ? (
51 |
54 | ) : null}
55 | >
56 | }
57 | >
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/useCancelMatch.ts:
--------------------------------------------------------------------------------
1 | import { useQueryClient } from "react-query";
2 | import { useCommand } from "../../api/useCommand";
3 |
4 | export const useCancelMatch = (matchId: string) => {
5 | const queryClient = useQueryClient();
6 | return useCommand({
7 | aggregate: "match",
8 | command: "cancel",
9 | aggregateId: matchId,
10 | options: {
11 | onSettled: async () => {
12 | await queryClient.invalidateQueries(`matches`);
13 | await queryClient.invalidateQueries(`openMatches`);
14 | },
15 | },
16 | });
17 | };
18 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/useCreateNewMatch.ts:
--------------------------------------------------------------------------------
1 | import { useQueryClient } from "react-query";
2 | import { useCommand } from "../../api/useCommand";
3 | import { useAppState } from "../../state/appState";
4 |
5 | export const useCreateNewMatch = () => {
6 | const [{ userId }] = useAppState();
7 | const queryClient = useQueryClient();
8 | return useCommand({
9 | aggregate: "user",
10 | command: "create-match-request",
11 | aggregateId: userId,
12 | options: {
13 | onSettled: async () => {
14 | await queryClient.invalidateQueries(`matches`);
15 | await queryClient.invalidateQueries(`openMatches`);
16 | },
17 | },
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/useJoinMatch.ts:
--------------------------------------------------------------------------------
1 | import { useQueryClient } from "react-query";
2 | import { useCommand } from "../../api/useCommand";
3 |
4 | export const useRequestJoinMatch = (matchId: string) => {
5 | const queryClient = useQueryClient();
6 | return useCommand({
7 | aggregate: "match",
8 | command: "join-request",
9 | aggregateId: matchId,
10 | options: {
11 | onSettled: async () => {
12 | await queryClient.invalidateQueries(`matches`);
13 | await queryClient.invalidateQueries(`openMatches`);
14 | },
15 | },
16 | });
17 | };
18 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/useMatch.ts:
--------------------------------------------------------------------------------
1 | import { useApiQuery } from "../../api/useApiQuery";
2 |
3 | export const useMatch = (id: string) => {
4 | return useApiQuery({
5 | endpoint: "projections.matches.getMatch",
6 | key: [`match`, id],
7 | input: { id },
8 | });
9 | };
10 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/useMyMatches.ts:
--------------------------------------------------------------------------------
1 | import { useApiQuery } from "../../api/useApiQuery";
2 |
3 | export const useMyMatches = () => {
4 | return useApiQuery({
5 | endpoint: "projections.matches.getMine",
6 | key: `matches`,
7 | input: {},
8 | });
9 | };
10 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/matches/useTakeTurn.ts:
--------------------------------------------------------------------------------
1 | import { useQueryClient } from "react-query";
2 | import { useCommand } from "../../api/useCommand";
3 |
4 | export const useTakeTurn = (matchId: string) => {
5 | const queryClient = useQueryClient();
6 | return useCommand({
7 | aggregate: "match",
8 | command: "take-turn",
9 | aggregateId: matchId,
10 | options: {
11 | onSettled: async () => {
12 | await queryClient.invalidateQueries(`matches`);
13 | await queryClient.invalidateQueries(`openMatches`);
14 | await queryClient.invalidateQueries([`match`, matchId]);
15 | },
16 | },
17 | });
18 | };
19 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/openMatches/ConnectedOpenMatchCard.tsx:
--------------------------------------------------------------------------------
1 | import { MatchProjection } from "@project/shared";
2 | import * as React from "react";
3 | import { useAppState } from "../../state/appState";
4 | import { useCancelMatch } from "../matches/useCancelMatch";
5 | import { useRequestJoinMatch } from "../matches/useJoinMatch";
6 | import { OpenMatchCard } from "./OpenMatchCard";
7 |
8 | interface Props {
9 | match: MatchProjection;
10 | }
11 |
12 | export const ConnectedOpenMatchCard: React.FC = ({ match }) => {
13 | const { mutate: cancel, isLoading: isCancelling } = useCancelMatch(match.id);
14 | const { mutate: join, isLoading: isJoining } = useRequestJoinMatch(match.id);
15 | const [{ userId }] = useAppState();
16 |
17 | if (!userId) return null;
18 |
19 | return (
20 | cancel({}) : undefined}
25 | onJoin={match.createdByUserId != userId ? () => join({}) : undefined}
26 | />
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/openMatches/ConnectedOpenMatches.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Wrap } from "@chakra-ui/react";
3 | import { useOpenMatches } from "./useOpenMatches";
4 | import { ConnectedOpenMatchCard } from "./ConnectedOpenMatchCard";
5 |
6 | interface Props {}
7 |
8 | export const ConnectedOpenMatches: React.FC = ({}) => {
9 | const { data: matches } = useOpenMatches();
10 |
11 | return (
12 |
13 | {matches?.map((m) => (
14 |
15 | ))}
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/openMatches/OpenMatchCard.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Badge, Button } from "@chakra-ui/react";
3 | import { MatchProjection } from "@project/shared";
4 | import { matchLiteral } from "variant";
5 | import { MatchCard } from "../matches/MatchCard";
6 |
7 | interface Props {
8 | match: MatchProjection;
9 | meId: string;
10 | onCancel?: () => any;
11 | onJoin?: () => any;
12 | isLoading: boolean;
13 | }
14 |
15 | export const OpenMatchCard: React.FC = ({ match, meId, onCancel, onJoin, isLoading }) => {
16 | return (
17 |
22 | {onJoin ? (
23 |
31 | ) : null}
32 | {onCancel ? (
33 |
36 | ) : null}
37 | >
38 | }
39 | >
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/packages/site/src/features/matches/openMatches/useOpenMatches.ts:
--------------------------------------------------------------------------------
1 | import { useAppState } from "../../state/appState";
2 | import { useApiQuery } from "../../api/useApiQuery";
3 |
4 | export const useOpenMatches = () => {
5 | const [{ userId }] = useAppState();
6 | return useApiQuery({
7 | endpoint: "projections.matches.getOpen",
8 | key: `openMatches`,
9 | input: {
10 | excludePlayer: userId,
11 | },
12 | });
13 | };
14 |
--------------------------------------------------------------------------------
/packages/site/src/features/me/ConnectedEditableUserName.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { EditableText } from "../editable/EditableText";
3 | import { useSetName } from "./useSetName";
4 |
5 | interface Props {
6 | name: string;
7 | }
8 |
9 | export const ConnectedEditableUserName: React.FC = ({ name }) => {
10 | const changeNameMutation = useSetName();
11 | return (
12 | changeNameMutation.mutate({ name })}
18 | />
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/packages/site/src/features/me/MyProfilePage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useMe } from "./useMe";
3 | import { LoadingPage } from "../loading/LoadingPage";
4 | import { SidebarPage } from "../page/SidebarPage";
5 | import { ConnectedEditableUserName } from "./ConnectedEditableUserName";
6 | import { Avatar, Box, Button, Center, HStack, Text, VStack } from "@chakra-ui/react";
7 | import { useSignout } from "../auth/useSignout";
8 | import { VscSignOut } from "react-icons/vsc";
9 |
10 | interface Props {}
11 |
12 | export const MyProfilePage: React.FC = ({}) => {
13 | const { me } = useMe();
14 | const onSignout = useSignout();
15 |
16 | if (!me) return ;
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | {me.avatar}}
31 | />
32 |
33 | {me.id}
34 |
35 | }>
36 | Signout
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/packages/site/src/features/me/useMe.ts:
--------------------------------------------------------------------------------
1 | import { ensure, getLogger } from "@project/essentials";
2 | import { useAppState } from "../state/appState";
3 | import { useApiQuery } from "../api/useApiQuery";
4 |
5 | export const useMe = () => {
6 | const [{ userId }] = useAppState();
7 | const query = useApiQuery({
8 | endpoint: "projections.users.findUserById",
9 | key: `me`,
10 | input: { id: ensure(userId) },
11 | options: {
12 | enabled: userId != undefined,
13 | },
14 | });
15 |
16 | return {
17 | ...query,
18 | me: query.data?.user,
19 | };
20 | };
21 |
--------------------------------------------------------------------------------
/packages/site/src/features/me/useSetName.ts:
--------------------------------------------------------------------------------
1 | import { ensure } from "@project/essentials";
2 | import { useAppState } from "../state/appState";
3 | import { useCommand } from "../api/useCommand";
4 |
5 | export const useSetName = () => {
6 | const [{ userId }] = useAppState();
7 | return useCommand({
8 | aggregate: "user",
9 | command: "set-name",
10 | aggregateId: ensure(userId),
11 | });
12 | };
13 |
--------------------------------------------------------------------------------
/packages/site/src/features/misc/IdIcon.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Box, BoxProps, Icon, Tooltip } from "@chakra-ui/react";
3 | import { FaHashtag, HiHashtag } from "react-icons/all";
4 |
5 | interface Props extends BoxProps {
6 | id: string;
7 | }
8 |
9 | export const IdIcon: React.FC = ({ id, ...rest }) => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/packages/site/src/features/page/SidebarPage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ConnectedDashboardSidebar } from "../sidebar/ConnectedDashboardSidebar";
3 | import { VStack } from "@chakra-ui/react";
4 |
5 | interface Props {}
6 |
7 | export const SidebarPage: React.FC = ({ children }) => {
8 | return (
9 |
10 |
11 |
12 | {children}
13 |
14 |
15 | );
16 | };
17 |
18 | //maxWidth={800} minWidth={800}
19 |
--------------------------------------------------------------------------------
/packages/site/src/features/root/RootPage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useHistory } from "react-router-dom";
3 | import { useAppState } from "../state/appState";
4 |
5 | interface Props {}
6 |
7 | export const RootPage: React.FC = ({}) => {
8 | const [state] = useAppState();
9 | const history = useHistory();
10 |
11 | React.useEffect(() => {
12 | if (!state.userId) history.push(`/signup`);
13 | else history.push(`/matches`);
14 | }, []);
15 |
16 | return null;
17 | };
18 |
--------------------------------------------------------------------------------
/packages/site/src/features/router/AuthRequired.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useIsAuthenticated } from "../state/useIsAuthenticated";
3 | import { useEffect } from "react";
4 | import { useHistory } from "react-router-dom";
5 | import { getLogger } from "@project/essentials";
6 |
7 | interface Props {}
8 |
9 | const logger = getLogger(`AuthRequired`);
10 |
11 | export const AuthRequired: React.FC = ({ children }) => {
12 | const isAuthed = useIsAuthenticated();
13 | const history = useHistory();
14 | useEffect(() => {
15 | if (!isAuthed) {
16 | logger.debug(`user not authed, redirecting them back to signup`);
17 | history.push(`/signup`);
18 | }
19 | }, [isAuthed]);
20 | return <>{isAuthed ? children : null}>;
21 | };
22 |
--------------------------------------------------------------------------------
/packages/site/src/features/router/Router.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { SignupPage } from "../signup/SignupPage";
3 | import { BrowserRouter, Switch, Route, Link } from "react-router-dom";
4 | import { RootPage } from "../root/RootPage";
5 | import { useColorMode } from "@chakra-ui/react";
6 | import { AuthRequired } from "./AuthRequired";
7 | import { MyProfilePage } from "../me/MyProfilePage";
8 | import { AdminPage } from "../admin/AdminPage";
9 | import { useAppStatePersistance } from "../state/useAppStatePersistance";
10 | import { MatchesPage } from "../matches/MatchesPage";
11 | import { MatchPage } from "../match/MatchPage";
12 |
13 | interface Props {}
14 |
15 | export const Router: React.FC = ({}) => {
16 | const { setColorMode } = useColorMode();
17 | useAppStatePersistance();
18 |
19 | React.useEffect(() => {
20 | setColorMode("dark");
21 | }, []);
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/packages/site/src/features/sidebar/ConnectedDashboardSidebar.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Avatar,
3 | Tab,
4 | TabList,
5 | TabPanel,
6 | TabPanels,
7 | Tabs,
8 | Box,
9 | VStack,
10 | Spacer,
11 | } from "@chakra-ui/react";
12 | import * as React from "react";
13 | import { useMe } from "../me/useMe";
14 | import { SidebarButton } from "./SidebarButton";
15 |
16 | interface Props {}
17 |
18 | export const ConnectedDashboardSidebar: React.FC = ({}) => {
19 | const { me } = useMe();
20 |
21 | return (
22 |
32 |
33 | {me?.avatar}} />
34 |
35 | Matches
36 |
37 | Admin
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/packages/site/src/features/sidebar/SidebarButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonProps, Link, LinkProps } from "@chakra-ui/react";
2 | import * as React from "react";
3 | import { useHistory } from "react-router-dom";
4 |
5 | interface Props extends ButtonProps {
6 | to: string;
7 | }
8 |
9 | export const SidebarButton: React.FC = ({ to, ...rest }) => {
10 | const history = useHistory();
11 | return (
12 |