From be01fa52995e1d0187a8948202798f8ec3f37a17 Mon Sep 17 00:00:00 2001 From: nke4 Date: Sun, 18 Oct 2026 19:47:56 +0200 Subject: [PATCH] Hi, I'm using whatsapp. --- .gitignore | 7 + Dockerfile | 17 + README.md | 1 + package-lock.json | 871 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 17 + public/admin.css | 59 ++++ public/admin.html | 84 +++++ public/admin.js | 204 +++++++++++ public/app.js | 214 ++++++++++++ public/index.html | 127 +++++++ public/style.css | 618 ++++++++++++++++++++++++++++++++ server.js | 243 +++++++++++++ 12 files changed, 2462 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/admin.css create mode 100644 public/admin.html create mode 100644 public/admin.js create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..432a527 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +data/entries.json +uploads/* +!uploads/.gitkeep +npm-debug.log +docker-compose.yml +server.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40ba26b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY . . + +ENV PORT=3000 +EXPOSE 3001 + +# data/ and uploads/ are where persistent state lives -- mount these as +# volumes (see docker-compose.yml) so entries survive container restarts. +VOLUME ["/app/data", "/app/uploads"] + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..28b7051 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +NIGGA \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cd106a7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,871 @@ +{ + "name": "Nick's Valorant Friendbook", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "Nick's Valorant Friendbook", + "version": "1.0.0", + "dependencies": { + "express": "^4.19.2", + "multer": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/multer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "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" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "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==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "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==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..20f77bf --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "Nick's Valorant Friendbook", + "version": "1.0.0", + "description": "Nick's Valorant Friendbook - my biggest mistake lol", + "main": "server.js", + "type": "commonjs", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "express": "^4.19.2", + "multer": "^2.0.0" + } +} diff --git a/public/admin.css b/public/admin.css new file mode 100644 index 0000000..118fbff --- /dev/null +++ b/public/admin.css @@ -0,0 +1,59 @@ +/* Admin-only additions, layered on top of style.css */ + +.admin-window { + max-width: 560px; + margin: 0 auto; +} + +#admin-main .admin-window { + max-width: none; +} + +#admin-main .window + .admin-toolbar, +#admin-main .admin-window + .admin-window { + margin-top: 26px; +} + +.admin-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 6px; +} + +.admin-actions { + display: flex; + gap: 10px; + margin-top: auto; + padding-top: 10px; +} + +.btn-admin { + font-family: var(--font-mono); + font-weight: 700; + letter-spacing: 0.06em; + padding: 8px 16px; + font-size: 0.72rem; + cursor: pointer; + border: 1px solid var(--ink); + background: transparent; + color: var(--ink); + text-transform: uppercase; + clip-path: polygon(6px 0, 100% 0, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0 100%, 0 6px); + transition: background 0.12s ease, color 0.12s ease, opacity 0.12s ease, border-color 0.12s ease; +} + +.btn-admin--approve { + border-color: var(--sky); +} +.btn-admin--approve:hover { background: var(--sky); color: var(--ink); } + +.btn-admin--deny { + border-color: var(--coral); + color: var(--coral); +} +.btn-admin--deny:hover { background: var(--coral); color: var(--paper); } + +.admin-card.is-busy { + opacity: 0.45; + pointer-events: none; +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..ec4ce20 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,84 @@ + + + + + +Mod Desk :: Merz-hater Club Friendbook + + + + + + + + + + + +
+
+

MOD DESK

+
+

approve or deny incoming friend book pages (โ—กโ€ฟโ—ก)

