diff --git a/.gitignore b/.gitignore index 2541558..6fa00ac 100644 --- a/.gitignore +++ b/.gitignore @@ -79,5 +79,6 @@ package-lock.json gateway/admins.json +gateway/spawningPool.json /game-api/serviceaccount.json modpack-base/ diff --git a/README.md b/README.md index 5961365..aa025ef 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,12 @@ - auto save/backup - chat +- sort Cards by most recently running +- EditCard.js can probably derive the list of games that support SpawningPool from Gateway, who can learn it based on gameApis reporting the feature + +# Dev Guide + +## Adding support for a new Game + +1. Create a new `./game-api/src/games/` manager, extending either `GenericDockerManager` or `BaseGameManager` +2. Add to `./ui/src/components/EditCard.js`'s `gameApiOptions` diff --git a/game-api/gman-nginx.conf b/game-api/gman-nginx.conf index b3dda6e..ba4b95e 100644 --- a/game-api/gman-nginx.conf +++ b/game-api/gman-nginx.conf @@ -116,13 +116,6 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:6737; } - location /valheim/ { - rewrite ^/(?:[a-z\-2]+)/?(.*)$ /$1 break; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass http://127.0.0.1:6756; - } location /valheim-loki/ { rewrite ^/(?:[a-z\-\d]+)/?(.*)$ /$1 break; proxy_set_header Host $host; diff --git a/game-api/jsconfig.json b/game-api/jsconfig.json new file mode 100644 index 0000000..e69de29 diff --git a/game-api/package.json b/game-api/package.json index 506f4fb..fdc0b3f 100644 --- a/game-api/package.json +++ b/game-api/package.json @@ -21,7 +21,7 @@ "express-timeout-handler": "^2.2.0", "fs-extra": "^11.1.0", "gamedig": "^3.0.9", - "minimist": "^1.2.2", + "minimist": "^1.2.6", "modern-rcon": "^1.0.3", "node-7z": "^2.1.2", "otplib": "^12.0.1", diff --git a/game-api/src/cliArgs.js b/game-api/src/cliArgs.js index c82f1e2..50fb561 100644 --- a/game-api/src/cliArgs.js +++ b/game-api/src/cliArgs.js @@ -21,8 +21,10 @@ module.exports = { listenPort: argv.port || 6726, gatewayUrl: argv.gatewayUrl || "https://gmanman.nebtown.info/gateway/", connectUrl: argv.connectUrl, + gamePort: argv.gamePort, rconPort: argv.rconPort, steamApiKey: argv.steamApiKey, saveName: argv.saveName || gameId.replace(new RegExp(`^${game}[\-_]?`), ""), + gamePassword: argv.gamePassword, serviceAccount: argv.serviceAccount || __dirname + "/../serviceaccount.json", }; diff --git a/game-api/src/games/ark.js b/game-api/src/games/ark.js index c947647..116ecb4 100644 --- a/game-api/src/games/ark.js +++ b/game-api/src/games/ark.js @@ -9,7 +9,7 @@ const { dockerIsProcessRunning, dockerLogRead, readEnvFileCsv, - writeEnvFileCsv, + writeEnvFile, steamWorkshopGetModSearch, } = require("./common-helpers"); @@ -81,7 +81,7 @@ module.exports = class ArkManager { .filter(({ enabled }) => enabled) .map(({ id }) => id) .join(","); - await writeEnvFileCsv("ARK_MODS", modsString); + await writeEnvFile({ ARK_MODS: modsString }); return true; } async getModSearch(query) { diff --git a/game-api/src/games/common-helpers.js b/game-api/src/games/common-helpers.js index 0291a84..22f100f 100644 --- a/game-api/src/games/common-helpers.js +++ b/game-api/src/games/common-helpers.js @@ -137,24 +137,31 @@ async function rconSRCDSConnect(port) { } async function readEnvFileCsv(envName) { - const env = dotenv.parse( - await fsPromises.readFile(path.join(gameDir, ".env")) - ); - if (!env[envName]) { + try { + const env = dotenv.parse( + await fsPromises.readFile(path.join(gameDir, ".env")) + ); + if (!env[envName]) { + return []; + } + return env[envName].trim().split(","); + } catch (e) { + console.warn("Unable to read .env", e); return []; } - return env[envName].trim().split(","); } -async function writeEnvFileCsv(envName, newEnvValue) { +async function writeEnvFile(changes) { const envFilePath = path.join(gameDir, ".env"); const envFileContents = (await fsPromises.readFile(envFilePath)) || ""; - const newEnvFile = - envFileContents - .toString() - .replace(new RegExp(`^${envName}=".*"\n?`, "ms"), "") - .replace(new RegExp(`^${envName}=.*$\n?`, "m"), "") - .trim() + `\n${envName}="${newEnvValue}"\n`; + let newEnvFile = envFileContents.toString(); + for (let envName in changes) { + newEnvFile = + newEnvFile + .replace(new RegExp(`^${envName}=".*?"\n?`, "ms"), "") + .replace(new RegExp(`^${envName}=.*?$\n?`, "m"), "") + .trim() + `\n${envName}="${changes[envName]}"\n`; + } await fsPromises.writeFile(envFilePath, newEnvFile); } @@ -296,7 +303,7 @@ module.exports = { rconConnect, rconSRCDSConnect, readEnvFileCsv, - writeEnvFileCsv, + writeEnvFile, steamWorkshopGetModSearch, BaseGameManager, }; diff --git a/game-api/src/games/docker.js b/game-api/src/games/docker.js index eb88fb7..955e0ad 100644 --- a/game-api/src/games/docker.js +++ b/game-api/src/games/docker.js @@ -39,11 +39,15 @@ module.exports = class GenericDockerManager extends BaseGameManager { }; } + getRconPort() { + return rconPort; + } + async rcon(command) { debugLog(`Running rcon: ${command}`); try { const response = await ( - await rconSRCDSConnect(rconPort) + await rconSRCDSConnect(this.getRconPort()) ).command(command, 500); debugLog(`Rcon response: ${response}`); return true; diff --git a/game-api/src/games/valheim.js b/game-api/src/games/valheim.js index d421eb4..277d421 100644 --- a/game-api/src/games/valheim.js +++ b/game-api/src/games/valheim.js @@ -7,17 +7,24 @@ const { gameId, debugLog, connectUrl, - rconPort, gameDir, + gameName, + saveName, + gamePassword, } = require("../cliArgs"); +let { gamePort, rconPort } = require("../cliArgs"); + +if (!gamePort) gamePort = 2456; +if (!rconPort) rconPort = gamePort + 1; + const { dockerComposePull, gamedigQueryPlayers, readEnvFileCsv, - writeEnvFileCsv, + writeEnvFile, } = require("./common-helpers"); const GenericDockerManager = require("./docker"); -const { spawnProcess } = require("../libjunkdrawer/fsPromises"); +const fs = require("../libjunkdrawer/fsPromises"); const fse = require("fs-extra"); const reDownloadUrl1 = /package\/download\/([^\/]+)\/([^\/]+)\/([^\/]+)\//; @@ -28,11 +35,15 @@ module.exports = class ValheimManager extends GenericDockerManager { getConnectUrl() { return connectUrl; } + getRconPort() { + return rconPort; + } oldGetPlayersResult = false; async getPlayers() { const lookupPromise = gamedigQueryPlayers({ type: "valheim", socketTimeout: 4000, + port: rconPort, }).then((result) => { this.oldGetPlayersResult = result; return result; @@ -84,13 +95,15 @@ module.exports = class ValheimManager extends GenericDockerManager { .filter(({ enabled }) => enabled) .map(({ id }) => allModsById[id].downloadUrl) .join(",\n"); - await writeEnvFileCsv("MODS", modsString); - const modsStringDisabled = modsList .filter(({ enabled }) => !enabled) .map(({ id }) => allModsById[id].downloadUrl) .join(",\n"); - await writeEnvFileCsv("MODS_OFF", modsStringDisabled); + + await writeEnvFile({ + MODS: modsString, + MODS_OFF: modsStringDisabled, + }); return true; } @@ -156,10 +169,34 @@ module.exports = class ValheimManager extends GenericDockerManager { } async getModPackHash() { return ( - await spawnProcess("bash", [ + await fs.spawnProcess("bash", [ "-c", `cd ${gameDir} && find server/BepInEx/{config,plugins} -type f -exec md5sum {} \\; | sort -k 2 | md5sum | cut -d ' ' -f1`, ]) ).trim(); } + + async setupInstanceFiles() { + if (!(await fs.exists(`${gameDir}docker-compose.yml`))) { + await fse.copy( + path.join(__dirname, `../../../game-setups/${game}`), + gameDir, + { + overwrite: false, + } + ); + } + if (!(await fs.exists(`${gameDir}.env`))) { + await fs.writeFile(`${gameDir}.env`, ""); + } + await writeEnvFile({ + API_ID: gameId, + API_NAME: gameName, + GAMEPASSWORD: gamePassword, + SAVENAME: saveName, + GAMEPORT: gamePort, + RCONPORT: rconPort, + EXTRAPORT: gamePort + 2, + }); + } }; diff --git a/game-api/src/index.js b/game-api/src/index.js index ec65551..6a83744 100644 --- a/game-api/src/index.js +++ b/game-api/src/index.js @@ -259,6 +259,10 @@ app.post("/rcon", async (request, response) => { app.listen(listenPort); console.log(`Listening on port ${listenPort}`); +if (gameManager.setupInstanceFiles) { + gameManager.setupInstanceFiles(); +} + async function registerWithGateway() { try { return await axios.post(`${gatewayUrl}register/`, { @@ -277,6 +281,7 @@ async function registerWithGateway() { gameManager.updateOnStart && "updateOnStart", gameManager.filesToBackup && "backup", gameManager.rcon && "rcon", + gameManager.setupInstanceFiles && "spawningPool", ].filter(Boolean), }); } catch (err) { diff --git a/game-api/yarn.lock b/game-api/yarn.lock index 880248c..526bebe 100644 --- a/game-api/yarn.lock +++ b/game-api/yarn.lock @@ -1676,10 +1676,10 @@ minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" -minimist@^1.2.2, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +minimist@^1.2.5, minimist@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mkdirp-classic@^0.5.2: version "0.5.3" diff --git a/gateway/.nvmrc b/gateway/.nvmrc new file mode 100644 index 0000000..b6a7d89 --- /dev/null +++ b/gateway/.nvmrc @@ -0,0 +1 @@ +16 diff --git a/gateway/jsconfig.json b/gateway/jsconfig.json new file mode 100644 index 0000000..e69de29 diff --git a/gateway/package.json b/gateway/package.json index c26f561..3326aab 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -15,6 +15,7 @@ "discord.js": "^13.6.0", "express": "^4.17.3", "express-prettify": "^0.1.2", + "fs-extra": "^11.1.0", "jsonwebtoken": "^8.5.1", "jwks-rsa": "^2.0.5", "md5": "^2.3.0", @@ -22,6 +23,7 @@ "moment": "^2.29.3", "moment-timezone": "^0.5.34", "otplib": "^12.0.1", + "ps-node": "^0.1.6", "sodium": "^3.0.2", "ws": "^7.4.2", "ytdl-core": "^4.11.0" diff --git a/gateway/src/index.js b/gateway/src/index.js index 44819be..00324dc 100644 --- a/gateway/src/index.js +++ b/gateway/src/index.js @@ -52,6 +52,12 @@ const { } = require("./routes/messages"); app.use("/messages", messagesRouter); +const { + spawningPoolRouter, + initSpawningPool, +} = require("./routes/spawningPool"); +app.use("/spawningPool", spawningPoolRouter); + app.post("/auth", async (request, response) => { const token = request.body.id_token; try { @@ -70,9 +76,10 @@ app.post("/auth", async (request, response) => { app.get("/register", (request, response) => { response.json({ games: Object.values(knownGameApis).map( - ({ game, id, name, connectUrl, features }) => ({ + ({ game, gameId, name, connectUrl, features }) => ({ game, - id, + id: gameId, // todo: remove once new UI deployed + gameId, name, connectUrl, features, @@ -83,7 +90,7 @@ app.get("/register", (request, response) => { /** * { game: "factorio", - id: "factorio-angelbob", + gameId: "factorio-angelbob", name: "Factorio Angelbob", connectUrl: "steam://connect/gman.nebtown.info:27015", features: [ @@ -94,8 +101,9 @@ app.get("/register", (request, response) => { } */ app.post("/register", (request, response) => { - knownGameApis[request.body.id] = { ...request.body, timeoutStartTime: 0 }; - debugLog(`Registered ${request.body.id}`, request.body); + const id = request.body.gameId || request.body.id; + knownGameApis[id] = { gameId: id, ...request.body, timeoutStartTime: 0 }; + debugLog(`Registered ${id}`, request.body); response.json({}); }); @@ -107,7 +115,10 @@ app.all("/:gameId/*", async (request, response) => { } const headers = request.headers || {}; const queryParams = request.query || {}; - const bodyParams = request.body || {}; + const bodyParams = + request.body && Object.values(request.body).length + ? request.body + : undefined; let requestURL = `${gameApi.url}${endpoint}`; const extraAxiosOptions = {}; if (endpoint.startsWith("mods/pack")) { @@ -143,7 +154,7 @@ app.all("/:gameId/*", async (request, response) => { gameApi.timeoutStartTime = 0; } catch (err) { if (err.code === "ECONNREFUSED") { - debugLog("Game API", gameApi.url, err.message); + debugLog("Game API ECONNREFUSED", gameApi.url, err.message); response.status(504).json({ message: "Game API offline" }); if (!gameApi.timeoutStartTime) { gameApi.timeoutStartTime = Date.now(); @@ -168,17 +179,20 @@ app.all("/:gameId/*", async (request, response) => { if (extraAxiosOptions.responseType === "stream") { responseData = await streamToString(responseData); } - debugLog( - "Game API", - gameApi.url, - err.message, - `Status: ${status} ${statusText}`, - responseData - ); + if (status !== 304) { + debugLog( + "Game API", + requestURL, + err.message, + `Status: ${status} ${statusText}`, + responseData, + responseHeaders + ); + } response.status(status).json(responseData); } } else { - console.warn("Game API", err); + console.warn("Game API 500", err); response.status(500).json({}); } } @@ -197,4 +211,5 @@ const httpServer = http.createServer(app); httpServer.listen(listenPort); initWebsocketListener(httpServer); initPlayerStatusPoller(knownGameApis); +initSpawningPool(); console.log(`Gateway listening on port ${listenPort}`); diff --git a/gateway/src/lib/fsPromises.js b/gateway/src/lib/fsPromises.js new file mode 100755 index 0000000..d056ba8 --- /dev/null +++ b/gateway/src/lib/fsPromises.js @@ -0,0 +1,41 @@ +const fs = require("fs"); +const util = require("util"); +const child_process = require("child_process"); + +module.exports = { + readFile: util.promisify(fs.readFile), + writeFile: util.promisify(fs.writeFile), + open: util.promisify(fs.open), + mkdir: util.promisify(fs.mkdir), + access: util.promisify(fs.access), + readdir: util.promisify(fs.readdir), + stat: util.promisify(fs.stat), +}; + +module.exports.exists = async (fileName) => { + try { + await module.exports.access(fileName); + return true; + } catch { + return false; + } +}; + +module.exports.spawnProcess = (cmd, args) => + new Promise((resolve, reject) => { + const cp = child_process.spawn(cmd, args); + const error = []; + const stdout = []; + cp.stdout.on("data", (data) => { + stdout.push(data.toString()); + }); + + cp.on("error", (e) => { + error.push(e.toString()); + }); + + cp.on("close", () => { + if (error.length) reject(error.join("")); + else resolve(stdout.join("")); + }); + }); diff --git a/gateway/src/lib/jsonPretty.js b/gateway/src/lib/jsonPretty.js new file mode 100755 index 0000000..2857a2b --- /dev/null +++ b/gateway/src/lib/jsonPretty.js @@ -0,0 +1,3 @@ +module.exports = { + jsonPretty: (obj) => JSON.stringify(obj, null, "\t"), +}; diff --git a/gateway/src/routes/spawningPool.js b/gateway/src/routes/spawningPool.js new file mode 100755 index 0000000..9a22096 --- /dev/null +++ b/gateway/src/routes/spawningPool.js @@ -0,0 +1,169 @@ +const child_process = require("node:child_process"); +const path = require("path"); +const axios = require("axios"); +const ps = require("ps-node"); +const express = require("express"); +const spawningPoolRouter = express.Router(); + +const { + exists, + mkdir, + readFile, + writeFile, + open, +} = require("../lib/fsPromises"); +const { jsonPretty } = require("../lib/jsonPretty"); + +/* +interface ChildApi { + gameId: String; + game: String; // like 'minecraft' + name: String; + gamePort: Number; + rconPort: Number; // often gamePort or gamePort+1 + apiPort: Number; // coooould be fully dynamic, but having it be predictable is likely nice + gamePassword: String; +} +*/ + +async function initSpawningPool() { + const spawningPools = await readSpawningPoolConfig(); + for (let childApi of Object.values(spawningPools)) { + console.log("Checking child gameApi ", childApi.gameId); + try { + const { data } = await axios.get( + `http://localhost:${childApi.apiPort}/control` + ); + console.debug("Got response", data); + } catch (e) { + console.error( + `checkChildApi ${childApi.gameId} error: `, + e.message, + ", respawning..." + ); + await spawnChildApi(childApi); + } + } +} + +async function readSpawningPoolConfig() { + try { + const fileContents = await readFile("spawningPool.json"); + return JSON.parse(fileContents); + } catch (e) { + if (!e.message.startsWith("ENOENT: no such file or directory")) { + console.error("Failed to read childApisConfig: ", e.message); + } + return {}; + } +} + +async function writeSpawningPoolConfig(data) { + await writeFile("spawningPool.json", jsonPretty(data)); +} + +async function spawnChildApi(childApi) { + const workingDir = `/servers/${childApi.gameId}/`; + if (!(await exists(workingDir))) { + await mkdir(workingDir); + } + const logFile = await open(`${workingDir}gameApi.log`, "a"); + + const subprocess = child_process.fork( + path.resolve(`../game-api/src/index`), + [ + `--game=${childApi.game}`, + `--gameId=${childApi.gameId}`, + `--gameName=${childApi.name}`, + `--port=${childApi.apiPort}`, + `--urlRoot=http://localhost:${childApi.apiPort}/`, + `--dir=${workingDir}`, + `--serviceAccount=${path.resolve("../game-api/serviceaccount.json")}`, + `--gamePort=${childApi.gamePort || ""}`, + `--rconPort=${childApi.rconPort || ""}`, + `--gamePassword=${childApi.gamePassword || ""}`, + `--saveName=${childApi.saveName || ""}`, + ], + { + detached: true, + stdio: ["pipe", logFile, logFile, "ipc"], + cwd: workingDir, + } + ); + + subprocess.unref(); + subprocess.disconnect(); // stop receiving IPC messages +} + +async function stopChildApi(gameId) { + return await new Promise((resolve, reject) => { + ps.lookup( + { + command: "node", + arguments: `--gameId=${gameId}`, + }, + function (err, resultList) { + if (err) { + console.error("stopChildApi lookup error: ", err); + return reject(err); + } + if (resultList.length === 0) { + return resolve(); // no process found + } + ps.kill(resultList[0].pid, function (err) { + if (err) { + console.error("stopChildApi kill error: ", err); + } + resolve(); + }); + } + ); + }); +} + +function generateUnusedApiPort(configs) { + const highestUsedPort = + Math.max(...Object.values(configs).map(({ apiPort }) => apiPort || 0)) || + 42100; + return highestUsedPort + 1; +} + +// routing stuff + +const { checkAuthMiddleware } = require("../lib/login"); +spawningPoolRouter.use( + checkAuthMiddleware([ + ["GET", "/"], + ["PUT", "/"], + ]) +); + +spawningPoolRouter.get("/", async function (req, response) { + response.json({ gameApis: Object.values(await readSpawningPoolConfig()) }); +}); + +spawningPoolRouter.put("/", async function (req, response) { + console.log("SpawningPool: Received PUT /", req.body); + const updatedGameApi = req.body.gameApi; + delete updatedGameApi["isNew"]; + const allConfigs = await readSpawningPoolConfig(); + allConfigs[updatedGameApi.gameId] = { + ...(allConfigs[updatedGameApi.gameId] || {}), + ...updatedGameApi, + }; + if (!allConfigs[updatedGameApi.gameId].apiPort) { + allConfigs[updatedGameApi.gameId].apiPort = + generateUnusedApiPort(allConfigs); + } + await writeSpawningPoolConfig(allConfigs); + + response.json({ status: "ok" }); + + await stopChildApi(updatedGameApi.gameId); + void initSpawningPool(); +}); + +module.exports = { + initSpawningPool, + spawningPoolRouter, +}; diff --git a/gateway/yarn.lock b/gateway/yarn.lock index d3bf30e..a1274f7 100644 --- a/gateway/yarn.lock +++ b/gateway/yarn.lock @@ -312,6 +312,11 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= +connected-domain@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/connected-domain/-/connected-domain-1.0.0.tgz#bfe77238c74be453a79f0cb6058deeb4f2358e93" + integrity sha512-lHlohUiJxlpunvDag2Y0pO20bnvarMjnrdciZeuJUqRwrf/5JHNhdpiPIr5GQ8IkqrFj5TDMQwcCjblGo1oeuA== + console-control-strings@^1.0.0, console-control-strings@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" @@ -542,6 +547,15 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= +fs-extra@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.0.tgz#5784b102104433bb0e090f48bfc4a30742c357ed" + integrity sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-minipass@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" @@ -581,6 +595,11 @@ glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== + has-unicode@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" @@ -647,6 +666,15 @@ jose@^2.0.5: dependencies: "@panva/asn1.js" "^1.0.0" +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + jsonwebtoken@^8.5.1: version "8.5.1" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" @@ -991,6 +1019,13 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +ps-node@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/ps-node/-/ps-node-0.1.6.tgz#9af67a99d7b1d0132e51a503099d38a8d2ace2c3" + integrity sha512-w7QJhUTbu70hpDso0YXDRNKCPNuchV8UTUZsAv0m7Qj5g85oHOJfr9drA1EjvK4nQK/bG8P97W4L6PJ3IQLoOA== + dependencies: + table-parser "^0.1.3" + pseudomap@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" @@ -1148,6 +1183,13 @@ strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +table-parser@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/table-parser/-/table-parser-0.1.3.tgz#0441cfce16a59481684c27d1b5a67ff15a43c7b0" + integrity sha512-LCYeuvqqoPII3lzzYaXKbC3Forb+d2u4bNwhk/9FlivuGRxPE28YEWAYcujeSlLLDlMfvy29+WPybFJZFiKMYg== + dependencies: + connected-domain "^1.0.0" + tar@^6.1.11: version "6.1.11" resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621" @@ -1198,6 +1240,11 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" diff --git a/ui/.nvmrc b/ui/.nvmrc new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/ui/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/ui/.postcssrc.json b/ui/.postcssrc.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/ui/.postcssrc.json @@ -0,0 +1 @@ +{} diff --git a/ui/gatsby-config.js b/ui/gatsby-config.js index 8fa830e..698f70a 100644 --- a/ui/gatsby-config.js +++ b/ui/gatsby-config.js @@ -8,6 +8,7 @@ module.exports = { "https://gmanman.nebtown.info/gateway/", }, plugins: [ + `gatsby-plugin-emotion`, { resolve: `gatsby-plugin-sass`, options: { implementation: require("sass") }, diff --git a/ui/jsconfig.json b/ui/jsconfig.json new file mode 100644 index 0000000..e69de29 diff --git a/ui/package.json b/ui/package.json index 4bca14f..1bee1a1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -26,6 +26,7 @@ "axios": "^1.2.1", "classnames": "^2.2.6", "gatsby": "^5.3.3", + "gatsby-plugin-emotion": "^8.3.0", "gatsby-plugin-image": "^3.3.2", "gatsby-plugin-manifest": "^5.3.1", "gatsby-plugin-sass": "^6.3.1", diff --git a/ui/src/components/CardFlip.js b/ui/src/components/CardFlip.js new file mode 100755 index 0000000..83a8b3d --- /dev/null +++ b/ui/src/components/CardFlip.js @@ -0,0 +1,55 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { css } from "@emotion/react"; + +CardFlip.propTypes = { + cardFront: PropTypes.node, + cardBack: PropTypes.node, + flipped: PropTypes.bool, +}; + +export default function CardFlip({ cardFront, cardBack, flipped }) { + const cardCommon = ` + backface-visibility: hidden; + position: absolute; + top: 0; + left: 0; + width: 100%; + `; + return ( +