first
This commit is contained in:
commit
649574ec11
11 files changed
+1739
No files matched your search
+31
@@ -0,0 +1,31 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
*.log
|
||||
|
||||
# environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS/editor files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# runtime/cache
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
.cache/
|
||||
|
||||
# coverage/build artifacts
|
||||
coverage/
|
||||
dist/
|
||||
build/
|
||||
@@ -0,0 +1,104 @@
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { createLogger, format, transports } = require("winston");
|
||||
const { consoleFormat } = require("winston-console-format");
|
||||
const util = require("util");
|
||||
|
||||
const logger = createLogger({
|
||||
level: "silly",
|
||||
format: format.combine(
|
||||
format.timestamp(),
|
||||
format.ms(),
|
||||
format.errors({ stack: true }),
|
||||
format.splat(),
|
||||
format.json()
|
||||
),
|
||||
defaultMeta: { service: "Test" },
|
||||
transports: [
|
||||
new transports.Console({
|
||||
format: format.combine(
|
||||
format.colorize({ all: true }),
|
||||
format.padLevels(),
|
||||
consoleFormat({
|
||||
showMeta: true,
|
||||
metaStrip: ["timestamp", "service"],
|
||||
inspectOptions: {
|
||||
depth: Infinity,
|
||||
colors: true,
|
||||
maxArrayLength: Infinity,
|
||||
breakLength: 120,
|
||||
compact: Infinity,
|
||||
},
|
||||
})
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const PUBLIC_DIR = path.join(__dirname, "public");
|
||||
|
||||
const MIME_TYPES = {
|
||||
".html": "text/html; charset=UTF-8",
|
||||
".css": "text/css; charset=UTF-8",
|
||||
".js": "application/javascript; charset=UTF-8",
|
||||
".json": "application/json; charset=UTF-8",
|
||||
".svg": "image/svg+xml",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".ico": "image/x-icon"
|
||||
};
|
||||
|
||||
function resolveFilePath(urlPath) {
|
||||
if (!urlPath || urlPath === "/") {
|
||||
return path.join(PUBLIC_DIR, "index.html");
|
||||
}
|
||||
|
||||
const safePath = path.normalize(urlPath).replace(/^(\.\.([/\\]|$))+/, "");
|
||||
return path.join(PUBLIC_DIR, safePath);
|
||||
}
|
||||
|
||||
function sendFile(filePath, res) {
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=UTF-8" });
|
||||
res.end("404 Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(500, { "Content-Type": "text/plain; charset=UTF-8" });
|
||||
res.end("500 Internal Server Error");
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
||||
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const reqPath = (req.url || "/").split("?")[0];
|
||||
const filePath = resolveFilePath(reqPath);
|
||||
|
||||
fs.stat(filePath, (err, stat) => {
|
||||
if (!err && stat.isDirectory()) {
|
||||
sendFile(path.join(filePath, "index.html"), res);
|
||||
return;
|
||||
}
|
||||
|
||||
sendFile(filePath, res);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
logger.info(`web running at http://localhost:${PORT}`);
|
||||
});
|
||||
Generated
+317
@@ -0,0 +1,317 @@
|
||||
{
|
||||
"name": "typeup",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "typeup",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"winston": "^3.19.0",
|
||||
"winston-console-format": "^1.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
||||
"integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"node_modules/@dabh/diagnostics": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz",
|
||||
"integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@so-ric/colorspace": "^1.1.6",
|
||||
"enabled": "2.0.x",
|
||||
"kuler": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@so-ric/colorspace": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
|
||||
"integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color": "^5.0.2",
|
||||
"text-hex": "1.0.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/triple-beam": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
|
||||
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||
"integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^3.1.3",
|
||||
"color-string": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
|
||||
"integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
||||
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/colors": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
|
||||
"integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"node_modules/enabled": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
|
||||
"integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fecha": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
|
||||
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fn.name": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
|
||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/kuler": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
||||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/logform": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
|
||||
"integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@colors/colors": "1.6.0",
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
"fecha": "^4.2.0",
|
||||
"ms": "^2.1.1",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"triple-beam": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/one-time": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
|
||||
"integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fn.name": "1.x.x"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-trace": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||
"integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/text-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/triple-beam": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
||||
"integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/winston": {
|
||||
"version": "3.19.0",
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.8",
|
||||
"async": "^3.2.3",
|
||||
"is-stream": "^2.0.0",
|
||||
"logform": "^2.7.0",
|
||||
"one-time": "^1.0.0",
|
||||
"readable-stream": "^3.4.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"stack-trace": "0.0.x",
|
||||
"triple-beam": "^1.3.0",
|
||||
"winston-transport": "^4.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/winston-console-format": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/winston-console-format/-/winston-console-format-1.0.8.tgz",
|
||||
"integrity": "sha512-dq7t/E0D0QRi4XIOwu6HM1+5e//WPqylH88GVjKEhQVrzGFg34MCz+G7pMJcXFBen9C0kBsu5GYgbYsE2LDwKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colors": "^1.4.0",
|
||||
"logform": "^2.2.0",
|
||||
"triple-beam": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/winston-transport": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
|
||||
"integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"logform": "^2.7.0",
|
||||
"readable-stream": "^3.6.2",
|
||||
"triple-beam": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "typeup",
|
||||
"version": "1.0.0",
|
||||
"description": "hackathon",
|
||||
"license": "ISC",
|
||||
"author": "st5lish",
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"winston": "^3.19.0",
|
||||
"winston-console-format": "^1.0.8"
|
||||
}
|
||||
}
|
||||
+562
@@ -0,0 +1,562 @@
|
||||
const STORAGE_KEY = "levelup-typing-save-v1";
|
||||
|
||||
const WORD_BANKS = {
|
||||
easy: [
|
||||
"apple", "river", "light", "happy", "quick", "smile", "cloud", "dream", "friend", "learn",
|
||||
"typing", "music", "garden", "planet", "window", "ticket", "puzzle", "silver", "orange", "pepper"
|
||||
],
|
||||
normal: [
|
||||
"speed", "skill", "sprint", "rhythm", "tempo", "focus", "kinetic", "logic", "phrase", "arcade",
|
||||
"streak", "vector", "groove", "momentum", "signal", "catalyst", "syntax", "drift", "horizon", "quantum"
|
||||
],
|
||||
hard: [
|
||||
"architect", "backspace", "fragment", "notation", "velocity", "parallel", "hexadecimal", "interface", "algorithm", "synchronize",
|
||||
"compilation", "resolution", "parameter", "validation", "hyperlink", "generator", "trajectory", "oscillation", "framework", "benchmark"
|
||||
],
|
||||
expert: [
|
||||
"extraordinary", "counterclockwise", "asymptotically", "deconstruction", "characteristically", "electromagnetic", "microarchitecture", "misconfiguration", "representational", "interoperability",
|
||||
"cryptographically", "institutionalize", "uncharacteristic", "transcontinental", "hyperparameter", "instrumentation", "multiprocessing", "disproportionate", "comprehensibility", "photosynthesis"
|
||||
]
|
||||
};
|
||||
|
||||
const DIFFICULTY = {
|
||||
easy: { label: "easy", xpMult: 0.9, roundWords: 38 },
|
||||
normal: { label: "normal", xpMult: 1, roundWords: 45 },
|
||||
hard: { label: "hard", xpMult: 1.25, roundWords: 50 },
|
||||
expert: { label: "expert", xpMult: 1.5, roundWords: 54 }
|
||||
};
|
||||
|
||||
const PERKS = [
|
||||
{
|
||||
id: "combo-boost",
|
||||
name: "combo boost",
|
||||
unlockLevel: 2,
|
||||
cost: 1,
|
||||
description: "Earn 15% more XP while your best combo is 5 or higher."
|
||||
},
|
||||
{
|
||||
id: "accuracy-bounty",
|
||||
name: "accuracy bounty",
|
||||
unlockLevel: 4,
|
||||
cost: 2,
|
||||
description: "If you finish with 96%+ accuracy, gain +30 bonus XP."
|
||||
},
|
||||
{
|
||||
id: "time-warp",
|
||||
name: "time warp",
|
||||
unlockLevel: 6,
|
||||
cost: 2,
|
||||
description: "Start rounds with +8 seconds for easier high-streak runs."
|
||||
},
|
||||
{
|
||||
id: "focus-engine",
|
||||
name: "focus engine",
|
||||
unlockLevel: 8,
|
||||
cost: 3,
|
||||
description: "At 98%+ accuracy, your WPM contributes an extra 25% XP."
|
||||
}
|
||||
];
|
||||
|
||||
const defaultState = {
|
||||
profileName: "rookie typer",
|
||||
difficulty: "normal",
|
||||
words: [],
|
||||
index: 0,
|
||||
typedChars: 0,
|
||||
correctWords: 0,
|
||||
wrongWords: 0,
|
||||
combo: 0,
|
||||
bestCombo: 0,
|
||||
timer: null,
|
||||
elapsedSeconds: 0,
|
||||
roundSeconds: 60,
|
||||
running: false,
|
||||
xp: 0,
|
||||
level: 1,
|
||||
shards: 0,
|
||||
lastRoundXp: 0,
|
||||
ownedPerks: [],
|
||||
stats: {
|
||||
totalRounds: 0,
|
||||
bestWpm: 0,
|
||||
bestAccuracy: 0,
|
||||
averageWpm: 0,
|
||||
averageAccuracy: 0
|
||||
},
|
||||
recentRounds: []
|
||||
};
|
||||
|
||||
const state = { ...defaultState };
|
||||
|
||||
const els = {
|
||||
textDisplay: document.getElementById("textDisplay"),
|
||||
typeInput: document.getElementById("typeInput"),
|
||||
newRoundBtn: document.getElementById("newRoundBtn"),
|
||||
settingsBtn: document.getElementById("settingsBtn"),
|
||||
closeSettingsBtn: document.getElementById("closeSettingsBtn"),
|
||||
settingsModal: document.getElementById("settingsModal"),
|
||||
resetConfirmModal: document.getElementById("resetConfirmModal"),
|
||||
cancelResetBtn: document.getElementById("cancelResetBtn"),
|
||||
confirmResetBtn: document.getElementById("confirmResetBtn"),
|
||||
playAgainBtn: document.getElementById("playAgainBtn"),
|
||||
results: document.getElementById("results"),
|
||||
resultsSummary: document.getElementById("resultsSummary"),
|
||||
wpm: document.getElementById("wpm"),
|
||||
accuracy: document.getElementById("accuracy"),
|
||||
combo: document.getElementById("combo"),
|
||||
time: document.getElementById("time"),
|
||||
profileTitle: document.getElementById("profileTitle"),
|
||||
levelText: document.getElementById("levelText"),
|
||||
xpText: document.getElementById("xpText"),
|
||||
shardText: document.getElementById("shardText"),
|
||||
xpFill: document.getElementById("xpFill"),
|
||||
xpGain: document.getElementById("xpGain"),
|
||||
perks: document.getElementById("perks"),
|
||||
profileName: document.getElementById("profileName"),
|
||||
difficultySelect: document.getElementById("difficultySelect"),
|
||||
difficultyBonus: document.getElementById("difficultyBonus"),
|
||||
resetProgressBtn: document.getElementById("resetProgressBtn")
|
||||
};
|
||||
|
||||
function xpNeededForLevel(level) {
|
||||
return 120 + (level - 1) * 60;
|
||||
}
|
||||
|
||||
function sanitizeDifficulty(value) {
|
||||
return DIFFICULTY[value] ? value : "normal";
|
||||
}
|
||||
|
||||
function saveProgress() {
|
||||
const payload = {
|
||||
profileName: state.profileName,
|
||||
difficulty: state.difficulty,
|
||||
xp: state.xp,
|
||||
level: state.level,
|
||||
shards: state.shards,
|
||||
lastRoundXp: state.lastRoundXp,
|
||||
ownedPerks: state.ownedPerks,
|
||||
stats: state.stats,
|
||||
recentRounds: state.recentRounds
|
||||
};
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function loadProgress() {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
state.profileName = typeof parsed.profileName === "string" && parsed.profileName.trim()
|
||||
? parsed.profileName.trim().slice(0, 24)
|
||||
: defaultState.profileName;
|
||||
state.difficulty = sanitizeDifficulty(parsed.difficulty);
|
||||
state.xp = Number.isFinite(parsed.xp) ? Math.max(0, Math.floor(parsed.xp)) : 0;
|
||||
state.level = Number.isFinite(parsed.level) ? Math.max(1, Math.floor(parsed.level)) : 1;
|
||||
state.shards = Number.isFinite(parsed.shards) ? Math.max(0, Math.floor(parsed.shards)) : 0;
|
||||
state.lastRoundXp = Number.isFinite(parsed.lastRoundXp) ? Math.max(0, Math.floor(parsed.lastRoundXp)) : 0;
|
||||
state.ownedPerks = Array.isArray(parsed.ownedPerks)
|
||||
? parsed.ownedPerks.filter((id) => PERKS.some((perk) => perk.id === id))
|
||||
: [];
|
||||
|
||||
const parsedStats = parsed.stats || {};
|
||||
state.stats = {
|
||||
totalRounds: Number.isFinite(parsedStats.totalRounds) ? Math.max(0, Math.floor(parsedStats.totalRounds)) : 0,
|
||||
bestWpm: Number.isFinite(parsedStats.bestWpm) ? Math.max(0, Math.floor(parsedStats.bestWpm)) : 0,
|
||||
bestAccuracy: Number.isFinite(parsedStats.bestAccuracy) ? Math.max(0, Math.floor(parsedStats.bestAccuracy)) : 0,
|
||||
averageWpm: Number.isFinite(parsedStats.averageWpm) ? Math.max(0, Number(parsedStats.averageWpm)) : 0,
|
||||
averageAccuracy: Number.isFinite(parsedStats.averageAccuracy) ? Math.max(0, Number(parsedStats.averageAccuracy)) : 0
|
||||
};
|
||||
|
||||
state.recentRounds = Array.isArray(parsed.recentRounds)
|
||||
? parsed.recentRounds.slice(0, 20)
|
||||
: [];
|
||||
} catch (error) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function trackRound(summary, earnedXp) {
|
||||
state.stats.totalRounds += 1;
|
||||
state.stats.bestWpm = Math.max(state.stats.bestWpm, summary.wpm);
|
||||
state.stats.bestAccuracy = Math.max(state.stats.bestAccuracy, summary.accuracy);
|
||||
|
||||
const rounds = state.stats.totalRounds;
|
||||
state.stats.averageWpm = ((state.stats.averageWpm * (rounds - 1)) + summary.wpm) / rounds;
|
||||
state.stats.averageAccuracy = ((state.stats.averageAccuracy * (rounds - 1)) + summary.accuracy) / rounds;
|
||||
|
||||
const snapshot = {
|
||||
stamp: Date.now(),
|
||||
wpm: summary.wpm,
|
||||
accuracy: summary.accuracy,
|
||||
bestCombo: summary.bestCombo,
|
||||
xp: earnedXp,
|
||||
difficulty: state.difficulty
|
||||
};
|
||||
|
||||
state.recentRounds.unshift(snapshot);
|
||||
if (state.recentRounds.length > 20) {
|
||||
state.recentRounds.length = 20;
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
els.settingsModal.hidden = false;
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
els.settingsModal.hidden = true;
|
||||
}
|
||||
|
||||
function openResetConfirm() {
|
||||
els.resetConfirmModal.hidden = false;
|
||||
}
|
||||
|
||||
function closeResetConfirm() {
|
||||
els.resetConfirmModal.hidden = true;
|
||||
}
|
||||
|
||||
function pickWords() {
|
||||
const config = DIFFICULTY[state.difficulty];
|
||||
const pool = WORD_BANKS[state.difficulty];
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < config.roundWords; i += 1) {
|
||||
result.push(pool[Math.floor(Math.random() * pool.length)]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function hasPerk(id) {
|
||||
return state.ownedPerks.includes(id);
|
||||
}
|
||||
|
||||
function renderWords() {
|
||||
els.textDisplay.innerHTML = "";
|
||||
state.words.forEach((word, idx) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "word";
|
||||
if (idx === state.index) span.classList.add("current");
|
||||
span.textContent = `${word} `;
|
||||
els.textDisplay.appendChild(span);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPerks() {
|
||||
els.perks.innerHTML = "";
|
||||
|
||||
PERKS.forEach((perk) => {
|
||||
const levelReady = state.level >= perk.unlockLevel;
|
||||
const owned = hasPerk(perk.id);
|
||||
const canBuy = levelReady && !owned && state.shards >= perk.cost;
|
||||
|
||||
const card = document.createElement("article");
|
||||
card.className = "perk";
|
||||
card.innerHTML = `
|
||||
<h3>${perk.name} · Lv ${perk.unlockLevel}</h3>
|
||||
<p>${perk.description}</p>
|
||||
<div class="perk-actions">
|
||||
<span class="perk-cost">Cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""}</span>
|
||||
<button class="perk-buy" data-perk-id="${perk.id}" ${canBuy ? "" : "disabled"}>
|
||||
${owned ? "owned" : levelReady ? "buy" : "locked"}
|
||||
</button>
|
||||
</div>
|
||||
<span class="status ${owned ? "active" : levelReady ? "unlocked" : "locked"}">
|
||||
${owned ? "active" : levelReady ? "shop open" : "locked"}
|
||||
</span>
|
||||
`;
|
||||
|
||||
els.perks.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
const config = DIFFICULTY[state.difficulty];
|
||||
els.profileName.value = state.profileName;
|
||||
els.difficultySelect.value = state.difficulty;
|
||||
els.difficultyBonus.textContent = `xp bonus: x${config.xpMult.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function renderProgress() {
|
||||
const levelNeed = xpNeededForLevel(state.level);
|
||||
const ratio = Math.min((state.xp / levelNeed) * 100, 100);
|
||||
|
||||
els.profileTitle.textContent = state.profileName;
|
||||
els.levelText.textContent = `level ${state.level}`;
|
||||
els.xpText.textContent = `${state.xp} / ${levelNeed} xp`;
|
||||
els.shardText.textContent = `perk shards: ${state.shards}`;
|
||||
els.xpFill.style.width = `${ratio}%`;
|
||||
els.xpGain.textContent = `+${state.lastRoundXp} xp last round`;
|
||||
|
||||
renderSettings();
|
||||
renderPerks();
|
||||
}
|
||||
|
||||
function updateLiveStats() {
|
||||
const elapsed = Math.max(state.elapsedSeconds / 60, 1 / 60);
|
||||
const wordsTypedEstimate = state.typedChars / 5;
|
||||
const wpm = Math.round(wordsTypedEstimate / elapsed);
|
||||
const totalWords = state.correctWords + state.wrongWords;
|
||||
const acc = totalWords === 0 ? 100 : Math.round((state.correctWords / totalWords) * 100);
|
||||
|
||||
els.wpm.textContent = String(wpm);
|
||||
els.accuracy.textContent = `${acc}%`;
|
||||
els.combo.textContent = `x${state.combo}`;
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
const remaining = Math.max(state.roundSeconds - state.elapsedSeconds, 0);
|
||||
els.time.textContent = `${remaining}s`;
|
||||
els.time.style.color = remaining <= 10 ? "#ffd587" : "";
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (state.running) return;
|
||||
|
||||
state.running = true;
|
||||
state.timer = setInterval(() => {
|
||||
state.elapsedSeconds += 1;
|
||||
updateTimer();
|
||||
updateLiveStats();
|
||||
|
||||
if (state.elapsedSeconds >= state.roundSeconds) {
|
||||
finishRound();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function awardXp(summary) {
|
||||
let xp = 35;
|
||||
xp += Math.floor(summary.wpm * 1.4);
|
||||
xp += Math.floor(summary.accuracy * 0.55);
|
||||
xp += Math.floor(summary.bestCombo * 2.5);
|
||||
|
||||
if (hasPerk("combo-boost") && summary.bestCombo >= 5) {
|
||||
xp = Math.floor(xp * 1.15);
|
||||
}
|
||||
|
||||
if (hasPerk("accuracy-bounty") && summary.accuracy >= 96) {
|
||||
xp += 30;
|
||||
}
|
||||
|
||||
if (hasPerk("focus-engine") && summary.accuracy >= 98) {
|
||||
xp += Math.floor(summary.wpm * 0.25);
|
||||
}
|
||||
|
||||
xp = Math.floor(xp * DIFFICULTY[state.difficulty].xpMult);
|
||||
|
||||
let pending = xp;
|
||||
let levelUps = 0;
|
||||
|
||||
while (pending > 0) {
|
||||
const need = xpNeededForLevel(state.level) - state.xp;
|
||||
if (pending >= need) {
|
||||
pending -= need;
|
||||
state.level += 1;
|
||||
levelUps += 1;
|
||||
state.xp = 0;
|
||||
} else {
|
||||
state.xp += pending;
|
||||
pending = 0;
|
||||
}
|
||||
}
|
||||
|
||||
state.shards += levelUps;
|
||||
state.lastRoundXp = xp;
|
||||
return xp;
|
||||
}
|
||||
|
||||
function markWordResult(correct) {
|
||||
const wordNodes = els.textDisplay.querySelectorAll(".word");
|
||||
const node = wordNodes[state.index];
|
||||
if (!node) return;
|
||||
|
||||
node.classList.remove("current");
|
||||
node.classList.add(correct ? "correct" : "incorrect");
|
||||
}
|
||||
|
||||
function setCurrentWord() {
|
||||
const wordNodes = els.textDisplay.querySelectorAll(".word");
|
||||
const current = wordNodes[state.index];
|
||||
if (current) {
|
||||
current.classList.add("current");
|
||||
current.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
function finishRound() {
|
||||
clearInterval(state.timer);
|
||||
state.running = false;
|
||||
els.typeInput.disabled = true;
|
||||
|
||||
const elapsedMinutes = Math.max(state.elapsedSeconds / 60, 1 / 60);
|
||||
const wpm = Math.round((state.typedChars / 5) / elapsedMinutes);
|
||||
const totalWords = state.correctWords + state.wrongWords;
|
||||
const accuracy = totalWords === 0 ? 100 : Math.round((state.correctWords / totalWords) * 100);
|
||||
|
||||
const summary = {
|
||||
wpm,
|
||||
accuracy,
|
||||
bestCombo: state.bestCombo
|
||||
};
|
||||
|
||||
const earnedXp = awardXp(summary);
|
||||
trackRound(summary, earnedXp);
|
||||
renderProgress();
|
||||
updateLiveStats();
|
||||
saveProgress();
|
||||
|
||||
els.results.hidden = false;
|
||||
els.resultsSummary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earnedXp} xp!`;
|
||||
}
|
||||
|
||||
function resetRound() {
|
||||
clearInterval(state.timer);
|
||||
state.words = pickWords();
|
||||
state.index = 0;
|
||||
state.typedChars = 0;
|
||||
state.correctWords = 0;
|
||||
state.wrongWords = 0;
|
||||
state.combo = 0;
|
||||
state.bestCombo = 0;
|
||||
state.elapsedSeconds = 0;
|
||||
state.roundSeconds = hasPerk("time-warp") ? 68 : 60;
|
||||
state.running = false;
|
||||
|
||||
renderWords();
|
||||
updateLiveStats();
|
||||
updateTimer();
|
||||
|
||||
els.typeInput.disabled = false;
|
||||
els.typeInput.value = "";
|
||||
els.typeInput.focus();
|
||||
els.results.hidden = true;
|
||||
}
|
||||
|
||||
function setProfileName(value) {
|
||||
const clean = value.trim().slice(0, 24);
|
||||
state.profileName = clean || defaultState.profileName;
|
||||
renderProgress();
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
function setDifficulty(value) {
|
||||
state.difficulty = sanitizeDifficulty(value);
|
||||
renderProgress();
|
||||
resetRound();
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
function buyPerk(perkId) {
|
||||
const perk = PERKS.find((item) => item.id === perkId);
|
||||
if (!perk) return;
|
||||
if (state.level < perk.unlockLevel) return;
|
||||
if (hasPerk(perkId)) return;
|
||||
if (state.shards < perk.cost) return;
|
||||
|
||||
state.shards -= perk.cost;
|
||||
state.ownedPerks.push(perk.id);
|
||||
renderProgress();
|
||||
resetRound();
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
function resetProgress() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
clearInterval(state.timer);
|
||||
|
||||
Object.assign(state, { ...defaultState, ownedPerks: [], stats: { ...defaultState.stats }, recentRounds: [] });
|
||||
renderProgress();
|
||||
resetRound();
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
els.typeInput.addEventListener("input", () => {
|
||||
const value = els.typeInput.value;
|
||||
|
||||
if (!state.running && value.length > 0) {
|
||||
startTimer();
|
||||
}
|
||||
|
||||
if (!value.endsWith(" ")) return;
|
||||
|
||||
const typedWord = value.trim();
|
||||
const targetWord = state.words[state.index];
|
||||
const correct = typedWord === targetWord;
|
||||
|
||||
state.typedChars += typedWord.length + 1;
|
||||
|
||||
if (correct) {
|
||||
state.correctWords += 1;
|
||||
state.combo += 1;
|
||||
if (state.combo > state.bestCombo) {
|
||||
state.bestCombo = state.combo;
|
||||
}
|
||||
} else {
|
||||
state.wrongWords += 1;
|
||||
state.combo = 0;
|
||||
}
|
||||
|
||||
markWordResult(correct);
|
||||
state.index += 1;
|
||||
els.typeInput.value = "";
|
||||
|
||||
if (state.index >= state.words.length) {
|
||||
finishRound();
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentWord();
|
||||
updateLiveStats();
|
||||
});
|
||||
|
||||
els.newRoundBtn.addEventListener("click", resetRound);
|
||||
els.playAgainBtn.addEventListener("click", resetRound);
|
||||
els.settingsBtn.addEventListener("click", openSettings);
|
||||
els.closeSettingsBtn.addEventListener("click", closeSettings);
|
||||
els.profileName.addEventListener("change", (event) => setProfileName(event.target.value));
|
||||
els.difficultySelect.addEventListener("change", (event) => setDifficulty(event.target.value));
|
||||
els.resetProgressBtn.addEventListener("click", openResetConfirm);
|
||||
els.cancelResetBtn.addEventListener("click", closeResetConfirm);
|
||||
els.confirmResetBtn.addEventListener("click", () => {
|
||||
resetProgress();
|
||||
closeResetConfirm();
|
||||
closeSettings();
|
||||
});
|
||||
|
||||
els.settingsModal.addEventListener("click", (event) => {
|
||||
if (event.target === els.settingsModal) {
|
||||
closeSettings();
|
||||
}
|
||||
});
|
||||
|
||||
els.resetConfirmModal.addEventListener("click", (event) => {
|
||||
if (event.target === els.resetConfirmModal) {
|
||||
closeResetConfirm();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && !els.resetConfirmModal.hidden) {
|
||||
closeResetConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape" && !els.settingsModal.hidden) {
|
||||
closeSettings();
|
||||
}
|
||||
});
|
||||
|
||||
els.perks.addEventListener("click", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLButtonElement)) return;
|
||||
const perkId = target.dataset.perkId;
|
||||
if (!perkId) return;
|
||||
buyPerk(perkId);
|
||||
});
|
||||
|
||||
loadProgress();
|
||||
renderProgress();
|
||||
resetRound();
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>typeup</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Sora:wght@600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<main class="layout">
|
||||
<section class="panel panel-main">
|
||||
<header class="game-head">
|
||||
<div class="head-actions">
|
||||
<a href="stats.html" class="btn btn-ghost">stats</a>
|
||||
<button id="settingsBtn" class="btn btn-ghost" type="button">settings</button>
|
||||
<button id="newRoundBtn" class="btn" type="button">reset</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stats-grid">
|
||||
<article class="stat-card">
|
||||
<p>wpm</p>
|
||||
<strong id="wpm">0</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<p>accuracy</p>
|
||||
<strong id="accuracy">100%</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<p>combo</p>
|
||||
<strong id="combo">x0</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<p>time</p>
|
||||
<strong id="time">60s</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="text-shell" id="textDisplay" aria-live="polite"></section>
|
||||
|
||||
<label class="input-wrap" for="typeInput">
|
||||
<input
|
||||
id="typeInput"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
placeholder="start typing to begin..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<section class="results" id="results" hidden>
|
||||
<h2>round complete!</h2>
|
||||
<p id="resultsSummary"></p>
|
||||
<button id="playAgainBtn" class="btn">go again</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="panel panel-side">
|
||||
<section class="profile">
|
||||
<h2 id="profileTitle">rookie typer</h2>
|
||||
<div class="level-line">
|
||||
<span id="levelText">level 1</span>
|
||||
<span id="xpText">0 / 120 xp</span>
|
||||
</div>
|
||||
<div class="level-line">
|
||||
<span id="shardText">shards: 0</span>
|
||||
</div>
|
||||
<div class="xp-bar">
|
||||
<div id="xpFill"></div>
|
||||
</div>
|
||||
<p id="xpGain" class="xp-gain">+0 xp last round</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="label">shop</p>
|
||||
<div id="perks" class="perks"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div id="settingsModal" class="modal" hidden>
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
|
||||
<div class="modal-head">
|
||||
<h2 id="settingsTitle">settings</h2>
|
||||
<button id="closeSettingsBtn" class="btn btn-ghost" type="button">close</button>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<label for="profileName">name</label>
|
||||
<input id="profileName" type="text" maxlength="24" placeholder="enter your name..." />
|
||||
|
||||
<label for="difficultySelect">difficulty</label>
|
||||
<select id="difficultySelect">
|
||||
<option value="easy">easy</option>
|
||||
<option value="normal" selected>normal</option>
|
||||
<option value="hard">hard</option>
|
||||
<option value="expert">expert</option>
|
||||
</select>
|
||||
|
||||
<p id="difficultyBonus" class="setting-note">xp bonus: x1.00</p>
|
||||
<button id="resetProgressBtn" class="btn btn-ghost" type="button">reset save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resetConfirmModal" class="modal" hidden>
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="resetConfirmTitle">
|
||||
<div class="modal-head">
|
||||
<h2 id="resetConfirmTitle">reset progress?</h2>
|
||||
</div>
|
||||
<p class="setting-note">this will erase your level, xp, perks, and history.</p>
|
||||
<div class="modal-actions">
|
||||
<button id="cancelResetBtn" class="btn btn-ghost" type="button">cancel</button>
|
||||
<button id="confirmResetBtn" class="btn" type="button">yes, reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="stats-layout">
|
||||
<section class="panel panel-main">
|
||||
<header class="stats-header">
|
||||
<a href="index.html" class="btn">back</a>
|
||||
</header>
|
||||
<ul></ul>
|
||||
<div class="stats-grid-large">
|
||||
<article class="stat-card">
|
||||
<p>rounds played</p>
|
||||
<strong id="sTotalRounds">0</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<p>top wpm</p>
|
||||
<strong id="sBestWpm">0</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<p>base accuracy</p>
|
||||
<strong id="sBestAcc">0%</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<p>average wpm</p>
|
||||
<strong id="sAvgWpm">0</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<p>average accuracy</p>
|
||||
<strong id="sAvgAcc">0%</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="results" style="margin-top: 1rem">
|
||||
<h2>recents</h2>
|
||||
<ul></ul>
|
||||
<ul id="historyList" class="history-list"></ul>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="stats.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
const STORAGE_KEY = "levelup-typing-save-v1";
|
||||
|
||||
function readSave() {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
function renderHistory(recentRounds) {
|
||||
const list = document.getElementById("historyList");
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = "";
|
||||
|
||||
if (!recentRounds || recentRounds.length === 0) {
|
||||
const item = document.createElement("li");
|
||||
item.className = "history-item";
|
||||
item.textContent = "no rounds played yet... complete a run to see your history!";
|
||||
list.appendChild(item);
|
||||
return;
|
||||
}
|
||||
|
||||
recentRounds.forEach((round) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "history-item";
|
||||
|
||||
const date = new Date(round.stamp || Date.now());
|
||||
const when = `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
|
||||
item.textContent = `${when} | ${round.wpm || 0} wpm / ${round.accuracy || 0}% acc / combo ${round.bestCombo || 0} / +${round.xp || 0} xp / ${(round.difficulty || "normal").toLowerCase()}`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const save = readSave() || {};
|
||||
const stats = save.stats || {};
|
||||
|
||||
setText("sTotalRounds", String(stats.totalRounds || 0));
|
||||
setText("sBestWpm", String(stats.bestWpm || 0));
|
||||
setText("sBestAcc", `${Math.round(stats.bestAccuracy || 0)}%`);
|
||||
setText("sAvgWpm", String(Math.round(stats.averageWpm || 0)));
|
||||
setText("sAvgAcc", `${Math.round(stats.averageAccuracy || 0)}%`);
|
||||
|
||||
renderHistory(Array.isArray(save.recentRounds) ? save.recentRounds : []);
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,473 @@
|
||||
@font-face {
|
||||
font-family: "CozetteCrossedSevenVector";
|
||||
src: url("fonts/CozetteCrossedSevenVector.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-1: #04070d;
|
||||
--bg-2: #0a1021;
|
||||
--card: rgba(8, 12, 22, 0.9);
|
||||
--border: rgba(111, 151, 172, 0.28);
|
||||
--accent: #8af6c9;
|
||||
--accent-2: #f5cc80;
|
||||
--text: #edf6ff;
|
||||
--muted: #89a1b5;
|
||||
--bad: #f18f8f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
font-family: "CozetteCrossedSevenVector", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background: var(--bg-1);
|
||||
overflow-x: hidden;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: fixed;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
filter: blur(80px);
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orb-left {
|
||||
top: -80px;
|
||||
left: -60px;
|
||||
background: #8fe8d0;
|
||||
}
|
||||
|
||||
.orb-right {
|
||||
bottom: -120px;
|
||||
right: -40px;
|
||||
background: #ffcf70;
|
||||
}
|
||||
|
||||
.layout {
|
||||
width: min(1140px, 94vw);
|
||||
margin: 2.2rem auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.panel-main {
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.panel-side {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.game-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--accent-2);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.72rem;
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-family: "CozetteCrossedSevenVector", monospace;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.35rem, 2vw, 2rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.16rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
padding: 0.58rem 1.1rem;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: #052218;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
transition: transform 170ms ease, filter 170ms ease;
|
||||
}
|
||||
|
||||
a.btn {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(194, 228, 210, 0.2);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
margin-top: 1.15rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1px solid rgba(148, 196, 231, 0.24);
|
||||
padding: 0.66rem;
|
||||
background: rgba(5, 10, 18, 0.6);
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
font-size: 1.42rem;
|
||||
display: inline-block;
|
||||
margin-top: 0.18rem;
|
||||
}
|
||||
|
||||
.text-shell {
|
||||
margin-top: 1rem;
|
||||
min-height: 170px;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(145, 175, 218, 0.25);
|
||||
background: rgba(4, 8, 15, 0.72);
|
||||
padding: 0.95rem;
|
||||
line-height: 1.8;
|
||||
font-size: clamp(1rem, 1.5vw, 1.2rem);
|
||||
}
|
||||
|
||||
.word {
|
||||
padding-right: 0.45rem;
|
||||
transition: color 100ms ease;
|
||||
}
|
||||
|
||||
.word.current {
|
||||
color: #d6ff84;
|
||||
}
|
||||
|
||||
.word.correct {
|
||||
color: #a8f7bf;
|
||||
}
|
||||
|
||||
.word.incorrect {
|
||||
color: var(--bad);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
margin-top: 0.95rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-wrap span {
|
||||
display: block;
|
||||
margin-bottom: 0.38rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
#typeInput {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(145, 205, 218, 0.32);
|
||||
background: rgba(6, 14, 18, 0.86);
|
||||
color: var(--text);
|
||||
font-size: 1.04rem;
|
||||
padding: 0.8rem 0.85rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#typeInput:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(159, 255, 169, 0.18);
|
||||
}
|
||||
|
||||
.results {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid rgba(158, 228, 164, 0.32);
|
||||
padding: 0.9rem;
|
||||
background: rgba(8, 16, 18, 0.62);
|
||||
animation: rise 240ms ease;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.level-line {
|
||||
margin-top: 0.35rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.xp-bar {
|
||||
margin-top: 0.5rem;
|
||||
height: 12px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
#xpFill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: #87f5b0;
|
||||
transition: width 320ms ease;
|
||||
}
|
||||
|
||||
.xp-gain {
|
||||
color: #baf1ff;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
margin-top: 0.45rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.settings-grid label {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.settings-grid input,
|
||||
.settings-grid select {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(145, 205, 218, 0.32);
|
||||
background: rgba(6, 14, 18, 0.86);
|
||||
color: var(--text);
|
||||
font-size: 0.92rem;
|
||||
padding: 0.56rem 0.62rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.settings-grid input:focus,
|
||||
.settings-grid select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.setting-note {
|
||||
margin: 0.2rem 0;
|
||||
color: #b9f4ff;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.perks {
|
||||
margin-top: 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.perk {
|
||||
border: 1px solid rgba(130, 160, 188, 0.24);
|
||||
padding: 0.7rem;
|
||||
background: rgba(8, 14, 24, 0.66);
|
||||
}
|
||||
|
||||
.perk h3 {
|
||||
margin: 0;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.perk p {
|
||||
margin: 0.28rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.perk-actions {
|
||||
margin-top: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.perk-cost {
|
||||
color: #c8eecf;
|
||||
font-size: 0.77rem;
|
||||
}
|
||||
|
||||
.perk-buy {
|
||||
border: none;
|
||||
padding: 0.28rem 0.74rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
background: #b6f8ba;
|
||||
color: #193027;
|
||||
}
|
||||
|
||||
.perk-buy:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.44;
|
||||
}
|
||||
|
||||
.perk .status {
|
||||
margin-top: 0.44rem;
|
||||
display: inline-block;
|
||||
padding: 0.18rem 0.54rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.status.locked {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #c0ccc6;
|
||||
}
|
||||
|
||||
.status.unlocked {
|
||||
background: rgba(167, 255, 141, 0.18);
|
||||
color: #b9ffad;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background: rgba(255, 231, 118, 0.2);
|
||||
color: #ffe58f;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 4, 10, 0.72);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 20;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(520px, 100%);
|
||||
border: 1px solid rgba(145, 175, 218, 0.25);
|
||||
background: rgba(7, 11, 20, 0.98);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 0.7rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.stats-layout {
|
||||
width: min(1040px, 94vw);
|
||||
margin: 2rem auto;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.stats-grid-large {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
border: 1px solid rgba(130, 160, 188, 0.24);
|
||||
padding: 0.62rem;
|
||||
background: rgba(8, 14, 24, 0.66);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stats-grid-large {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user