+
+ +
+ + +
+
+ + + + login required +
+
+
+ + +
+ + +
+
+ + + + +
+ + + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..c15516f --- /dev/null +++ b/public/admin.js @@ -0,0 +1,204 @@ +(() => { + "use strict"; + + const STORAGE_KEY = "friendbook_admin_pw"; + + const passwordGate = document.getElementById("password-gate"); + const passwordForm = document.getElementById("password-form"); + const passwordInput = document.getElementById("admin-password"); + const passwordError = document.getElementById("password-error"); + + const adminMain = document.getElementById("admin-main"); + const logoutBtn = document.getElementById("logout-btn"); + + const pendingGrid = document.getElementById("pending-grid"); + const pendingStatus = document.getElementById("pending-status"); + const approvedGrid = document.getElementById("approved-grid"); + const approvedStatus = document.getElementById("approved-status"); + + // ---------------- password storage (per-tab only) ---------------- + function getPassword() { + return sessionStorage.getItem(STORAGE_KEY) || ""; + } + function setPassword(pw) { + sessionStorage.setItem(STORAGE_KEY, pw); + } + function clearPassword() { + sessionStorage.removeItem(STORAGE_KEY); + } + + // ---------------- gate / main toggling ---------------- + function showGate(errorMsg) { + adminMain.hidden = true; + passwordGate.hidden = false; + if (errorMsg) { + passwordError.textContent = errorMsg; + passwordError.hidden = false; + } else { + passwordError.hidden = true; + passwordError.textContent = ""; + } + passwordInput.value = ""; + passwordInput.focus(); + } + + function showAdmin() { + passwordGate.hidden = true; + adminMain.hidden = false; + loadEntries(); + } + + // ---------------- authenticated fetch helper ---------------- + async function adminFetch(url, options) { + const opts = options || {}; + const headers = Object.assign({}, opts.headers, { "x-admin-password": getPassword() }); + const res = await fetch(url, Object.assign({}, opts, { headers })); + if (res.status === 401) { + clearPassword(); + showGate("Incorrect password. Try again."); + throw new Error("unauthorized"); + } + return res; + } + + // ---------------- login form ---------------- + passwordForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const pw = passwordInput.value; + setPassword(pw); + try { + const res = await fetch("/api/admin/login", { + method: "POST", + headers: { "x-admin-password": pw }, + }); + if (!res.ok) { + clearPassword(); + showGate("Incorrect password. Try again."); + return; + } + showAdmin(); + } catch (err) { + showGate("Could not reach the server. Is it running?"); + } + }); + + logoutBtn.addEventListener("click", () => { + clearPassword(); + showGate(); + }); + + // ---------------- rendering ---------------- + function escapeHtml(str) { + const div = document.createElement("div"); + div.textContent = str == null ? "" : String(str); + return div.innerHTML; + } + + function renderRow(label, value) { + if (!value) return ""; + return `
${escapeHtml(label)}${escapeHtml(value)}
`; + } + + function renderEntry(entry) { + const card = document.createElement("article"); + card.className = "entry-card admin-card"; + + const badgesHtml = (entry.badges || []) + .map((src) => `badge`) + .join(""); + + card.innerHTML = ` + +

${escapeHtml(entry.name)}

+ ${renderRow("Fun fact", entry.funFact)} + ${renderRow("Favourite colour", entry.favColor)} + ${renderRow("Favourite music genre", entry.favGenre)} + ${renderRow("Favourite agent", entry.petPeeves)} + ${renderRow("On Valo", entry.merzHate)} + ${renderRow("IGN", entry.socialLink)} + ${badgesHtml ? `
${badgesHtml}
` : ""} +
${escapeHtml(new Date(entry.createdAt).toLocaleString())}
+
+ `; + + const actions = card.querySelector(".admin-actions"); + + if (entry.status === "pending") { + const approveBtn = document.createElement("button"); + approveBtn.type = "button"; + approveBtn.className = "btn-admin btn-admin--approve"; + approveBtn.textContent = "Approve"; + approveBtn.addEventListener("click", () => act(entry.id, "approve", card)); + + const denyBtn = document.createElement("button"); + denyBtn.type = "button"; + denyBtn.className = "btn-admin btn-admin--deny"; + denyBtn.textContent = "Deny"; + denyBtn.addEventListener("click", () => act(entry.id, "deny", card)); + + actions.append(approveBtn, denyBtn); + } else { + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "btn-admin btn-admin--deny"; + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", () => act(entry.id, "deny", card)); + actions.append(removeBtn); + } + + return card; + } + + async function act(id, action, card) { + card.classList.add("is-busy"); + try { + const res = await adminFetch(`/api/admin/entries/${id}/${action}`, { method: "POST" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "Something went wrong."); + loadEntries(); + } catch (err) { + if (err.message !== "unauthorized") { + alert(err.message); + card.classList.remove("is-busy"); + } + } + } + + async function loadEntries() { + pendingStatus.textContent = "loading..."; + approvedStatus.textContent = "loading..."; + pendingGrid.innerHTML = ""; + approvedGrid.innerHTML = ""; + + try { + const res = await adminFetch("/api/admin/entries"); + if (!res.ok) throw new Error("Could not load entries."); + const entries = await res.json(); + + const pending = entries.filter((e) => e.status === "pending"); + const approved = entries.filter((e) => e.status === "approved"); + + pendingStatus.textContent = pending.length + ? `${pending.length} waiting for review` + : "nothing waiting โ€” inbox zero (โ—กโ€ฟโ—ก)"; + approvedStatus.textContent = approved.length + ? `${approved.length} live on the site` + : "no approved pages yet"; + + pending.forEach((entry) => pendingGrid.appendChild(renderEntry(entry))); + approved.forEach((entry) => approvedGrid.appendChild(renderEntry(entry))); + } catch (err) { + if (err.message !== "unauthorized") { + pendingStatus.textContent = "couldn't load entries. is the server running?"; + approvedStatus.textContent = ""; + } + } + } + + // ---------------- boot ---------------- + if (getPassword()) { + showAdmin(); + } else { + showGate(); + } +})(); diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..a4f96e8 --- /dev/null +++ b/public/app.js @@ -0,0 +1,214 @@ +(() => { + "use strict"; + + const MOODS = ["๐Ÿ˜Ž", "๐Ÿ”ฅ", "๐Ÿ’€", "๐Ÿ˜ญ", "๐Ÿฅฒ", "โœจ", "๐Ÿ˜ค", "๐Ÿ–ค", "๐Ÿซ ", "๐Ÿ‘พ"]; + const MAX_BADGES = 5; + + // ---------------- tabs ---------------- + const tabForm = document.getElementById("tab-form"); + const tabGallery = document.getElementById("tab-gallery"); + const panelForm = document.getElementById("panel-form"); + const panelGallery = document.getElementById("panel-gallery"); + + function showPanel(which) { + const showForm = which === "form"; + panelForm.classList.toggle("is-hidden", !showForm); + panelGallery.classList.toggle("is-hidden", showForm); + tabForm.classList.toggle("is-active", showForm); + tabGallery.classList.toggle("is-active", !showForm); + tabForm.setAttribute("aria-selected", String(showForm)); + tabGallery.setAttribute("aria-selected", String(!showForm)); + if (!showForm) loadEntries(); + } + + tabForm.addEventListener("click", () => showPanel("form")); + tabGallery.addEventListener("click", () => showPanel("gallery")); + + // ---------------- mood picker ---------------- + const moodPicker = document.querySelector(".mood-picker"); + const moodInput = document.getElementById("mood"); + + MOODS.forEach((emoji, i) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "mood-btn"; + btn.textContent = emoji; + btn.setAttribute("role", "radio"); + btn.setAttribute("aria-pressed", i === MOODS.length - 1 ? "true" : "false"); // default last = ๐Ÿ–ค + btn.setAttribute("aria-label", `Mood: ${emoji}`); + btn.addEventListener("click", () => { + moodPicker.querySelectorAll(".mood-btn").forEach((b) => b.setAttribute("aria-pressed", "false")); + btn.setAttribute("aria-pressed", "true"); + moodInput.value = emoji; + }); + moodPicker.appendChild(btn); + }); + moodInput.value = MOODS[MOODS.length - 1]; + + // ---------------- badge preview ---------------- + const badgeInput = document.getElementById("badges"); + const badgePreview = document.getElementById("badge-preview"); + const formError = document.getElementById("form-error"); + const formSuccess = document.getElementById("form-success"); + + badgeInput.addEventListener("change", () => { + badgePreview.innerHTML = ""; + const files = Array.from(badgeInput.files || []); + + if (files.length > MAX_BADGES) { + showError(`Please choose at most ${MAX_BADGES} images.`); + badgeInput.value = ""; + return; + } + clearError(); + + files.forEach((file) => { + const url = URL.createObjectURL(file); + const img = document.createElement("img"); + img.src = url; + img.alt = file.name; + img.onload = () => URL.revokeObjectURL(url); + badgePreview.appendChild(img); + }); + }); + + function showError(msg) { + clearSuccess(); + formError.textContent = msg; + formError.hidden = false; + } + function clearError() { + formError.hidden = true; + formError.textContent = ""; + } + function showSuccess(msg) { + clearError(); + formSuccess.textContent = msg; + formSuccess.hidden = false; + } + function clearSuccess() { + formSuccess.hidden = true; + formSuccess.textContent = ""; + } + + // ---------------- form submit ---------------- + const form = document.getElementById("entry-form"); + const submitBtn = document.getElementById("submit-btn"); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + clearError(); + clearSuccess(); + + const name = document.getElementById("name").value.trim(); + if (!name) { + showError("Your Name is required."); + document.getElementById("name").focus(); + return; + } + + const formData = new FormData(form); + + submitBtn.disabled = true; + submitBtn.querySelector("span").textContent = "ADDING..."; + + try { + const res = await fetch("/api/entries", { method: "POST", body: formData }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error(data.error || "Something went wrong submitting your page."); + } + + form.reset(); + badgePreview.innerHTML = ""; + moodPicker.querySelectorAll(".mood-btn").forEach((b, i) => + b.setAttribute("aria-pressed", i === MOODS.length - 1 ? "true" : "false") + ); + moodInput.value = MOODS[MOODS.length - 1]; + + showSuccess("Thanks! Your page has been submitted and is waiting for approval โ€” check back on \u201cEveryone's pages\u201d soon ๐Ÿ’Œ"); + } catch (err) { + showError(err.message); + } finally { + submitBtn.disabled = false; + submitBtn.querySelector("span").textContent = "ADD MY PAGE"; + } + }); + + // ---------------- gallery ---------------- + const galleryGrid = document.getElementById("gallery-grid"); + const galleryStatus = document.getElementById("gallery-status"); + + function escapeHtml(str) { + const div = document.createElement("div"); + div.textContent = str == null ? "" : String(str); + return div.innerHTML; + } + + function ordinal(n) { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); + } + + function formatDate(ts) { + const d = new Date(ts); + const months = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", + ]; + return `${ordinal(d.getDate())} ${months[d.getMonth()]} ${d.getFullYear()}`; + } + + function renderRow(label, value) { + if (!value) return ""; + return `
${escapeHtml(label)}${escapeHtml(value)}
`; + } + + function renderCard(entry) { + const card = document.createElement("article"); + card.className = "entry-card"; + + const badgesHtml = (entry.badges || []) + .map((src) => `badge`) + .join(""); + + card.innerHTML = ` + +

${escapeHtml(entry.name)}

+ ${renderRow("Fun fact", entry.funFact)} + ${renderRow("Favourite colour", entry.favColor)} + ${renderRow("Favourite music genre", entry.favGenre)} + ${renderRow("Favourite agent", entry.petPeeves)} + ${renderRow("On Valo", entry.merzHate)} + ${renderRow("IGN", entry.socialLink)} + ${badgesHtml ? `
${badgesHtml}
` : ""} +
${escapeHtml(formatDate(entry.createdAt))}
+ `; + return card; + } + + async function loadEntries() { + galleryStatus.textContent = "flipping through pages..."; + galleryGrid.innerHTML = ""; + try { + const res = await fetch("/api/entries"); + if (!res.ok) throw new Error("Could not load pages."); + const entries = await res.json(); + + if (!entries.length) { + galleryStatus.textContent = "no pages yet โ€” be the first to sign the book!"; + return; + } + + galleryStatus.textContent = `${entries.length} page${entries.length === 1 ? "" : "s"} taped in so far`; + entries.forEach((entry) => galleryGrid.appendChild(renderCard(entry))); + } catch (err) { + galleryStatus.textContent = "hmm, couldn't load the pages. is the server running?"; + } + } + + // initial load in the background so the gallery is ready when switched to + loadEntries(); +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..121ff4b --- /dev/null +++ b/public/index.html @@ -0,0 +1,127 @@ + + + + + +Val Friendbook :: Friendbook + + + + + + + + + +
+
+

NICK'S VALO FRIENDBOOK

+
+

a friendbook for the wonderful peeps I've played with (โ—กโ€ฟโ—ก)

+ + +
+ +
+ + +
+
+ + + + the friend book +
+ +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+ + + + + +
+
+ + + + +
+ + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..c32397c --- /dev/null +++ b/public/style.css @@ -0,0 +1,618 @@ +/* ============================================================ + MERZ-HATER CLUB โ€” design tokens + tactical-HUD scrapbook: void black, signal red, chamfered + panels, torn/jagged edges, chromatic-ghost display type. + built for a friend book, not a shooter menu -- so the ASCII + emotes and warm copy stay untouched underneath the armor. + + void: #0a0a0d page background, recessed wells + panel: #131316 raised panel / window surface + ink: #ece7db warm bone text (was "ink") + ink-soft: #8b8b95 muted secondary text + signal: #ff3b3f primary red accent (css var --coral) + ember: #c23616 secondary rust-red (css var --tape) + maroon: #7a1d1f tertiary deep red (css var --lilac) + confirm: #5c1417 deep maroon, admin "approve" only (css var --sky) + line: #2a2a30 hairline borders + + display font: Anton (title + card names only โ€” spend it once) + tech font: Chakra Petch (labels, buttons, readouts) + body font: Rajdhani (paragraph text) + ============================================================ */ + +:root { + --paper: #0a0a0d; + --paper-deep: #131316; + --ink: #ece7db; + --ink-soft: #8b8b95; + --lilac: #7a1d1f; + --sky: #5c1417; + --coral: #ff3b3f; + --tape: #c23616; + --line: #2a2a30; + --font-display: "Anton", sans-serif; + --font-mono: "Chakra Petch", sans-serif; + --font-body: "Rajdhani", sans-serif; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--font-body); + font-size: 17px; + min-height: 100%; +} + +body { + background-image: + repeating-linear-gradient(to right, rgba(236, 231, 219, 0.035) 0 1px, transparent 1px 64px), + repeating-linear-gradient(to bottom, rgba(236, 231, 219, 0.035) 0 1px, transparent 1px 64px), + radial-gradient(circle at 8% 0%, rgba(255, 59, 63, 0.14), transparent 40%), + radial-gradient(circle at 96% 12%, rgba(122, 29, 31, 0.22), transparent 45%); + background-attachment: fixed; +} + +::selection { background: var(--coral); color: var(--paper); } + +:focus-visible { + outline: 2px solid var(--coral); + outline-offset: 3px; +} + +a { color: var(--ink); text-decoration-color: var(--coral); text-decoration-thickness: 2px; } +a:hover { text-decoration-color: var(--tape); } + +/* faint CRT grain -- decorative, non-interactive */ +.scanlines { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 50; + opacity: 0.35; + background-image: radial-gradient(rgba(255, 59, 63, 0.09) 1px, transparent 1px); + background-size: 3px 3px; + mix-blend-mode: screen; +} + +/* ---------------- hero / title ---------------- */ + +.hero { + padding: 60px 20px 0; + text-align: center; +} + +.hero__glitchbox { + display: inline-block; + position: relative; +} + +.glitch { + font-family: var(--font-display); + font-weight: 400; + font-size: clamp(2.6rem, 9vw, 5.2rem); + line-height: 0.95; + letter-spacing: 0.01em; + margin: 0; + padding: 0.05em 0.2em; + color: var(--ink); + text-transform: uppercase; + position: relative; + z-index: 1; + animation: flicker-in 0.7s ease-out 1; +} + +/* chromatic-ghost duplicates, sliced like a torn broadcast signal */ +.glitch::before, +.glitch::after { + content: attr(data-text); + position: absolute; + inset: 0; + padding: 0.05em 0.2em; + pointer-events: none; +} +.glitch::before { + color: var(--coral); + transform: translate(3px, -2px); + mix-blend-mode: screen; + opacity: 0.65; + clip-path: polygon(0 0, 100% 0, 100% 42%, 0 42%); +} +.glitch::after { + color: var(--tape); + transform: translate(-3px, 2px); + mix-blend-mode: screen; + opacity: 0.55; + clip-path: polygon(0 58%, 100% 58%, 100% 100%, 0 100%); +} + +@keyframes flicker-in { + 0% { opacity: 0; } + 8% { opacity: 1; } + 11% { opacity: 0.15; } + 16% { opacity: 1; } + 60% { opacity: 1; } + 64% { opacity: 0.4; } + 68% { opacity: 1; } + 100% { opacity: 1; } +} + +/* jagged red backing behind the title */ +.hero__glitchbox::before { + content: ""; + position: absolute; + left: -12px; + right: -12px; + bottom: 0.16em; + height: 0.46em; + background: var(--coral); + opacity: 0.16; + z-index: 0; + clip-path: polygon( + 0 35%, 6% 0, 13% 42%, 20% 6%, 28% 46%, 36% 4%, 44% 42%, + 52% 8%, 60% 46%, 68% 2%, 76% 42%, 84% 8%, 92% 44%, 100% 12%, + 100% 100%, 0 100% + ); +} + +.hero__tagline { + display: inline-block; + position: relative; + margin: 24px 0 0; + font-family: var(--font-mono); + font-size: 0.82rem; + color: var(--ink-soft); + background: var(--paper-deep); + border: 1px solid var(--line); + padding: 8px 16px 8px 30px; + clip-path: polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px); +} +.hero__tagline::before { + content: ""; + position: absolute; + left: 13px; + top: 50%; + transform: translateY(-50%); + width: 6px; + height: 6px; + background: var(--coral); + box-shadow: 0 0 6px 1px var(--coral); +} + +/* jagged divider closing out the hero, torn black/red edge */ +.hero::after { + content: ""; + display: block; + height: 14px; + margin: 32px 0 0; + opacity: 0.55; + background: + linear-gradient(135deg, var(--paper) 25%, transparent 25%) -7px 0 / 14px 14px, + linear-gradient(225deg, var(--paper) 25%, transparent 25%) -7px 0 / 14px 14px, + linear-gradient(315deg, var(--paper) 25%, transparent 25%) 0 0 / 14px 14px, + linear-gradient(45deg, var(--paper) 25%, var(--coral) 25%) 0 0 / 14px 14px; +} + +@media (prefers-reduced-motion: reduce) { + .glitch { animation: none; } +} + +/* ---------------- tabs ---------------- */ + +.tabs { + display: inline-flex; + gap: 6px; + flex-wrap: wrap; + justify-content: center; + padding: 22px 0 0; +} + +.tab-btn { + font-family: var(--font-mono); + background: var(--paper-deep); + color: var(--ink-soft); + border: 1px solid var(--line); + border-bottom: none; + padding: 10px 20px 8px; + cursor: pointer; + letter-spacing: 0.03em; + font-size: 0.8rem; + text-transform: uppercase; + clip-path: polygon(12px 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%, 0 12px); + transition: color 0.15s ease, border-color 0.15s ease; +} + +.tab-btn__index { color: var(--coral); margin-right: 6px; font-weight: 700; } + +.tab-btn:hover { color: var(--ink); border-color: var(--coral); } + +.tab-btn.is-active { + background: var(--paper-deep); + color: var(--ink); + border-color: var(--coral); + box-shadow: 0 -3px 0 0 var(--coral) inset; +} + +.tab-btn.is-active .tab-btn__index { color: var(--coral); } + +/* ---------------- window chrome (a tactical readout panel) ---------------- */ + +main { + max-width: 980px; + margin: 0 auto; + padding: 0 20px 60px; +} + +.window { + position: relative; + background: var(--paper-deep); + border: 1px solid var(--line); + box-shadow: 10px 10px 0 0 rgba(255, 59, 63, 0.15); + margin-top: 20px; + padding-left: 26px; + clip-path: polygon(0 0, 100% 0, 100% calc(100% - 22px), calc(100% - 22px) 100%, 0 100%); +} + +#panel-gallery.window { box-shadow: 10px 10px 0 0 rgba(194, 54, 22, 0.2); } + +.window.is-hidden { display: none; } + +/* diagonal hazard stripe standing in for the old spiral holes */ +.window::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 26px; + border-right: 1px solid var(--line); + background-image: repeating-linear-gradient(-45deg, var(--coral) 0 6px, transparent 6px 12px); + opacity: 0.3; +} + +/* torn red teeth capping every panel */ +.window::after { + content: ""; + position: absolute; + top: -6px; + left: 0; + right: 0; + height: 6px; + background: + linear-gradient(135deg, var(--paper) 25%, transparent 25%) 0 0 / 12px 12px, + linear-gradient(225deg, var(--paper) 25%, transparent 25%) 0 0 / 12px 12px, + linear-gradient(315deg, var(--paper) 25%, transparent 25%) 6px 0 / 12px 12px, + linear-gradient(45deg, var(--paper) 25%, var(--coral) 25%) 6px 0 / 12px 12px; +} + +#panel-gallery.window::after { + background: + linear-gradient(135deg, var(--paper) 25%, transparent 25%) 0 0 / 12px 12px, + linear-gradient(225deg, var(--paper) 25%, transparent 25%) 0 0 / 12px 12px, + linear-gradient(315deg, var(--paper) 25%, transparent 25%) 6px 0 / 12px 12px, + linear-gradient(45deg, var(--paper) 25%, var(--tape) 25%) 6px 0 / 12px 12px; +} + +.window__bar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px 10px 10px; + border-bottom: 1px solid var(--line); + background: rgba(255, 59, 63, 0.04); +} + +.dot { + width: 8px; + height: 8px; + display: inline-block; + clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); +} +.dot--blue { background: var(--tape); } +.dot--pink { background: var(--coral); } +.dot--white { background: var(--ink-soft); } + +.window__title { + margin-left: 6px; + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.window__body { padding: 28px; } + +/* ---------------- form ---------------- */ + +.form { display: flex; flex-direction: column; gap: 20px; } + +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; +} + +@media (max-width: 620px) { + .field-row { grid-template-columns: 1fr; } +} + +.field { display: flex; flex-direction: column; gap: 6px; } + +.field label { + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.05em; + color: var(--ink-soft); + text-transform: uppercase; +} + +.field .req { color: var(--coral); } +.field .hint { color: var(--ink-soft); text-transform: none; font-family: var(--font-body); font-style: italic; font-size: 0.8rem; } + +input[type="text"], +textarea { + background: var(--paper); + border: 1px solid var(--line); + color: var(--ink); + font-family: var(--font-body); + font-size: 1rem; + padding: 11px 13px; + resize: vertical; + clip-path: polygon(8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%, 0 8px); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +input[type="text"]::placeholder, +textarea::placeholder { color: #55555e; font-style: normal; } + +input[type="text"]:focus, +textarea:focus { + border-color: var(--coral); + outline: none; + box-shadow: 0 0 0 2px rgba(255, 59, 63, 0.22); +} + +input[type="file"] { + font-family: var(--font-body); + color: var(--ink-soft); + border: 1px dashed var(--line); + padding: 14px; + background: rgba(236, 231, 219, 0.02); + clip-path: polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px); +} + +/* mood picker */ + +.mood-picker { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mood-btn { + font-size: 1.25rem; + line-height: 1; + background: var(--paper); + border: 1px solid var(--line); + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + clip-path: polygon(22% 0, 78% 0, 100% 22%, 100% 78%, 78% 100%, 22% 100%, 0 78%, 0 22%); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.mood-btn:hover { border-color: var(--coral); } + +.mood-btn[aria-pressed="true"] { + border-color: var(--coral); + background: rgba(255, 59, 63, 0.14); + box-shadow: 0 0 0 1px var(--coral); +} + +/* badge preview thumbnails in the form */ + +.badge-preview { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 4px; +} + +.badge-preview img { + width: 52px; + height: 52px; + object-fit: cover; + background: var(--paper); + padding: 3px; + border: 1px solid var(--line); + clip-path: polygon(6px 0, 100% 0, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0 100%, 0 6px); +} + +.form-error { + color: var(--coral); + font-family: var(--font-mono); + font-size: 0.82rem; + border: 1px solid var(--coral); + padding: 9px 12px; + background: rgba(255, 59, 63, 0.08); + clip-path: polygon(8px 0, 100% 0, 100% 100%, 0 100%, 0 8px); +} + +.form-success { + color: var(--ink); + font-family: var(--font-mono); + font-size: 0.82rem; + border: 1px solid var(--line); + padding: 9px 12px; + background: rgba(236, 231, 219, 0.04); + clip-path: polygon(8px 0, 100% 0, 100% 100%, 0 100%, 0 8px); +} + +.btn-submit { + align-self: flex-start; + font-family: var(--font-mono); + font-weight: 700; + letter-spacing: 0.1em; + background: transparent; + color: var(--coral); + border: 2px solid var(--coral); + padding: 13px 24px; + cursor: pointer; + font-size: 0.88rem; + text-transform: uppercase; + clip-path: polygon(12px 0, 100% 0, 100% calc(100% - 12px), calc(100% - 12px) 100%, 0 100%, 0 12px); + transition: background 0.12s ease, color 0.12s ease; +} + +.btn-submit:hover { background: var(--coral); color: var(--paper); } +.btn-submit:active { transform: scale(0.98); } +.btn-submit:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } + +/* ---------------- gallery ---------------- */ + +.gallery-status { + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 0.85rem; + letter-spacing: 0.03em; +} + +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + gap: 30px 22px; + padding-top: 6px; +} + +.entry-card { + position: relative; + background: var(--paper); + border: 1px solid var(--line); + border-left: 3px solid var(--coral); + padding: 22px 18px 16px; + display: flex; + flex-direction: column; + gap: 10px; + clip-path: polygon(0 0, 100% 0, 100% calc(100% - 16px), calc(100% - 16px) 100%, 0 100%); + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.gallery-grid .entry-card:nth-child(3n) { border-left-color: var(--lilac); } +.gallery-grid .entry-card:nth-child(4n) { border-left-color: var(--tape); } + +.entry-card:hover { transform: translateY(-3px); } + +/* target-lock corner bracket, replaces the old washi tape */ +.entry-card::before { + content: ""; + position: absolute; + top: -1px; + left: -1px; + width: 20px; + height: 20px; + border-top: 2px solid var(--coral); + border-left: 2px solid var(--coral); +} +.gallery-grid .entry-card:nth-child(3n)::before { border-color: var(--lilac); } +.gallery-grid .entry-card:nth-child(4n)::before { border-color: var(--tape); } + +.entry-card__mood { + position: absolute; + top: -13px; + right: -10px; + font-size: 1.2rem; + background: var(--paper); + border: 1px solid var(--coral); + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + clip-path: polygon(22% 0, 78% 0, 100% 22%, 100% 78%, 78% 100%, 22% 100%, 0 78%, 0 22%); +} + +.entry-card__name { + font-family: var(--font-display); + font-weight: 400; + font-size: 1.4rem; + text-transform: uppercase; + letter-spacing: 0.01em; + color: var(--ink); + margin: 0; + padding: 0 20px 8px 0; + border-bottom: 2px solid var(--coral); + line-height: 1.05; +} + +.entry-card__row { font-size: 0.86rem; line-height: 1.45; } + +.entry-card__row .k { + color: var(--coral); + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.64rem; + display: block; + margin-bottom: 2px; +} + +.entry-card__row .v { color: var(--ink); word-break: break-word; } + +.entry-card__link { font-size: 0.85rem; word-break: break-all; } + +.entry-card__badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 2px; +} + +.entry-card__badges img { + width: 40px; + height: 40px; + object-fit: cover; + background: var(--paper); + padding: 2px; + border: 1px solid var(--line); + clip-path: polygon(4px 0, 100% 0, 100% calc(100% - 4px), calc(100% - 4px) 100%, 0 100%, 0 4px); +} + +.entry-card__time { + margin-top: auto; + padding-top: 8px; + border-top: 1px solid var(--line); + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +@media (prefers-reduced-motion: reduce) { + .entry-card, + .tab-btn, + .btn-submit, + .mood-btn, + input[type="text"], + textarea { transition: none; } +} + +/* ---------------- footer ---------------- */ + +.site-footer { + text-align: center; + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 0.85rem; + letter-spacing: 0.03em; + padding: 18px 20px 34px; +} + +.site-footer p::before { content: "> "; color: var(--coral); } diff --git a/server.js b/server.js new file mode 100644 index 0000000..41f56c1 --- /dev/null +++ b/server.js @@ -0,0 +1,243 @@ +// Merz-hater Club Friendbook โ€” backend server +// Plain Express + Multer + a JSON-file "database". +// No native modules, no external services โ€” designed to be easy to self-host. + +const express = require("express"); +const multer = require("multer"); +const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); + +const PORT = process.env.PORT || 3030; + +// Entries now require manual approval before they show up in the public +// gallery. The moderation endpoints below are protected by this password. +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "changeme"; +if (!process.env.ADMIN_PASSWORD) { + console.warn( + 'โš ๏ธ ADMIN_PASSWORD is not set โ€” using the default "changeme". ' + + "Set ADMIN_PASSWORD in your environment before deploying anywhere public." + ); +} + +const DATA_DIR = path.join(__dirname, "data"); +const UPLOADS_DIR = path.join(__dirname, "uploads"); +const DB_FILE = path.join(DATA_DIR, "entries.json"); + +// ---- make sure our storage folders/files exist ---- +for (const dir of [DATA_DIR, UPLOADS_DIR]) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} +if (!fs.existsSync(DB_FILE)) { + fs.writeFileSync(DB_FILE, "[]", "utf8"); +} + +// ---- tiny write queue so concurrent submissions can't corrupt the JSON file ---- +let writeChain = Promise.resolve(); +function readEntries() { + const raw = fs.readFileSync(DB_FILE, "utf8"); + let entries; + try { + entries = JSON.parse(raw || "[]"); + } catch (err) { + console.error("entries.json was corrupted, starting fresh:", err); + return []; + } + // Entries saved before moderation existed have no `status`. Treat them + // as already-approved so upgrading doesn't hide pages that were live. + return entries.map((e) => (e.status ? e : { ...e, status: "approved" })); +} +function saveEntries(entries) { + writeChain = writeChain.then( + () => + new Promise((resolve, reject) => { + fs.writeFile(DB_FILE, JSON.stringify(entries, null, 2), "utf8", (err) => { + if (err) return reject(err); + resolve(); + }); + }) + ); + return writeChain; +} + +// ---- image upload handling ---- +const ALLOWED_MIME = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); +const MAX_FILE_BYTES = 3 * 1024 * 1024; // 3MB per badge image +const MAX_BADGES = 5; + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, UPLOADS_DIR), + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const safeExt = [".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(ext) ? ext : ""; + cb(null, `${Date.now()}-${crypto.randomBytes(6).toString("hex")}${safeExt}`); + }, +}); + +const upload = multer({ + storage, + limits: { fileSize: MAX_FILE_BYTES, files: MAX_BADGES }, + fileFilter: (req, file, cb) => { + if (!ALLOWED_MIME.has(file.mimetype)) { + return cb(new Error("Only PNG, JPG, GIF, or WEBP images are allowed for badges.")); + } + cb(null, true); + }, +}); + +// ---- helpers ---- +function escapeAndTrim(value, maxLen) { + if (typeof value !== "string") return ""; + return value.trim().slice(0, maxLen); +} + +const ALLOWED_MOODS = ["๐Ÿ˜Ž", "๐Ÿ”ฅ", "๐Ÿ’€", "๐Ÿ˜ญ", "๐Ÿฅฒ", "โœจ", "๐Ÿ˜ค", "๐Ÿ–ค", "๐Ÿซ ", "๐Ÿ‘พ"]; + +// ---- admin auth ---- +function passwordsMatch(supplied, expected) { + const suppliedBuf = Buffer.from(String(supplied || "")); + const expectedBuf = Buffer.from(String(expected || "")); + if (suppliedBuf.length !== expectedBuf.length) { + // still run a compare of equal length so wrong-length guesses don't + // return noticeably faster than right-length ones. + crypto.timingSafeEqual(expectedBuf, expectedBuf); + return false; + } + return crypto.timingSafeEqual(suppliedBuf, expectedBuf); +} + +function requireAdmin(req, res, next) { + const supplied = req.get("x-admin-password"); + if (!passwordsMatch(supplied, ADMIN_PASSWORD)) { + return res.status(401).json({ error: "Incorrect admin password." }); + } + next(); +} + +// ---- app ---- +const app = express(); + +app.use(express.static(path.join(__dirname, "public"))); +app.use("/uploads", express.static(UPLOADS_DIR)); + +// List every APPROVED entry, newest first. Pending/denied entries never +// show up here โ€” only in the admin endpoints below. +app.get("/api/entries", (req, res) => { + const entries = readEntries() + .filter((e) => e.status === "approved") + .sort((a, b) => b.createdAt - a.createdAt); + res.json(entries); +}); + +// Create a new entry (multipart/form-data because of the file uploads) +app.post("/api/entries", (req, res) => { + upload.array("badges", MAX_BADGES)(req, res, async (err) => { + if (err) { + return res.status(400).json({ error: err.message || "Upload failed." }); + } + + const body = req.body || {}; + const name = escapeAndTrim(body.name, 60); + if (!name) { + return res.status(400).json({ error: "Your Name is required." }); + } + + const mood = ALLOWED_MOODS.includes(body.mood) ? body.mood : "๐Ÿ–ค"; + + const entry = { + id: crypto.randomUUID(), + name, + funFact: escapeAndTrim(body.funFact, 500), + favColor: escapeAndTrim(body.favColor, 40), + petPeeves: escapeAndTrim(body.petPeeves, 300), + favGenre: escapeAndTrim(body.favGenre, 100), + merzHate: escapeAndTrim(body.merzHate, 400), + socialLink: escapeAndTrim(body.socialLink, 300), + mood, + badges: (req.files || []).map((f) => `/uploads/${f.filename}`), + status: "pending", + createdAt: Date.now(), + }; + + const entries = readEntries(); + entries.push(entry); + try { + await saveEntries(entries); + } catch (e) { + console.error(e); + return res.status(500).json({ error: "Could not save your page. Try again." }); + } + + res.status(201).json(entry); + }); +}); + +// ---- admin/moderation routes ---- + +// Just checks the password โ€” used by the admin login form. +app.post("/api/admin/login", requireAdmin, (req, res) => { + res.json({ ok: true }); +}); + +// Every entry (pending + approved), newest first. +app.get("/api/admin/entries", requireAdmin, (req, res) => { + const entries = readEntries().sort((a, b) => b.createdAt - a.createdAt); + res.json(entries); +}); + +// Approve a pending entry so it shows up in the public gallery. +app.post("/api/admin/entries/:id/approve", requireAdmin, async (req, res) => { + const entries = readEntries(); + const entry = entries.find((e) => e.id === req.params.id); + if (!entry) { + return res.status(404).json({ error: "Entry not found." }); + } + entry.status = "approved"; + + try { + await saveEntries(entries); + } catch (e) { + console.error(e); + return res.status(500).json({ error: "Could not save that change. Try again." }); + } + res.json(entry); +}); + +// Deny (or remove) an entry entirely, cleaning up any uploaded badge images. +app.post("/api/admin/entries/:id/deny", requireAdmin, async (req, res) => { + const entries = readEntries(); + const idx = entries.findIndex((e) => e.id === req.params.id); + if (idx === -1) { + return res.status(404).json({ error: "Entry not found." }); + } + const [removed] = entries.splice(idx, 1); + + for (const badgePath of removed.badges || []) { + const filePath = path.join(UPLOADS_DIR, path.basename(badgePath)); + fs.unlink(filePath, (err) => { + if (err && err.code !== "ENOENT") console.error("Could not remove badge file:", err); + }); + } + + try { + await saveEntries(entries); + } catch (e) { + console.error(e); + return res.status(500).json({ error: "Could not save that change. Try again." }); + } + res.json({ ok: true }); +}); + +// Convenience alias so /admin works without typing the filename. +app.get("/admin", (req, res) => { + res.sendFile(path.join(__dirname, "public", "admin.html")); +}); + +// Fallback: send index.html for the root route +app.get("/", (req, res) => { + res.sendFile(path.join(__dirname, "public", "index.html")); +}); + +app.listen(PORT, () => { + console.log(`Merz-hater Club Friendbook running at http://localhost:${PORT}`); +});