diff --git a/.gitignore b/.gitignore index ef1f633..c97245e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ uploads/ docker-compose.dev.yml +.vscode/ .env npm-debug.log* yarn-debug.log* diff --git a/docker-compose.yml b/docker-compose.yml index 4c950a5..a06abbc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: environment: NODE_ENV: ${NODE_ENV:-production} PLAYER_PORT: ${PLAYER_PORT:-3001} + PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001} DB_HOST: ${DB_HOST:-mysql} DB_PORT: ${DB_PORT:-3306} DB_NAME: ${DB_NAME:-signage} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c15ab9d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1927 @@ +{ + "name": "pulse-signage", + "version": "1.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pulse-signage", + "version": "1.2.0", + "dependencies": { + "bootstrap-icons": "1.11.3", + "dotenv": "^17.4.2", + "express": "^4.21.2", + "handlebars": "^4.7.8", + "multer": "^1.4.5-lts.1", + "mysql2": "^3.14.3", + "qrcode": "^1.5.4", + "ws": "^8.21.0" + }, + "devDependencies": { + "nodemon": "^3.1.10" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.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==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "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.15.1", + "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/bootstrap-icons": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", + "integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "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==", + "license": "MIT", + "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/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "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/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "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/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==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/mysql2": { + "version": "3.22.6", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.6.tgz", + "integrity": "sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.4.0" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=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==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "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/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.0.tgz", + "integrity": "sha512-JCY91/D1lVq7p6RzuHg9XFQH9vT9L22bljsk+uTdMWke4ZqPCCE+iIMNPmJPUUTywn6eW2nncaWc+MizKZgF4A==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "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.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.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==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json index b8227e1..91e6f8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "1.1.5", + "version": "1.4.0", "private": false, "description": "Pulse Signage application with MySQL and media uploads", "repository": { @@ -16,11 +16,13 @@ "dev:player": "nodemon -r dotenv/config src/player.js" }, "dependencies": { + "bootstrap-icons": "1.11.3", "dotenv": "^17.4.2", "express": "^4.21.2", "handlebars": "^4.7.8", "multer": "^1.4.5-lts.1", "mysql2": "^3.14.3", + "qrcode": "^1.5.4", "ws": "^8.21.0" }, "devDependencies": { diff --git a/src/common.js b/src/common.js index 01ab94f..bae30af 100644 --- a/src/common.js +++ b/src/common.js @@ -22,5 +22,7 @@ module.exports = { extractTemplateRegions: data.extractTemplateRegions, buildTemplatePayload: data.buildTemplatePayload, mediaKind: player.mediaKind, - renderPlayerPage: player.renderPlayerPage + renderPlayerPage: player.renderPlayerPage, + renderPlayerOnboardingLandingPage: player.renderPlayerOnboardingLandingPage, + renderPlayerOnboardingFormPage: player.renderPlayerOnboardingFormPage }; diff --git a/src/data/admin.js b/src/data/admin.js index ca3a4e0..723f32e 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -2,7 +2,7 @@ async function fetchAdminData(pool) { const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC'); const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC'); const [templates] = await pool.query(` - SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at, + SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height FROM slide_templates st LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id diff --git a/src/data/templates.js b/src/data/templates.js index 26f1a0a..63e5792 100644 --- a/src/data/templates.js +++ b/src/data/templates.js @@ -2,14 +2,44 @@ const { parseJsonSafe, readFormArray } = require('./utils'); const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html']; +function sanitizeBackgroundColor(value) { + const raw = String(value || '').trim(); + if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) { + return raw; + } + return '#111111'; +} + function normalizeTemplateRegionType(value) { const rawType = String(value || 'text').trim(); return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text'; } +function normalizeTemplateRegionName(value) { + return String(value || '').trim(); +} + +function ensureUniqueTemplateRegionNames(regions) { + const seen = new Map(); + for (let i = 0; i < regions.length; i += 1) { + const region = regions[i]; + const regionName = normalizeTemplateRegionName(region.region_key || region.label); + if (!regionName) { + continue; + } + const normalized = regionName.toLowerCase(); + if (seen.has(normalized)) { + const error = new Error('Region names must be unique on this template.'); + error.statusCode = 400; + throw error; + } + seen.set(normalized, true); + } +} + async function fetchTemplateById(pool, id) { const [templates] = await pool.query(` - SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at, + SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height FROM slide_templates st LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id @@ -26,7 +56,7 @@ async function fetchTemplateById(pool, id) { async function fetchTemplatesData(pool) { const [templates] = await pool.query(` - SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at, + SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height FROM slide_templates st LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id @@ -107,6 +137,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) { const filesByField = getFilesByField(req.files || []); const backgroundImage = filesByField.background_image; const removeBackgroundImage = Boolean(req.body.remove_background_image); + const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color)); const backgroundImagePath = backgroundImage ? `/uploads/${backgroundImage.filename}` : removeBackgroundImage @@ -153,12 +184,15 @@ async function buildTemplatePayload(pool, req, existingTemplate) { }]; } + ensureUniqueTemplateRegionNames(regions); + return { name, canvasSizeId: resolvedCanvasSizeId, canvasSizeWidth: canvasWidth, canvasSizeHeight: canvasHeight, backgroundImagePath, + backgroundColor, regions }; } diff --git a/src/db.js b/src/db.js index a43982a..641aa4c 100644 --- a/src/db.js +++ b/src/db.js @@ -67,6 +67,7 @@ async function ensureSchema(pool) { name VARCHAR(255) NOT NULL, canvas_size_id INT NULL, background_image_path VARCHAR(512) NULL, + background_color VARCHAR(32) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci @@ -74,6 +75,7 @@ async function ensureSchema(pool) { await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL'); await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL'); + await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL'); await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); await addColumnIfMissing(pool, 'slide_templates', 'created_by', 'INT NULL'); await addColumnIfMissing(pool, 'slide_templates', 'modified_by', 'INT NULL'); @@ -196,6 +198,20 @@ async function ensureSchema(pool) { await addColumnIfMissing(pool, 'screens', 'created_by', 'INT NULL'); await addColumnIfMissing(pool, 'screens', 'modified_by', 'INT NULL'); + await pool.query(` + CREATE TABLE IF NOT EXISTS player_onboarding_devices ( + device_id VARCHAR(128) PRIMARY KEY, + client_name VARCHAR(255) NULL, + screen_id INT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL'); + await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL'); + await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await pool.query(` CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/src/player.js b/src/player.js index 4683e17..835d481 100644 --- a/src/player.js +++ b/src/player.js @@ -1,151 +1,12 @@ const express = require('express'); const fs = require('fs'); const http = require('http'); -const crypto = require('crypto'); const path = require('path'); -const { WebSocketServer, WebSocket } = require('ws'); const common = require('./common'); - -// Playlist assembly and revision helpers. -async function buildScreenPlaylist(pool, slug) { - const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]); - if (!screenRows.length) { - return { screen: null, playlist: null, slides: [] }; - } - - const screen = screenRows[0]; - if (!screen.playlist_id) { - return { - screen, - playlist: null, - slides: [], - revision: getPlaylistRevision(screen, null, [], [], []) - }; - } - - const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]); - const playlist = playlistRows[0] || null; - const [slideRows] = await pool.query(` - SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at, - ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, - st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height - FROM playlist_slides ps - JOIN slides sl ON sl.id = ps.slide_id - LEFT JOIN slide_templates st ON st.id = sl.template_id - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id - WHERE ps.playlist_id = ? - ORDER BY ps.position ASC, ps.id ASC - `, [screen.playlist_id]); - - const templateIds = slideRows - .filter(function (slide) { return slide.template_id; }) - .map(function (slide) { return slide.template_id; }); - const templatesById = {}; - let templateRows = []; - let regionRows = []; - if (templateIds.length) { - [templateRows] = await pool.query(` - SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at, - cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height - FROM slide_templates st - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id - WHERE st.id IN (?) - `, [templateIds]); - [regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]); - templateRows.forEach(function (template) { - template.regions = regionRows.filter(function (region) { return region.template_id === template.id; }); - templatesById[template.id] = template; - }); - } - - const slides = slideRows.map(function (slide) { - return { - id: slide.id, - title: slide.title, - body: slide.body, - duration_seconds: slide.duration_seconds, - schedule_mode: slide.schedule_mode, - schedule_start_datetime: slide.schedule_start_datetime, - schedule_end_datetime: slide.schedule_end_datetime, - schedule_start_time: slide.schedule_start_time, - schedule_end_time: slide.schedule_end_time, - schedule_days_json: slide.schedule_days_json, - media_url: slide.media_path, - media_type: slide.media_type, - kind: common.mediaKind(slide.media_path), - template_id: slide.template_id, - template: slide.template_id ? templatesById[slide.template_id] || null : null, - content: common.parseJsonSafe(slide.content_json) || {} - }; - }); - - const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows); - - return { screen, playlist, slides, revision }; -} - -function updatePlaylistRevisionHash(hash, value) { - hash.update(String(value === null || value === undefined ? '' : value)); - hash.update('\0'); -} - -function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows) { - const hash = crypto.createHash('sha1'); - - updatePlaylistRevisionHash(hash, screen && screen.id); - updatePlaylistRevisionHash(hash, screen && screen.playlist_id); - updatePlaylistRevisionHash(hash, screen && screen.modified_at); - - updatePlaylistRevisionHash(hash, playlist && playlist.id); - updatePlaylistRevisionHash(hash, playlist && playlist.modified_at); - updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides); - - (Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) { - updatePlaylistRevisionHash(hash, slide.id); - updatePlaylistRevisionHash(hash, slide.title); - updatePlaylistRevisionHash(hash, slide.body); - updatePlaylistRevisionHash(hash, slide.template_id); - updatePlaylistRevisionHash(hash, slide.content_json); - updatePlaylistRevisionHash(hash, slide.media_path); - updatePlaylistRevisionHash(hash, slide.media_type); - updatePlaylistRevisionHash(hash, slide.modified_at); - updatePlaylistRevisionHash(hash, slide.position); - updatePlaylistRevisionHash(hash, slide.duration_seconds); - updatePlaylistRevisionHash(hash, slide.schedule_mode); - updatePlaylistRevisionHash(hash, slide.schedule_start_datetime); - updatePlaylistRevisionHash(hash, slide.schedule_end_datetime); - updatePlaylistRevisionHash(hash, slide.schedule_start_time); - updatePlaylistRevisionHash(hash, slide.schedule_end_time); - updatePlaylistRevisionHash(hash, slide.schedule_days_json); - }); - - (Array.isArray(templateRows) ? templateRows : []).forEach(function (template) { - updatePlaylistRevisionHash(hash, template.id); - updatePlaylistRevisionHash(hash, template.name); - updatePlaylistRevisionHash(hash, template.canvas_size_id); - updatePlaylistRevisionHash(hash, template.canvas_size_width); - updatePlaylistRevisionHash(hash, template.canvas_size_height); - updatePlaylistRevisionHash(hash, template.background_image_path); - updatePlaylistRevisionHash(hash, template.modified_at); - }); - - (Array.isArray(regionRows) ? regionRows : []).forEach(function (region) { - updatePlaylistRevisionHash(hash, region.id); - updatePlaylistRevisionHash(hash, region.template_id); - updatePlaylistRevisionHash(hash, region.region_key); - updatePlaylistRevisionHash(hash, region.region_type); - updatePlaylistRevisionHash(hash, region.label); - updatePlaylistRevisionHash(hash, region.font_family); - updatePlaylistRevisionHash(hash, region.x); - updatePlaylistRevisionHash(hash, region.y); - updatePlaylistRevisionHash(hash, region.width); - updatePlaylistRevisionHash(hash, region.height); - updatePlaylistRevisionHash(hash, region.z_index); - updatePlaylistRevisionHash(hash, region.modified_at); - }); - - return hash.digest('hex'); -} +const { createPlayerRuntime } = require('./player/runtime'); +const { createPlayerPlaylistService } = require('./player/playlist'); +const { normalizeDeviceId, registerPlayerOnboardingRoutes } = require('./player/onboarding'); +const { registerPlayerRoutes } = require('./player/routes'); // Player runtime, upload API, and websocket wiring. @@ -155,327 +16,30 @@ async function start() { const PORT = Number(process.env.PLAYER_PORT || 3001); const ASSET_DIR = path.join(__dirname, 'player', 'public'); const UPLOAD_DIR = path.join(__dirname, '..', 'uploads'); - const connectionsBySlug = new Map(); - const dashboardListenersBySlug = new Map(); + const playerRuntime = createPlayerRuntime({ + pool: pool, + normalizeDeviceId: normalizeDeviceId + }); + const playerPlaylistService = createPlayerPlaylistService({ + pool: pool, + common: common + }); const server = http.createServer(app); - const wss = new WebSocketServer({ noServer: true }); - + playerRuntime.installWebsocket(server); app.use(express.json()); - - // Static assets and mirrored uploads are served from the player container. - app.use('/assets', express.static(ASSET_DIR)); - app.use('/uploads', express.static(UPLOAD_DIR)); - - app.get('/api/uploads/config', function (_req, res) { - res.json({ - uploadDir: UPLOAD_DIR - }); + registerPlayerOnboardingRoutes(app, { + pool: pool, + common: common, + playerRuntime: playerRuntime, + QRCode: require('qrcode') }); - - app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) { - try { - const filename = path.basename(String(req.params.filename || '').trim()); - if (!filename) { - return res.status(400).json({ error: 'Filename is required' }); - } - const filePath = path.join(UPLOAD_DIR, filename); - const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); - await fs.promises.mkdir(UPLOAD_DIR, { recursive: true }); - await fs.promises.writeFile(filePath, body); - res.json({ ok: true, filename: filename }); - } catch (error) { - next(error); - } - }); - - app.delete('/api/uploads/:filename', async function (req, res, next) { - try { - const filename = path.basename(String(req.params.filename || '').trim()); - if (!filename) { - return res.status(400).json({ error: 'Filename is required' }); - } - const filePath = path.join(UPLOAD_DIR, filename); - try { - await fs.promises.unlink(filePath); - } catch (error) { - if (!error || error.code !== 'ENOENT') { - throw error; - } - } - res.json({ ok: true, filename: filename }); - } catch (error) { - next(error); - } - }); - - function getConnectionBucket(slug) { - const key = String(slug || '').trim(); - if (!key) { - return null; - } - if (!connectionsBySlug.has(key)) { - connectionsBySlug.set(key, new Map()); - } - return connectionsBySlug.get(key); - } - - function removeConnection(slug, connectionId) { - const bucket = connectionsBySlug.get(slug); - if (!bucket) { - return; - } - bucket.delete(connectionId); - if (!bucket.size) { - connectionsBySlug.delete(slug); - } - } - - function getDashboardListenerBucket(slug) { - const key = String(slug || '').trim(); - if (!key) { - return null; - } - if (!dashboardListenersBySlug.has(key)) { - dashboardListenersBySlug.set(key, new Set()); - } - return dashboardListenersBySlug.get(key); - } - - function removeDashboardListener(slug, socket) { - const key = String(slug || '').trim(); - const bucket = dashboardListenersBySlug.get(key); - if (!bucket) { - return; - } - bucket.delete(socket); - if (!bucket.size) { - dashboardListenersBySlug.delete(key); - } - } - - function buildClientLabel(connection) { - const clientId = String(connection.clientId || '').trim(); - const userAgent = String(connection.userAgent || '').trim(); - const clientIp = String(connection.clientIp || '').trim(); - const viewport = connection.viewport && typeof connection.viewport === 'object' - ? connection.viewport - : null; - const labelParts = []; - - if (userAgent) { - labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent); - } - - if (clientId) { - labelParts.push(`id ${clientId.slice(-6)}`); - } - - if (clientIp) { - labelParts.push(clientIp); - } - - if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) { - labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`); - } - - if (!labelParts.length) { - return connection.remoteAddress || 'connected client'; - } - - return labelParts.join(' • '); - } - - function snapshotConnections(slug) { - const bucket = connectionsBySlug.get(String(slug || '').trim()); - if (!bucket) { - return []; - } - - return Array.from(bucket.values()).map(function (connection) { - return { - id: connection.id, - clientId: connection.clientId || null, - label: connection.label, - userAgent: connection.userAgent || null, - viewport: connection.viewport || null, - page: connection.page || null, - currentSlide: connection.currentSlide || null, - paused: Boolean(connection.paused), - blackout: Boolean(connection.blackout), - currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null, - currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null, - clientIp: connection.clientIp || null, - remoteAddress: connection.remoteAddress || null, - connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null, - lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null - }; - }); - } - - function broadcastConnectionSnapshot(slug) { - const key = String(slug || '').trim(); - const bucket = dashboardListenersBySlug.get(key); - if (!bucket || !bucket.size) { - return; - } - - const payload = JSON.stringify({ - type: 'snapshot', - slug: key, - connections: snapshotConnections(slug), - sentAt: new Date().toISOString() - }); - - bucket.forEach(function (socket) { - if (socket.readyState === WebSocket.OPEN) { - socket.send(payload); - } - }); - } - - function sendCommandToConnection(slug, connectionId, commandOrPayload) { - const bucket = connectionsBySlug.get(String(slug || '').trim()); - if (!bucket || !bucket.size) { - return 0; - } - - const target = bucket.get(String(connectionId || '').trim()); - if (!target || target.socket.readyState !== WebSocket.OPEN) { - return 0; - } - - const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null - ? Object.assign({}, commandOrPayload) - : { command: commandOrPayload }; - payload.type = 'command'; - payload.targetConnectionId = target.id; - payload.sentAt = new Date().toISOString(); - - target.socket.send(JSON.stringify(payload)); - return 1; - } - - function broadcastCommand(slug, commandOrPayload) { - const bucket = connectionsBySlug.get(String(slug || '').trim()); - if (!bucket || !bucket.size) { - return 0; - } - - let sent = 0; - bucket.forEach(function (connection) { - if (connection.socket.readyState !== WebSocket.OPEN) { - return; - } - const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null - ? Object.assign({}, commandOrPayload) - : { command: commandOrPayload }; - payload.type = 'command'; - payload.sentAt = new Date().toISOString(); - - connection.socket.send(JSON.stringify(payload)); - sent += 1; - }); - return sent; - } - - app.get('/', function (_req, res) { - res.send('Pulse Signage player service'); - }); - - // Screen playback endpoints render the active playlist for a slug. - app.get('/screen/:slug', function (req, res) { - res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); - res.set('Pragma', 'no-cache'); - buildScreenPlaylist(pool, req.params.slug).then(function (data) { - res.send(common.renderPlayerPage(req.params.slug, data)); - }).catch(function (error) { - console.error(error); - res.status(500).send('Internal server error'); - }); - }); - - app.get('/api/screens/:slug/playlist', async function (req, res, next) { - try { - res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); - const data = await buildScreenPlaylist(pool, req.params.slug); - if (!data.screen) { - return res.status(404).json({ error: 'Screen not found' }); - } - const etag = '"' + String(data.revision || '') + '"'; - res.set('ETag', etag); - if (String(req.headers['if-none-match'] || '').split(',').map(function (value) { - return String(value || '').trim(); - }).includes(etag)) { - return res.status(304).end(); - } - res.json(data); - } catch (error) { - next(error); - } - }); - - app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) { - try { - const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]); - if (!screenRows.length) { - return res.status(404).json({ error: 'Screen not found' }); - } - const connections = snapshotConnections(req.params.slug); - res.json({ - screen: screenRows[0], - screenSlug: req.params.slug, - count: connections.length, - connections: connections - }); - } catch (error) { - next(error); - } - }); - - app.post('/api/screens/:slug/commands', async function (req, res, next) { - try { - const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase(); - const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim(); - const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') - ? req.body.blackout - : req.query.blackout; - if (!command) { - return res.status(400).json({ error: 'Command is required' }); - } - if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) { - return res.status(400).json({ error: 'Unsupported command' }); - } - - const isRedirectCommand = command === 'redirect'; - let screenRows = []; - if (!isRedirectCommand) { - [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]); - if (!screenRows.length) { - return res.status(404).json({ error: 'Screen not found' }); - } - } - - const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) - ? Object.assign({}, req.body, { command: command }) - : command; - if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') { - commandPayload.blackout = blackoutValue; - } - - const sent = connectionId - ? sendCommandToConnection(req.params.slug, connectionId, commandPayload) - : broadcastCommand(req.params.slug, commandPayload); - - res.json({ - screen: screenRows[0] || null, - screenSlug: req.params.slug, - command: command, - connectionId: connectionId || null, - sent: sent - }); - } catch (error) { - next(error); - } + registerPlayerRoutes(app, { + pool: pool, + common: common, + uploadDir: UPLOAD_DIR, + assetDir: ASSET_DIR, + playerRuntime: playerRuntime, + playerPlaylistService: playerPlaylistService }); app.use(function (error, _req, res, _next) { @@ -485,124 +49,6 @@ async function start() { await common.ensureSchema(pool); fs.mkdirSync(UPLOAD_DIR, { recursive: true }); - // Websocket upgrades split dashboard snapshots from player client sessions. - server.on('upgrade', function (request, socket, head) { - let pathname = ''; - try { - pathname = new URL(request.url, 'http://localhost').pathname; - } catch (_error) { - socket.destroy(); - return; - } - - const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/); - const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/); - if (!dashboardMatch && !playerMatch) { - socket.destroy(); - return; - } - - const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]); - wss.handleUpgrade(request, socket, head, function (ws) { - wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player'); - }); - }); - - wss.on('connection', function (socket, request, slug, role) { - if (role === 'dashboard') { - const listenerBucket = getDashboardListenerBucket(slug); - if (!listenerBucket) { - socket.close(); - return; - } - - listenerBucket.add(socket); - socket.send(JSON.stringify({ - type: 'snapshot', - slug: String(slug || '').trim(), - connections: snapshotConnections(slug), - sentAt: new Date().toISOString() - })); - - socket.on('close', function () { - removeDashboardListener(slug, socket); - }); - - socket.on('error', function () { - removeDashboardListener(slug, socket); - }); - return; - } - - const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null; - const forwardedFor = String(request.headers['x-forwarded-for'] || '').split(',')[0].trim(); - const connectionId = crypto.randomUUID(); - const connection = { - id: connectionId, - slug: slug, - socket: socket, - clientId: null, - userAgent: null, - viewport: null, - page: null, - paused: false, - blackout: false, - clientIp: forwardedFor || remoteAddress, - remoteAddress: remoteAddress, - label: forwardedFor || remoteAddress || 'connected client', - connectedAt: new Date(), - lastSeenAt: new Date() - }; - const bucket = getConnectionBucket(slug); - - if (!bucket) { - socket.close(); - return; - } - - bucket.set(connectionId, connection); - - socket.on('message', function (rawMessage) { - connection.lastSeenAt = new Date(); - let payload = null; - try { - payload = JSON.parse(String(rawMessage || '')); - } catch (_error) { - return; - } - - if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) { - return; - } - - connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId; - connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent; - connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport; - connection.page = payload.page ? String(payload.page).trim() : connection.page; - connection.paused = Boolean(payload.paused); - connection.blackout = Boolean(payload.blackout); - connection.clientIp = payload.clientIp ? String(payload.clientIp).trim() : connection.clientIp; - connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? { - id: payload.currentSlide.id || null, - title: payload.currentSlide.title || '', - kind: payload.currentSlide.kind || '', - playlistSignature: payload.currentSlide.playlistSignature || '' - } : connection.currentSlide; - connection.label = buildClientLabel(connection); - connection.lastSeenAt = new Date(); - broadcastConnectionSnapshot(slug); - }); - - socket.on('close', function () { - removeConnection(slug, connectionId); - broadcastConnectionSnapshot(slug); - }); - - socket.on('error', function () { - removeConnection(slug, connectionId); - broadcastConnectionSnapshot(slug); - }); - }); server.listen(PORT, function () { console.log(`Pulse Signage app listening on port ${PORT}`); @@ -617,3 +63,4 @@ if (require.main === module) { process.exit(1); }); } + diff --git a/src/player/onboarding.js b/src/player/onboarding.js new file mode 100644 index 0000000..b8594d7 --- /dev/null +++ b/src/player/onboarding.js @@ -0,0 +1,170 @@ +function normalizeDeviceId(value) { + return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128); +} + +function getPublicBaseUrl(req) { + const configured = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + if (configured) { + return configured; + } + const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0]; + const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http'); + const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0]; + const host = forwardedHost || String(req.headers.host || '').trim(); + return `${protocol}://${host}`.replace(/\/$/, ''); +} + +async function getOnboardingStatus(pool, deviceId) { + const normalizedDeviceId = normalizeDeviceId(deviceId); + if (!normalizedDeviceId) { + return null; + } + + const [rows] = await pool.query( + `SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id + FROM player_onboarding_devices d + LEFT JOIN screens s ON s.id = d.screen_id + WHERE d.device_id = ?`, + [normalizedDeviceId] + ); + + return rows[0] || null; +} + +async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen) { + const normalizedDeviceId = normalizeDeviceId(deviceId); + const normalizedClientName = String(clientName || '').trim(); + const normalizedScreenSlug = String(screenSlug || '').trim(); + if (!normalizedDeviceId) { + throw new Error('Device ID is required.'); + } + if (!normalizedClientName) { + throw new Error('Client name is required.'); + } + + if (!normalizedScreenSlug) { + throw new Error('Screen is required.'); + } + + const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]); + if (!screenRows.length) { + throw new Error('Screen not found.'); + } + const screen = screenRows[0]; + + const available = typeof isNameAvailableOnScreen === 'function' + ? await isNameAvailableOnScreen(pool, normalizedClientName, normalizedDeviceId) + : true; + if (!available) { + const error = new Error('Client name already exists.'); + error.statusCode = 400; + throw error; + } + + await pool.query( + 'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP', + [normalizedDeviceId, normalizedClientName, screen.id] + ); + + return getOnboardingStatus(pool, normalizedDeviceId); +} + +function registerPlayerOnboardingRoutes(app, options) { + const pool = options && options.pool ? options.pool : null; + const common = options && options.common ? options.common : null; + const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; + const QRCode = options && options.QRCode ? options.QRCode : null; + + if (!app || !pool || !common || !playerRuntime || !QRCode) { + throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.'); + } + + app.get('/', function (_req, res) { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.send(common.renderPlayerOnboardingLandingPage()); + }); + + app.get('/onboard', function (req, res) { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim())); + }); + + app.get('/api/onboarding/status', async function (req, res, next) { + try { + const status = await getOnboardingStatus(pool, req.query.deviceId); + res.json({ + deviceId: normalizeDeviceId(req.query.deviceId), + onboarded: Boolean(status && status.screen_id), + clientName: status ? status.client_name : null, + screenId: status ? status.screen_id : null, + screenSlug: status ? status.screen_slug : null, + screenName: status ? status.screen_name : null, + playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null + }); + } catch (error) { + next(error); + } + }); + + app.get('/api/onboarding/screens', async function (_req, res, next) { + try { + const [rows] = await pool.query('SELECT id, name, slug FROM screens ORDER BY name ASC, id ASC'); + res.json({ screens: rows }); + } catch (error) { + next(error); + } + }); + + app.get('/api/onboarding/qr', async function (req, res, next) { + try { + const deviceId = normalizeDeviceId(req.query.deviceId); + if (!deviceId) { + return res.status(400).json({ error: 'Device ID is required' }); + } + const onboardingUrl = `${getPublicBaseUrl(req)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; + const svg = await QRCode.toString(onboardingUrl, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' }); + res.set('Content-Type', 'image/svg+xml; charset=utf-8'); + res.set('Cache-Control', 'no-store'); + res.send(svg); + } catch (error) { + next(error); + } + }); + + app.post('/api/onboarding', async function (req, res, next) { + try { + const deviceId = normalizeDeviceId(req.body && req.body.deviceId); + const clientName = String((req.body && req.body.clientName) || '').trim(); + const screenSlug = String((req.body && req.body.screenSlug) || '').trim(); + if (!deviceId) { + return res.status(400).json({ error: 'Device ID is required' }); + } + if (!clientName) { + return res.status(400).json({ error: 'Client name is required' }); + } + if (!screenSlug) { + return res.status(400).json({ error: 'Screen is required' }); + } + + const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen); + res.json({ + deviceId: deviceId, + clientName: status ? status.client_name : clientName, + screenId: status ? status.screen_id : null, + screenSlug: status ? status.screen_slug : null, + screenName: status ? status.screen_name : null, + playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null + }); + } catch (error) { + next(error); + } + }); +} + +module.exports = { + normalizeDeviceId: normalizeDeviceId, + getPublicBaseUrl: getPublicBaseUrl, + getOnboardingStatus: getOnboardingStatus, + bindDeviceToScreen: bindDeviceToScreen, + registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes +}; \ No newline at end of file diff --git a/src/player/player-client-name.script.html b/src/player/player-client-name.script.html new file mode 100644 index 0000000..b69a147 --- /dev/null +++ b/src/player/player-client-name.script.html @@ -0,0 +1,98 @@ + diff --git a/src/player/player-onboarding-form.script.html b/src/player/player-onboarding-form.script.html new file mode 100644 index 0000000..f231b63 --- /dev/null +++ b/src/player/player-onboarding-form.script.html @@ -0,0 +1,88 @@ + diff --git a/src/player/player-onboarding-landing.script.html b/src/player/player-onboarding-landing.script.html new file mode 100644 index 0000000..14d1601 --- /dev/null +++ b/src/player/player-onboarding-landing.script.html @@ -0,0 +1,141 @@ + diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html index f1de478..97ad65f 100644 --- a/src/player/player-page.script.html +++ b/src/player/player-page.script.html @@ -226,9 +226,12 @@ if (!socket || socket.readyState !== WebSocket.OPEN) { return; } + var clientName = getOnboardingClientName(); socket.send(JSON.stringify({ type: 'hello', clientId: getCommandClientId(), + clientName: clientName || null, + deviceId: getOnboardingDeviceId() || null, userAgent: window.navigator.userAgent || '', page: window.location.href, viewport: getCurrentViewport(), @@ -255,6 +258,8 @@ commandSocket.send(JSON.stringify({ type: 'state', clientId: getCommandClientId(), + clientName: getOnboardingClientName() || null, + deviceId: getOnboardingDeviceId() || null, userAgent: window.navigator.userAgent || '', page: window.location.href, viewport: getCurrentViewport(), @@ -488,6 +493,11 @@ case 'refresh': refresh(); return; + case 'setclientname': + if (payload.clientName) { + applyOnboardingClientName(payload.clientName, commandSocket); + } + return; case 'redirect': if (payload.url) { window.location.replace(String(payload.url)); @@ -542,6 +552,12 @@ commandSocket = socket; socket.onopen = function () { + if (typeof syncOnboardingClientNameFromServer === 'function') { + syncOnboardingClientNameFromServer(socket).then(function () { + sendCommandHello(socket); + }); + return; + } sendCommandHello(socket); }; @@ -1038,6 +1054,7 @@ canvasWidth: canvasSize.width, canvasHeight: canvasSize.height, background: template.background_image_path ? '' : '', + backgroundColor: template.background_color || '#111111', regions: regions }; @@ -1101,7 +1118,8 @@ const regionContent = content[region.regionKey] || {}; return plan.renderRegion(region, regionContent); }).join('') : ''; - return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '
' + (layout ? layout.background : '') + regions + '
'); + const stageStyle = layout ? 'background-color:' + escapeHtml(layout.backgroundColor || '#111111') + ';' : ''; + return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '
' + (layout ? layout.background : '') + regions + '
'); } // Media rendering helpers. diff --git a/src/player/player-page.template.html b/src/player/player-page.template.html index 40b7000..4712089 100644 --- a/src/player/player-page.template.html +++ b/src/player/player-page.template.html @@ -7,8 +7,8 @@ - -
Loading screen...
+ + {{{BODY}}} {{SCRIPT_BLOCK}} diff --git a/src/player/playlist.js b/src/player/playlist.js new file mode 100644 index 0000000..c2d7e22 --- /dev/null +++ b/src/player/playlist.js @@ -0,0 +1,162 @@ +const crypto = require('crypto'); + +function createPlayerPlaylistService(options) { + const pool = options && options.pool ? options.pool : null; + const common = options && options.common ? options.common : null; + + if (!pool) { + throw new Error('pool is required'); + } + if (!common) { + throw new Error('common is required'); + } + + async function buildScreenPlaylist(slug) { + const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]); + if (!screenRows.length) { + return { screen: null, playlist: null, slides: [] }; + } + + const screen = screenRows[0]; + if (!screen.playlist_id) { + return { + screen: screen, + playlist: null, + slides: [], + revision: getPlaylistRevision(screen, null, [], [], []) + }; + } + + const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]); + const playlist = playlistRows[0] || null; + const [slideRows] = await pool.query(` + SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at, + ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, + st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height + FROM playlist_slides ps + JOIN slides sl ON sl.id = ps.slide_id + LEFT JOIN slide_templates st ON st.id = sl.template_id + LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + WHERE ps.playlist_id = ? + ORDER BY ps.position ASC, ps.id ASC + `, [screen.playlist_id]); + + const templateIds = slideRows + .filter(function (slide) { return slide.template_id; }) + .map(function (slide) { return slide.template_id; }); + const templatesById = {}; + let templateRows = []; + let regionRows = []; + if (templateIds.length) { + [templateRows] = await pool.query(` + SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, + cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height + FROM slide_templates st + LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + WHERE st.id IN (?) + `, [templateIds]); + [regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]); + templateRows.forEach(function (template) { + template.regions = regionRows.filter(function (region) { return region.template_id === template.id; }); + templatesById[template.id] = template; + }); + } + + const slides = slideRows.map(function (slide) { + return { + id: slide.id, + title: slide.title, + body: slide.body, + duration_seconds: slide.duration_seconds, + schedule_mode: slide.schedule_mode, + schedule_start_datetime: slide.schedule_start_datetime, + schedule_end_datetime: slide.schedule_end_datetime, + schedule_start_time: slide.schedule_start_time, + schedule_end_time: slide.schedule_end_time, + schedule_days_json: slide.schedule_days_json, + media_url: slide.media_path, + media_type: slide.media_type, + kind: common.mediaKind(slide.media_path), + template_id: slide.template_id, + template: slide.template_id ? templatesById[slide.template_id] || null : null, + content: common.parseJsonSafe(slide.content_json) || {} + }; + }); + + const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows); + + return { screen: screen, playlist: playlist, slides: slides, revision: revision }; + } + + function updatePlaylistRevisionHash(hash, value) { + hash.update(String(value === null || value === undefined ? '' : value)); + hash.update('\0'); + } + + function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows) { + const hash = crypto.createHash('sha1'); + + updatePlaylistRevisionHash(hash, screen && screen.id); + updatePlaylistRevisionHash(hash, screen && screen.playlist_id); + updatePlaylistRevisionHash(hash, screen && screen.modified_at); + + updatePlaylistRevisionHash(hash, playlist && playlist.id); + updatePlaylistRevisionHash(hash, playlist && playlist.modified_at); + updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides); + + (Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) { + updatePlaylistRevisionHash(hash, slide.id); + updatePlaylistRevisionHash(hash, slide.title); + updatePlaylistRevisionHash(hash, slide.body); + updatePlaylistRevisionHash(hash, slide.template_id); + updatePlaylistRevisionHash(hash, slide.content_json); + updatePlaylistRevisionHash(hash, slide.media_path); + updatePlaylistRevisionHash(hash, slide.media_type); + updatePlaylistRevisionHash(hash, slide.modified_at); + updatePlaylistRevisionHash(hash, slide.position); + updatePlaylistRevisionHash(hash, slide.duration_seconds); + updatePlaylistRevisionHash(hash, slide.schedule_mode); + updatePlaylistRevisionHash(hash, slide.schedule_start_datetime); + updatePlaylistRevisionHash(hash, slide.schedule_end_datetime); + updatePlaylistRevisionHash(hash, slide.schedule_start_time); + updatePlaylistRevisionHash(hash, slide.schedule_end_time); + updatePlaylistRevisionHash(hash, slide.schedule_days_json); + }); + + (Array.isArray(templateRows) ? templateRows : []).forEach(function (template) { + updatePlaylistRevisionHash(hash, template.id); + updatePlaylistRevisionHash(hash, template.name); + updatePlaylistRevisionHash(hash, template.canvas_size_id); + updatePlaylistRevisionHash(hash, template.canvas_size_width); + updatePlaylistRevisionHash(hash, template.canvas_size_height); + updatePlaylistRevisionHash(hash, template.background_image_path); + updatePlaylistRevisionHash(hash, template.background_color); + updatePlaylistRevisionHash(hash, template.modified_at); + }); + + (Array.isArray(regionRows) ? regionRows : []).forEach(function (region) { + updatePlaylistRevisionHash(hash, region.id); + updatePlaylistRevisionHash(hash, region.template_id); + updatePlaylistRevisionHash(hash, region.region_key); + updatePlaylistRevisionHash(hash, region.region_type); + updatePlaylistRevisionHash(hash, region.label); + updatePlaylistRevisionHash(hash, region.font_family); + updatePlaylistRevisionHash(hash, region.x); + updatePlaylistRevisionHash(hash, region.y); + updatePlaylistRevisionHash(hash, region.width); + updatePlaylistRevisionHash(hash, region.height); + updatePlaylistRevisionHash(hash, region.z_index); + updatePlaylistRevisionHash(hash, region.modified_at); + }); + + return hash.digest('hex'); + } + + return { + buildScreenPlaylist: buildScreenPlaylist + }; +} + +module.exports = { + createPlayerPlaylistService: createPlayerPlaylistService +}; diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css index 827d558..62034a0 100644 --- a/src/player/public/css/player.css +++ b/src/player/public/css/player.css @@ -9,6 +9,19 @@ body { font-family: Arial, sans-serif; } +body.onboarding-page { + background: + radial-gradient(circle at top, rgba(82, 144, 255, 0.28), transparent 32%), + radial-gradient(circle at bottom right, rgba(34, 197, 94, 0.18), transparent 26%), + linear-gradient(160deg, #09111f 0%, #0b1323 52%, #111827 100%); + overflow-x: hidden; + overflow-y: auto; +} + +body.onboarding-page #app { + display: none; +} + #app { width: 100%; height: 100%; @@ -19,6 +32,166 @@ body { position: relative; } +.onboarding-shell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: clamp(16px, 3vw, 40px); + box-sizing: border-box; +} + +.onboarding-card { + width: min(100%, 1040px); + padding: clamp(20px, 3vw, 40px); + border-radius: 30px; + background: rgba(10, 17, 30, 0.82); + border: 1px solid rgba(148, 163, 184, 0.18); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.45); + backdrop-filter: blur(14px); + box-sizing: border-box; +} + +.onboarding-card h1 { + margin: 0 0 12px; + font-size: clamp(2rem, 4vw, 3.2rem); + line-height: 1.05; +} + +.onboarding-kicker { + margin: 0 0 12px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: #8ab4ff; + font-size: 0.82rem; +} + +.onboarding-copy { + margin: 0 0 28px; + color: #cbd5e1; + font-size: 1.03rem; + line-height: 1.5; +} + +.onboarding-layout { + display: grid; + grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr); + gap: clamp(20px, 3vw, 32px); + align-items: stretch; +} + +.onboarding-qr-pane { + display: grid; + gap: 16px; + align-content: start; +} + +.onboarding-qr-frame { + display: flex; + justify-content: center; + padding: 22px; + border-radius: 26px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.onboarding-qr-frame img { + width: min(100%, 320px); + aspect-ratio: 1; + display: block; + background: #fff; + border-radius: 18px; +} + +.onboarding-form { + display: grid; + gap: 14px; +} + +.onboarding-form--local { + align-content: start; + padding: 22px; + border-radius: 26px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.onboarding-form label { + display: grid; + gap: 9px; + color: #e2e8f0; +} + +.onboarding-form input[type="text"] { + width: 100%; + box-sizing: border-box; + min-height: 48px; + padding: 12px 16px; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgba(15, 23, 42, 0.9); + color: #f8fafc; + font-size: 1rem; +} + +.onboarding-form select { + width: 100%; + box-sizing: border-box; + min-height: 48px; + padding: 12px 16px; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgba(15, 23, 42, 0.9); + color: #f8fafc; + font-size: 1rem; +} + +.onboarding-form input[type="text"]::placeholder { + color: #94a3b8; +} + +.onboarding-form button { + appearance: none; + border: 0; + border-radius: 12px; + background: linear-gradient(135deg, #60a5fa, #22c55e); + color: #08111f; + font-size: 1rem; + font-weight: 700; + min-height: 48px; + padding: 12px 18px; + cursor: pointer; +} + +.onboarding-status { + margin-top: 8px; + min-height: 1.4em; + color: #cbd5e1; + font-size: 0.96rem; +} + +.onboarding-card--landing .onboarding-status { + text-align: center; +} + +@media (max-width: 860px), (orientation: portrait) { + .onboarding-shell { + align-items: center; + } + + .onboarding-layout { + grid-template-columns: 1fr; + } + + .onboarding-card { + width: 100%; + } + + .onboarding-qr-frame img { + width: min(100%, 280px); + } +} + .slide-shell { position: absolute; inset: 0; diff --git a/src/player/render-helpers.js b/src/player/render-helpers.js new file mode 100644 index 0000000..bcbb98f --- /dev/null +++ b/src/player/render-helpers.js @@ -0,0 +1,345 @@ +const fs = require('fs'); +const path = require('path'); + +function mediaKind(mediaPath) { + const ext = path.extname(mediaPath || '').toLowerCase(); + if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].includes(ext)) { + return 'image'; + } + if (['.mp4', '.webm', '.ogg'].includes(ext)) { + return 'video'; + } + if (ext === '.pdf') { + return 'pdf'; + } + return 'file'; +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function sanitizeFontFamily(value) { + return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim(); +} + +function sanitizeFontSize(value) { + return Math.max(8, Number(value || 0) || 24); +} + +function sanitizeTextColor(value, fallback) { + const raw = String(value || '').trim(); + if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) { + return raw; + } + return fallback || '#000000'; +} + +const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; + +function sanitizeRichTextAttributes(tagName, attrText) { + const allowedAttributes = { + a: ['href', 'title', 'target', 'rel', 'class', 'style'], + blockquote: ['class', 'style'], + div: ['class', 'style'], + figure: ['class', 'style'], + figcaption: ['class', 'style'], + h1: ['class', 'style'], + h2: ['class', 'style'], + h3: ['class', 'style'], + h4: ['class', 'style'], + h5: ['class', 'style'], + h6: ['class', 'style'], + li: ['class', 'style'], + ol: ['class', 'style', 'start'], + p: ['class', 'style'], + pre: ['class', 'style'], + span: ['class', 'style'], + table: ['class', 'style'], + td: ['class', 'style', 'colspan', 'rowspan'], + th: ['class', 'style', 'colspan', 'rowspan', 'scope'], + tr: ['class', 'style'], + ul: ['class', 'style'] + }; + const allowed = allowedAttributes[tagName] || []; + if (!allowed.length) { + return ''; + } + + const attrs = []; + String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => { + const lowerKey = String(key || '').toLowerCase(); + if (!allowed.includes(lowerKey)) { + return ''; + } + const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; + if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { + return ''; + } + if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) { + return ''; + } + if (lowerKey === 'target') { + const targetValue = String(value || '').trim(); + if (targetValue === '_blank') { + attrs.push(' target="_blank"'); + if (!attrs.includes(' rel="noreferrer noopener"')) { + attrs.push(' rel="noreferrer noopener"'); + } + return ''; + } + } + attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"'); + return ''; + }); + + return attrs.join(''); +} + +function safeJsonForScript(value) { + return JSON.stringify(value === undefined ? null : value).replace(//gi, ''); + output = output.replace(//gi, ''); + return output.replace(/<[^>]+>/g, (tag) => { + const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i); + if (!match) { + return ''; + } + const closing = Boolean(match[1]); + const name = String(match[2] || '').toLowerCase(); + const attrText = String(match[3] || ''); + if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) { + return ''; + } + if (closing) { + return ``; + } + return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`; + }); +} + +function renderEditorJsBlock(block) { + if (!block || !block.type || !block.data) { + return ''; + } + + if (block.type === 'header') { + const level = Math.max(1, Math.min(6, Number(block.data.level || 2))); + return '' + sanitizeRichText(block.data.text || '') + ''; + } else if (block.type === 'list') { + const tag = block.data.style === 'ordered' ? 'ol' : 'ul'; + const items = Array.isArray(block.data.items) ? block.data.items : []; + return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map((item) => renderEditorJsListItem(item, tag)).join('') + ''; + } else if (block.type === 'delimiter') { + return '
'; + } else if (block.type === 'code') { + return '
' + escapeHtml(block.data.code || '') + '
'; + } else if (block.type === 'table') { + return renderEditorJsTable(block.data); + } else if (block.type === 'paragraph') { + return '

' + sanitizeRichText(block.data.text || '') + '

'; + } + + return ''; +} + +function renderEditorJsListItem(item, tag) { + if (item && typeof item === 'object') { + const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : ''); + const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : []; + const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map((child) => renderEditorJsListItem(child, tag)).join('') + '' : ''; + return '
  • ' + sanitizeRichText(content || '') + nested + '
  • '; + } + return '
  • ' + sanitizeRichText(item || '') + '
  • '; +} + +function renderEditorJsTable(data) { + const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : []; + if (!rows.length) { + return ''; + } + const hasHeadings = Boolean(data.withHeadings); + const tableRows = rows.map(function (row, rowIndex) { + const cells = Array.isArray(row) ? row : []; + const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td'; + const cellAttrs = cellTag === 'th' ? ' scope="col"' : ''; + return '' + cells.map(function (cell) { + return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + ''; + }).join('') + ''; + }).join(''); + return '' + tableRows + '
    '; +} + +function renderEditorJsContent(value) { + if (value && typeof value === 'object') { + if (Array.isArray(value.blocks)) { + return value.blocks.map(renderEditorJsBlock).join(''); + } + if (value.value !== undefined) { + return renderEditorJsContent(value.value); + } + } + const raw = String(value || ''); + try { + const parsed = JSON.parse(raw); + if (parsed && Array.isArray(parsed.blocks)) { + return parsed.blocks.map(renderEditorJsBlock).join(''); + } + } catch (_error) { + // fall through to legacy HTML rendering + } + return sanitizeRichText(raw); +} + +function renderHtmlRegionContent(value) { + const html = String(value || '').trim(); + if (!html) { + return '
    HTML
    '; + } + return ''; +} + +function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) { + const width = Math.max(1, Number(canvasWidth || 0) || 1920); + const height = Math.max(1, Number(canvasHeight || 0) || 1080); + const viewportWidth = Math.max(1, Number(maxWidth || 0) || width); + const viewportHeight = Math.max(1, Number(maxHeight || 0) || height); + const scale = Math.min(viewportWidth / width, viewportHeight / height); + return { + width: Math.round(width * scale), + height: Math.round(height * scale) + }; +} + +const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html'); +const playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html'); +const playerPageScriptPath = path.join(__dirname, 'player-page.script.html'); +const playerOnboardingLandingScriptPath = path.join(__dirname, 'player-onboarding-landing.script.html'); +const playerOnboardingFormScriptPath = path.join(__dirname, 'player-onboarding-form.script.html'); +let playerPageTemplateCache = null; +let playerClientNameScriptCache = null; +let playerPageScriptCache = null; +let playerOnboardingLandingScriptCache = null; +let playerOnboardingFormScriptCache = null; + +function loadTemplate(filePath, cache) { + const stat = fs.statSync(filePath); + if (cache.value && cache.mtimeMs === stat.mtimeMs) { + return cache.value; + } + const compiled = require('handlebars').compile(fs.readFileSync(filePath, 'utf8')); + cache.value = compiled; + cache.mtimeMs = stat.mtimeMs; + return compiled; +} + +function getPlayerPageTemplate() { + return loadTemplate(playerPageTemplatePath, playerPageTemplateCache || (playerPageTemplateCache = {})); +} + +function getPlayerClientNameScript() { + return loadTemplate(playerClientNameScriptPath, playerClientNameScriptCache || (playerClientNameScriptCache = {})); +} + +function getPlayerPageScript() { + return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {})); +} + +function getPlayerOnboardingLandingScript() { + return loadTemplate(playerOnboardingLandingScriptPath, playerOnboardingLandingScriptCache || (playerOnboardingLandingScriptCache = {})); +} + +function getPlayerOnboardingFormScript() { + return loadTemplate(playerOnboardingFormScriptPath, playerOnboardingFormScriptCache || (playerOnboardingFormScriptCache = {})); +} + +module.exports = { + mediaKind: mediaKind, + escapeHtml: escapeHtml, + sanitizeFontFamily: sanitizeFontFamily, + sanitizeFontSize: sanitizeFontSize, + sanitizeTextColor: sanitizeTextColor, + sanitizeRichTextAttributes: sanitizeRichTextAttributes, + safeJsonForScript: safeJsonForScript, + parseMaybeJson: parseMaybeJson, + normalizeContentValue: normalizeContentValue, + normalizeSlide: normalizeSlide, + sanitizeRichText: sanitizeRichText, + renderEditorJsBlock: renderEditorJsBlock, + renderEditorJsListItem: renderEditorJsListItem, + renderEditorJsTable: renderEditorJsTable, + renderEditorJsContent: renderEditorJsContent, + renderHtmlRegionContent: renderHtmlRegionContent, + fitCanvasSize: fitCanvasSize, + loadTemplate: loadTemplate, + getPlayerPageTemplate: getPlayerPageTemplate, + getPlayerClientNameScript: getPlayerClientNameScript, + getPlayerPageScript: getPlayerPageScript, + getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript, + getPlayerOnboardingFormScript: getPlayerOnboardingFormScript +}; \ No newline at end of file diff --git a/src/player/render.js b/src/player/render.js index 29baff3..4256b70 100644 --- a/src/player/render.js +++ b/src/player/render.js @@ -1,320 +1,123 @@ -const fs = require('fs'); -const path = require('path'); const Handlebars = require('handlebars'); +const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers'); -function mediaKind(mediaPath) { - const ext = path.extname(mediaPath || '').toLowerCase(); - if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].includes(ext)) { - return 'image'; - } - if (['.mp4', '.webm', '.ogg'].includes(ext)) { - return 'video'; - } - if (ext === '.pdf') { - return 'pdf'; - } - return 'file'; -} - -function escapeHtml(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function sanitizeFontFamily(value) { - return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim(); -} - -function sanitizeFontSize(value) { - return Math.max(8, Number(value || 0) || 24); -} - -function sanitizeTextColor(value, fallback) { - const raw = String(value || '').trim(); - if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) { - return raw; - } - return fallback || '#000000'; -} - -const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; - -function sanitizeRichTextAttributes(tagName, attrText) { - const allowedAttributes = { - a: ['href', 'title', 'target', 'rel', 'class', 'style'], - blockquote: ['class', 'style'], - div: ['class', 'style'], - figure: ['class', 'style'], - figcaption: ['class', 'style'], - h1: ['class', 'style'], - h2: ['class', 'style'], - h3: ['class', 'style'], - h4: ['class', 'style'], - h5: ['class', 'style'], - h6: ['class', 'style'], - li: ['class', 'style'], - ol: ['class', 'style', 'start'], - p: ['class', 'style'], - pre: ['class', 'style'], - span: ['class', 'style'], - table: ['class', 'style'], - td: ['class', 'style', 'colspan', 'rowspan'], - th: ['class', 'style', 'colspan', 'rowspan', 'scope'], - tr: ['class', 'style'], - ul: ['class', 'style'] - }; - const allowed = allowedAttributes[tagName] || []; - if (!allowed.length) { - return ''; - } - - const attrs = []; - String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => { - const lowerKey = String(key || '').toLowerCase(); - if (!allowed.includes(lowerKey)) { - return ''; - } - const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; - if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { - return ''; - } - if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) { - return ''; - } - if (lowerKey === 'target') { - const targetValue = String(value || '').trim(); - if (targetValue === '_blank') { - attrs.push(' target="_blank"'); - if (!attrs.includes(' rel="noreferrer noopener"')) { - attrs.push(' rel="noreferrer noopener"'); - } - return ''; - } - } - attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"'); - return ''; - }); - - return attrs.join(''); -} - -function safeJsonForScript(value) { - return JSON.stringify(value === undefined ? null : value).replace(//gi, ''); - output = output.replace(//gi, ''); - return output.replace(/<[^>]+>/g, (tag) => { - const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i); - if (!match) { - return ''; - } - const closing = Boolean(match[1]); - const name = String(match[2] || '').toLowerCase(); - const attrText = String(match[3] || ''); - if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) { - return ''; - } - if (closing) { - return ``; - } - return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`; +function renderPage(template, options) { + return template({ + TITLE: options.title, + BODY_CLASS: options.bodyClass || '', + BODY: new Handlebars.SafeString(options.body || ''), + SCRIPT_BLOCK: new Handlebars.SafeString(options.script || '') }); } -function renderEditorJsBlock(block) { - if (!block || !block.type || !block.data) { - return ''; - } - - if (block.type === 'header') { - const level = Math.max(1, Math.min(6, Number(block.data.level || 2))); - return '' + sanitizeRichText(block.data.text || '') + ''; - } else if (block.type === 'list') { - const tag = block.data.style === 'ordered' ? 'ol' : 'ul'; - const items = Array.isArray(block.data.items) ? block.data.items : []; - return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map((item) => renderEditorJsListItem(item, tag)).join('') + ''; - } else if (block.type === 'delimiter') { - return '
    '; - } else if (block.type === 'code') { - return '
    ' + escapeHtml(block.data.code || '') + '
    '; - } else if (block.type === 'table') { - return renderEditorJsTable(block.data); - } else if (block.type === 'paragraph') { - return '

    ' + sanitizeRichText(block.data.text || '') + '

    '; - } - - return ''; +function renderOnboardingLandingBody() { + return [ + '
    ', + '
    ', + '

    Pulse Signage

    ', + '

    Onboard this player

    ', + '

    Choose an existing screen, name the client, and either scan the QR code or finish right here with a keyboard and mouse.

    ', + '
    ', + '
    ', + '
    ', + ' Onboarding QR code', + '
    ', + '
    Preparing onboarding link...
    ', + '
    ', + '
    ', + ' ', + ' ', + ' ', + '
    ', + '
    ', + '
    ', + '
    ', + '
    ' + ].join(''); } -function renderEditorJsListItem(item, tag) { - if (item && typeof item === 'object') { - const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : ''); - const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : []; - const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map((child) => renderEditorJsListItem(child, tag)).join('') + '' : ''; - return '
  • ' + sanitizeRichText(content || '') + nested + '
  • '; - } - return '
  • ' + sanitizeRichText(item || '') + '
  • '; +function renderOnboardingFormBody(deviceId) { + return [ + '
    ', + '
    ', + '

    Pulse Signage

    ', + '

    Name this client

    ', + '

    Pick an existing screen and give this player a friendly name that will persist after refreshes.

    ', + '
    ', + ' ', + ' ', + ' ', + ' ', + '
    ', + '
    ', + '
    ', + '
    ' + ].join(''); } -function renderEditorJsTable(data) { - const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : []; - if (!rows.length) { - return ''; - } - const hasHeadings = Boolean(data.withHeadings); - const tableRows = rows.map(function (row, rowIndex) { - const cells = Array.isArray(row) ? row : []; - const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td'; - const cellAttrs = cellTag === 'th' ? ' scope="col"' : ''; - return '' + cells.map(function (cell) { - return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + ''; - }).join('') + ''; - }).join(''); - return '' + tableRows + '
    '; + +function renderOnboardingLandingScript() { + return getPlayerOnboardingLandingScript()(); } -function renderEditorJsContent(value) { - if (value && typeof value === 'object') { - if (Array.isArray(value.blocks)) { - return value.blocks.map(renderEditorJsBlock).join(''); - } - if (value.value !== undefined) { - return renderEditorJsContent(value.value); - } - } - const raw = String(value || ''); - try { - const parsed = JSON.parse(raw); - if (parsed && Array.isArray(parsed.blocks)) { - return parsed.blocks.map(renderEditorJsBlock).join(''); - } - } catch (_error) { - // fall through to legacy HTML rendering - } - return sanitizeRichText(raw); -} - -function renderHtmlRegionContent(value) { - const html = String(value || '').trim(); - if (!html) { - return '
    HTML
    '; - } - return ''; -} - -function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) { - const width = Math.max(1, Number(canvasWidth || 0) || 1920); - const height = Math.max(1, Number(canvasHeight || 0) || 1080); - const viewportWidth = Math.max(1, Number(maxWidth || 0) || width); - const viewportHeight = Math.max(1, Number(maxHeight || 0) || height); - const scale = Math.min(viewportWidth / width, viewportHeight / height); - return { - width: Math.round(width * scale), - height: Math.round(height * scale) - }; -} - -const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html'); -const playerPageScriptPath = path.join(__dirname, 'player-page.script.html'); -let playerPageTemplateCache = null; -let playerPageScriptCache = null; - -function loadTemplate(filePath, cache) { - const stat = fs.statSync(filePath); - if (cache.value && cache.mtimeMs === stat.mtimeMs) { - return cache.value; - } - const compiled = Handlebars.compile(fs.readFileSync(filePath, 'utf8')); - cache.value = compiled; - cache.mtimeMs = stat.mtimeMs; - return compiled; -} - -function getPlayerPageTemplate() { - return loadTemplate(playerPageTemplatePath, playerPageTemplateCache || (playerPageTemplateCache = {})); -} - -function getPlayerPageScript() { - return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {})); +function renderOnboardingFormScript(deviceId) { + return getPlayerOnboardingFormScript()({ + DEVICE_ID_JSON: new Handlebars.SafeString(JSON.stringify(deviceId || '')) + }); } function renderPlayerPage(slug, initialData) { + const onboardingScript = getPlayerClientNameScript()(); const template = getPlayerPageTemplate(); const script = getPlayerPageScript()({ SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)), INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)) }); - return template({ - TITLE: 'Screen ' + slug, - SCRIPT_BLOCK: new Handlebars.SafeString(script) + return renderPage(template, { + title: 'Screen ' + slug, + body: '
    Loading screen...
    ', + script: onboardingScript + script + }); +} + +function renderPlayerOnboardingLandingPage() { + return renderPage(getPlayerPageTemplate(), { + title: 'Onboard player', + bodyClass: 'onboarding-page', + body: renderOnboardingLandingBody(), + script: renderOnboardingLandingScript() + }); +} + +function renderPlayerOnboardingFormPage(deviceId) { + return renderPage(getPlayerPageTemplate(), { + title: 'Onboard screen', + bodyClass: 'onboarding-page', + body: renderOnboardingFormBody(deviceId), + script: renderOnboardingFormScript(deviceId) }); } module.exports = { mediaKind, - renderPlayerPage + renderPlayerPage, + renderPlayerOnboardingLandingPage, + renderPlayerOnboardingFormPage }; diff --git a/src/player/routes.js b/src/player/routes.js new file mode 100644 index 0000000..742bde2 --- /dev/null +++ b/src/player/routes.js @@ -0,0 +1,159 @@ +const fs = require('fs'); +const express = require('express'); + +function registerPlayerRoutes(app, options) { + const pool = options && options.pool ? options.pool : null; + const common = options && options.common ? options.common : null; + const uploadDir = options && options.uploadDir ? options.uploadDir : null; + const assetDir = options && options.assetDir ? options.assetDir : null; + const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; + const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null; + + if (!app || !pool || !common || !uploadDir || !assetDir || !playerRuntime || !playerPlaylistService) { + throw new Error('registerPlayerRoutes requires app, pool, common, uploadDir, assetDir, playerRuntime, and playerPlaylistService.'); + } + + app.use('/assets', express.static(assetDir)); + app.use('/uploads', express.static(uploadDir)); + + app.get('/api/uploads/config', function (_req, res) { + res.json({ + uploadDir: uploadDir + }); + }); + + app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) { + try { + const filename = require('path').basename(String(req.params.filename || '').trim()); + if (!filename) { + return res.status(400).json({ error: 'Filename is required' }); + } + const filePath = require('path').join(uploadDir, filename); + const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); + await fs.promises.mkdir(uploadDir, { recursive: true }); + await fs.promises.writeFile(filePath, body); + res.json({ ok: true, filename: filename }); + } catch (error) { + next(error); + } + }); + + app.delete('/api/uploads/:filename', async function (req, res, next) { + try { + const filename = require('path').basename(String(req.params.filename || '').trim()); + if (!filename) { + return res.status(400).json({ error: 'Filename is required' }); + } + const filePath = require('path').join(uploadDir, filename); + try { + await fs.promises.unlink(filePath); + } catch (error) { + if (!error || error.code !== 'ENOENT') { + throw error; + } + } + res.json({ ok: true, filename: filename }); + } catch (error) { + next(error); + } + }); + + app.get('/screen/:slug', function (req, res) { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.set('Pragma', 'no-cache'); + playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) { + res.send(common.renderPlayerPage(req.params.slug, data)); + }).catch(function (error) { + console.error(error); + res.status(500).send('Internal server error'); + }); + }); + + app.get('/api/screens/:slug/playlist', async function (req, res, next) { + try { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug); + if (!data.screen) { + return res.status(404).json({ error: 'Screen not found' }); + } + const etag = '"' + String(data.revision || '') + '"'; + res.set('ETag', etag); + if (String(req.headers['if-none-match'] || '').split(',').map(function (value) { + return String(value || '').trim(); + }).includes(etag)) { + return res.status(304).end(); + } + res.json(data); + } catch (error) { + next(error); + } + }); + + app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) { + try { + const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]); + if (!screenRows.length) { + return res.status(404).json({ error: 'Screen not found' }); + } + const connections = playerRuntime.snapshotConnections(req.params.slug); + res.json({ + screen: screenRows[0], + screenSlug: req.params.slug, + count: connections.length, + connections: connections + }); + } catch (error) { + next(error); + } + }); + + app.post('/api/screens/:slug/commands', async function (req, res, next) { + try { + const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase(); + const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim(); + const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') + ? req.body.blackout + : req.query.blackout; + if (!command) { + return res.status(400).json({ error: 'Command is required' }); + } + if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right', 'setclientname'].indexOf(command) === -1) { + return res.status(400).json({ error: 'Unsupported command' }); + } + + const isRedirectCommand = command === 'redirect'; + let screenRows = []; + if (!isRedirectCommand) { + [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]); + if (!screenRows.length) { + return res.status(404).json({ error: 'Screen not found' }); + } + } + + const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) + ? Object.assign({}, req.body, { command: command }) + : command; + if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') { + commandPayload.blackout = blackoutValue; + } + + const sent = connectionId + ? playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload) + : playerRuntime.broadcastCommand(req.params.slug, commandPayload); + + res.json({ + screen: screenRows[0] || null, + screenSlug: req.params.slug, + command: command, + connectionId: connectionId || null, + sent: sent + }); + } catch (error) { + next(error); + } + }); +} + +module.exports = { + registerPlayerRoutes: registerPlayerRoutes +}; \ No newline at end of file diff --git a/src/player/runtime.js b/src/player/runtime.js new file mode 100644 index 0000000..4bec3bf --- /dev/null +++ b/src/player/runtime.js @@ -0,0 +1,416 @@ +const crypto = require('crypto'); +const { WebSocketServer, WebSocket } = require('ws'); + +function createPlayerRuntime(options) { + const pool = options && options.pool ? options.pool : null; + const normalizeDeviceId = typeof options.normalizeDeviceId === 'function' + ? options.normalizeDeviceId + : function (value) { + return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128); + }; + const connectionsBySlug = new Map(); + const dashboardListenersBySlug = new Map(); + const wss = new WebSocketServer({ noServer: true }); + + function normalizeClientIp(value) { + const ip = String(value || '').trim(); + if (!ip) { + return null; + } + + if (ip.toLowerCase().startsWith('::ffff:')) { + return ip.slice(7).trim() || null; + } + + return ip; + } + + function getConnectionBucket(slug) { + const key = String(slug || '').trim(); + if (!key) { + return null; + } + if (!connectionsBySlug.has(key)) { + connectionsBySlug.set(key, new Map()); + } + return connectionsBySlug.get(key); + } + + function removeConnection(slug, connectionId) { + const bucket = connectionsBySlug.get(String(slug || '').trim()); + if (!bucket) { + return; + } + bucket.delete(connectionId); + if (!bucket.size) { + connectionsBySlug.delete(String(slug || '').trim()); + } + } + + function getDashboardListenerBucket(slug) { + const key = String(slug || '').trim(); + if (!key) { + return null; + } + if (!dashboardListenersBySlug.has(key)) { + dashboardListenersBySlug.set(key, new Set()); + } + return dashboardListenersBySlug.get(key); + } + + function removeDashboardListener(slug, socket) { + const key = String(slug || '').trim(); + const bucket = dashboardListenersBySlug.get(key); + if (!bucket) { + return; + } + bucket.delete(socket); + if (!bucket.size) { + dashboardListenersBySlug.delete(key); + } + } + + function buildClientLabel(connection) { + const clientName = String(connection.clientName || '').trim(); + const clientId = String(connection.clientId || '').trim(); + const userAgent = String(connection.userAgent || '').trim(); + const clientIp = String(connection.clientIp || '').trim(); + const viewport = connection.viewport && typeof connection.viewport === 'object' + ? connection.viewport + : null; + const labelParts = []; + + if (userAgent) { + labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent); + } + + if (clientName) { + labelParts.push(clientName); + } else if (clientId) { + labelParts.push(`id ${clientId.slice(-6)}`); + } + + if (clientIp) { + labelParts.push(clientIp); + } + + if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) { + labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`); + } + + if (!labelParts.length) { + return connection.remoteAddress || 'connected client'; + } + + return labelParts.join(' • '); + } + + function snapshotConnections(slug) { + const bucket = connectionsBySlug.get(String(slug || '').trim()); + if (!bucket) { + return []; + } + + return Array.from(bucket.values()).map(function (connection) { + return { + id: connection.id, + clientId: connection.clientId || null, + clientName: connection.clientName || null, + deviceId: connection.deviceId || null, + label: connection.label, + userAgent: connection.userAgent || null, + viewport: connection.viewport || null, + page: connection.page || null, + currentSlide: connection.currentSlide || null, + paused: Boolean(connection.paused), + blackout: Boolean(connection.blackout), + currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null, + currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null, + clientIp: connection.clientIp || null, + remoteAddress: connection.remoteAddress || null, + connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null, + lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null + }; + }); + } + + async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) { + const normalizedName = String(clientName || '').trim(); + if (!normalizedName) { + return false; + } + + const normalizedDeviceId = normalizeDeviceId(excludeDeviceId); + const lowerName = normalizedName.toLowerCase(); + const liveDeviceIds = new Set(); + const liveClientIds = new Set(); + + for (const bucket of connectionsBySlug.values()) { + if (!bucket || typeof bucket.values !== 'function') { + continue; + } + for (const connection of bucket.values()) { + const existingDeviceId = normalizeDeviceId(connection && connection.deviceId ? connection.deviceId : ''); + const existingClientId = normalizeDeviceId(connection && connection.clientId ? connection.clientId : ''); + if (existingDeviceId) { + liveDeviceIds.add(existingDeviceId); + } + if (existingClientId) { + liveClientIds.add(existingClientId); + } + const existingName = String(connection && connection.clientName ? connection.clientName : '').trim(); + if (!existingName) { + continue; + } + if (existingName.toLowerCase() !== lowerName) { + continue; + } + if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) { + continue; + } + return false; + } + } + + const activePool = poolArg || pool; + if (!activePool || (!liveDeviceIds.size && !liveClientIds.size)) { + return true; + } + + try { + const [deviceRows] = await activePool.query( + `SELECT device_id + FROM player_onboarding_devices + WHERE client_name IS NOT NULL + AND TRIM(client_name) <> '' + AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))`, + [normalizedName] + ); + + for (let i = 0; i < deviceRows.length; i += 1) { + const deviceId = normalizeDeviceId(deviceRows[i] && deviceRows[i].device_id ? deviceRows[i].device_id : ''); + if (!deviceId) { + continue; + } + if (normalizedDeviceId && deviceId === normalizedDeviceId) { + continue; + } + if (liveDeviceIds.has(deviceId)) { + return false; + } + } + } catch (_error) { + return true; + } + + return true; + } + + function broadcastConnectionSnapshot(slug) { + const key = String(slug || '').trim(); + const bucket = dashboardListenersBySlug.get(key); + if (!bucket || !bucket.size) { + return; + } + + const payload = JSON.stringify({ + type: 'snapshot', + slug: key, + connections: snapshotConnections(slug), + sentAt: new Date().toISOString() + }); + + bucket.forEach(function (socket) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(payload); + } + }); + } + + function sendCommandToConnection(slug, connectionId, commandOrPayload) { + const bucket = connectionsBySlug.get(String(slug || '').trim()); + if (!bucket || !bucket.size) { + return 0; + } + + const target = bucket.get(String(connectionId || '').trim()); + if (!target || target.socket.readyState !== WebSocket.OPEN) { + return 0; + } + + const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null + ? Object.assign({}, commandOrPayload) + : { command: commandOrPayload }; + payload.type = 'command'; + payload.targetConnectionId = target.id; + payload.sentAt = new Date().toISOString(); + + target.socket.send(JSON.stringify(payload)); + return 1; + } + + function broadcastCommand(slug, commandOrPayload) { + const bucket = connectionsBySlug.get(String(slug || '').trim()); + if (!bucket || !bucket.size) { + return 0; + } + + let sent = 0; + bucket.forEach(function (connection) { + if (connection.socket.readyState !== WebSocket.OPEN) { + return; + } + const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null + ? Object.assign({}, commandOrPayload) + : { command: commandOrPayload }; + payload.type = 'command'; + payload.sentAt = new Date().toISOString(); + + connection.socket.send(JSON.stringify(payload)); + sent += 1; + }); + return sent; + } + + function handleUpgrade(request, socket, head) { + let pathname = ''; + try { + pathname = new URL(request.url, 'http://localhost').pathname; + } catch (_error) { + socket.destroy(); + return; + } + + const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/); + const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/); + if (!dashboardMatch && !playerMatch) { + socket.destroy(); + return; + } + + const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]); + wss.handleUpgrade(request, socket, head, function (ws) { + wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player'); + }); + } + + wss.on('connection', function (socket, request, slug, role) { + if (role === 'dashboard') { + const listenerBucket = getDashboardListenerBucket(slug); + if (!listenerBucket) { + socket.close(); + return; + } + + listenerBucket.add(socket); + socket.send(JSON.stringify({ + type: 'snapshot', + slug: String(slug || '').trim(), + connections: snapshotConnections(slug), + sentAt: new Date().toISOString() + })); + + socket.on('close', function () { + removeDashboardListener(slug, socket); + }); + + socket.on('error', function () { + removeDashboardListener(slug, socket); + }); + return; + } + + const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null; + const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]); + const normalizedRemoteAddress = normalizeClientIp(remoteAddress); + const connectionId = crypto.randomUUID(); + const connection = { + id: connectionId, + slug: slug, + socket: socket, + clientId: null, + clientName: null, + deviceId: null, + userAgent: null, + viewport: null, + page: null, + paused: false, + blackout: false, + clientIp: forwardedFor || normalizedRemoteAddress, + remoteAddress: normalizedRemoteAddress, + label: forwardedFor || normalizedRemoteAddress || 'connected client', + connectedAt: new Date(), + lastSeenAt: new Date() + }; + const bucket = getConnectionBucket(slug); + + if (!bucket) { + socket.close(); + return; + } + + bucket.set(connectionId, connection); + + socket.on('message', function (rawMessage) { + connection.lastSeenAt = new Date(); + let payload = null; + try { + payload = JSON.parse(String(rawMessage || '')); + } catch (_error) { + return; + } + + if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) { + return; + } + + connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId; + connection.clientName = payload.clientName ? String(payload.clientName).trim() : connection.clientName; + connection.deviceId = payload.deviceId ? normalizeDeviceId(payload.deviceId) || connection.deviceId : connection.deviceId; + if (!connection.clientName && connection.clientId) { + connection.clientName = connection.clientId; + } + connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent; + connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport; + connection.page = payload.page ? String(payload.page).trim() : connection.page; + connection.paused = Boolean(payload.paused); + connection.blackout = Boolean(payload.blackout); + connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp; + connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? { + id: payload.currentSlide.id || null, + title: payload.currentSlide.title || '', + kind: payload.currentSlide.kind || '', + playlistSignature: payload.currentSlide.playlistSignature || '' + } : connection.currentSlide; + connection.label = buildClientLabel(connection); + connection.lastSeenAt = new Date(); + broadcastConnectionSnapshot(slug); + }); + + socket.on('close', function () { + removeConnection(slug, connectionId); + broadcastConnectionSnapshot(slug); + }); + + socket.on('error', function () { + removeConnection(slug, connectionId); + broadcastConnectionSnapshot(slug); + }); + }); + + function installWebsocket(server) { + server.on('upgrade', handleUpgrade); + } + + return { + installWebsocket: installWebsocket, + snapshotConnections: snapshotConnections, + isClientNameAvailableOnScreen: isClientNameAvailableOnScreen, + sendCommandToConnection: sendCommandToConnection, + broadcastCommand: broadcastCommand + }; +} + +module.exports = { + createPlayerRuntime: createPlayerRuntime +}; diff --git a/src/web.js b/src/web.js index 150ddc0..0885570 100644 --- a/src/web.js +++ b/src/web.js @@ -4,10 +4,34 @@ const multer = require('multer'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const { WebSocketServer, WebSocket } = require('ws'); const common = require('./common'); const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth'); const pages = require('./web/routes'); +const registerAuthRoutes = require('./web/routes/auth'); +const registerAdminPagesRoutes = require('./web/routes/admin-pages'); +const registerAdminAccountRoutes = require('./web/routes/admin-account'); +const registerAdminUsersRoutes = require('./web/routes/admin-users'); +const registerAdminManageRoutes = require('./web/routes/admin-manage'); +const registerAdminScreenCommandRoutes = require('./web/routes/admin-screen-commands'); +const registerAdminContentRoutes = require('./web/routes/admin-content'); +const { createWebBootstrap } = require('./web/bootstrap'); +const { createPlayerActionService } = require('./web/player-actions'); +const { createSessionService } = require('./web/session'); +const { + formatDashboardDate, + readArrayField, + parseDateTimeLocal, + parseTimeLocal, + normalizeScheduleMode, + getAuditUserId, + getCanvasSignature, + fetchPlaylistCanvasSignature, + fetchScreensByPlaylistId, + fetchScreensBySlideId, + fetchScreensByTemplateId, + fetchOrderedPlaylistSlides, + redirectAfterSave +} = require('./web/helpers'); const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, ''); const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws'); @@ -15,933 +39,6 @@ const SESSION_COOKIE_NAME = 'digital_signage_session'; const SESSION_MAX_AGE_DAYS = Number(process.env.SESSION_MAX_AGE_DAYS || 14); const SESSION_MAX_AGE_MS = (Number.isFinite(SESSION_MAX_AGE_DAYS) && SESSION_MAX_AGE_DAYS > 0 ? SESSION_MAX_AGE_DAYS : 14) * 24 * 60 * 60 * 1000; -const playerSnapshotCache = new Map(); -const playerSnapshotSockets = new Map(); -let playerUploadSyncMode = null; -let playerUploadSyncModePromise = null; -const pendingPlayerUploadSyncs = new Map(); -let pendingPlayerUploadSyncFlushTimer = null; -let pendingPlayerUploadSyncFlushInFlight = null; -const pendingPlaylistUploadSyncs = new Map(); -let pendingPlaylistUploadSyncFlushTimer = null; -let pendingPlaylistUploadSyncFlushInFlight = null; - -function normalizeUploadRoot(uploadDir) { - return path.resolve(String(uploadDir || '').trim()); -} - -function readArrayField(body, keys) { - const searchKeys = Array.isArray(keys) ? keys : [keys]; - for (let i = 0; i < searchKeys.length; i += 1) { - const value = body[searchKeys[i]]; - if (Array.isArray(value)) { - return value; - } - if (value !== undefined && value !== null && value !== '') { - return [value]; - } - } - return []; -} - -function parseDateTimeLocal(value) { - if (!value) { - return null; - } - const date = new Date(String(value)); - return Number.isNaN(date.getTime()) ? null : date; -} - -function parseTimeLocal(value) { - const raw = String(value || '').trim(); - if (!raw) { - return null; - } - if (!/^\d{2}:\d{2}(:\d{2})?$/.test(raw)) { - return null; - } - return raw.length === 5 ? raw + ':00' : raw; -} - -function normalizeScheduleMode(value) { - const mode = String(value || 'always'); - if (mode === 'dates' || mode === 'times') { - return mode; - } - return 'always'; -} - - -// Session and auth helpers. -function getAuditUserId(req) { - return req && req.currentUser ? Number(req.currentUser.id) : null; -} - -function getCanvasSignature(width, height) { - const normalizedWidth = Number(width); - const normalizedHeight = Number(height); - if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) { - return null; - } - return normalizedWidth + 'x' + normalizedHeight; -} - -async function fetchPlaylistCanvasSignature(pool, playlistId) { - const [rows] = await pool.query(` - SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height - FROM playlist_slides ps - JOIN slides sl ON sl.id = ps.slide_id - LEFT JOIN slide_templates st ON st.id = sl.template_id - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id - WHERE ps.playlist_id = ? - AND cs.width IS NOT NULL - AND cs.height IS NOT NULL - `, [playlistId]); - const signatures = Array.from(new Set(rows.map((row) => getCanvasSignature(row.canvas_width, row.canvas_height)).filter(Boolean))); - if (!signatures.length) { - return null; - } - return signatures.length === 1 ? signatures[0] : 'mismatch'; -} - -// Player snapshot and dashboard helpers. -function getPlayerSnapshotSocketUrl(slug) { - const url = new URL(PLAYER_WS_BASE_URL); - url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`; - url.search = ''; - return url.toString(); -} - -function storePlayerSnapshot(slug, connections) { - const normalizedConnections = Array.isArray(connections) ? connections : []; - playerSnapshotCache.set(String(slug || '').trim(), { - slug: String(slug || '').trim(), - count: normalizedConnections.length, - connections: normalizedConnections - }); -} - -function clearPlayerSnapshotSocket(slug) { - const key = String(slug || '').trim(); - playerSnapshotSockets.delete(key); -} - -function ensurePlayerSnapshotSubscription(slug) { - const key = String(slug || '').trim(); - if (!key || playerSnapshotSockets.has(key)) { - return; - } - - const socket = new WebSocket(getPlayerSnapshotSocketUrl(key)); - playerSnapshotSockets.set(key, socket); - - socket.onmessage = function (event) { - try { - const payload = JSON.parse(String(event.data || '{}')); - if (!payload || payload.type !== 'snapshot' || payload.slug !== key) { - return; - } - storePlayerSnapshot(key, payload.connections || []); - broadcastDashboardState().catch(function (error) { - console.error(error); - }); - } catch (_error) { - // Ignore malformed player snapshot payloads. - } - }; - - socket.onclose = function () { - clearPlayerSnapshotSocket(key); - setTimeout(function () { - ensurePlayerSnapshotSubscription(key); - }, 2000); - }; - - socket.onerror = function () { - try { - socket.close(); - } catch (_error) { - // ignore close errors - } - }; -} - -function enrichScreensWithConnections(screens, connectionsBySlug) { - return (screens || []).map(function (screen) { - const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; - return Object.assign({}, screen, { - player_connection_count: connectionState.count || 0, - player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : [] - }); - }); -} - -function buildClientRows(screens, connectionsBySlug) { - return (screens || []).flatMap(function (screen) { - const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; - return (connectionState.connections || []).map(function (connection) { - return Object.assign({}, connection, { - screen_slug: screen.slug, - screen_name: screen.name, - playlist_name: screen.playlist_name || null, - connectedAtLabel: formatDashboardDate(connection.connectedAt), - lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt), - player_url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(screen.slug)}` - }); - }); - }); -} - -// Player command helpers. -async function forwardPlayerCommand(slug, commandOrPayload, connectionId) { - const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null - ? Object.assign({}, commandOrPayload) - : { command: commandOrPayload }; - if (connectionId) { - payload.connectionId = connectionId; - } - const response = await fetch(`${PLAYER_INTERNAL_BASE_URL}/api/screens/${encodeURIComponent(slug)}/commands`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json' - }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - const errorText = await response.text().catch(function () { return ''; }); - const error = new Error(errorText || `Unable to send command to player ${slug}.`); - error.statusCode = response.status; - throw error; - } - - return response.json().catch(function () { - return { ok: true }; - }); -} - -async function notifyPlayerScreens(slugs, commandOrPayload) { - const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) { - return String(slug || '').trim(); - }).filter(Boolean))); - - if (!uniqueSlugs.length) { - return 0; - } - - const results = await Promise.allSettled(uniqueSlugs.map(function (slug) { - return forwardPlayerCommand(slug, commandOrPayload || 'refresh'); - })); - - return results.filter(function (result) { - return result.status === 'fulfilled'; - }).length; -} - -// Screen lookup helpers used by the admin routes. -async function fetchAllScreenSlugs(connection) { - const [rows] = await connection.query('SELECT slug FROM screens WHERE slug IS NOT NULL'); - return rows.map(function (row) { - return row.slug; - }); -} - -async function fetchScreensByPlaylistId(connection, playlistId) { - const [rows] = await connection.query( - 'SELECT slug FROM screens WHERE playlist_id = ? AND slug IS NOT NULL', - [playlistId] - ); - return rows.map(function (row) { - return row.slug; - }); -} - -async function fetchScreensBySlideId(connection, slideId) { - const [rows] = await connection.query( - `SELECT DISTINCT s.slug - FROM screens s - JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id - WHERE ps.slide_id = ? - AND s.slug IS NOT NULL`, - [slideId] - ); - return rows.map(function (row) { - return row.slug; - }); -} - -async function fetchScreensByTemplateId(connection, templateId) { - const [rows] = await connection.query( - `SELECT DISTINCT s.slug - FROM screens s - JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id - JOIN slides sl ON sl.id = ps.slide_id - WHERE sl.template_id = ? - AND s.slug IS NOT NULL`, - [templateId] - ); - return rows.map(function (row) { - return row.slug; - }); -} - -async function fetchSlideIdsByTemplateId(connection, templateId) { - const [rows] = await connection.query( - 'SELECT id FROM slides WHERE template_id = ?', - [templateId] - ); - return rows.map(function (row) { - return Number(row.id); - }).filter(function (value) { - return Number.isFinite(value); - }); -} - -function formatDashboardDate(value) { - if (!value) { - return ''; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return ''; - } - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: '2-digit', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - second: '2-digit' - }).format(date); -} - -async function buildDashboardState(pool) { - const data = await common.fetchAdminData(pool); - const screensData = data.screens || []; - screensData.forEach(function (screen) { - ensurePlayerSnapshotSubscription(screen.slug); - }); - - const connectionsBySlug = {}; - screensData.forEach(function (screen) { - const cached = playerSnapshotCache.get(String(screen.slug || '').trim()); - if (cached && Array.isArray(cached.connections)) { - connectionsBySlug[screen.slug] = cached; - } - }); - - const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug).map(function (screen) { - return Object.assign({}, screen, { - player_url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(screen.slug)}` - }); - }); - const clients = buildClientRows(data.screens || [], connectionsBySlug); - const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) { - return socket && socket.readyState === WebSocket.OPEN; - }); - - return { - playlists: data.playlists || [], - screens: screens, - clients: clients, - slides: data.slides || [], - playerServiceConnected: playerServiceConnected, - connectedClientsCount: screens.reduce(function (total, screen) { - return total + Number(screen.player_connection_count || 0); - }, 0) - }; -} - -function buildDashboardPayload(state) { - return JSON.stringify({ - type: 'dashboard-state', - state: state - }); -} - -async function fetchOrderedPlaylistSlides(connection, playlistId) { - const [rows] = await connection.query( - 'SELECT id, position FROM playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC', - [playlistId] - ); - return rows; -} - -// Upload synchronization and retry helpers. -function createUploadMiddleware(uploadDir) { - const storage = multer.diskStorage({ - destination: function (_req, _file, cb) { - cb(null, uploadDir); - }, - filename: function (_req, file, cb) { - const safeExt = path.extname(file.originalname || '').toLowerCase(); - const stamp = `${Date.now()}-${crypto.randomUUID()}`; - cb(null, `${stamp}${safeExt}`); - } - }); - return multer({ storage }); -} - -function normalizeUploadReference(uploadPath) { - const value = String(uploadPath || '').trim(); - if (!value || !value.startsWith('/uploads/')) { - return null; - } - return value; -} - -function collectUploadReferencesFromValue(value, refs) { - if (!value) { - return refs; - } - const stack = [value]; - while (stack.length) { - const current = stack.pop(); - if (Array.isArray(current)) { - current.forEach(function (item) { - stack.push(item); - }); - continue; - } - if (current && typeof current === 'object') { - Object.keys(current).forEach(function (key) { - stack.push(current[key]); - }); - continue; - } - if (typeof current === 'string') { - const reference = normalizeUploadReference(current); - if (reference) { - refs.add(reference); - } - } - } - return refs; -} - -function collectUploadReferencesFromSlide(slide) { - const refs = new Set(); - if (!slide) { - return refs; - } - collectUploadReferencesFromValue(slide.media_path, refs); - collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs); - return refs; -} - -function collectUploadReferencesFromTemplate(template) { - const refs = new Set(); - if (!template) { - return refs; - } - collectUploadReferencesFromValue(template.background_image_path, refs); - return refs; -} - -function collectUploadReferencesFromPayload(payload) { - const refs = new Set(); - if (!payload) { - return refs; - } - collectUploadReferencesFromValue(payload.mediaPath, refs); - collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs); - collectUploadReferencesFromValue(payload.backgroundImagePath, refs); - return refs; -} - -async function countUploadReferences(pool, uploadPath) { - const [slideRows] = await pool.query( - `SELECT COUNT(*) AS ref_count - FROM slides - WHERE media_path = ? - OR JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`, - [uploadPath, uploadPath] - ); - const [templateRows] = await pool.query( - 'SELECT COUNT(*) AS ref_count FROM slide_templates WHERE background_image_path = ?', - [uploadPath] - ); - return Number(slideRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0); -} - -async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) { - const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean))); - for (let i = 0; i < uniquePaths.length; i += 1) { - const uploadPath = uniquePaths[i]; - const referenceCount = await countUploadReferences(pool, uploadPath); - if (referenceCount > 0) { - continue; - } - - const filePath = path.join(uploadDir, path.basename(uploadPath)); - try { - await fs.promises.unlink(filePath); - } catch (error) { - if (error && error.code !== 'ENOENT') { - console.warn('Unable to remove unused upload file:', filePath, error); - } - } - queuePlayerUploadSync({ - type: 'delete', - uploadPath: uploadPath, - uploadDir: uploadDir - }); - } -} - -async function getPlayerUploadSyncMode(localUploadDir) { - if (playerUploadSyncMode) { - return playerUploadSyncMode; - } - if (playerUploadSyncModePromise) { - return playerUploadSyncModePromise; - } - - playerUploadSyncModePromise = (async function () { - try { - const response = await fetch(`${PLAYER_INTERNAL_BASE_URL}/api/uploads/config`, { - headers: { - Accept: 'application/json' - } - }); - if (!response.ok) { - return null; - } - const data = await response.json(); - const playerUploadDir = data && data.uploadDir ? normalizeUploadRoot(data.uploadDir) : null; - if (!playerUploadDir) { - return null; - } - return playerUploadDir === normalizeUploadRoot(localUploadDir) ? 'shared' : 'different'; - } catch (_error) { - return null; - } - })().then(function (mode) { - if (mode) { - playerUploadSyncMode = mode; - } - playerUploadSyncModePromise = null; - return mode; - }, function () { - playerUploadSyncModePromise = null; - return null; - }); - - return playerUploadSyncModePromise; -} - -async function shouldMirrorUploads(localUploadDir) { - return Boolean(localUploadDir); -} - -function queuePlayerUploadSync(operation) { - if (!operation || !operation.uploadPath) { - return; - } - - pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), { - type: operation.type === 'delete' ? 'delete' : 'put', - uploadPath: normalizeUploadReference(operation.uploadPath), - uploadDir: operation.uploadDir || null - }); - - schedulePendingPlayerUploadSyncFlush(); -} - -function schedulePendingPlayerUploadSyncFlush() { - if (pendingPlayerUploadSyncFlushTimer) { - return; - } - - pendingPlayerUploadSyncFlushTimer = setTimeout(function () { - pendingPlayerUploadSyncFlushTimer = null; - flushPendingPlayerUploadSyncs().catch(function (error) { - console.warn('Unable to flush pending upload syncs:', error); - }); - }, 5000); -} - -async function pushUploadFileToPlayer(uploadPath, localUploadDir) { - if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) { - return false; - } - - const filename = path.basename(uploadPath); - const sourcePath = path.join(localUploadDir, filename); - let fileBuffer = null; - try { - fileBuffer = await fs.promises.readFile(sourcePath); - } catch (error) { - if (!error || error.code !== 'ENOENT') { - console.warn('Unable to read upload for player sync:', sourcePath, error); - } - return; - } - - try { - const response = await fetch(`${PLAYER_INTERNAL_BASE_URL}/api/uploads/${encodeURIComponent(filename)}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/octet-stream' - }, - body: fileBuffer - }); - if (!response.ok) { - console.warn('Unable to sync upload to player:', filename, response.status, response.statusText); - return false; - } - return true; - } catch (error) { - console.warn('Unable to sync upload to player:', filename, error); - return false; - } -} - -async function removeUploadFileFromPlayer(uploadPath, localUploadDir) { - if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) { - return false; - } - - const filename = path.basename(uploadPath); - try { - const response = await fetch(`${PLAYER_INTERNAL_BASE_URL}/api/uploads/${encodeURIComponent(filename)}`, { - method: 'DELETE', - headers: { - Accept: 'application/json' - } - }); - if (!response.ok && response.status !== 404) { - console.warn('Unable to remove upload from player:', filename, response.status, response.statusText); - return false; - } - return true; - } catch (error) { - console.warn('Unable to remove upload from player:', filename, error); - return false; - } -} - -async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) { - if (!(await shouldMirrorUploads(localUploadDir))) { - return; - } - - const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean))); - for (let i = 0; i < uniqueRefs.length; i += 1) { - const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir); - if (!success) { - queuePlayerUploadSync({ - type: 'put', - uploadPath: uniqueRefs[i], - uploadDir: localUploadDir - }); - } - } -} - -async function syncExistingUploadsToPlayer(pool, localUploadDir) { - if (!(await shouldMirrorUploads(localUploadDir))) { - return; - } - - const data = await common.fetchAdminData(pool); - const uploadRefs = new Set(); - (data.slides || []).forEach(function (slide) { - collectUploadReferencesFromSlide(slide).forEach(function (reference) { - uploadRefs.add(reference); - }); - }); - (data.templates || []).forEach(function (template) { - collectUploadReferencesFromTemplate(template).forEach(function (reference) { - uploadRefs.add(reference); - }); - }); - Array.from(uploadRefs).forEach(function (uploadPath) { - queuePlayerUploadSync({ - type: 'put', - uploadPath: uploadPath, - uploadDir: localUploadDir - }); - }); - await flushPendingPlayerUploadSyncs(); -} - -function getVisibleCurrentSlideIds() { - const visibleSlideIds = new Set(); - playerSnapshotCache.forEach(function (snapshot) { - const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : []; - connections.forEach(function (connection) { - const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object' - ? connection.currentSlide - : null; - const slideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null - ? String(currentSlide.id).trim() - : ''; - if (slideId) { - visibleSlideIds.add(slideId); - } - }); - }); - return visibleSlideIds; -} - -function isScreenRefreshBlocked(screenSlug, blockedSlideIds) { - const slideIds = Array.isArray(blockedSlideIds) - ? blockedSlideIds.map(function (value) { - return String(value || '').trim(); - }).filter(Boolean) - : []; - if (!slideIds.length) { - return false; - } - const snapshot = playerSnapshotCache.get(String(screenSlug || '').trim()); - const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : []; - return connections.some(function (connection) { - const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object' - ? connection.currentSlide - : null; - const currentSlideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null - ? String(currentSlide.id).trim() - : ''; - return currentSlideId && slideIds.includes(currentSlideId); - }); -} - -function splitRefreshScreenSlugsByVisibility(screenSlugs, blockedSlideIds) { - const ready = []; - const blocked = []; - Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).forEach(function (screenSlug) { - const normalizedScreenSlug = String(screenSlug || '').trim(); - if (!normalizedScreenSlug) { - return; - } - if (isScreenRefreshBlocked(normalizedScreenSlug, blockedSlideIds)) { - blocked.push(normalizedScreenSlug); - } else { - ready.push(normalizedScreenSlug); - } - }); - return { ready: ready, blocked: blocked }; -} - -function normalizePlaylistUploadSyncOperation(options) { - return { - key: String(options && options.key ? options.key : '').trim(), - pool: options && options.pool ? options.pool : null, - localUploadDir: options && options.localUploadDir ? options.localUploadDir : null, - previousUploadRefs: Array.from(new Set(options && options.previousUploadRefs ? options.previousUploadRefs : [])), - nextUploadRefs: Array.from(new Set(options && options.nextUploadRefs ? options.nextUploadRefs : [])), - blockedSlideIds: Array.from(new Set(options && options.blockedSlideIds ? options.blockedSlideIds : [])).map(function (value) { - return String(value || '').trim(); - }).filter(Boolean), - refreshScreenSlugs: Array.from(new Set(options && options.refreshScreenSlugs ? options.refreshScreenSlugs : [])).map(function (value) { - return String(value || '').trim(); - }).filter(Boolean) - }; -} - -function queuePlaylistUploadSync(operation) { - if (!operation || !operation.key) { - return; - } - pendingPlaylistUploadSyncs.set(operation.key, normalizePlaylistUploadSyncOperation(operation)); - schedulePendingPlaylistUploadSyncFlush(); -} - -function schedulePendingPlaylistUploadSyncFlush() { - if (pendingPlaylistUploadSyncFlushTimer) { - return; - } - - pendingPlaylistUploadSyncFlushTimer = setTimeout(function () { - pendingPlaylistUploadSyncFlushTimer = null; - flushPendingPlaylistUploadSyncs().catch(function (error) { - console.warn('Unable to flush pending playlist upload syncs:', error); - }); - }, 5000); -} - -async function syncPlaylistUploadsOnChange(options) { - const operation = normalizePlaylistUploadSyncOperation(options); - if (!operation.key) { - return; - } - - if (operation.previousUploadRefs.length) { - const nextUploadRefSet = new Set(operation.nextUploadRefs); - await removeUnusedUploadFiles(operation.pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) { - return !nextUploadRefSet.has(reference); - })); - } - - if (operation.nextUploadRefs.length) { - await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir); - } - - if (operation.refreshScreenSlugs.length) { - const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds); - if (refreshTargets.ready.length) { - await notifyPlayerScreens(refreshTargets.ready, 'refresh'); - } - refreshTargets.blocked.forEach(function (screenSlug) { - queuePlaylistUploadSync({ - key: operation.key + ':refresh:' + screenSlug, - blockedSlideIds: operation.blockedSlideIds, - refreshScreenSlugs: [screenSlug] - }); - }); - } -} - -async function flushPendingPlaylistUploadSyncs() { - if (pendingPlaylistUploadSyncFlushInFlight) { - return pendingPlaylistUploadSyncFlushInFlight; - } - - if (!pendingPlaylistUploadSyncs.size) { - return null; - } - - pendingPlaylistUploadSyncFlushInFlight = (async function () { - const pendingEntries = Array.from(pendingPlaylistUploadSyncs.values()); - for (let i = 0; i < pendingEntries.length; i += 1) { - const operation = pendingEntries[i]; - const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds); - if (!refreshTargets.ready.length) { - continue; - } - - if (refreshTargets.ready.length) { - await notifyPlayerScreens(refreshTargets.ready, 'refresh'); - } - - pendingPlaylistUploadSyncs.delete(operation.key); - } - })().finally(function () { - pendingPlaylistUploadSyncFlushInFlight = null; - if (pendingPlaylistUploadSyncs.size) { - schedulePendingPlaylistUploadSyncFlush(); - } - }); - - return pendingPlaylistUploadSyncFlushInFlight; -} - -async function flushPendingPlayerUploadSyncs() { - if (pendingPlayerUploadSyncFlushInFlight) { - return pendingPlayerUploadSyncFlushInFlight; - } - - if (!pendingPlayerUploadSyncs.size) { - return null; - } - - pendingPlayerUploadSyncFlushInFlight = (async function () { - const pendingEntries = Array.from(pendingPlayerUploadSyncs.values()); - for (let i = 0; i < pendingEntries.length; i += 1) { - const operation = pendingEntries[i]; - let success = false; - if (operation.type === 'delete') { - success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir); - } else { - success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir); - } - if (success) { - pendingPlayerUploadSyncs.delete(operation.uploadPath); - } - } - })().finally(function () { - pendingPlayerUploadSyncFlushInFlight = null; - if (pendingPlayerUploadSyncs.size) { - schedulePendingPlayerUploadSyncFlush(); - } - }); - - return pendingPlayerUploadSyncFlushInFlight; -} - -function parseCookies(cookieHeader) { - return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) { - if (!pair) { - return cookies; - } - const separatorIndex = pair.indexOf('='); - if (separatorIndex === -1) { - return cookies; - } - const name = decodeURIComponent(pair.slice(0, separatorIndex).trim()); - const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim()); - if (name) { - cookies[name] = value; - } - return cookies; - }, {}); -} - -function serializeCookie(name, value, options) { - const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`]; - if (options && options.maxAge !== undefined) { - parts.push(`Max-Age=${Math.max(0, Math.trunc(Number(options.maxAge) / 1000))}`); - } - parts.push('Path=/'); - parts.push('HttpOnly'); - parts.push('SameSite=Lax'); - return parts.join('; '); -} - -function clearSessionCookie(res) { - res.setHeader('Set-Cookie', serializeCookie(SESSION_COOKIE_NAME, '', { maxAge: 0 })); -} - -function setSessionCookie(res, token) { - res.setHeader('Set-Cookie', serializeCookie(SESSION_COOKIE_NAME, token, { maxAge: SESSION_MAX_AGE_MS })); -} - -async function loadCurrentUser(pool, req) { - const cookies = parseCookies(req.headers.cookie || ''); - const token = cookies[SESSION_COOKIE_NAME]; - if (!token) { - return null; - } - - const tokenHash = hashSessionToken(token); - const [rows] = await pool.query( - `SELECT s.user_id, u.id, u.name, u.username - FROM auth_sessions s - JOIN users u ON u.id = s.user_id - WHERE s.session_hash = ? - AND s.expires_at > NOW() - LIMIT 1`, - [tokenHash] - ); - if (!rows.length) { - return null; - } - - await pool.query('UPDATE auth_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]); - return rows[0]; -} - -async function createUserSession(pool, userId) { - const token = createSessionToken(); - const tokenHash = hashSessionToken(token); - const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_MS); - await pool.query( - 'INSERT INTO auth_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', - [tokenHash, userId, expiresAt, userId, userId] - ); - return token; -} - -function requireAuth(req, res, next) { - if (req.currentUser) { - return next(); - } - res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.')); -} - -// Admin app routes and websocket wiring. async function start() { const app = express(); const server = http.createServer(app); @@ -949,13 +46,56 @@ async function start() { const PORT = Number(process.env.WEB_PORT || 3000); const UPLOAD_DIR = path.join(__dirname, '..', 'uploads'); const ASSET_DIR = path.join(__dirname, 'web', 'public'); - const dashboardWs = new WebSocketServer({ noServer: true }); - const dashboardClients = new Set(); - const dashboardRefreshIntervalMs = Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000); - let dashboardRefreshInFlight = null; - fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + const playerActionService = createPlayerActionService({ + common: common, + playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL + }); + const notifyPlayerScreens = function (slugs, commandOrPayload) { + const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) { + return String(slug || '').trim(); + }).filter(Boolean))); - const upload = createUploadMiddleware(UPLOAD_DIR); + if (!uniqueSlugs.length) { + return Promise.resolve(0); + } + + return Promise.allSettled(uniqueSlugs.map(function (slug) { + return playerActionService.forwardPlayerCommand(slug, commandOrPayload || 'refresh'); + })).then(function (results) { + return results.filter(function (result) { + return result.status === 'fulfilled'; + }).length; + }); + }; + const webBootstrap = createWebBootstrap({ + pool: pool, + common: common, + playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, + playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, + uploadDir: UPLOAD_DIR, + dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000), + formatDashboardDate: formatDashboardDate, + notifyPlayerScreens: notifyPlayerScreens + }); + const upload = webBootstrap.upload; + const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide; + const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate; + const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload; + const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange; + const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer; + const broadcastDashboardState = webBootstrap.broadcastDashboardState; + const sessionService = createSessionService({ + sessionCookieName: SESSION_COOKIE_NAME, + sessionMaxAgeMs: SESSION_MAX_AGE_MS, + hashSessionToken: hashSessionToken, + createSessionToken: createSessionToken + }); + const parseCookies = sessionService.parseCookies; + const clearSessionCookie = sessionService.clearSessionCookie; + const setSessionCookie = sessionService.setSessionCookie; + const loadCurrentUser = sessionService.loadCurrentUser; + const createUserSession = sessionService.createUserSession; + const requireAuth = sessionService.requireAuth; app.use(express.urlencoded({ extended: true })); app.use(express.json()); @@ -971,1253 +111,94 @@ async function start() { } }); - app.get('/', function (_req, res) { - res.redirect(_req.currentUser ? '/admin' : '/login'); - }); - - app.get('/login', function (req, res) { - if (req.currentUser) { - return res.redirect('/admin'); - } - res.send(pages.renderLoginPage(req.query.message ? String(req.query.message) : '')); - }); - - app.post('/login', async function (req, res, next) { - try { - const username = String(req.body.username || '').trim(); - const password = String(req.body.password || ''); - if (!username || !password) { - return res.status(400).send('Username and password are required.'); - } - - const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE username = ? LIMIT 1', [username]); - const user = rows[0] || null; - if (!user || !verifyPassword(password, user)) { - return res.redirect('/login?message=' + encodeURIComponent('Invalid username or password.')); - } - - const token = await createUserSession(pool, user.id); - setSessionCookie(res, token); - res.redirect('/admin'); - } catch (error) { - next(error); - } - }); - - app.get('/logout', async function (req, res, next) { - try { - const cookies = parseCookies(req.headers.cookie || ''); - const token = cookies[SESSION_COOKIE_NAME]; - if (token) { - await pool.query('DELETE FROM auth_sessions WHERE session_hash = ?', [hashSessionToken(token)]); - } - clearSessionCookie(res); - res.redirect('/login?message=' + encodeURIComponent('You have been signed out.')); - } catch (error) { - next(error); - } - }); - app.use('/admin', requireAuth); - // Dashboard and account pages live in the authenticated admin area. - app.get('/admin', async function (req, res, next) { - try { - const data = await buildDashboardState(pool); - res.send(pages.renderDashboardPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } + registerAuthRoutes(app, { + pool: pool, + pages: pages, + createUserSession: createUserSession, + setSessionCookie: setSessionCookie, + clearSessionCookie: clearSessionCookie, + parseCookies: parseCookies, + hashSessionToken: hashSessionToken, + verifyPassword: verifyPassword, + sessionCookieName: SESSION_COOKIE_NAME }); - app.get('/admin/account', function (req, res) { - res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '')); + registerAdminPagesRoutes(app, { + pool: pool, + common: common, + pages: pages, + buildDashboardState: webBootstrap.buildDashboardState }); - app.post('/admin/account/name', async function (req, res, next) { - try { - const name = String(req.body.name || '').trim(); - if (!name) { - return res.status(400).send('Name is required.'); - } - - const actorId = getAuditUserId(req); - const [result] = await pool.query('UPDATE users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]); - if (!result.affectedRows) { - return res.status(404).send('User not found.'); - } - - res.redirect('/admin/account?message=' + encodeURIComponent('Name updated.')); - } catch (error) { - next(error); - } + registerAdminAccountRoutes(app, { + pool: pool, + pages: pages, + formatDashboardDate: formatDashboardDate, + getAuditUserId: getAuditUserId, + verifyPassword: verifyPassword, + hashPassword: hashPassword, + createUserSession: createUserSession, + setSessionCookie: setSessionCookie }); - app.post('/admin/account/password', async function (req, res, next) { - try { - const currentPassword = String(req.body.current_password || ''); - const newPassword = String(req.body.new_password || ''); - const confirmPassword = String(req.body.confirm_password || ''); - - const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE id = ? LIMIT 1', [req.currentUser.id]); - const user = rows[0] || null; - if (!user) { - return res.status(404).send('User not found.'); - } - if (!verifyPassword(currentPassword, user)) { - return res.status(400).send('Current password is incorrect.'); - } - if (!newPassword || newPassword.length < 8) { - return res.status(400).send('New password must be at least 8 characters.'); - } - if (newPassword !== confirmPassword) { - return res.status(400).send('New passwords do not match.'); - } - - const passwordRecord = hashPassword(newPassword); - await pool.query( - 'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?', - [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id] - ); - await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [user.id]); - - const token = await createUserSession(pool, user.id); - setSessionCookie(res, token); - res.redirect('/admin/account?message=' + encodeURIComponent('Password updated.')); - } catch (error) { - next(error); - } + registerAdminUsersRoutes(app, { + pool: pool, + pages: pages, + formatDashboardDate: formatDashboardDate, + getAuditUserId: getAuditUserId, + hashPassword: hashPassword }); - app.get('/admin/users', async function (req, res, next) { - try { - const [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users ORDER BY id ASC'); - const users = rows.map(function (user) { - return Object.assign({}, user, { - isCurrentUser: Number(user.id) === Number(req.currentUser.id), - createdAtLabel: formatDashboardDate(user.created_at), - modifiedAtLabel: formatDashboardDate(user.modified_at) - }); - }); - res.send(pages.renderUsersPage({ users: users }, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } + registerAdminManageRoutes(app, { + pool: pool, + common: common, + pages: pages, + fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides, + fetchScreensByPlaylistId: fetchScreensByPlaylistId, + fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature, + getCanvasSignature: getCanvasSignature, + normalizeScheduleMode: normalizeScheduleMode, + parseDateTimeLocal: parseDateTimeLocal, + parseTimeLocal: parseTimeLocal, + readArrayField: readArrayField, + getAuditUserId: getAuditUserId, + redirectAfterSave: redirectAfterSave, + notifyPlayerScreens: notifyPlayerScreens, + broadcastDashboardState: broadcastDashboardState, + getScreenDeleteBlockMessage: playerActionService.getScreenDeleteBlockMessage, + getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage, + forwardPlayerCommand: playerActionService.forwardPlayerCommand, + playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL }); - app.get('/admin/users/new', function (req, res) { - res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser)); + registerAdminScreenCommandRoutes(app, { + pool: pool, + forwardPlayerCommand: playerActionService.forwardPlayerCommand }); - app.get('/admin/users/:id/edit', async function (req, res, next) { - try { - const userId = Number(req.params.id); - if (!Number.isInteger(userId) || userId <= 0) { - return res.status(400).send('Invalid user.'); - } - - const [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users WHERE id = ? LIMIT 1', [userId]); - const user = rows[0] || null; - if (!user) { - return res.status(404).send('User not found.'); - } - if (Number(req.currentUser.id) === userId) { - return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.')); - } - - res.send(pages.renderUsersEditPage(Object.assign({}, user, { - isCurrentUser: false, - createdAtLabel: formatDashboardDate(user.created_at), - modifiedAtLabel: formatDashboardDate(user.modified_at) - }), req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } + registerAdminContentRoutes(app, { + pool: pool, + common: common, + pages: pages, + upload: upload, + uploadDir: UPLOAD_DIR, + fetchScreensBySlideId: fetchScreensBySlideId, + fetchScreensByTemplateId: fetchScreensByTemplateId, + collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, + collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, + collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, + syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, + getAuditUserId: getAuditUserId, + redirectAfterSave: redirectAfterSave, + notifyPlayerScreens: notifyPlayerScreens, + broadcastDashboardState: broadcastDashboardState, + getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage, + getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage, + getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage }); - app.post('/admin/users', async function (req, res, next) { - try { - const name = String(req.body.name || '').trim(); - const username = String(req.body.username || '').trim(); - const password = String(req.body.password || ''); - const confirmPassword = String(req.body.confirm_password || ''); - - if (!name) { - return res.redirect('/admin/users/new?message=' + encodeURIComponent('Name is required.')); - } - if (!username) { - return res.redirect('/admin/users/new?message=' + encodeURIComponent('Username is required.')); - } - if (!password || password.length < 8) { - return res.redirect('/admin/users/new?message=' + encodeURIComponent('Password must be at least 8 characters.')); - } - if (password !== confirmPassword) { - return res.redirect('/admin/users/new?message=' + encodeURIComponent('Passwords do not match.')); - } - - const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]); - if (existingRows.length) { - return res.redirect('/admin/users/new?message=' + encodeURIComponent('That username already exists.')); - } - - const passwordRecord = hashPassword(password); - const actorId = getAuditUserId(req); - await pool.query( - 'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', - [name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId] - ); - res.redirect('/admin/users?message=' + encodeURIComponent('User created.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/users/:id/username', async function (req, res, next) { - try { - const userId = Number(req.params.id); - const name = String(req.body.name || '').trim(); - const username = String(req.body.username || '').trim(); - - if (!Number.isInteger(userId) || userId <= 0) { - return res.status(400).send('Invalid user.'); - } - if (!name) { - return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.')); - } - if (Number(req.currentUser.id) === userId) { - return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own username or password.')); - } - if (!username) { - return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.')); - } - - const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]); - if (existingRows.length) { - return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.')); - } - - const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]); - if (!result.affectedRows) { - return res.status(404).send('User not found.'); - } - res.redirect('/admin/users?message=' + encodeURIComponent('Username updated.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/users/:id/password', async function (req, res, next) { - try { - const userId = Number(req.params.id); - const password = String(req.body.password || ''); - const confirmPassword = String(req.body.confirm_password || ''); - - if (!Number.isInteger(userId) || userId <= 0) { - return res.status(400).send('Invalid user.'); - } - if (Number(req.currentUser.id) === userId) { - return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to change your own password.')); - } - if (!password || password.length < 8) { - return res.redirect('/admin/users?message=' + encodeURIComponent('Password must be at least 8 characters.')); - } - if (password !== confirmPassword) { - return res.redirect('/admin/users?message=' + encodeURIComponent('Passwords do not match.')); - } - - const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]); - if (!rows.length) { - return res.status(404).send('User not found.'); - } - - const passwordRecord = hashPassword(password); - await pool.query( - 'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?', - [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId] - ); - await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]); - res.redirect('/admin/users?message=' + encodeURIComponent('Password updated.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/users/:id/delete', async function (req, res, next) { - try { - const userId = Number(req.params.id); - if (!Number.isInteger(userId) || userId <= 0) { - return res.status(400).send('Invalid user.'); - } - if (Number(req.currentUser.id) === userId) { - return res.redirect('/admin/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.')); - } - - const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users'); - if (!countRows.length || Number(countRows[0].user_count) <= 1) { - return res.redirect('/admin/users?message=' + encodeURIComponent('At least one user must remain.')); - } - - const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]); - if (!result.affectedRows) { - return res.status(404).send('User not found.'); - } - res.redirect('/admin/users?message=' + encodeURIComponent('User deleted.')); - } catch (error) { - next(error); - } - }); - - app.get('/admin/clients', async function (req, res, next) { - try { - const data = await common.fetchAdminData(pool); - res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - async function getDashboardStateSnapshot() { - if (!dashboardRefreshInFlight) { - dashboardRefreshInFlight = buildDashboardState(pool).finally(function () { - dashboardRefreshInFlight = null; - }); - } - return dashboardRefreshInFlight; - } - - function sendDashboardState(socket, state) { - if (!socket || socket.readyState !== WebSocket.OPEN) { - return; - } - socket.send(buildDashboardPayload(state)); - } - - async function broadcastDashboardState() { - if (!dashboardClients.size) { - return; - } - const state = await getDashboardStateSnapshot(); - const payload = buildDashboardPayload(state); - dashboardClients.forEach(function (socket) { - if (socket.readyState === WebSocket.OPEN) { - socket.send(payload); - } - }); - } - - async function sendDashboardStateToSocket(socket) { - try { - const state = await getDashboardStateSnapshot(); - sendDashboardState(socket, state); - } catch (_error) { - try { - socket.close(); - } catch (_closeError) { - // ignore close errors - } - } - } - - app.post('/admin/commands', async function (req, res, next) { - try { - const command = String(req.body.command || '').trim().toLowerCase(); - if (!command) { - return res.status(400).send('Command is required.'); - } - if (['reload', 'blackout'].indexOf(command) === -1) { - return res.status(400).send('Unsupported command.'); - } - - const slugs = await fetchAllScreenSlugs(pool); - const sent = command === 'blackout' - ? await notifyPlayerScreens(slugs, { - command: 'blackout', - blackout: req.body.blackout - }) - : await notifyPlayerScreens(slugs, command); - await broadcastDashboardState(); - - const message = sent - ? `Sent ${command} command to ${sent} screen${sent === 1 ? '' : 's'}.` - : `No screens were available for the ${command} command.`; - - if (String(req.get('X-Requested-With') || '').toLowerCase() === 'xmlhttprequest') { - return res.json({ - ok: true, - command: command, - sent: sent, - message: message - }); - } - - res.redirect('/admin?message=' + encodeURIComponent(message)); - } catch (error) { - next(error); - } - }); - - app.post('/admin/screens/:slug/commands', async function (req, res, next) { - try { - const command = String(req.body.command || '').trim().toLowerCase(); - const connectionId = String(req.body.connectionId || '').trim(); - const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') - ? req.body.blackout - : undefined; - if (!command) { - return res.status(400).send('Command is required.'); - } - if (['refresh', 'reload', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) { - return res.status(400).send('Unsupported command.'); - } - const commandPayload = command === 'blackout' && blackoutValue !== undefined - ? { - command: command, - blackout: blackoutValue - } - : command; - await forwardPlayerCommand(req.params.slug, commandPayload, connectionId); - await broadcastDashboardState(); - - if (String(req.get('X-Requested-With') || '').toLowerCase() === 'xmlhttprequest') { - return res.json({ - ok: true, - message: connectionId ? `Sent ${command} command to a connected client on ${req.params.slug}.` : `Sent ${command} command to ${req.params.slug}.` - }); - } - - res.redirect('/admin?message=' + encodeURIComponent(connectionId ? `Sent ${command} command to a connected client on ${req.params.slug}.` : `Sent ${command} command to ${req.params.slug}.`)); - } catch (error) { - next(error); - } - }); - - app.get('/admin/playlists', async function (req, res, next) { - try { - const data = await common.fetchAdminData(pool); - if (req.query.edit) { - const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser)); - } - res.send(pages.renderPlaylistsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.get('/admin/playlists/new', function (req, res) { - res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser)); - }); - - app.post('/admin/playlists', async function (req, res, next) { - try { - const name = String(req.body.name || '').trim(); - const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0; - if (!name) { - return res.status(400).send('Playlist name is required.'); - } - const actorId = getAuditUserId(req); - await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]); - res.redirect('/admin/playlists?message=' + encodeURIComponent('Playlist created.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/playlists/:id', async function (req, res, next) { - const connection = await pool.getConnection(); - try { - const name = String(req.body.name || '').trim(); - const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0; - if (!name) { - return res.status(400).send('Playlist name is required.'); - } - const playlist = await common.fetchPlaylistById(connection, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - - const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id); - - const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']); - const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']); - const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']); - const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']); - const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']); - const scheduleStartTimes = readArrayField(req.body, ['schedule_start_time[]', 'schedule_start_time']); - const scheduleEndTimes = readArrayField(req.body, ['schedule_end_time[]', 'schedule_end_time']); - const scheduleDaysJsonValues = readArrayField(req.body, ['schedule_days_json[]', 'schedule_days_json']); - - if (durations.length && durations.length !== slideIds.length) { - return res.status(400).send('Playlist slide data is invalid.'); - } - if (scheduleModes.length && scheduleModes.length !== slideIds.length) { - return res.status(400).send('Playlist schedule data is invalid.'); - } - - const normalizedSlides = []; - const seenSlideIds = new Set(); - for (let i = 0; i < slideIds.length; i += 1) { - const slideId = Number(slideIds[i]); - if (!Number.isInteger(slideId) || slideId <= 0) { - return res.status(400).send('Invalid slide selection.'); - } - if (seenSlideIds.has(slideId)) { - return res.status(400).send('A slide can only be added to a playlist once.'); - } - seenSlideIds.add(slideId); - - const durationRaw = Number(durations[i]); - const durationSeconds = Number.isFinite(durationRaw) ? Math.max(1, Math.trunc(durationRaw)) : 10; - const scheduleMode = normalizeScheduleMode(scheduleModes[i]); - - let scheduleStartDatetime = null; - let scheduleEndDatetime = null; - let scheduleStartTime = null; - let scheduleEndTime = null; - let scheduleDaysJson = null; - - if (scheduleMode === 'dates') { - scheduleStartDatetime = parseDateTimeLocal(scheduleStartDateTimes[i]); - scheduleEndDatetime = parseDateTimeLocal(scheduleEndDateTimes[i]); - if (!scheduleStartDatetime || !scheduleEndDatetime) { - return res.status(400).send('Start and end datetimes are required for date scheduling.'); - } - if (scheduleEndDatetime < scheduleStartDatetime) { - return res.status(400).send('End datetime must be after start datetime.'); - } - } else if (scheduleMode === 'times') { - scheduleStartTime = parseTimeLocal(scheduleStartTimes[i]); - scheduleEndTime = parseTimeLocal(scheduleEndTimes[i]); - if (!scheduleStartTime || !scheduleEndTime) { - return res.status(400).send('Start and end times are required for time scheduling.'); - } - - let scheduleDays = []; - try { - const parsedDays = JSON.parse(String(scheduleDaysJsonValues[i] || '[]')); - scheduleDays = Array.isArray(parsedDays) ? parsedDays : []; - } catch (_error) { - scheduleDays = []; - } - scheduleDays = scheduleDays - .map(function (value) { return Number(value); }) - .filter(function (value) { return Number.isInteger(value) && value >= 0 && value <= 6; }); - if (!scheduleDays.length) { - return res.status(400).send('Select at least one day for time scheduling.'); - } - scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort()); - } - - normalizedSlides.push({ - slideId, - position: i, - durationSeconds, - scheduleMode, - scheduleStartDatetime, - scheduleEndDatetime, - scheduleStartTime, - scheduleEndTime, - scheduleDaysJson - }); - } - - if (normalizedSlides.length) { - const [slides] = await connection.query( - `SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height - FROM slides sl - LEFT JOIN slide_templates st ON st.id = sl.template_id - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id - WHERE sl.id IN (?)`, - [normalizedSlides.map(function (item) { return item.slideId; })] - ); - if (slides.length !== normalizedSlides.length) { - return res.status(400).send('One or more selected slides no longer exist.'); - } - const signatures = Array.from(new Set( - slides - .map(function (slide) { return getCanvasSignature(slide.canvas_width, slide.canvas_height); }) - .filter(Boolean) - )); - if (signatures.length > 1) { - return res.status(400).send('All playlist slides must share the same canvas size.'); - } - } - - const actorId = getAuditUserId(req); - - await connection.beginTransaction(); - await connection.query('UPDATE playlists SET name = ?, fade_between_slides = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, actorId, playlist.id]); - await connection.query('DELETE FROM playlist_slides WHERE playlist_id = ?', [playlist.id]); - for (let i = 0; i < normalizedSlides.length; i += 1) { - const item = normalizedSlides[i]; - await connection.query( - 'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - playlist.id, - item.slideId, - item.position, - item.durationSeconds, - item.scheduleMode, - item.scheduleStartDatetime, - item.scheduleEndDatetime, - item.scheduleStartTime, - item.scheduleEndTime, - item.scheduleDaysJson, - actorId, - actorId - ] - ); - } - await connection.commit(); - await notifyPlayerScreens(affectedScreens, 'refresh'); - await broadcastDashboardState(); - res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Playlist updated.')); - } catch (error) { - try { - await connection.rollback(); - } catch (_rollbackError) { - // Ignore rollback failures and surface the original error. - } - next(error); - } finally { - connection.release(); - } - }); - - app.post('/admin/playlists/:id/delete', async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - await pool.query('DELETE FROM playlists WHERE id = ?', [playlist.id]); - res.redirect('/admin/playlists?message=' + encodeURIComponent('Playlist deleted.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/playlists/:id/slides', async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const slideId = Number(req.body.slide_id); - if (!slideId) { - return res.status(400).send('Slide is required.'); - } - const playlistCanvasSignature = await fetchPlaylistCanvasSignature(pool, playlist.id); - if (playlistCanvasSignature === 'mismatch') { - return res.status(400).send('This playlist already contains slides with different canvas sizes.'); - } - const slide = await common.fetchSlideById(pool, slideId); - if (!slide) { - return res.status(404).send('Slide not found'); - } - const slideCanvasSignature = getCanvasSignature(slide.canvas_width, slide.canvas_height); - if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) { - return res.status(400).send('The slide canvas size must match the existing playlist items.'); - } - const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10)); - const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_slides WHERE playlist_id = ?', [playlist.id]); - const nextPosition = Number(positionRows[0].max_position) + 1; - const actorId = getAuditUserId(req); - await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]); - await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); - await broadcastDashboardState(); - res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/playlists/:id/slides/:playlistSlideId', async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const playlistSlideId = Number(req.params.playlistSlideId); - const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10)); - const actorId = getAuditUserId(req); - const [result] = await pool.query( - 'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', - [durationSeconds, actorId, playlistSlideId, playlist.id] - ); - if (!result.affectedRows) { - return res.status(404).send('Playlist slide not found'); - } - await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); - await broadcastDashboardState(); - res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/playlists/:id/slides/:playlistSlideId/move', async function (req, res, next) { - const connection = await pool.getConnection(); - try { - const playlist = await common.fetchPlaylistById(connection, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const playlistSlideId = Number(req.params.playlistSlideId); - const direction = String(req.body.direction || '').toLowerCase(); - if (direction !== 'up' && direction !== 'down') { - return res.status(400).send('Invalid move direction.'); - } - - await connection.beginTransaction(); - const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id); - const currentIndex = orderedSlides.findIndex(function (item) { - return Number(item.id) === playlistSlideId; - }); - if (currentIndex === -1) { - await connection.rollback(); - return res.status(404).send('Playlist slide not found'); - } - - const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; - if (swapIndex < 0 || swapIndex >= orderedSlides.length) { - await connection.rollback(); - return res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.')); - } - - const currentSlide = orderedSlides[currentIndex]; - const swapSlide = orderedSlides[swapIndex]; - const actorId = getAuditUserId(req); - - await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]); - await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]); - await connection.commit(); - await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh'); - await broadcastDashboardState(); - res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.')); - } catch (error) { - await connection.rollback(); - next(error); - } finally { - connection.release(); - } - }); - - app.get('/admin/playlists/:id/slides/:playlistSlideId/config', async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const data = await common.fetchAdminData(pool); - const playlistSlide = (data.playlistSlides || []).find(function (item) { - return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id; - }); - if (!playlistSlide) { - return res.status(404).send('Playlist slide not found'); - } - playlistSlide.schedule_days = common.parseJsonSafe(playlistSlide.schedule_days_json) || []; - return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.post('/admin/playlists/:id/slides/:playlistSlideId/config', async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const rowKey = String(req.body.row_key || '').trim(); - if (rowKey) { - return res.status(400).send('Schedule changes from the playlist editor are staged until you click Save changes.'); - } - const playlistSlideId = Number(req.params.playlistSlideId); - let scheduleMode = normalizeScheduleMode(req.body.schedule_mode); - let scheduleStartDatetime = null; - let scheduleEndDatetime = null; - let scheduleStartTime = null; - let scheduleEndTime = null; - let scheduleDaysJson = null; - - const hasDateRange = Boolean(req.body.schedule_start_datetime && req.body.schedule_end_datetime); - const hasTimeRange = Boolean(req.body.schedule_start_time && req.body.schedule_end_time); - const hasSelectedDays = Boolean(readArrayField(req.body, ['schedule_days', 'schedule_days[]']).length); - - if (scheduleMode === 'dates' && !hasDateRange) { - scheduleMode = 'always'; - } else if (scheduleMode === 'times' && (!hasTimeRange || !hasSelectedDays)) { - scheduleMode = 'always'; - } - - if (scheduleMode === 'dates') { - scheduleStartDatetime = parseDateTimeLocal(req.body.schedule_start_datetime); - scheduleEndDatetime = parseDateTimeLocal(req.body.schedule_end_datetime); - if (!scheduleStartDatetime || !scheduleEndDatetime) { - scheduleMode = 'always'; - scheduleStartDatetime = null; - scheduleEndDatetime = null; - } else if (scheduleEndDatetime < scheduleStartDatetime) { - return res.status(400).send('End datetime must be after start datetime.'); - } - } else if (scheduleMode === 'times') { - scheduleStartTime = parseTimeLocal(req.body.schedule_start_time); - scheduleEndTime = parseTimeLocal(req.body.schedule_end_time); - const scheduleDays = readArrayField(req.body, ['schedule_days', 'schedule_days[]']).map(function (value) { - return Number(value); - }).filter(function (value) { - return Number.isInteger(value) && value >= 0 && value <= 6; - }); - if (!scheduleStartTime || !scheduleEndTime) { - scheduleMode = 'always'; - scheduleStartTime = null; - scheduleEndTime = null; - scheduleDaysJson = null; - } else if (!scheduleDays.length) { - scheduleMode = 'always'; - scheduleStartTime = null; - scheduleEndTime = null; - scheduleDaysJson = null; - } else { - if (scheduleEndTime < scheduleStartTime) { - return res.status(400).send('End time must be after start time.'); - } - scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort()); - } - } - - const actorId = getAuditUserId(req); - const [result] = await pool.query( - 'UPDATE playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', - [scheduleMode, scheduleStartDatetime, scheduleEndDatetime, scheduleStartTime, scheduleEndTime, scheduleDaysJson, actorId, playlistSlideId, playlist.id] - ); - if (!result.affectedRows) { - return res.status(404).send('Playlist slide not found'); - } - await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); - await broadcastDashboardState(); - res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/playlists/:id/slides/:playlistSlideId/delete', async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - await pool.query('DELETE FROM playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]); - await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); - await broadcastDashboardState(); - res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.')); - } catch (error) { - next(error); - } - }); - - app.get('/admin/screens', async function (req, res, next) { - try { - const data = await buildDashboardState(pool); - if (req.query.edit) { - const screen = await common.fetchScreenById(pool, Number(req.query.edit)); - if (!screen) { - return res.status(404).send('Screen not found'); - } - const editData = await common.fetchScreenEditData(pool); - return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser)); - } - res.send(pages.renderScreensPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.get('/admin/screens/:id/edit', async function (req, res, next) { - try { - const screen = await common.fetchScreenById(pool, Number(req.params.id)); - if (!screen) { - return res.status(404).send('Screen not found'); - } - const editData = await common.fetchScreenEditData(pool); - return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.get('/admin/playlists/:id/edit', async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const data = await common.fetchAdminData(pool); - res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.get('/admin/screens/new', async function (req, res, next) { - try { - const data = await common.fetchScreenEditData(pool); - res.send(pages.renderScreenFormPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.post('/admin/screens', async function (req, res, next) { - try { - const name = String(req.body.name || '').trim(); - if (!name) { - return res.status(400).send('Screen name is required.'); - } - const slugInput = String(req.body.slug || '').trim(); - const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null; - const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name)); - const actorId = getAuditUserId(req); - await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]); - res.redirect('/admin/screens?message=' + encodeURIComponent('Screen created.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/screens/:id', async function (req, res, next) { - try { - const name = String(req.body.name || '').trim(); - if (!name) { - return res.status(400).send('Screen name is required.'); - } - const screen = await common.fetchScreenById(pool, Number(req.params.id)); - if (!screen) { - return res.status(404).send('Screen not found'); - } - const slugInput = String(req.body.slug || '').trim(); - const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null; - const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id); - const previousSlug = String(screen.slug || '').trim(); - await pool.query('UPDATE screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]); - if (previousSlug && previousSlug !== slug) { - await forwardPlayerCommand(previousSlug, { - command: 'redirect', - url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}` - }); - } - res.redirect('/admin/screens?edit=' + screen.id + '&message=' + encodeURIComponent('Screen updated.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/screens/:id/delete', async function (req, res, next) { - try { - const screen = await common.fetchScreenById(pool, Number(req.params.id)); - if (!screen) { - return res.status(404).send('Screen not found'); - } - await pool.query('DELETE FROM screens WHERE id = ?', [screen.id]); - res.redirect('/admin/screens?message=' + encodeURIComponent('Screen deleted.')); - } catch (error) { - next(error); - } - }); - - app.get('/admin/slides', async function (req, res, next) { - try { - const data = await common.fetchAdminData(pool); - res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.get('/admin/slides/new', async function (req, res, next) { - try { - const data = await common.fetchTemplatesData(pool); - res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.get('/admin/slides/:id/edit', async function (req, res, next) { - try { - const slide = await common.fetchSlideById(pool, Number(req.params.id)); - if (!slide) { - return res.status(404).send('Slide not found'); - } - const data = await common.fetchTemplatesData(pool); - res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.post('/admin/slides', upload.any(), async function (req, res, next) { - try { - const payload = await common.buildSlidePayload(pool, req, null); - const actorId = getAuditUserId(req); - const [result] = await pool.query( - 'INSERT INTO slides (title, body, template_id, content_json, media_path, media_type, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - [payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, actorId] - ); - await syncPlaylistUploadsOnChange({ - key: 'slide:create:' + result.insertId, - pool: pool, - localUploadDir: UPLOAD_DIR, - nextUploadRefs: collectUploadReferencesFromPayload(payload) - }); - res.redirect('/admin/slides/' + result.insertId + '/edit?message=' + encodeURIComponent('Slide created.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/slides/:id', upload.any(), async function (req, res, next) { - try { - const slide = await common.fetchSlideById(pool, Number(req.params.id)); - if (!slide) { - return res.status(404).send('Slide not found'); - } - const affectedScreens = await fetchScreensBySlideId(pool, slide.id); - const existingUploadRefs = collectUploadReferencesFromSlide(slide); - const payload = await common.buildSlidePayload(pool, req, slide); - const nextUploadRefs = collectUploadReferencesFromPayload(payload); - const actorId = getAuditUserId(req); - await pool.query( - 'UPDATE slides SET title = ?, body = ?, template_id = ?, content_json = ?, media_path = ?, media_type = ?, modified_by = ? WHERE id = ?', - [payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, slide.id] - ); - await syncPlaylistUploadsOnChange({ - key: 'slide:update:' + slide.id, - pool: pool, - localUploadDir: UPLOAD_DIR, - previousUploadRefs: existingUploadRefs, - nextUploadRefs: nextUploadRefs, - blockedSlideIds: [slide.id], - refreshScreenSlugs: affectedScreens - }); - await broadcastDashboardState(); - res.redirect('/admin/slides/' + slide.id + '/edit?message=' + encodeURIComponent('Slide updated.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/slides/:id/delete', async function (req, res, next) { - try { - const slide = await common.fetchSlideById(pool, Number(req.params.id)); - if (!slide) { - return res.status(404).send('Slide not found'); - } - const affectedScreens = await fetchScreensBySlideId(pool, slide.id); - const uploadRefs = collectUploadReferencesFromSlide(slide); - await pool.query('DELETE FROM slides WHERE id = ?', [slide.id]); - await syncPlaylistUploadsOnChange({ - key: 'slide:delete:' + slide.id, - pool: pool, - localUploadDir: UPLOAD_DIR, - previousUploadRefs: uploadRefs, - blockedSlideIds: [slide.id], - refreshScreenSlugs: affectedScreens - }); - await broadcastDashboardState(); - res.redirect('/admin/slides?message=' + encodeURIComponent('Slide deleted.')); - } catch (error) { - next(error); - } - }); - - app.get('/admin/templates', async function (req, res, next) { - try { - const data = await common.fetchTemplatesData(pool); - res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.get('/admin/templates/new', async function (req, res, next) { - try { - const data = await common.fetchCanvasSizesData(pool); - res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.post('/admin/templates', upload.any(), async function (req, res, next) { - try { - const payload = await common.buildTemplatePayload(pool, req, null); - const actorId = getAuditUserId(req); - const [result] = await pool.query( - 'INSERT INTO slide_templates (name, canvas_size_id, background_image_path, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', - [payload.name, payload.canvasSizeId, payload.backgroundImagePath, actorId, actorId] - ); - for (let i = 0; i < payload.regions.length; i += 1) { - const region = payload.regions[i]; - await pool.query( - 'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] - ); - } - await syncPlaylistUploadsOnChange({ - key: 'template:create:' + result.insertId, - pool: pool, - localUploadDir: UPLOAD_DIR, - nextUploadRefs: collectUploadReferencesFromPayload(payload) - }); - res.redirect('/admin/templates/' + result.insertId + '/edit?message=' + encodeURIComponent('Template created.')); - } catch (error) { - next(error); - } - }); - - app.get('/admin/templates/:id/edit', async function (req, res, next) { - try { - const template = await common.fetchTemplateById(pool, Number(req.params.id)); - if (!template) { - return res.status(404).send('Template not found'); - } - const sizeData = await common.fetchCanvasSizesData(pool); - res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.post('/admin/templates/:id', upload.any(), async function (req, res, next) { - try { - const template = await common.fetchTemplateById(pool, Number(req.params.id)); - if (!template) { - return res.status(404).send('Template not found'); - } - const existingUploadRefs = collectUploadReferencesFromTemplate(template); - const payload = await common.buildTemplatePayload(pool, req, template); - const nextUploadRefs = collectUploadReferencesFromPayload(payload); - const affectedScreens = await fetchScreensByTemplateId(pool, template.id); - const actorId = getAuditUserId(req); - await pool.query( - 'UPDATE slide_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, modified_by = ? WHERE id = ?', - [payload.name, payload.canvasSizeId, payload.backgroundImagePath, actorId, template.id] - ); - await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]); - for (let i = 0; i < payload.regions.length; i += 1) { - const region = payload.regions[i]; - await pool.query( - 'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [template.id, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] - ); - } - await syncPlaylistUploadsOnChange({ - key: 'template:update:' + template.id, - pool: pool, - localUploadDir: UPLOAD_DIR, - previousUploadRefs: existingUploadRefs, - nextUploadRefs: nextUploadRefs - }); - await notifyPlayerScreens(affectedScreens, 'refresh'); - res.redirect('/admin/templates/' + template.id + '/edit?message=' + encodeURIComponent('Template updated.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/templates/:id/delete', async function (req, res, next) { - try { - const template = await common.fetchTemplateById(pool, Number(req.params.id)); - if (!template) { - return res.status(404).send('Template not found'); - } - const uploadRefs = collectUploadReferencesFromTemplate(template); - const affectedScreens = await fetchScreensByTemplateId(pool, template.id); - await pool.query('UPDATE slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]); - await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]); - await pool.query('DELETE FROM slide_templates WHERE id = ?', [template.id]); - await syncPlaylistUploadsOnChange({ - key: 'template:delete:' + template.id, - pool: pool, - localUploadDir: UPLOAD_DIR, - previousUploadRefs: uploadRefs - }); - await notifyPlayerScreens(affectedScreens, 'refresh'); - res.redirect('/admin/templates?message=' + encodeURIComponent('Template deleted.')); - } catch (error) { - next(error); - } - }); - - app.get('/admin/canvas-sizes', async function (req, res, next) { - try { - const data = await common.fetchAdminData(pool); - res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.get('/admin/canvas-sizes/new', function (req, res) { - res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser)); - }); - - app.post('/admin/canvas-sizes', async function (req, res, next) { - try { - const payload = common.buildCanvasSizePayload(req, null); - const actorId = getAuditUserId(req); - await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]); - res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent('Canvas size created.')); - } catch (error) { - next(error); - } - }); - - app.get('/admin/canvas-sizes/:id/edit', async function (req, res, next) { - try { - const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); - if (!canvasSize) { - return res.status(404).send('Canvas size not found'); - } - res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.post('/admin/canvas-sizes/:id', async function (req, res, next) { - try { - const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); - if (!canvasSize) { - return res.status(404).send('Canvas size not found'); - } - const payload = common.buildCanvasSizePayload(req, canvasSize); - await pool.query('UPDATE canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]); - res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent('Canvas size updated.')); - } catch (error) { - next(error); - } - }); - - app.post('/admin/canvas-sizes/:id/delete', async function (req, res, next) { - try { - const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); - if (!canvasSize) { - return res.status(404).send('Canvas size not found'); - } - await pool.query('UPDATE slide_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]); - await pool.query('DELETE FROM canvas_sizes WHERE id = ?', [canvasSize.id]); - res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.')); - } catch (error) { - next(error); - } - }); app.use(function (error, _req, res, _next) { console.error(error); @@ -2230,58 +211,12 @@ async function start() { syncExistingUploadsToPlayer(pool, UPLOAD_DIR).catch(function (error) { console.warn('Unable to sync existing uploads to player:', error); }); - server.on('upgrade', async function (request, socket, head) { - let pathname = ''; - try { - pathname = new URL(request.url, 'http://localhost').pathname; - } catch (_error) { - socket.destroy(); - return; - } - - if (pathname !== '/ws/admin/dashboard') { - socket.destroy(); - return; - } - - try { - const currentUser = await loadCurrentUser(pool, request); - if (!currentUser) { - socket.destroy(); - return; - } - } catch (_error) { - socket.destroy(); - return; - } - - dashboardWs.handleUpgrade(request, socket, head, function (ws) { - dashboardWs.emit('connection', ws, request); - }); - }); - - dashboardWs.on('connection', function (socket) { - dashboardClients.add(socket); - sendDashboardStateToSocket(socket); - - socket.on('close', function () { - dashboardClients.delete(socket); - }); - - socket.on('error', function () { - dashboardClients.delete(socket); - }); - }); + webBootstrap.installDashboardWebsocket(server, loadCurrentUser); server.listen(PORT, function () { console.log(`Pulse Signage app listening on port ${PORT}`); }); - setInterval(function () { - broadcastDashboardState().catch(function (error) { - console.error(error); - }); - }, dashboardRefreshIntervalMs); } module.exports = { start }; diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js new file mode 100644 index 0000000..bb975d3 --- /dev/null +++ b/src/web/bootstrap.js @@ -0,0 +1,208 @@ +const { WebSocketServer, WebSocket } = require('ws'); +const { createDashboardStateService } = require('./dashboard-state'); +const { createUploadSyncService } = require('./upload-sync'); + +function createWebBootstrap(options) { + const pool = options && options.pool; + const common = options && options.common; + const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); + const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, ''); + const uploadDir = String(options && options.uploadDir || '').trim(); + const dashboardRefreshIntervalMs = Number(options && options.dashboardRefreshIntervalMs || 2000); + const formatDashboardDate = options && options.formatDashboardDate; + const notifyPlayerScreens = options && options.notifyPlayerScreens; + + if (!pool || !common || !uploadDir || typeof formatDashboardDate !== 'function' || typeof notifyPlayerScreens !== 'function') { + throw new Error('createWebBootstrap requires the web bootstrap dependencies.'); + } + + const dashboardWs = new WebSocketServer({ noServer: true }); + const dashboardClients = new Set(); + const playerSnapshotCache = new Map(); + const playerSnapshotSockets = new Map(); + let dashboardRefreshInFlight = null; + let broadcastDashboardState = null; + + function getPlayerSnapshotSocketUrl(slug) { + const url = new URL(playerInternalBaseUrl.replace(/^http/, 'ws')); + url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`; + url.search = ''; + return url.toString(); + } + + function storePlayerSnapshot(slug, connections) { + const normalizedSlug = String(slug || '').trim(); + const normalizedConnections = Array.isArray(connections) ? connections : []; + playerSnapshotCache.set(normalizedSlug, { + slug: normalizedSlug, + count: normalizedConnections.length, + connections: normalizedConnections + }); + } + + function clearPlayerSnapshotSocket(slug) { + const key = String(slug || '').trim(); + playerSnapshotSockets.delete(key); + } + + function ensurePlayerSnapshotSubscription(slug) { + const key = String(slug || '').trim(); + if (!key || playerSnapshotSockets.has(key)) { + return; + } + + const socket = new WebSocket(getPlayerSnapshotSocketUrl(key)); + playerSnapshotSockets.set(key, socket); + + socket.onmessage = function (event) { + try { + const payload = JSON.parse(String(event.data || '{}')); + if (!payload || payload.type !== 'snapshot' || payload.slug !== key) { + return; + } + storePlayerSnapshot(key, payload.connections || []); + if (broadcastDashboardState) { + broadcastDashboardState().catch(function (error) { + console.error(error); + }); + } + } catch (_error) { + // Ignore malformed player snapshot payloads. + } + }; + + socket.onclose = function () { + clearPlayerSnapshotSocket(key); + setTimeout(function () { + ensurePlayerSnapshotSubscription(key); + }, 2000); + }; + + socket.onerror = function () { + try { + socket.close(); + } catch (_error) { + // ignore close errors + } + }; + } + + const dashboardStateService = createDashboardStateService({ + pool: pool, + common: common, + playerSnapshotCache: playerSnapshotCache, + playerSnapshotSockets: playerSnapshotSockets, + ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription, + playerPublicBaseUrl: playerPublicBaseUrl, + formatDashboardDate: formatDashboardDate + }); + const buildDashboardState = dashboardStateService.buildDashboardState; + + const uploadSyncService = createUploadSyncService({ + common: common, + playerInternalBaseUrl: playerInternalBaseUrl, + playerSnapshotCache: playerSnapshotCache, + notifyPlayerScreens: notifyPlayerScreens + }); + + const upload = uploadSyncService.createUploadMiddleware(uploadDir); + const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide; + const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate; + const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload; + const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange; + const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer; + + async function sendDashboardStateToSocket(socket) { + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + const state = await buildDashboardState(); + socket.send(JSON.stringify({ type: 'dashboard-state', state: state })); + } + + broadcastDashboardState = async function () { + if (dashboardRefreshInFlight) { + return dashboardRefreshInFlight; + } + + dashboardRefreshInFlight = (async function () { + const state = await buildDashboardState(); + const payload = JSON.stringify({ type: 'dashboard-state', state: state }); + for (const socket of dashboardClients) { + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(payload); + } + } + return state; + })().finally(function () { + dashboardRefreshInFlight = null; + }); + + return dashboardRefreshInFlight; + }; + + function installDashboardWebsocket(server, loadCurrentUser) { + server.on('upgrade', async function (request, socket, head) { + let pathname = ''; + try { + pathname = new URL(request.url, 'http://localhost').pathname; + } catch (_error) { + socket.destroy(); + return; + } + + if (pathname !== '/ws/admin/dashboard') { + socket.destroy(); + return; + } + + try { + const currentUser = await loadCurrentUser(pool, request); + if (!currentUser) { + socket.destroy(); + return; + } + } catch (_error) { + socket.destroy(); + return; + } + + dashboardWs.handleUpgrade(request, socket, head, function (ws) { + dashboardWs.emit('connection', ws, request); + }); + }); + + dashboardWs.on('connection', function (socket) { + dashboardClients.add(socket); + sendDashboardStateToSocket(socket); + + socket.on('close', function () { + dashboardClients.delete(socket); + }); + + socket.on('error', function () { + dashboardClients.delete(socket); + }); + }); + + setInterval(function () { + broadcastDashboardState().catch(function (error) { + console.error(error); + }); + }, dashboardRefreshIntervalMs); + } + + return { + upload: upload, + buildDashboardState: buildDashboardState, + collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, + collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, + collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, + syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, + syncExistingUploadsToPlayer: syncExistingUploadsToPlayer, + broadcastDashboardState: broadcastDashboardState, + installDashboardWebsocket: installDashboardWebsocket + }; +} + +module.exports = { createWebBootstrap }; \ No newline at end of file diff --git a/src/web/dashboard-state.js b/src/web/dashboard-state.js new file mode 100644 index 0000000..1cc0df1 --- /dev/null +++ b/src/web/dashboard-state.js @@ -0,0 +1,112 @@ +const { WebSocket } = require('ws'); + +function normalizeClientName(value) { + return String(value || '').trim(); +} + +function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug) { + return (screens || []).map(function (screen) { + const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; + return Object.assign({}, screen, { + client_name: onboardingNameBySlug[screen.slug] || screen.client_name || null, + player_connection_count: connectionState.count || 0, + player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : [] + }); + }); +} + +function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate) { + return (screens || []).flatMap(function (screen) { + const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; + return (connectionState.connections || []).map(function (connection) { + const deviceId = String(connection.deviceId || '').trim(); + return Object.assign({}, connection, { + screen_slug: screen.slug, + screen_name: screen.name, + client_name: (deviceId && onboardingNameByDeviceId && onboardingNameByDeviceId[deviceId]) || connection.clientName || onboardingNameBySlug[screen.slug] || String(connection.clientId || '').trim() || null, + playlist_name: screen.playlist_name || null, + connectedAtLabel: formatDashboardDate(connection.connectedAt), + lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt), + player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}` + }); + }); + }); +} + +function createDashboardStateService(options) { + const pool = options && options.pool; + const common = options && options.common; + const playerSnapshotCache = options && options.playerSnapshotCache; + const playerSnapshotSockets = options && options.playerSnapshotSockets; + const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription; + const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, ''); + const formatDashboardDate = options && options.formatDashboardDate; + + if (!pool || !common || !playerSnapshotCache || !playerSnapshotSockets || typeof ensurePlayerSnapshotSubscription !== 'function' || typeof formatDashboardDate !== 'function') { + throw new Error('createDashboardStateService requires the dashboard dependencies.'); + } + + async function buildDashboardState() { + const data = await common.fetchAdminData(pool); + const screensData = data.screens || []; + screensData.forEach(function (screen) { + ensurePlayerSnapshotSubscription(screen.slug); + }); + + const [onboardingRows] = await pool.query( + `SELECT s.slug, pod.device_id, pod.client_name + FROM player_onboarding_devices pod + JOIN screens s ON s.id = pod.screen_id + WHERE pod.client_name IS NOT NULL + AND TRIM(pod.client_name) <> ''` + ); + const onboardingNameBySlug = {}; + const onboardingNameByDeviceId = {}; + onboardingRows.forEach(function (row) { + const clientName = normalizeClientName(row.client_name); + const slug = normalizeClientName(row.slug); + const deviceId = normalizeClientName(row.device_id); + if (slug) { + onboardingNameBySlug[slug] = clientName; + } + if (deviceId) { + onboardingNameByDeviceId[deviceId] = clientName; + } + }); + + const connectionsBySlug = {}; + screensData.forEach(function (screen) { + const cached = playerSnapshotCache.get(String(screen.slug || '').trim()); + if (cached && Array.isArray(cached.connections)) { + connectionsBySlug[screen.slug] = cached; + } + }); + + const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) { + return Object.assign({}, screen, { + player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}` + }); + }); + const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate); + const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) { + return socket && socket.readyState === WebSocket.OPEN; + }); + + return { + playlists: data.playlists || [], + screens: screens, + clients: clients, + slides: data.slides || [], + playerServiceConnected: playerServiceConnected, + connectedClientsCount: screens.reduce(function (total, screen) { + return total + Number(screen.player_connection_count || 0); + }, 0) + }; + } + + return { + buildDashboardState: buildDashboardState + }; +} + +module.exports = { createDashboardStateService }; \ No newline at end of file diff --git a/src/web/helpers.js b/src/web/helpers.js new file mode 100644 index 0000000..c5cd080 --- /dev/null +++ b/src/web/helpers.js @@ -0,0 +1,192 @@ +const dashboardDateFormatter = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: '2-digit', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit' +}); + +function normalizeUploadRoot(uploadDir) { + return require('path').resolve(String(uploadDir || '').trim()); +} + +function formatDashboardDate(value) { + if (!value) { + return ''; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + return dashboardDateFormatter.format(date); +} + +function buildDashboardPayload(state) { + return JSON.stringify({ + type: 'dashboard-state', + state: state + }); +} + +function readArrayField(body, keys) { + const searchKeys = Array.isArray(keys) ? keys : [keys]; + for (let i = 0; i < searchKeys.length; i += 1) { + const key = searchKeys[i]; + const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined; + if (Array.isArray(value)) { + return value.filter(function (item) { + return item !== undefined && item !== null && String(item).trim() !== ''; + }).map(function (item) { + return String(item); + }); + } + if (value !== undefined && value !== null && String(value).trim() !== '') { + return [String(value)]; + } + } + return []; +} + +function parseDateTimeLocal(value) { + if (!value) { + return null; + } + const date = new Date(String(value)); + return Number.isNaN(date.getTime()) ? null : date; +} + +function parseTimeLocal(value) { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + if (!/^\d{2}:\d{2}(:\d{2})?$/.test(raw)) { + return null; + } + return raw.length === 5 ? raw + ':00' : raw; +} + +function normalizeScheduleMode(value) { + const mode = String(value || 'always'); + if (mode === 'dates' || mode === 'times') { + return mode; + } + return 'always'; +} + +function getAuditUserId(req) { + return req && req.currentUser ? Number(req.currentUser.id) : null; +} + +function getCanvasSignature(width, height) { + const normalizedWidth = Number(width); + const normalizedHeight = Number(height); + if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) { + return null; + } + return normalizedWidth + 'x' + normalizedHeight; +} + +async function fetchPlaylistCanvasSignature(pool, playlistId) { + const [rows] = await pool.query( + `SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height + FROM playlist_slides ps + JOIN slides sl ON sl.id = ps.slide_id + LEFT JOIN slide_templates st ON st.id = sl.template_id + LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + WHERE ps.playlist_id = ? + AND cs.width IS NOT NULL + AND cs.height IS NOT NULL`, + [playlistId] + ); + const signatures = Array.from(new Set(rows.map(function (row) { + return getCanvasSignature(row.canvas_width, row.canvas_height); + }).filter(Boolean))); + if (!signatures.length) { + return null; + } + return signatures.length === 1 ? signatures[0] : 'mismatch'; +} + +async function fetchScreensByPlaylistId(connection, playlistId) { + const [rows] = await connection.query( + 'SELECT slug FROM screens WHERE playlist_id = ? AND slug IS NOT NULL', + [playlistId] + ); + return rows.map(function (row) { + return row.slug; + }); +} + +async function fetchScreensBySlideId(connection, slideId) { + const [rows] = await connection.query( + `SELECT DISTINCT s.slug + FROM screens s + JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id + WHERE ps.slide_id = ? + AND s.slug IS NOT NULL`, + [slideId] + ); + return rows.map(function (row) { + return row.slug; + }); +} + +async function fetchScreensByTemplateId(connection, templateId) { + const [rows] = await connection.query( + `SELECT DISTINCT s.slug + FROM screens s + JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id + JOIN slides sl ON sl.id = ps.slide_id + WHERE sl.template_id = ? + AND s.slug IS NOT NULL`, + [templateId] + ); + return rows.map(function (row) { + return row.slug; + }); +} + +async function fetchOrderedPlaylistSlides(connection, playlistId) { + const [rows] = await connection.query( + 'SELECT id, position FROM playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC', + [playlistId] + ); + return rows; +} + +function redirectAfterSave(req, res, defaultUrl, options) { + const safeOptions = options || {}; + const action = String((req && req.body && req.body.action) || req.query.action || '').toLowerCase(); + if (action === 'close') { + return res.redirect(safeOptions.closeUrl || defaultUrl); + } + if (action === 'new') { + return res.redirect(safeOptions.newUrl || defaultUrl); + } + const message = safeOptions.message || ''; + if (message) { + const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&'; + return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message)); + } + return res.redirect(defaultUrl); +} + +module.exports = { + normalizeUploadRoot: normalizeUploadRoot, + formatDashboardDate: formatDashboardDate, + buildDashboardPayload: buildDashboardPayload, + readArrayField: readArrayField, + parseDateTimeLocal: parseDateTimeLocal, + parseTimeLocal: parseTimeLocal, + normalizeScheduleMode: normalizeScheduleMode, + getAuditUserId: getAuditUserId, + getCanvasSignature: getCanvasSignature, + fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature, + fetchScreensByPlaylistId: fetchScreensByPlaylistId, + fetchScreensBySlideId: fetchScreensBySlideId, + fetchScreensByTemplateId: fetchScreensByTemplateId, + fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides, + redirectAfterSave: redirectAfterSave +}; \ No newline at end of file diff --git a/src/web/player-actions.js b/src/web/player-actions.js new file mode 100644 index 0000000..e1bbc58 --- /dev/null +++ b/src/web/player-actions.js @@ -0,0 +1,72 @@ +function createPlayerActionService(options) { + const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); + const common = options && options.common; + + if (!playerInternalBaseUrl || !common) { + throw new Error('createPlayerActionService requires the player action dependencies.'); + } + + async function forwardPlayerCommand(slug, commandOrPayload, connectionId) { + const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null + ? Object.assign({}, commandOrPayload) + : { command: commandOrPayload }; + if (connectionId) { + payload.connectionId = connectionId; + } + const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text().catch(function () { return ''; }); + const error = new Error(errorText || `Unable to send command to player ${slug}.`); + error.statusCode = response.status; + throw error; + } + + return response.json().catch(function () { + return { ok: true }; + }); + } + + async function getScreenDeleteBlockMessage(pool, screen) { + const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM player_onboarding_devices WHERE screen_id = ?', [screen.id]); + return Number(rows[0] && rows[0].ref_count) > 0 ? 'This screen is still linked to onboarding devices.' : ''; + } + + async function getSlideDeleteBlockMessage(pool, slide) { + const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM playlist_slides WHERE slide_id = ?', [slide.id]); + return Number(rows[0] && rows[0].ref_count) > 0 ? 'This slide is still used by one or more playlists.' : ''; + } + + async function getTemplateDeleteBlockMessage(pool, template) { + const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slides WHERE template_id = ?', [template.id]); + return Number(rows[0] && rows[0].ref_count) > 0 ? 'This template is still used by one or more slides.' : ''; + } + + async function getCanvasSizeDeleteBlockMessage(pool, canvasSize) { + const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slide_templates WHERE canvas_size_id = ?', [canvasSize.id]); + return Number(rows[0] && rows[0].ref_count) > 0 ? 'This canvas size is still used by one or more templates.' : ''; + } + + async function getPlaylistDeleteBlockMessage(pool, playlist) { + const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM screens WHERE playlist_id = ?', [playlist.id]); + return Number(rows[0] && rows[0].ref_count) > 0 ? 'This playlist is still assigned to one or more screens.' : ''; + } + + return { + forwardPlayerCommand: forwardPlayerCommand, + getScreenDeleteBlockMessage: getScreenDeleteBlockMessage, + getSlideDeleteBlockMessage: getSlideDeleteBlockMessage, + getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage, + getCanvasSizeDeleteBlockMessage: getCanvasSizeDeleteBlockMessage, + getPlaylistDeleteBlockMessage: getPlaylistDeleteBlockMessage + }; +} + +module.exports = { createPlayerActionService }; \ No newline at end of file diff --git a/src/web/public/adminlte/bootstrap-icons/css/bootstrap-icons.min.css b/src/web/public/adminlte/bootstrap-icons/css/bootstrap-icons.min.css new file mode 100644 index 0000000..dadd6dc --- /dev/null +++ b/src/web/public/adminlte/bootstrap-icons/css/bootstrap-icons.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/) + * Copyright 2019-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */@font-face{font-display:block;font-family:bootstrap-icons;src:url("fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),url("fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff")}.bi::before,[class*=" bi-"]::before,[class^=bi-]::before{display:inline-block;font-family:bootstrap-icons!important;font-style:normal;font-weight:400!important;font-variant:normal;text-transform:none;line-height:1;vertical-align:-.125em;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.bi-123::before{content:"\f67f"}.bi-alarm-fill::before{content:"\f101"}.bi-alarm::before{content:"\f102"}.bi-align-bottom::before{content:"\f103"}.bi-align-center::before{content:"\f104"}.bi-align-end::before{content:"\f105"}.bi-align-middle::before{content:"\f106"}.bi-align-start::before{content:"\f107"}.bi-align-top::before{content:"\f108"}.bi-alt::before{content:"\f109"}.bi-app-indicator::before{content:"\f10a"}.bi-app::before{content:"\f10b"}.bi-archive-fill::before{content:"\f10c"}.bi-archive::before{content:"\f10d"}.bi-arrow-90deg-down::before{content:"\f10e"}.bi-arrow-90deg-left::before{content:"\f10f"}.bi-arrow-90deg-right::before{content:"\f110"}.bi-arrow-90deg-up::before{content:"\f111"}.bi-arrow-bar-down::before{content:"\f112"}.bi-arrow-bar-left::before{content:"\f113"}.bi-arrow-bar-right::before{content:"\f114"}.bi-arrow-bar-up::before{content:"\f115"}.bi-arrow-clockwise::before{content:"\f116"}.bi-arrow-counterclockwise::before{content:"\f117"}.bi-arrow-down-circle-fill::before{content:"\f118"}.bi-arrow-down-circle::before{content:"\f119"}.bi-arrow-down-left-circle-fill::before{content:"\f11a"}.bi-arrow-down-left-circle::before{content:"\f11b"}.bi-arrow-down-left-square-fill::before{content:"\f11c"}.bi-arrow-down-left-square::before{content:"\f11d"}.bi-arrow-down-left::before{content:"\f11e"}.bi-arrow-down-right-circle-fill::before{content:"\f11f"}.bi-arrow-down-right-circle::before{content:"\f120"}.bi-arrow-down-right-square-fill::before{content:"\f121"}.bi-arrow-down-right-square::before{content:"\f122"}.bi-arrow-down-right::before{content:"\f123"}.bi-arrow-down-short::before{content:"\f124"}.bi-arrow-down-square-fill::before{content:"\f125"}.bi-arrow-down-square::before{content:"\f126"}.bi-arrow-down-up::before{content:"\f127"}.bi-arrow-down::before{content:"\f128"}.bi-arrow-left-circle-fill::before{content:"\f129"}.bi-arrow-left-circle::before{content:"\f12a"}.bi-arrow-left-right::before{content:"\f12b"}.bi-arrow-left-short::before{content:"\f12c"}.bi-arrow-left-square-fill::before{content:"\f12d"}.bi-arrow-left-square::before{content:"\f12e"}.bi-arrow-left::before{content:"\f12f"}.bi-arrow-repeat::before{content:"\f130"}.bi-arrow-return-left::before{content:"\f131"}.bi-arrow-return-right::before{content:"\f132"}.bi-arrow-right-circle-fill::before{content:"\f133"}.bi-arrow-right-circle::before{content:"\f134"}.bi-arrow-right-short::before{content:"\f135"}.bi-arrow-right-square-fill::before{content:"\f136"}.bi-arrow-right-square::before{content:"\f137"}.bi-arrow-right::before{content:"\f138"}.bi-arrow-up-circle-fill::before{content:"\f139"}.bi-arrow-up-circle::before{content:"\f13a"}.bi-arrow-up-left-circle-fill::before{content:"\f13b"}.bi-arrow-up-left-circle::before{content:"\f13c"}.bi-arrow-up-left-square-fill::before{content:"\f13d"}.bi-arrow-up-left-square::before{content:"\f13e"}.bi-arrow-up-left::before{content:"\f13f"}.bi-arrow-up-right-circle-fill::before{content:"\f140"}.bi-arrow-up-right-circle::before{content:"\f141"}.bi-arrow-up-right-square-fill::before{content:"\f142"}.bi-arrow-up-right-square::before{content:"\f143"}.bi-arrow-up-right::before{content:"\f144"}.bi-arrow-up-short::before{content:"\f145"}.bi-arrow-up-square-fill::before{content:"\f146"}.bi-arrow-up-square::before{content:"\f147"}.bi-arrow-up::before{content:"\f148"}.bi-arrows-angle-contract::before{content:"\f149"}.bi-arrows-angle-expand::before{content:"\f14a"}.bi-arrows-collapse::before{content:"\f14b"}.bi-arrows-expand::before{content:"\f14c"}.bi-arrows-fullscreen::before{content:"\f14d"}.bi-arrows-move::before{content:"\f14e"}.bi-aspect-ratio-fill::before{content:"\f14f"}.bi-aspect-ratio::before{content:"\f150"}.bi-asterisk::before{content:"\f151"}.bi-at::before{content:"\f152"}.bi-award-fill::before{content:"\f153"}.bi-award::before{content:"\f154"}.bi-back::before{content:"\f155"}.bi-backspace-fill::before{content:"\f156"}.bi-backspace-reverse-fill::before{content:"\f157"}.bi-backspace-reverse::before{content:"\f158"}.bi-backspace::before{content:"\f159"}.bi-badge-3d-fill::before{content:"\f15a"}.bi-badge-3d::before{content:"\f15b"}.bi-badge-4k-fill::before{content:"\f15c"}.bi-badge-4k::before{content:"\f15d"}.bi-badge-8k-fill::before{content:"\f15e"}.bi-badge-8k::before{content:"\f15f"}.bi-badge-ad-fill::before{content:"\f160"}.bi-badge-ad::before{content:"\f161"}.bi-badge-ar-fill::before{content:"\f162"}.bi-badge-ar::before{content:"\f163"}.bi-badge-cc-fill::before{content:"\f164"}.bi-badge-cc::before{content:"\f165"}.bi-badge-hd-fill::before{content:"\f166"}.bi-badge-hd::before{content:"\f167"}.bi-badge-tm-fill::before{content:"\f168"}.bi-badge-tm::before{content:"\f169"}.bi-badge-vo-fill::before{content:"\f16a"}.bi-badge-vo::before{content:"\f16b"}.bi-badge-vr-fill::before{content:"\f16c"}.bi-badge-vr::before{content:"\f16d"}.bi-badge-wc-fill::before{content:"\f16e"}.bi-badge-wc::before{content:"\f16f"}.bi-bag-check-fill::before{content:"\f170"}.bi-bag-check::before{content:"\f171"}.bi-bag-dash-fill::before{content:"\f172"}.bi-bag-dash::before{content:"\f173"}.bi-bag-fill::before{content:"\f174"}.bi-bag-plus-fill::before{content:"\f175"}.bi-bag-plus::before{content:"\f176"}.bi-bag-x-fill::before{content:"\f177"}.bi-bag-x::before{content:"\f178"}.bi-bag::before{content:"\f179"}.bi-bar-chart-fill::before{content:"\f17a"}.bi-bar-chart-line-fill::before{content:"\f17b"}.bi-bar-chart-line::before{content:"\f17c"}.bi-bar-chart-steps::before{content:"\f17d"}.bi-bar-chart::before{content:"\f17e"}.bi-basket-fill::before{content:"\f17f"}.bi-basket::before{content:"\f180"}.bi-basket2-fill::before{content:"\f181"}.bi-basket2::before{content:"\f182"}.bi-basket3-fill::before{content:"\f183"}.bi-basket3::before{content:"\f184"}.bi-battery-charging::before{content:"\f185"}.bi-battery-full::before{content:"\f186"}.bi-battery-half::before{content:"\f187"}.bi-battery::before{content:"\f188"}.bi-bell-fill::before{content:"\f189"}.bi-bell::before{content:"\f18a"}.bi-bezier::before{content:"\f18b"}.bi-bezier2::before{content:"\f18c"}.bi-bicycle::before{content:"\f18d"}.bi-binoculars-fill::before{content:"\f18e"}.bi-binoculars::before{content:"\f18f"}.bi-blockquote-left::before{content:"\f190"}.bi-blockquote-right::before{content:"\f191"}.bi-book-fill::before{content:"\f192"}.bi-book-half::before{content:"\f193"}.bi-book::before{content:"\f194"}.bi-bookmark-check-fill::before{content:"\f195"}.bi-bookmark-check::before{content:"\f196"}.bi-bookmark-dash-fill::before{content:"\f197"}.bi-bookmark-dash::before{content:"\f198"}.bi-bookmark-fill::before{content:"\f199"}.bi-bookmark-heart-fill::before{content:"\f19a"}.bi-bookmark-heart::before{content:"\f19b"}.bi-bookmark-plus-fill::before{content:"\f19c"}.bi-bookmark-plus::before{content:"\f19d"}.bi-bookmark-star-fill::before{content:"\f19e"}.bi-bookmark-star::before{content:"\f19f"}.bi-bookmark-x-fill::before{content:"\f1a0"}.bi-bookmark-x::before{content:"\f1a1"}.bi-bookmark::before{content:"\f1a2"}.bi-bookmarks-fill::before{content:"\f1a3"}.bi-bookmarks::before{content:"\f1a4"}.bi-bookshelf::before{content:"\f1a5"}.bi-bootstrap-fill::before{content:"\f1a6"}.bi-bootstrap-reboot::before{content:"\f1a7"}.bi-bootstrap::before{content:"\f1a8"}.bi-border-all::before{content:"\f1a9"}.bi-border-bottom::before{content:"\f1aa"}.bi-border-center::before{content:"\f1ab"}.bi-border-inner::before{content:"\f1ac"}.bi-border-left::before{content:"\f1ad"}.bi-border-middle::before{content:"\f1ae"}.bi-border-outer::before{content:"\f1af"}.bi-border-right::before{content:"\f1b0"}.bi-border-style::before{content:"\f1b1"}.bi-border-top::before{content:"\f1b2"}.bi-border-width::before{content:"\f1b3"}.bi-border::before{content:"\f1b4"}.bi-bounding-box-circles::before{content:"\f1b5"}.bi-bounding-box::before{content:"\f1b6"}.bi-box-arrow-down-left::before{content:"\f1b7"}.bi-box-arrow-down-right::before{content:"\f1b8"}.bi-box-arrow-down::before{content:"\f1b9"}.bi-box-arrow-in-down-left::before{content:"\f1ba"}.bi-box-arrow-in-down-right::before{content:"\f1bb"}.bi-box-arrow-in-down::before{content:"\f1bc"}.bi-box-arrow-in-left::before{content:"\f1bd"}.bi-box-arrow-in-right::before{content:"\f1be"}.bi-box-arrow-in-up-left::before{content:"\f1bf"}.bi-box-arrow-in-up-right::before{content:"\f1c0"}.bi-box-arrow-in-up::before{content:"\f1c1"}.bi-box-arrow-left::before{content:"\f1c2"}.bi-box-arrow-right::before{content:"\f1c3"}.bi-box-arrow-up-left::before{content:"\f1c4"}.bi-box-arrow-up-right::before{content:"\f1c5"}.bi-box-arrow-up::before{content:"\f1c6"}.bi-box-seam::before{content:"\f1c7"}.bi-box::before{content:"\f1c8"}.bi-braces::before{content:"\f1c9"}.bi-bricks::before{content:"\f1ca"}.bi-briefcase-fill::before{content:"\f1cb"}.bi-briefcase::before{content:"\f1cc"}.bi-brightness-alt-high-fill::before{content:"\f1cd"}.bi-brightness-alt-high::before{content:"\f1ce"}.bi-brightness-alt-low-fill::before{content:"\f1cf"}.bi-brightness-alt-low::before{content:"\f1d0"}.bi-brightness-high-fill::before{content:"\f1d1"}.bi-brightness-high::before{content:"\f1d2"}.bi-brightness-low-fill::before{content:"\f1d3"}.bi-brightness-low::before{content:"\f1d4"}.bi-broadcast-pin::before{content:"\f1d5"}.bi-broadcast::before{content:"\f1d6"}.bi-brush-fill::before{content:"\f1d7"}.bi-brush::before{content:"\f1d8"}.bi-bucket-fill::before{content:"\f1d9"}.bi-bucket::before{content:"\f1da"}.bi-bug-fill::before{content:"\f1db"}.bi-bug::before{content:"\f1dc"}.bi-building::before{content:"\f1dd"}.bi-bullseye::before{content:"\f1de"}.bi-calculator-fill::before{content:"\f1df"}.bi-calculator::before{content:"\f1e0"}.bi-calendar-check-fill::before{content:"\f1e1"}.bi-calendar-check::before{content:"\f1e2"}.bi-calendar-date-fill::before{content:"\f1e3"}.bi-calendar-date::before{content:"\f1e4"}.bi-calendar-day-fill::before{content:"\f1e5"}.bi-calendar-day::before{content:"\f1e6"}.bi-calendar-event-fill::before{content:"\f1e7"}.bi-calendar-event::before{content:"\f1e8"}.bi-calendar-fill::before{content:"\f1e9"}.bi-calendar-minus-fill::before{content:"\f1ea"}.bi-calendar-minus::before{content:"\f1eb"}.bi-calendar-month-fill::before{content:"\f1ec"}.bi-calendar-month::before{content:"\f1ed"}.bi-calendar-plus-fill::before{content:"\f1ee"}.bi-calendar-plus::before{content:"\f1ef"}.bi-calendar-range-fill::before{content:"\f1f0"}.bi-calendar-range::before{content:"\f1f1"}.bi-calendar-week-fill::before{content:"\f1f2"}.bi-calendar-week::before{content:"\f1f3"}.bi-calendar-x-fill::before{content:"\f1f4"}.bi-calendar-x::before{content:"\f1f5"}.bi-calendar::before{content:"\f1f6"}.bi-calendar2-check-fill::before{content:"\f1f7"}.bi-calendar2-check::before{content:"\f1f8"}.bi-calendar2-date-fill::before{content:"\f1f9"}.bi-calendar2-date::before{content:"\f1fa"}.bi-calendar2-day-fill::before{content:"\f1fb"}.bi-calendar2-day::before{content:"\f1fc"}.bi-calendar2-event-fill::before{content:"\f1fd"}.bi-calendar2-event::before{content:"\f1fe"}.bi-calendar2-fill::before{content:"\f1ff"}.bi-calendar2-minus-fill::before{content:"\f200"}.bi-calendar2-minus::before{content:"\f201"}.bi-calendar2-month-fill::before{content:"\f202"}.bi-calendar2-month::before{content:"\f203"}.bi-calendar2-plus-fill::before{content:"\f204"}.bi-calendar2-plus::before{content:"\f205"}.bi-calendar2-range-fill::before{content:"\f206"}.bi-calendar2-range::before{content:"\f207"}.bi-calendar2-week-fill::before{content:"\f208"}.bi-calendar2-week::before{content:"\f209"}.bi-calendar2-x-fill::before{content:"\f20a"}.bi-calendar2-x::before{content:"\f20b"}.bi-calendar2::before{content:"\f20c"}.bi-calendar3-event-fill::before{content:"\f20d"}.bi-calendar3-event::before{content:"\f20e"}.bi-calendar3-fill::before{content:"\f20f"}.bi-calendar3-range-fill::before{content:"\f210"}.bi-calendar3-range::before{content:"\f211"}.bi-calendar3-week-fill::before{content:"\f212"}.bi-calendar3-week::before{content:"\f213"}.bi-calendar3::before{content:"\f214"}.bi-calendar4-event::before{content:"\f215"}.bi-calendar4-range::before{content:"\f216"}.bi-calendar4-week::before{content:"\f217"}.bi-calendar4::before{content:"\f218"}.bi-camera-fill::before{content:"\f219"}.bi-camera-reels-fill::before{content:"\f21a"}.bi-camera-reels::before{content:"\f21b"}.bi-camera-video-fill::before{content:"\f21c"}.bi-camera-video-off-fill::before{content:"\f21d"}.bi-camera-video-off::before{content:"\f21e"}.bi-camera-video::before{content:"\f21f"}.bi-camera::before{content:"\f220"}.bi-camera2::before{content:"\f221"}.bi-capslock-fill::before{content:"\f222"}.bi-capslock::before{content:"\f223"}.bi-card-checklist::before{content:"\f224"}.bi-card-heading::before{content:"\f225"}.bi-card-image::before{content:"\f226"}.bi-card-list::before{content:"\f227"}.bi-card-text::before{content:"\f228"}.bi-caret-down-fill::before{content:"\f229"}.bi-caret-down-square-fill::before{content:"\f22a"}.bi-caret-down-square::before{content:"\f22b"}.bi-caret-down::before{content:"\f22c"}.bi-caret-left-fill::before{content:"\f22d"}.bi-caret-left-square-fill::before{content:"\f22e"}.bi-caret-left-square::before{content:"\f22f"}.bi-caret-left::before{content:"\f230"}.bi-caret-right-fill::before{content:"\f231"}.bi-caret-right-square-fill::before{content:"\f232"}.bi-caret-right-square::before{content:"\f233"}.bi-caret-right::before{content:"\f234"}.bi-caret-up-fill::before{content:"\f235"}.bi-caret-up-square-fill::before{content:"\f236"}.bi-caret-up-square::before{content:"\f237"}.bi-caret-up::before{content:"\f238"}.bi-cart-check-fill::before{content:"\f239"}.bi-cart-check::before{content:"\f23a"}.bi-cart-dash-fill::before{content:"\f23b"}.bi-cart-dash::before{content:"\f23c"}.bi-cart-fill::before{content:"\f23d"}.bi-cart-plus-fill::before{content:"\f23e"}.bi-cart-plus::before{content:"\f23f"}.bi-cart-x-fill::before{content:"\f240"}.bi-cart-x::before{content:"\f241"}.bi-cart::before{content:"\f242"}.bi-cart2::before{content:"\f243"}.bi-cart3::before{content:"\f244"}.bi-cart4::before{content:"\f245"}.bi-cash-stack::before{content:"\f246"}.bi-cash::before{content:"\f247"}.bi-cast::before{content:"\f248"}.bi-chat-dots-fill::before{content:"\f249"}.bi-chat-dots::before{content:"\f24a"}.bi-chat-fill::before{content:"\f24b"}.bi-chat-left-dots-fill::before{content:"\f24c"}.bi-chat-left-dots::before{content:"\f24d"}.bi-chat-left-fill::before{content:"\f24e"}.bi-chat-left-quote-fill::before{content:"\f24f"}.bi-chat-left-quote::before{content:"\f250"}.bi-chat-left-text-fill::before{content:"\f251"}.bi-chat-left-text::before{content:"\f252"}.bi-chat-left::before{content:"\f253"}.bi-chat-quote-fill::before{content:"\f254"}.bi-chat-quote::before{content:"\f255"}.bi-chat-right-dots-fill::before{content:"\f256"}.bi-chat-right-dots::before{content:"\f257"}.bi-chat-right-fill::before{content:"\f258"}.bi-chat-right-quote-fill::before{content:"\f259"}.bi-chat-right-quote::before{content:"\f25a"}.bi-chat-right-text-fill::before{content:"\f25b"}.bi-chat-right-text::before{content:"\f25c"}.bi-chat-right::before{content:"\f25d"}.bi-chat-square-dots-fill::before{content:"\f25e"}.bi-chat-square-dots::before{content:"\f25f"}.bi-chat-square-fill::before{content:"\f260"}.bi-chat-square-quote-fill::before{content:"\f261"}.bi-chat-square-quote::before{content:"\f262"}.bi-chat-square-text-fill::before{content:"\f263"}.bi-chat-square-text::before{content:"\f264"}.bi-chat-square::before{content:"\f265"}.bi-chat-text-fill::before{content:"\f266"}.bi-chat-text::before{content:"\f267"}.bi-chat::before{content:"\f268"}.bi-check-all::before{content:"\f269"}.bi-check-circle-fill::before{content:"\f26a"}.bi-check-circle::before{content:"\f26b"}.bi-check-square-fill::before{content:"\f26c"}.bi-check-square::before{content:"\f26d"}.bi-check::before{content:"\f26e"}.bi-check2-all::before{content:"\f26f"}.bi-check2-circle::before{content:"\f270"}.bi-check2-square::before{content:"\f271"}.bi-check2::before{content:"\f272"}.bi-chevron-bar-contract::before{content:"\f273"}.bi-chevron-bar-down::before{content:"\f274"}.bi-chevron-bar-expand::before{content:"\f275"}.bi-chevron-bar-left::before{content:"\f276"}.bi-chevron-bar-right::before{content:"\f277"}.bi-chevron-bar-up::before{content:"\f278"}.bi-chevron-compact-down::before{content:"\f279"}.bi-chevron-compact-left::before{content:"\f27a"}.bi-chevron-compact-right::before{content:"\f27b"}.bi-chevron-compact-up::before{content:"\f27c"}.bi-chevron-contract::before{content:"\f27d"}.bi-chevron-double-down::before{content:"\f27e"}.bi-chevron-double-left::before{content:"\f27f"}.bi-chevron-double-right::before{content:"\f280"}.bi-chevron-double-up::before{content:"\f281"}.bi-chevron-down::before{content:"\f282"}.bi-chevron-expand::before{content:"\f283"}.bi-chevron-left::before{content:"\f284"}.bi-chevron-right::before{content:"\f285"}.bi-chevron-up::before{content:"\f286"}.bi-circle-fill::before{content:"\f287"}.bi-circle-half::before{content:"\f288"}.bi-circle-square::before{content:"\f289"}.bi-circle::before{content:"\f28a"}.bi-clipboard-check::before{content:"\f28b"}.bi-clipboard-data::before{content:"\f28c"}.bi-clipboard-minus::before{content:"\f28d"}.bi-clipboard-plus::before{content:"\f28e"}.bi-clipboard-x::before{content:"\f28f"}.bi-clipboard::before{content:"\f290"}.bi-clock-fill::before{content:"\f291"}.bi-clock-history::before{content:"\f292"}.bi-clock::before{content:"\f293"}.bi-cloud-arrow-down-fill::before{content:"\f294"}.bi-cloud-arrow-down::before{content:"\f295"}.bi-cloud-arrow-up-fill::before{content:"\f296"}.bi-cloud-arrow-up::before{content:"\f297"}.bi-cloud-check-fill::before{content:"\f298"}.bi-cloud-check::before{content:"\f299"}.bi-cloud-download-fill::before{content:"\f29a"}.bi-cloud-download::before{content:"\f29b"}.bi-cloud-drizzle-fill::before{content:"\f29c"}.bi-cloud-drizzle::before{content:"\f29d"}.bi-cloud-fill::before{content:"\f29e"}.bi-cloud-fog-fill::before{content:"\f29f"}.bi-cloud-fog::before{content:"\f2a0"}.bi-cloud-fog2-fill::before{content:"\f2a1"}.bi-cloud-fog2::before{content:"\f2a2"}.bi-cloud-hail-fill::before{content:"\f2a3"}.bi-cloud-hail::before{content:"\f2a4"}.bi-cloud-haze-fill::before{content:"\f2a6"}.bi-cloud-haze::before{content:"\f2a7"}.bi-cloud-haze2-fill::before{content:"\f2a8"}.bi-cloud-lightning-fill::before{content:"\f2a9"}.bi-cloud-lightning-rain-fill::before{content:"\f2aa"}.bi-cloud-lightning-rain::before{content:"\f2ab"}.bi-cloud-lightning::before{content:"\f2ac"}.bi-cloud-minus-fill::before{content:"\f2ad"}.bi-cloud-minus::before{content:"\f2ae"}.bi-cloud-moon-fill::before{content:"\f2af"}.bi-cloud-moon::before{content:"\f2b0"}.bi-cloud-plus-fill::before{content:"\f2b1"}.bi-cloud-plus::before{content:"\f2b2"}.bi-cloud-rain-fill::before{content:"\f2b3"}.bi-cloud-rain-heavy-fill::before{content:"\f2b4"}.bi-cloud-rain-heavy::before{content:"\f2b5"}.bi-cloud-rain::before{content:"\f2b6"}.bi-cloud-slash-fill::before{content:"\f2b7"}.bi-cloud-slash::before{content:"\f2b8"}.bi-cloud-sleet-fill::before{content:"\f2b9"}.bi-cloud-sleet::before{content:"\f2ba"}.bi-cloud-snow-fill::before{content:"\f2bb"}.bi-cloud-snow::before{content:"\f2bc"}.bi-cloud-sun-fill::before{content:"\f2bd"}.bi-cloud-sun::before{content:"\f2be"}.bi-cloud-upload-fill::before{content:"\f2bf"}.bi-cloud-upload::before{content:"\f2c0"}.bi-cloud::before{content:"\f2c1"}.bi-clouds-fill::before{content:"\f2c2"}.bi-clouds::before{content:"\f2c3"}.bi-cloudy-fill::before{content:"\f2c4"}.bi-cloudy::before{content:"\f2c5"}.bi-code-slash::before{content:"\f2c6"}.bi-code-square::before{content:"\f2c7"}.bi-code::before{content:"\f2c8"}.bi-collection-fill::before{content:"\f2c9"}.bi-collection-play-fill::before{content:"\f2ca"}.bi-collection-play::before{content:"\f2cb"}.bi-collection::before{content:"\f2cc"}.bi-columns-gap::before{content:"\f2cd"}.bi-columns::before{content:"\f2ce"}.bi-command::before{content:"\f2cf"}.bi-compass-fill::before{content:"\f2d0"}.bi-compass::before{content:"\f2d1"}.bi-cone-striped::before{content:"\f2d2"}.bi-cone::before{content:"\f2d3"}.bi-controller::before{content:"\f2d4"}.bi-cpu-fill::before{content:"\f2d5"}.bi-cpu::before{content:"\f2d6"}.bi-credit-card-2-back-fill::before{content:"\f2d7"}.bi-credit-card-2-back::before{content:"\f2d8"}.bi-credit-card-2-front-fill::before{content:"\f2d9"}.bi-credit-card-2-front::before{content:"\f2da"}.bi-credit-card-fill::before{content:"\f2db"}.bi-credit-card::before{content:"\f2dc"}.bi-crop::before{content:"\f2dd"}.bi-cup-fill::before{content:"\f2de"}.bi-cup-straw::before{content:"\f2df"}.bi-cup::before{content:"\f2e0"}.bi-cursor-fill::before{content:"\f2e1"}.bi-cursor-text::before{content:"\f2e2"}.bi-cursor::before{content:"\f2e3"}.bi-dash-circle-dotted::before{content:"\f2e4"}.bi-dash-circle-fill::before{content:"\f2e5"}.bi-dash-circle::before{content:"\f2e6"}.bi-dash-square-dotted::before{content:"\f2e7"}.bi-dash-square-fill::before{content:"\f2e8"}.bi-dash-square::before{content:"\f2e9"}.bi-dash::before{content:"\f2ea"}.bi-diagram-2-fill::before{content:"\f2eb"}.bi-diagram-2::before{content:"\f2ec"}.bi-diagram-3-fill::before{content:"\f2ed"}.bi-diagram-3::before{content:"\f2ee"}.bi-diamond-fill::before{content:"\f2ef"}.bi-diamond-half::before{content:"\f2f0"}.bi-diamond::before{content:"\f2f1"}.bi-dice-1-fill::before{content:"\f2f2"}.bi-dice-1::before{content:"\f2f3"}.bi-dice-2-fill::before{content:"\f2f4"}.bi-dice-2::before{content:"\f2f5"}.bi-dice-3-fill::before{content:"\f2f6"}.bi-dice-3::before{content:"\f2f7"}.bi-dice-4-fill::before{content:"\f2f8"}.bi-dice-4::before{content:"\f2f9"}.bi-dice-5-fill::before{content:"\f2fa"}.bi-dice-5::before{content:"\f2fb"}.bi-dice-6-fill::before{content:"\f2fc"}.bi-dice-6::before{content:"\f2fd"}.bi-disc-fill::before{content:"\f2fe"}.bi-disc::before{content:"\f2ff"}.bi-discord::before{content:"\f300"}.bi-display-fill::before{content:"\f301"}.bi-display::before{content:"\f302"}.bi-distribute-horizontal::before{content:"\f303"}.bi-distribute-vertical::before{content:"\f304"}.bi-door-closed-fill::before{content:"\f305"}.bi-door-closed::before{content:"\f306"}.bi-door-open-fill::before{content:"\f307"}.bi-door-open::before{content:"\f308"}.bi-dot::before{content:"\f309"}.bi-download::before{content:"\f30a"}.bi-droplet-fill::before{content:"\f30b"}.bi-droplet-half::before{content:"\f30c"}.bi-droplet::before{content:"\f30d"}.bi-earbuds::before{content:"\f30e"}.bi-easel-fill::before{content:"\f30f"}.bi-easel::before{content:"\f310"}.bi-egg-fill::before{content:"\f311"}.bi-egg-fried::before{content:"\f312"}.bi-egg::before{content:"\f313"}.bi-eject-fill::before{content:"\f314"}.bi-eject::before{content:"\f315"}.bi-emoji-angry-fill::before{content:"\f316"}.bi-emoji-angry::before{content:"\f317"}.bi-emoji-dizzy-fill::before{content:"\f318"}.bi-emoji-dizzy::before{content:"\f319"}.bi-emoji-expressionless-fill::before{content:"\f31a"}.bi-emoji-expressionless::before{content:"\f31b"}.bi-emoji-frown-fill::before{content:"\f31c"}.bi-emoji-frown::before{content:"\f31d"}.bi-emoji-heart-eyes-fill::before{content:"\f31e"}.bi-emoji-heart-eyes::before{content:"\f31f"}.bi-emoji-laughing-fill::before{content:"\f320"}.bi-emoji-laughing::before{content:"\f321"}.bi-emoji-neutral-fill::before{content:"\f322"}.bi-emoji-neutral::before{content:"\f323"}.bi-emoji-smile-fill::before{content:"\f324"}.bi-emoji-smile-upside-down-fill::before{content:"\f325"}.bi-emoji-smile-upside-down::before{content:"\f326"}.bi-emoji-smile::before{content:"\f327"}.bi-emoji-sunglasses-fill::before{content:"\f328"}.bi-emoji-sunglasses::before{content:"\f329"}.bi-emoji-wink-fill::before{content:"\f32a"}.bi-emoji-wink::before{content:"\f32b"}.bi-envelope-fill::before{content:"\f32c"}.bi-envelope-open-fill::before{content:"\f32d"}.bi-envelope-open::before{content:"\f32e"}.bi-envelope::before{content:"\f32f"}.bi-eraser-fill::before{content:"\f330"}.bi-eraser::before{content:"\f331"}.bi-exclamation-circle-fill::before{content:"\f332"}.bi-exclamation-circle::before{content:"\f333"}.bi-exclamation-diamond-fill::before{content:"\f334"}.bi-exclamation-diamond::before{content:"\f335"}.bi-exclamation-octagon-fill::before{content:"\f336"}.bi-exclamation-octagon::before{content:"\f337"}.bi-exclamation-square-fill::before{content:"\f338"}.bi-exclamation-square::before{content:"\f339"}.bi-exclamation-triangle-fill::before{content:"\f33a"}.bi-exclamation-triangle::before{content:"\f33b"}.bi-exclamation::before{content:"\f33c"}.bi-exclude::before{content:"\f33d"}.bi-eye-fill::before{content:"\f33e"}.bi-eye-slash-fill::before{content:"\f33f"}.bi-eye-slash::before{content:"\f340"}.bi-eye::before{content:"\f341"}.bi-eyedropper::before{content:"\f342"}.bi-eyeglasses::before{content:"\f343"}.bi-facebook::before{content:"\f344"}.bi-file-arrow-down-fill::before{content:"\f345"}.bi-file-arrow-down::before{content:"\f346"}.bi-file-arrow-up-fill::before{content:"\f347"}.bi-file-arrow-up::before{content:"\f348"}.bi-file-bar-graph-fill::before{content:"\f349"}.bi-file-bar-graph::before{content:"\f34a"}.bi-file-binary-fill::before{content:"\f34b"}.bi-file-binary::before{content:"\f34c"}.bi-file-break-fill::before{content:"\f34d"}.bi-file-break::before{content:"\f34e"}.bi-file-check-fill::before{content:"\f34f"}.bi-file-check::before{content:"\f350"}.bi-file-code-fill::before{content:"\f351"}.bi-file-code::before{content:"\f352"}.bi-file-diff-fill::before{content:"\f353"}.bi-file-diff::before{content:"\f354"}.bi-file-earmark-arrow-down-fill::before{content:"\f355"}.bi-file-earmark-arrow-down::before{content:"\f356"}.bi-file-earmark-arrow-up-fill::before{content:"\f357"}.bi-file-earmark-arrow-up::before{content:"\f358"}.bi-file-earmark-bar-graph-fill::before{content:"\f359"}.bi-file-earmark-bar-graph::before{content:"\f35a"}.bi-file-earmark-binary-fill::before{content:"\f35b"}.bi-file-earmark-binary::before{content:"\f35c"}.bi-file-earmark-break-fill::before{content:"\f35d"}.bi-file-earmark-break::before{content:"\f35e"}.bi-file-earmark-check-fill::before{content:"\f35f"}.bi-file-earmark-check::before{content:"\f360"}.bi-file-earmark-code-fill::before{content:"\f361"}.bi-file-earmark-code::before{content:"\f362"}.bi-file-earmark-diff-fill::before{content:"\f363"}.bi-file-earmark-diff::before{content:"\f364"}.bi-file-earmark-easel-fill::before{content:"\f365"}.bi-file-earmark-easel::before{content:"\f366"}.bi-file-earmark-excel-fill::before{content:"\f367"}.bi-file-earmark-excel::before{content:"\f368"}.bi-file-earmark-fill::before{content:"\f369"}.bi-file-earmark-font-fill::before{content:"\f36a"}.bi-file-earmark-font::before{content:"\f36b"}.bi-file-earmark-image-fill::before{content:"\f36c"}.bi-file-earmark-image::before{content:"\f36d"}.bi-file-earmark-lock-fill::before{content:"\f36e"}.bi-file-earmark-lock::before{content:"\f36f"}.bi-file-earmark-lock2-fill::before{content:"\f370"}.bi-file-earmark-lock2::before{content:"\f371"}.bi-file-earmark-medical-fill::before{content:"\f372"}.bi-file-earmark-medical::before{content:"\f373"}.bi-file-earmark-minus-fill::before{content:"\f374"}.bi-file-earmark-minus::before{content:"\f375"}.bi-file-earmark-music-fill::before{content:"\f376"}.bi-file-earmark-music::before{content:"\f377"}.bi-file-earmark-person-fill::before{content:"\f378"}.bi-file-earmark-person::before{content:"\f379"}.bi-file-earmark-play-fill::before{content:"\f37a"}.bi-file-earmark-play::before{content:"\f37b"}.bi-file-earmark-plus-fill::before{content:"\f37c"}.bi-file-earmark-plus::before{content:"\f37d"}.bi-file-earmark-post-fill::before{content:"\f37e"}.bi-file-earmark-post::before{content:"\f37f"}.bi-file-earmark-ppt-fill::before{content:"\f380"}.bi-file-earmark-ppt::before{content:"\f381"}.bi-file-earmark-richtext-fill::before{content:"\f382"}.bi-file-earmark-richtext::before{content:"\f383"}.bi-file-earmark-ruled-fill::before{content:"\f384"}.bi-file-earmark-ruled::before{content:"\f385"}.bi-file-earmark-slides-fill::before{content:"\f386"}.bi-file-earmark-slides::before{content:"\f387"}.bi-file-earmark-spreadsheet-fill::before{content:"\f388"}.bi-file-earmark-spreadsheet::before{content:"\f389"}.bi-file-earmark-text-fill::before{content:"\f38a"}.bi-file-earmark-text::before{content:"\f38b"}.bi-file-earmark-word-fill::before{content:"\f38c"}.bi-file-earmark-word::before{content:"\f38d"}.bi-file-earmark-x-fill::before{content:"\f38e"}.bi-file-earmark-x::before{content:"\f38f"}.bi-file-earmark-zip-fill::before{content:"\f390"}.bi-file-earmark-zip::before{content:"\f391"}.bi-file-earmark::before{content:"\f392"}.bi-file-easel-fill::before{content:"\f393"}.bi-file-easel::before{content:"\f394"}.bi-file-excel-fill::before{content:"\f395"}.bi-file-excel::before{content:"\f396"}.bi-file-fill::before{content:"\f397"}.bi-file-font-fill::before{content:"\f398"}.bi-file-font::before{content:"\f399"}.bi-file-image-fill::before{content:"\f39a"}.bi-file-image::before{content:"\f39b"}.bi-file-lock-fill::before{content:"\f39c"}.bi-file-lock::before{content:"\f39d"}.bi-file-lock2-fill::before{content:"\f39e"}.bi-file-lock2::before{content:"\f39f"}.bi-file-medical-fill::before{content:"\f3a0"}.bi-file-medical::before{content:"\f3a1"}.bi-file-minus-fill::before{content:"\f3a2"}.bi-file-minus::before{content:"\f3a3"}.bi-file-music-fill::before{content:"\f3a4"}.bi-file-music::before{content:"\f3a5"}.bi-file-person-fill::before{content:"\f3a6"}.bi-file-person::before{content:"\f3a7"}.bi-file-play-fill::before{content:"\f3a8"}.bi-file-play::before{content:"\f3a9"}.bi-file-plus-fill::before{content:"\f3aa"}.bi-file-plus::before{content:"\f3ab"}.bi-file-post-fill::before{content:"\f3ac"}.bi-file-post::before{content:"\f3ad"}.bi-file-ppt-fill::before{content:"\f3ae"}.bi-file-ppt::before{content:"\f3af"}.bi-file-richtext-fill::before{content:"\f3b0"}.bi-file-richtext::before{content:"\f3b1"}.bi-file-ruled-fill::before{content:"\f3b2"}.bi-file-ruled::before{content:"\f3b3"}.bi-file-slides-fill::before{content:"\f3b4"}.bi-file-slides::before{content:"\f3b5"}.bi-file-spreadsheet-fill::before{content:"\f3b6"}.bi-file-spreadsheet::before{content:"\f3b7"}.bi-file-text-fill::before{content:"\f3b8"}.bi-file-text::before{content:"\f3b9"}.bi-file-word-fill::before{content:"\f3ba"}.bi-file-word::before{content:"\f3bb"}.bi-file-x-fill::before{content:"\f3bc"}.bi-file-x::before{content:"\f3bd"}.bi-file-zip-fill::before{content:"\f3be"}.bi-file-zip::before{content:"\f3bf"}.bi-file::before{content:"\f3c0"}.bi-files-alt::before{content:"\f3c1"}.bi-files::before{content:"\f3c2"}.bi-film::before{content:"\f3c3"}.bi-filter-circle-fill::before{content:"\f3c4"}.bi-filter-circle::before{content:"\f3c5"}.bi-filter-left::before{content:"\f3c6"}.bi-filter-right::before{content:"\f3c7"}.bi-filter-square-fill::before{content:"\f3c8"}.bi-filter-square::before{content:"\f3c9"}.bi-filter::before{content:"\f3ca"}.bi-flag-fill::before{content:"\f3cb"}.bi-flag::before{content:"\f3cc"}.bi-flower1::before{content:"\f3cd"}.bi-flower2::before{content:"\f3ce"}.bi-flower3::before{content:"\f3cf"}.bi-folder-check::before{content:"\f3d0"}.bi-folder-fill::before{content:"\f3d1"}.bi-folder-minus::before{content:"\f3d2"}.bi-folder-plus::before{content:"\f3d3"}.bi-folder-symlink-fill::before{content:"\f3d4"}.bi-folder-symlink::before{content:"\f3d5"}.bi-folder-x::before{content:"\f3d6"}.bi-folder::before{content:"\f3d7"}.bi-folder2-open::before{content:"\f3d8"}.bi-folder2::before{content:"\f3d9"}.bi-fonts::before{content:"\f3da"}.bi-forward-fill::before{content:"\f3db"}.bi-forward::before{content:"\f3dc"}.bi-front::before{content:"\f3dd"}.bi-fullscreen-exit::before{content:"\f3de"}.bi-fullscreen::before{content:"\f3df"}.bi-funnel-fill::before{content:"\f3e0"}.bi-funnel::before{content:"\f3e1"}.bi-gear-fill::before{content:"\f3e2"}.bi-gear-wide-connected::before{content:"\f3e3"}.bi-gear-wide::before{content:"\f3e4"}.bi-gear::before{content:"\f3e5"}.bi-gem::before{content:"\f3e6"}.bi-geo-alt-fill::before{content:"\f3e7"}.bi-geo-alt::before{content:"\f3e8"}.bi-geo-fill::before{content:"\f3e9"}.bi-geo::before{content:"\f3ea"}.bi-gift-fill::before{content:"\f3eb"}.bi-gift::before{content:"\f3ec"}.bi-github::before{content:"\f3ed"}.bi-globe::before{content:"\f3ee"}.bi-globe2::before{content:"\f3ef"}.bi-google::before{content:"\f3f0"}.bi-graph-down::before{content:"\f3f1"}.bi-graph-up::before{content:"\f3f2"}.bi-grid-1x2-fill::before{content:"\f3f3"}.bi-grid-1x2::before{content:"\f3f4"}.bi-grid-3x2-gap-fill::before{content:"\f3f5"}.bi-grid-3x2-gap::before{content:"\f3f6"}.bi-grid-3x2::before{content:"\f3f7"}.bi-grid-3x3-gap-fill::before{content:"\f3f8"}.bi-grid-3x3-gap::before{content:"\f3f9"}.bi-grid-3x3::before{content:"\f3fa"}.bi-grid-fill::before{content:"\f3fb"}.bi-grid::before{content:"\f3fc"}.bi-grip-horizontal::before{content:"\f3fd"}.bi-grip-vertical::before{content:"\f3fe"}.bi-hammer::before{content:"\f3ff"}.bi-hand-index-fill::before{content:"\f400"}.bi-hand-index-thumb-fill::before{content:"\f401"}.bi-hand-index-thumb::before{content:"\f402"}.bi-hand-index::before{content:"\f403"}.bi-hand-thumbs-down-fill::before{content:"\f404"}.bi-hand-thumbs-down::before{content:"\f405"}.bi-hand-thumbs-up-fill::before{content:"\f406"}.bi-hand-thumbs-up::before{content:"\f407"}.bi-handbag-fill::before{content:"\f408"}.bi-handbag::before{content:"\f409"}.bi-hash::before{content:"\f40a"}.bi-hdd-fill::before{content:"\f40b"}.bi-hdd-network-fill::before{content:"\f40c"}.bi-hdd-network::before{content:"\f40d"}.bi-hdd-rack-fill::before{content:"\f40e"}.bi-hdd-rack::before{content:"\f40f"}.bi-hdd-stack-fill::before{content:"\f410"}.bi-hdd-stack::before{content:"\f411"}.bi-hdd::before{content:"\f412"}.bi-headphones::before{content:"\f413"}.bi-headset::before{content:"\f414"}.bi-heart-fill::before{content:"\f415"}.bi-heart-half::before{content:"\f416"}.bi-heart::before{content:"\f417"}.bi-heptagon-fill::before{content:"\f418"}.bi-heptagon-half::before{content:"\f419"}.bi-heptagon::before{content:"\f41a"}.bi-hexagon-fill::before{content:"\f41b"}.bi-hexagon-half::before{content:"\f41c"}.bi-hexagon::before{content:"\f41d"}.bi-hourglass-bottom::before{content:"\f41e"}.bi-hourglass-split::before{content:"\f41f"}.bi-hourglass-top::before{content:"\f420"}.bi-hourglass::before{content:"\f421"}.bi-house-door-fill::before{content:"\f422"}.bi-house-door::before{content:"\f423"}.bi-house-fill::before{content:"\f424"}.bi-house::before{content:"\f425"}.bi-hr::before{content:"\f426"}.bi-hurricane::before{content:"\f427"}.bi-image-alt::before{content:"\f428"}.bi-image-fill::before{content:"\f429"}.bi-image::before{content:"\f42a"}.bi-images::before{content:"\f42b"}.bi-inbox-fill::before{content:"\f42c"}.bi-inbox::before{content:"\f42d"}.bi-inboxes-fill::before{content:"\f42e"}.bi-inboxes::before{content:"\f42f"}.bi-info-circle-fill::before{content:"\f430"}.bi-info-circle::before{content:"\f431"}.bi-info-square-fill::before{content:"\f432"}.bi-info-square::before{content:"\f433"}.bi-info::before{content:"\f434"}.bi-input-cursor-text::before{content:"\f435"}.bi-input-cursor::before{content:"\f436"}.bi-instagram::before{content:"\f437"}.bi-intersect::before{content:"\f438"}.bi-journal-album::before{content:"\f439"}.bi-journal-arrow-down::before{content:"\f43a"}.bi-journal-arrow-up::before{content:"\f43b"}.bi-journal-bookmark-fill::before{content:"\f43c"}.bi-journal-bookmark::before{content:"\f43d"}.bi-journal-check::before{content:"\f43e"}.bi-journal-code::before{content:"\f43f"}.bi-journal-medical::before{content:"\f440"}.bi-journal-minus::before{content:"\f441"}.bi-journal-plus::before{content:"\f442"}.bi-journal-richtext::before{content:"\f443"}.bi-journal-text::before{content:"\f444"}.bi-journal-x::before{content:"\f445"}.bi-journal::before{content:"\f446"}.bi-journals::before{content:"\f447"}.bi-joystick::before{content:"\f448"}.bi-justify-left::before{content:"\f449"}.bi-justify-right::before{content:"\f44a"}.bi-justify::before{content:"\f44b"}.bi-kanban-fill::before{content:"\f44c"}.bi-kanban::before{content:"\f44d"}.bi-key-fill::before{content:"\f44e"}.bi-key::before{content:"\f44f"}.bi-keyboard-fill::before{content:"\f450"}.bi-keyboard::before{content:"\f451"}.bi-ladder::before{content:"\f452"}.bi-lamp-fill::before{content:"\f453"}.bi-lamp::before{content:"\f454"}.bi-laptop-fill::before{content:"\f455"}.bi-laptop::before{content:"\f456"}.bi-layer-backward::before{content:"\f457"}.bi-layer-forward::before{content:"\f458"}.bi-layers-fill::before{content:"\f459"}.bi-layers-half::before{content:"\f45a"}.bi-layers::before{content:"\f45b"}.bi-layout-sidebar-inset-reverse::before{content:"\f45c"}.bi-layout-sidebar-inset::before{content:"\f45d"}.bi-layout-sidebar-reverse::before{content:"\f45e"}.bi-layout-sidebar::before{content:"\f45f"}.bi-layout-split::before{content:"\f460"}.bi-layout-text-sidebar-reverse::before{content:"\f461"}.bi-layout-text-sidebar::before{content:"\f462"}.bi-layout-text-window-reverse::before{content:"\f463"}.bi-layout-text-window::before{content:"\f464"}.bi-layout-three-columns::before{content:"\f465"}.bi-layout-wtf::before{content:"\f466"}.bi-life-preserver::before{content:"\f467"}.bi-lightbulb-fill::before{content:"\f468"}.bi-lightbulb-off-fill::before{content:"\f469"}.bi-lightbulb-off::before{content:"\f46a"}.bi-lightbulb::before{content:"\f46b"}.bi-lightning-charge-fill::before{content:"\f46c"}.bi-lightning-charge::before{content:"\f46d"}.bi-lightning-fill::before{content:"\f46e"}.bi-lightning::before{content:"\f46f"}.bi-link-45deg::before{content:"\f470"}.bi-link::before{content:"\f471"}.bi-linkedin::before{content:"\f472"}.bi-list-check::before{content:"\f473"}.bi-list-nested::before{content:"\f474"}.bi-list-ol::before{content:"\f475"}.bi-list-stars::before{content:"\f476"}.bi-list-task::before{content:"\f477"}.bi-list-ul::before{content:"\f478"}.bi-list::before{content:"\f479"}.bi-lock-fill::before{content:"\f47a"}.bi-lock::before{content:"\f47b"}.bi-mailbox::before{content:"\f47c"}.bi-mailbox2::before{content:"\f47d"}.bi-map-fill::before{content:"\f47e"}.bi-map::before{content:"\f47f"}.bi-markdown-fill::before{content:"\f480"}.bi-markdown::before{content:"\f481"}.bi-mask::before{content:"\f482"}.bi-megaphone-fill::before{content:"\f483"}.bi-megaphone::before{content:"\f484"}.bi-menu-app-fill::before{content:"\f485"}.bi-menu-app::before{content:"\f486"}.bi-menu-button-fill::before{content:"\f487"}.bi-menu-button-wide-fill::before{content:"\f488"}.bi-menu-button-wide::before{content:"\f489"}.bi-menu-button::before{content:"\f48a"}.bi-menu-down::before{content:"\f48b"}.bi-menu-up::before{content:"\f48c"}.bi-mic-fill::before{content:"\f48d"}.bi-mic-mute-fill::before{content:"\f48e"}.bi-mic-mute::before{content:"\f48f"}.bi-mic::before{content:"\f490"}.bi-minecart-loaded::before{content:"\f491"}.bi-minecart::before{content:"\f492"}.bi-moisture::before{content:"\f493"}.bi-moon-fill::before{content:"\f494"}.bi-moon-stars-fill::before{content:"\f495"}.bi-moon-stars::before{content:"\f496"}.bi-moon::before{content:"\f497"}.bi-mouse-fill::before{content:"\f498"}.bi-mouse::before{content:"\f499"}.bi-mouse2-fill::before{content:"\f49a"}.bi-mouse2::before{content:"\f49b"}.bi-mouse3-fill::before{content:"\f49c"}.bi-mouse3::before{content:"\f49d"}.bi-music-note-beamed::before{content:"\f49e"}.bi-music-note-list::before{content:"\f49f"}.bi-music-note::before{content:"\f4a0"}.bi-music-player-fill::before{content:"\f4a1"}.bi-music-player::before{content:"\f4a2"}.bi-newspaper::before{content:"\f4a3"}.bi-node-minus-fill::before{content:"\f4a4"}.bi-node-minus::before{content:"\f4a5"}.bi-node-plus-fill::before{content:"\f4a6"}.bi-node-plus::before{content:"\f4a7"}.bi-nut-fill::before{content:"\f4a8"}.bi-nut::before{content:"\f4a9"}.bi-octagon-fill::before{content:"\f4aa"}.bi-octagon-half::before{content:"\f4ab"}.bi-octagon::before{content:"\f4ac"}.bi-option::before{content:"\f4ad"}.bi-outlet::before{content:"\f4ae"}.bi-paint-bucket::before{content:"\f4af"}.bi-palette-fill::before{content:"\f4b0"}.bi-palette::before{content:"\f4b1"}.bi-palette2::before{content:"\f4b2"}.bi-paperclip::before{content:"\f4b3"}.bi-paragraph::before{content:"\f4b4"}.bi-patch-check-fill::before{content:"\f4b5"}.bi-patch-check::before{content:"\f4b6"}.bi-patch-exclamation-fill::before{content:"\f4b7"}.bi-patch-exclamation::before{content:"\f4b8"}.bi-patch-minus-fill::before{content:"\f4b9"}.bi-patch-minus::before{content:"\f4ba"}.bi-patch-plus-fill::before{content:"\f4bb"}.bi-patch-plus::before{content:"\f4bc"}.bi-patch-question-fill::before{content:"\f4bd"}.bi-patch-question::before{content:"\f4be"}.bi-pause-btn-fill::before{content:"\f4bf"}.bi-pause-btn::before{content:"\f4c0"}.bi-pause-circle-fill::before{content:"\f4c1"}.bi-pause-circle::before{content:"\f4c2"}.bi-pause-fill::before{content:"\f4c3"}.bi-pause::before{content:"\f4c4"}.bi-peace-fill::before{content:"\f4c5"}.bi-peace::before{content:"\f4c6"}.bi-pen-fill::before{content:"\f4c7"}.bi-pen::before{content:"\f4c8"}.bi-pencil-fill::before{content:"\f4c9"}.bi-pencil-square::before{content:"\f4ca"}.bi-pencil::before{content:"\f4cb"}.bi-pentagon-fill::before{content:"\f4cc"}.bi-pentagon-half::before{content:"\f4cd"}.bi-pentagon::before{content:"\f4ce"}.bi-people-fill::before{content:"\f4cf"}.bi-people::before{content:"\f4d0"}.bi-percent::before{content:"\f4d1"}.bi-person-badge-fill::before{content:"\f4d2"}.bi-person-badge::before{content:"\f4d3"}.bi-person-bounding-box::before{content:"\f4d4"}.bi-person-check-fill::before{content:"\f4d5"}.bi-person-check::before{content:"\f4d6"}.bi-person-circle::before{content:"\f4d7"}.bi-person-dash-fill::before{content:"\f4d8"}.bi-person-dash::before{content:"\f4d9"}.bi-person-fill::before{content:"\f4da"}.bi-person-lines-fill::before{content:"\f4db"}.bi-person-plus-fill::before{content:"\f4dc"}.bi-person-plus::before{content:"\f4dd"}.bi-person-square::before{content:"\f4de"}.bi-person-x-fill::before{content:"\f4df"}.bi-person-x::before{content:"\f4e0"}.bi-person::before{content:"\f4e1"}.bi-phone-fill::before{content:"\f4e2"}.bi-phone-landscape-fill::before{content:"\f4e3"}.bi-phone-landscape::before{content:"\f4e4"}.bi-phone-vibrate-fill::before{content:"\f4e5"}.bi-phone-vibrate::before{content:"\f4e6"}.bi-phone::before{content:"\f4e7"}.bi-pie-chart-fill::before{content:"\f4e8"}.bi-pie-chart::before{content:"\f4e9"}.bi-pin-angle-fill::before{content:"\f4ea"}.bi-pin-angle::before{content:"\f4eb"}.bi-pin-fill::before{content:"\f4ec"}.bi-pin::before{content:"\f4ed"}.bi-pip-fill::before{content:"\f4ee"}.bi-pip::before{content:"\f4ef"}.bi-play-btn-fill::before{content:"\f4f0"}.bi-play-btn::before{content:"\f4f1"}.bi-play-circle-fill::before{content:"\f4f2"}.bi-play-circle::before{content:"\f4f3"}.bi-play-fill::before{content:"\f4f4"}.bi-play::before{content:"\f4f5"}.bi-plug-fill::before{content:"\f4f6"}.bi-plug::before{content:"\f4f7"}.bi-plus-circle-dotted::before{content:"\f4f8"}.bi-plus-circle-fill::before{content:"\f4f9"}.bi-plus-circle::before{content:"\f4fa"}.bi-plus-square-dotted::before{content:"\f4fb"}.bi-plus-square-fill::before{content:"\f4fc"}.bi-plus-square::before{content:"\f4fd"}.bi-plus::before{content:"\f4fe"}.bi-power::before{content:"\f4ff"}.bi-printer-fill::before{content:"\f500"}.bi-printer::before{content:"\f501"}.bi-puzzle-fill::before{content:"\f502"}.bi-puzzle::before{content:"\f503"}.bi-question-circle-fill::before{content:"\f504"}.bi-question-circle::before{content:"\f505"}.bi-question-diamond-fill::before{content:"\f506"}.bi-question-diamond::before{content:"\f507"}.bi-question-octagon-fill::before{content:"\f508"}.bi-question-octagon::before{content:"\f509"}.bi-question-square-fill::before{content:"\f50a"}.bi-question-square::before{content:"\f50b"}.bi-question::before{content:"\f50c"}.bi-rainbow::before{content:"\f50d"}.bi-receipt-cutoff::before{content:"\f50e"}.bi-receipt::before{content:"\f50f"}.bi-reception-0::before{content:"\f510"}.bi-reception-1::before{content:"\f511"}.bi-reception-2::before{content:"\f512"}.bi-reception-3::before{content:"\f513"}.bi-reception-4::before{content:"\f514"}.bi-record-btn-fill::before{content:"\f515"}.bi-record-btn::before{content:"\f516"}.bi-record-circle-fill::before{content:"\f517"}.bi-record-circle::before{content:"\f518"}.bi-record-fill::before{content:"\f519"}.bi-record::before{content:"\f51a"}.bi-record2-fill::before{content:"\f51b"}.bi-record2::before{content:"\f51c"}.bi-reply-all-fill::before{content:"\f51d"}.bi-reply-all::before{content:"\f51e"}.bi-reply-fill::before{content:"\f51f"}.bi-reply::before{content:"\f520"}.bi-rss-fill::before{content:"\f521"}.bi-rss::before{content:"\f522"}.bi-rulers::before{content:"\f523"}.bi-save-fill::before{content:"\f524"}.bi-save::before{content:"\f525"}.bi-save2-fill::before{content:"\f526"}.bi-save2::before{content:"\f527"}.bi-scissors::before{content:"\f528"}.bi-screwdriver::before{content:"\f529"}.bi-search::before{content:"\f52a"}.bi-segmented-nav::before{content:"\f52b"}.bi-server::before{content:"\f52c"}.bi-share-fill::before{content:"\f52d"}.bi-share::before{content:"\f52e"}.bi-shield-check::before{content:"\f52f"}.bi-shield-exclamation::before{content:"\f530"}.bi-shield-fill-check::before{content:"\f531"}.bi-shield-fill-exclamation::before{content:"\f532"}.bi-shield-fill-minus::before{content:"\f533"}.bi-shield-fill-plus::before{content:"\f534"}.bi-shield-fill-x::before{content:"\f535"}.bi-shield-fill::before{content:"\f536"}.bi-shield-lock-fill::before{content:"\f537"}.bi-shield-lock::before{content:"\f538"}.bi-shield-minus::before{content:"\f539"}.bi-shield-plus::before{content:"\f53a"}.bi-shield-shaded::before{content:"\f53b"}.bi-shield-slash-fill::before{content:"\f53c"}.bi-shield-slash::before{content:"\f53d"}.bi-shield-x::before{content:"\f53e"}.bi-shield::before{content:"\f53f"}.bi-shift-fill::before{content:"\f540"}.bi-shift::before{content:"\f541"}.bi-shop-window::before{content:"\f542"}.bi-shop::before{content:"\f543"}.bi-shuffle::before{content:"\f544"}.bi-signpost-2-fill::before{content:"\f545"}.bi-signpost-2::before{content:"\f546"}.bi-signpost-fill::before{content:"\f547"}.bi-signpost-split-fill::before{content:"\f548"}.bi-signpost-split::before{content:"\f549"}.bi-signpost::before{content:"\f54a"}.bi-sim-fill::before{content:"\f54b"}.bi-sim::before{content:"\f54c"}.bi-skip-backward-btn-fill::before{content:"\f54d"}.bi-skip-backward-btn::before{content:"\f54e"}.bi-skip-backward-circle-fill::before{content:"\f54f"}.bi-skip-backward-circle::before{content:"\f550"}.bi-skip-backward-fill::before{content:"\f551"}.bi-skip-backward::before{content:"\f552"}.bi-skip-end-btn-fill::before{content:"\f553"}.bi-skip-end-btn::before{content:"\f554"}.bi-skip-end-circle-fill::before{content:"\f555"}.bi-skip-end-circle::before{content:"\f556"}.bi-skip-end-fill::before{content:"\f557"}.bi-skip-end::before{content:"\f558"}.bi-skip-forward-btn-fill::before{content:"\f559"}.bi-skip-forward-btn::before{content:"\f55a"}.bi-skip-forward-circle-fill::before{content:"\f55b"}.bi-skip-forward-circle::before{content:"\f55c"}.bi-skip-forward-fill::before{content:"\f55d"}.bi-skip-forward::before{content:"\f55e"}.bi-skip-start-btn-fill::before{content:"\f55f"}.bi-skip-start-btn::before{content:"\f560"}.bi-skip-start-circle-fill::before{content:"\f561"}.bi-skip-start-circle::before{content:"\f562"}.bi-skip-start-fill::before{content:"\f563"}.bi-skip-start::before{content:"\f564"}.bi-slack::before{content:"\f565"}.bi-slash-circle-fill::before{content:"\f566"}.bi-slash-circle::before{content:"\f567"}.bi-slash-square-fill::before{content:"\f568"}.bi-slash-square::before{content:"\f569"}.bi-slash::before{content:"\f56a"}.bi-sliders::before{content:"\f56b"}.bi-smartwatch::before{content:"\f56c"}.bi-snow::before{content:"\f56d"}.bi-snow2::before{content:"\f56e"}.bi-snow3::before{content:"\f56f"}.bi-sort-alpha-down-alt::before{content:"\f570"}.bi-sort-alpha-down::before{content:"\f571"}.bi-sort-alpha-up-alt::before{content:"\f572"}.bi-sort-alpha-up::before{content:"\f573"}.bi-sort-down-alt::before{content:"\f574"}.bi-sort-down::before{content:"\f575"}.bi-sort-numeric-down-alt::before{content:"\f576"}.bi-sort-numeric-down::before{content:"\f577"}.bi-sort-numeric-up-alt::before{content:"\f578"}.bi-sort-numeric-up::before{content:"\f579"}.bi-sort-up-alt::before{content:"\f57a"}.bi-sort-up::before{content:"\f57b"}.bi-soundwave::before{content:"\f57c"}.bi-speaker-fill::before{content:"\f57d"}.bi-speaker::before{content:"\f57e"}.bi-speedometer::before{content:"\f57f"}.bi-speedometer2::before{content:"\f580"}.bi-spellcheck::before{content:"\f581"}.bi-square-fill::before{content:"\f582"}.bi-square-half::before{content:"\f583"}.bi-square::before{content:"\f584"}.bi-stack::before{content:"\f585"}.bi-star-fill::before{content:"\f586"}.bi-star-half::before{content:"\f587"}.bi-star::before{content:"\f588"}.bi-stars::before{content:"\f589"}.bi-stickies-fill::before{content:"\f58a"}.bi-stickies::before{content:"\f58b"}.bi-sticky-fill::before{content:"\f58c"}.bi-sticky::before{content:"\f58d"}.bi-stop-btn-fill::before{content:"\f58e"}.bi-stop-btn::before{content:"\f58f"}.bi-stop-circle-fill::before{content:"\f590"}.bi-stop-circle::before{content:"\f591"}.bi-stop-fill::before{content:"\f592"}.bi-stop::before{content:"\f593"}.bi-stoplights-fill::before{content:"\f594"}.bi-stoplights::before{content:"\f595"}.bi-stopwatch-fill::before{content:"\f596"}.bi-stopwatch::before{content:"\f597"}.bi-subtract::before{content:"\f598"}.bi-suit-club-fill::before{content:"\f599"}.bi-suit-club::before{content:"\f59a"}.bi-suit-diamond-fill::before{content:"\f59b"}.bi-suit-diamond::before{content:"\f59c"}.bi-suit-heart-fill::before{content:"\f59d"}.bi-suit-heart::before{content:"\f59e"}.bi-suit-spade-fill::before{content:"\f59f"}.bi-suit-spade::before{content:"\f5a0"}.bi-sun-fill::before{content:"\f5a1"}.bi-sun::before{content:"\f5a2"}.bi-sunglasses::before{content:"\f5a3"}.bi-sunrise-fill::before{content:"\f5a4"}.bi-sunrise::before{content:"\f5a5"}.bi-sunset-fill::before{content:"\f5a6"}.bi-sunset::before{content:"\f5a7"}.bi-symmetry-horizontal::before{content:"\f5a8"}.bi-symmetry-vertical::before{content:"\f5a9"}.bi-table::before{content:"\f5aa"}.bi-tablet-fill::before{content:"\f5ab"}.bi-tablet-landscape-fill::before{content:"\f5ac"}.bi-tablet-landscape::before{content:"\f5ad"}.bi-tablet::before{content:"\f5ae"}.bi-tag-fill::before{content:"\f5af"}.bi-tag::before{content:"\f5b0"}.bi-tags-fill::before{content:"\f5b1"}.bi-tags::before{content:"\f5b2"}.bi-telegram::before{content:"\f5b3"}.bi-telephone-fill::before{content:"\f5b4"}.bi-telephone-forward-fill::before{content:"\f5b5"}.bi-telephone-forward::before{content:"\f5b6"}.bi-telephone-inbound-fill::before{content:"\f5b7"}.bi-telephone-inbound::before{content:"\f5b8"}.bi-telephone-minus-fill::before{content:"\f5b9"}.bi-telephone-minus::before{content:"\f5ba"}.bi-telephone-outbound-fill::before{content:"\f5bb"}.bi-telephone-outbound::before{content:"\f5bc"}.bi-telephone-plus-fill::before{content:"\f5bd"}.bi-telephone-plus::before{content:"\f5be"}.bi-telephone-x-fill::before{content:"\f5bf"}.bi-telephone-x::before{content:"\f5c0"}.bi-telephone::before{content:"\f5c1"}.bi-terminal-fill::before{content:"\f5c2"}.bi-terminal::before{content:"\f5c3"}.bi-text-center::before{content:"\f5c4"}.bi-text-indent-left::before{content:"\f5c5"}.bi-text-indent-right::before{content:"\f5c6"}.bi-text-left::before{content:"\f5c7"}.bi-text-paragraph::before{content:"\f5c8"}.bi-text-right::before{content:"\f5c9"}.bi-textarea-resize::before{content:"\f5ca"}.bi-textarea-t::before{content:"\f5cb"}.bi-textarea::before{content:"\f5cc"}.bi-thermometer-half::before{content:"\f5cd"}.bi-thermometer-high::before{content:"\f5ce"}.bi-thermometer-low::before{content:"\f5cf"}.bi-thermometer-snow::before{content:"\f5d0"}.bi-thermometer-sun::before{content:"\f5d1"}.bi-thermometer::before{content:"\f5d2"}.bi-three-dots-vertical::before{content:"\f5d3"}.bi-three-dots::before{content:"\f5d4"}.bi-toggle-off::before{content:"\f5d5"}.bi-toggle-on::before{content:"\f5d6"}.bi-toggle2-off::before{content:"\f5d7"}.bi-toggle2-on::before{content:"\f5d8"}.bi-toggles::before{content:"\f5d9"}.bi-toggles2::before{content:"\f5da"}.bi-tools::before{content:"\f5db"}.bi-tornado::before{content:"\f5dc"}.bi-trash-fill::before{content:"\f5dd"}.bi-trash::before{content:"\f5de"}.bi-trash2-fill::before{content:"\f5df"}.bi-trash2::before{content:"\f5e0"}.bi-tree-fill::before{content:"\f5e1"}.bi-tree::before{content:"\f5e2"}.bi-triangle-fill::before{content:"\f5e3"}.bi-triangle-half::before{content:"\f5e4"}.bi-triangle::before{content:"\f5e5"}.bi-trophy-fill::before{content:"\f5e6"}.bi-trophy::before{content:"\f5e7"}.bi-tropical-storm::before{content:"\f5e8"}.bi-truck-flatbed::before{content:"\f5e9"}.bi-truck::before{content:"\f5ea"}.bi-tsunami::before{content:"\f5eb"}.bi-tv-fill::before{content:"\f5ec"}.bi-tv::before{content:"\f5ed"}.bi-twitch::before{content:"\f5ee"}.bi-twitter::before{content:"\f5ef"}.bi-type-bold::before{content:"\f5f0"}.bi-type-h1::before{content:"\f5f1"}.bi-type-h2::before{content:"\f5f2"}.bi-type-h3::before{content:"\f5f3"}.bi-type-italic::before{content:"\f5f4"}.bi-type-strikethrough::before{content:"\f5f5"}.bi-type-underline::before{content:"\f5f6"}.bi-type::before{content:"\f5f7"}.bi-ui-checks-grid::before{content:"\f5f8"}.bi-ui-checks::before{content:"\f5f9"}.bi-ui-radios-grid::before{content:"\f5fa"}.bi-ui-radios::before{content:"\f5fb"}.bi-umbrella-fill::before{content:"\f5fc"}.bi-umbrella::before{content:"\f5fd"}.bi-union::before{content:"\f5fe"}.bi-unlock-fill::before{content:"\f5ff"}.bi-unlock::before{content:"\f600"}.bi-upc-scan::before{content:"\f601"}.bi-upc::before{content:"\f602"}.bi-upload::before{content:"\f603"}.bi-vector-pen::before{content:"\f604"}.bi-view-list::before{content:"\f605"}.bi-view-stacked::before{content:"\f606"}.bi-vinyl-fill::before{content:"\f607"}.bi-vinyl::before{content:"\f608"}.bi-voicemail::before{content:"\f609"}.bi-volume-down-fill::before{content:"\f60a"}.bi-volume-down::before{content:"\f60b"}.bi-volume-mute-fill::before{content:"\f60c"}.bi-volume-mute::before{content:"\f60d"}.bi-volume-off-fill::before{content:"\f60e"}.bi-volume-off::before{content:"\f60f"}.bi-volume-up-fill::before{content:"\f610"}.bi-volume-up::before{content:"\f611"}.bi-vr::before{content:"\f612"}.bi-wallet-fill::before{content:"\f613"}.bi-wallet::before{content:"\f614"}.bi-wallet2::before{content:"\f615"}.bi-watch::before{content:"\f616"}.bi-water::before{content:"\f617"}.bi-whatsapp::before{content:"\f618"}.bi-wifi-1::before{content:"\f619"}.bi-wifi-2::before{content:"\f61a"}.bi-wifi-off::before{content:"\f61b"}.bi-wifi::before{content:"\f61c"}.bi-wind::before{content:"\f61d"}.bi-window-dock::before{content:"\f61e"}.bi-window-sidebar::before{content:"\f61f"}.bi-window::before{content:"\f620"}.bi-wrench::before{content:"\f621"}.bi-x-circle-fill::before{content:"\f622"}.bi-x-circle::before{content:"\f623"}.bi-x-diamond-fill::before{content:"\f624"}.bi-x-diamond::before{content:"\f625"}.bi-x-octagon-fill::before{content:"\f626"}.bi-x-octagon::before{content:"\f627"}.bi-x-square-fill::before{content:"\f628"}.bi-x-square::before{content:"\f629"}.bi-x::before{content:"\f62a"}.bi-youtube::before{content:"\f62b"}.bi-zoom-in::before{content:"\f62c"}.bi-zoom-out::before{content:"\f62d"}.bi-bank::before{content:"\f62e"}.bi-bank2::before{content:"\f62f"}.bi-bell-slash-fill::before{content:"\f630"}.bi-bell-slash::before{content:"\f631"}.bi-cash-coin::before{content:"\f632"}.bi-check-lg::before{content:"\f633"}.bi-coin::before{content:"\f634"}.bi-currency-bitcoin::before{content:"\f635"}.bi-currency-dollar::before{content:"\f636"}.bi-currency-euro::before{content:"\f637"}.bi-currency-exchange::before{content:"\f638"}.bi-currency-pound::before{content:"\f639"}.bi-currency-yen::before{content:"\f63a"}.bi-dash-lg::before{content:"\f63b"}.bi-exclamation-lg::before{content:"\f63c"}.bi-file-earmark-pdf-fill::before{content:"\f63d"}.bi-file-earmark-pdf::before{content:"\f63e"}.bi-file-pdf-fill::before{content:"\f63f"}.bi-file-pdf::before{content:"\f640"}.bi-gender-ambiguous::before{content:"\f641"}.bi-gender-female::before{content:"\f642"}.bi-gender-male::before{content:"\f643"}.bi-gender-trans::before{content:"\f644"}.bi-headset-vr::before{content:"\f645"}.bi-info-lg::before{content:"\f646"}.bi-mastodon::before{content:"\f647"}.bi-messenger::before{content:"\f648"}.bi-piggy-bank-fill::before{content:"\f649"}.bi-piggy-bank::before{content:"\f64a"}.bi-pin-map-fill::before{content:"\f64b"}.bi-pin-map::before{content:"\f64c"}.bi-plus-lg::before{content:"\f64d"}.bi-question-lg::before{content:"\f64e"}.bi-recycle::before{content:"\f64f"}.bi-reddit::before{content:"\f650"}.bi-safe-fill::before{content:"\f651"}.bi-safe2-fill::before{content:"\f652"}.bi-safe2::before{content:"\f653"}.bi-sd-card-fill::before{content:"\f654"}.bi-sd-card::before{content:"\f655"}.bi-skype::before{content:"\f656"}.bi-slash-lg::before{content:"\f657"}.bi-translate::before{content:"\f658"}.bi-x-lg::before{content:"\f659"}.bi-safe::before{content:"\f65a"}.bi-apple::before{content:"\f65b"}.bi-microsoft::before{content:"\f65d"}.bi-windows::before{content:"\f65e"}.bi-behance::before{content:"\f65c"}.bi-dribbble::before{content:"\f65f"}.bi-line::before{content:"\f660"}.bi-medium::before{content:"\f661"}.bi-paypal::before{content:"\f662"}.bi-pinterest::before{content:"\f663"}.bi-signal::before{content:"\f664"}.bi-snapchat::before{content:"\f665"}.bi-spotify::before{content:"\f666"}.bi-stack-overflow::before{content:"\f667"}.bi-strava::before{content:"\f668"}.bi-wordpress::before{content:"\f669"}.bi-vimeo::before{content:"\f66a"}.bi-activity::before{content:"\f66b"}.bi-easel2-fill::before{content:"\f66c"}.bi-easel2::before{content:"\f66d"}.bi-easel3-fill::before{content:"\f66e"}.bi-easel3::before{content:"\f66f"}.bi-fan::before{content:"\f670"}.bi-fingerprint::before{content:"\f671"}.bi-graph-down-arrow::before{content:"\f672"}.bi-graph-up-arrow::before{content:"\f673"}.bi-hypnotize::before{content:"\f674"}.bi-magic::before{content:"\f675"}.bi-person-rolodex::before{content:"\f676"}.bi-person-video::before{content:"\f677"}.bi-person-video2::before{content:"\f678"}.bi-person-video3::before{content:"\f679"}.bi-person-workspace::before{content:"\f67a"}.bi-radioactive::before{content:"\f67b"}.bi-webcam-fill::before{content:"\f67c"}.bi-webcam::before{content:"\f67d"}.bi-yin-yang::before{content:"\f67e"}.bi-bandaid-fill::before{content:"\f680"}.bi-bandaid::before{content:"\f681"}.bi-bluetooth::before{content:"\f682"}.bi-body-text::before{content:"\f683"}.bi-boombox::before{content:"\f684"}.bi-boxes::before{content:"\f685"}.bi-dpad-fill::before{content:"\f686"}.bi-dpad::before{content:"\f687"}.bi-ear-fill::before{content:"\f688"}.bi-ear::before{content:"\f689"}.bi-envelope-check-fill::before{content:"\f68b"}.bi-envelope-check::before{content:"\f68c"}.bi-envelope-dash-fill::before{content:"\f68e"}.bi-envelope-dash::before{content:"\f68f"}.bi-envelope-exclamation-fill::before{content:"\f691"}.bi-envelope-exclamation::before{content:"\f692"}.bi-envelope-plus-fill::before{content:"\f693"}.bi-envelope-plus::before{content:"\f694"}.bi-envelope-slash-fill::before{content:"\f696"}.bi-envelope-slash::before{content:"\f697"}.bi-envelope-x-fill::before{content:"\f699"}.bi-envelope-x::before{content:"\f69a"}.bi-explicit-fill::before{content:"\f69b"}.bi-explicit::before{content:"\f69c"}.bi-git::before{content:"\f69d"}.bi-infinity::before{content:"\f69e"}.bi-list-columns-reverse::before{content:"\f69f"}.bi-list-columns::before{content:"\f6a0"}.bi-meta::before{content:"\f6a1"}.bi-nintendo-switch::before{content:"\f6a4"}.bi-pc-display-horizontal::before{content:"\f6a5"}.bi-pc-display::before{content:"\f6a6"}.bi-pc-horizontal::before{content:"\f6a7"}.bi-pc::before{content:"\f6a8"}.bi-playstation::before{content:"\f6a9"}.bi-plus-slash-minus::before{content:"\f6aa"}.bi-projector-fill::before{content:"\f6ab"}.bi-projector::before{content:"\f6ac"}.bi-qr-code-scan::before{content:"\f6ad"}.bi-qr-code::before{content:"\f6ae"}.bi-quora::before{content:"\f6af"}.bi-quote::before{content:"\f6b0"}.bi-robot::before{content:"\f6b1"}.bi-send-check-fill::before{content:"\f6b2"}.bi-send-check::before{content:"\f6b3"}.bi-send-dash-fill::before{content:"\f6b4"}.bi-send-dash::before{content:"\f6b5"}.bi-send-exclamation-fill::before{content:"\f6b7"}.bi-send-exclamation::before{content:"\f6b8"}.bi-send-fill::before{content:"\f6b9"}.bi-send-plus-fill::before{content:"\f6ba"}.bi-send-plus::before{content:"\f6bb"}.bi-send-slash-fill::before{content:"\f6bc"}.bi-send-slash::before{content:"\f6bd"}.bi-send-x-fill::before{content:"\f6be"}.bi-send-x::before{content:"\f6bf"}.bi-send::before{content:"\f6c0"}.bi-steam::before{content:"\f6c1"}.bi-terminal-dash::before{content:"\f6c3"}.bi-terminal-plus::before{content:"\f6c4"}.bi-terminal-split::before{content:"\f6c5"}.bi-ticket-detailed-fill::before{content:"\f6c6"}.bi-ticket-detailed::before{content:"\f6c7"}.bi-ticket-fill::before{content:"\f6c8"}.bi-ticket-perforated-fill::before{content:"\f6c9"}.bi-ticket-perforated::before{content:"\f6ca"}.bi-ticket::before{content:"\f6cb"}.bi-tiktok::before{content:"\f6cc"}.bi-window-dash::before{content:"\f6cd"}.bi-window-desktop::before{content:"\f6ce"}.bi-window-fullscreen::before{content:"\f6cf"}.bi-window-plus::before{content:"\f6d0"}.bi-window-split::before{content:"\f6d1"}.bi-window-stack::before{content:"\f6d2"}.bi-window-x::before{content:"\f6d3"}.bi-xbox::before{content:"\f6d4"}.bi-ethernet::before{content:"\f6d5"}.bi-hdmi-fill::before{content:"\f6d6"}.bi-hdmi::before{content:"\f6d7"}.bi-usb-c-fill::before{content:"\f6d8"}.bi-usb-c::before{content:"\f6d9"}.bi-usb-fill::before{content:"\f6da"}.bi-usb-plug-fill::before{content:"\f6db"}.bi-usb-plug::before{content:"\f6dc"}.bi-usb-symbol::before{content:"\f6dd"}.bi-usb::before{content:"\f6de"}.bi-boombox-fill::before{content:"\f6df"}.bi-displayport::before{content:"\f6e1"}.bi-gpu-card::before{content:"\f6e2"}.bi-memory::before{content:"\f6e3"}.bi-modem-fill::before{content:"\f6e4"}.bi-modem::before{content:"\f6e5"}.bi-motherboard-fill::before{content:"\f6e6"}.bi-motherboard::before{content:"\f6e7"}.bi-optical-audio-fill::before{content:"\f6e8"}.bi-optical-audio::before{content:"\f6e9"}.bi-pci-card::before{content:"\f6ea"}.bi-router-fill::before{content:"\f6eb"}.bi-router::before{content:"\f6ec"}.bi-thunderbolt-fill::before{content:"\f6ef"}.bi-thunderbolt::before{content:"\f6f0"}.bi-usb-drive-fill::before{content:"\f6f1"}.bi-usb-drive::before{content:"\f6f2"}.bi-usb-micro-fill::before{content:"\f6f3"}.bi-usb-micro::before{content:"\f6f4"}.bi-usb-mini-fill::before{content:"\f6f5"}.bi-usb-mini::before{content:"\f6f6"}.bi-cloud-haze2::before{content:"\f6f7"}.bi-device-hdd-fill::before{content:"\f6f8"}.bi-device-hdd::before{content:"\f6f9"}.bi-device-ssd-fill::before{content:"\f6fa"}.bi-device-ssd::before{content:"\f6fb"}.bi-displayport-fill::before{content:"\f6fc"}.bi-mortarboard-fill::before{content:"\f6fd"}.bi-mortarboard::before{content:"\f6fe"}.bi-terminal-x::before{content:"\f6ff"}.bi-arrow-through-heart-fill::before{content:"\f700"}.bi-arrow-through-heart::before{content:"\f701"}.bi-badge-sd-fill::before{content:"\f702"}.bi-badge-sd::before{content:"\f703"}.bi-bag-heart-fill::before{content:"\f704"}.bi-bag-heart::before{content:"\f705"}.bi-balloon-fill::before{content:"\f706"}.bi-balloon-heart-fill::before{content:"\f707"}.bi-balloon-heart::before{content:"\f708"}.bi-balloon::before{content:"\f709"}.bi-box2-fill::before{content:"\f70a"}.bi-box2-heart-fill::before{content:"\f70b"}.bi-box2-heart::before{content:"\f70c"}.bi-box2::before{content:"\f70d"}.bi-braces-asterisk::before{content:"\f70e"}.bi-calendar-heart-fill::before{content:"\f70f"}.bi-calendar-heart::before{content:"\f710"}.bi-calendar2-heart-fill::before{content:"\f711"}.bi-calendar2-heart::before{content:"\f712"}.bi-chat-heart-fill::before{content:"\f713"}.bi-chat-heart::before{content:"\f714"}.bi-chat-left-heart-fill::before{content:"\f715"}.bi-chat-left-heart::before{content:"\f716"}.bi-chat-right-heart-fill::before{content:"\f717"}.bi-chat-right-heart::before{content:"\f718"}.bi-chat-square-heart-fill::before{content:"\f719"}.bi-chat-square-heart::before{content:"\f71a"}.bi-clipboard-check-fill::before{content:"\f71b"}.bi-clipboard-data-fill::before{content:"\f71c"}.bi-clipboard-fill::before{content:"\f71d"}.bi-clipboard-heart-fill::before{content:"\f71e"}.bi-clipboard-heart::before{content:"\f71f"}.bi-clipboard-minus-fill::before{content:"\f720"}.bi-clipboard-plus-fill::before{content:"\f721"}.bi-clipboard-pulse::before{content:"\f722"}.bi-clipboard-x-fill::before{content:"\f723"}.bi-clipboard2-check-fill::before{content:"\f724"}.bi-clipboard2-check::before{content:"\f725"}.bi-clipboard2-data-fill::before{content:"\f726"}.bi-clipboard2-data::before{content:"\f727"}.bi-clipboard2-fill::before{content:"\f728"}.bi-clipboard2-heart-fill::before{content:"\f729"}.bi-clipboard2-heart::before{content:"\f72a"}.bi-clipboard2-minus-fill::before{content:"\f72b"}.bi-clipboard2-minus::before{content:"\f72c"}.bi-clipboard2-plus-fill::before{content:"\f72d"}.bi-clipboard2-plus::before{content:"\f72e"}.bi-clipboard2-pulse-fill::before{content:"\f72f"}.bi-clipboard2-pulse::before{content:"\f730"}.bi-clipboard2-x-fill::before{content:"\f731"}.bi-clipboard2-x::before{content:"\f732"}.bi-clipboard2::before{content:"\f733"}.bi-emoji-kiss-fill::before{content:"\f734"}.bi-emoji-kiss::before{content:"\f735"}.bi-envelope-heart-fill::before{content:"\f736"}.bi-envelope-heart::before{content:"\f737"}.bi-envelope-open-heart-fill::before{content:"\f738"}.bi-envelope-open-heart::before{content:"\f739"}.bi-envelope-paper-fill::before{content:"\f73a"}.bi-envelope-paper-heart-fill::before{content:"\f73b"}.bi-envelope-paper-heart::before{content:"\f73c"}.bi-envelope-paper::before{content:"\f73d"}.bi-filetype-aac::before{content:"\f73e"}.bi-filetype-ai::before{content:"\f73f"}.bi-filetype-bmp::before{content:"\f740"}.bi-filetype-cs::before{content:"\f741"}.bi-filetype-css::before{content:"\f742"}.bi-filetype-csv::before{content:"\f743"}.bi-filetype-doc::before{content:"\f744"}.bi-filetype-docx::before{content:"\f745"}.bi-filetype-exe::before{content:"\f746"}.bi-filetype-gif::before{content:"\f747"}.bi-filetype-heic::before{content:"\f748"}.bi-filetype-html::before{content:"\f749"}.bi-filetype-java::before{content:"\f74a"}.bi-filetype-jpg::before{content:"\f74b"}.bi-filetype-js::before{content:"\f74c"}.bi-filetype-jsx::before{content:"\f74d"}.bi-filetype-key::before{content:"\f74e"}.bi-filetype-m4p::before{content:"\f74f"}.bi-filetype-md::before{content:"\f750"}.bi-filetype-mdx::before{content:"\f751"}.bi-filetype-mov::before{content:"\f752"}.bi-filetype-mp3::before{content:"\f753"}.bi-filetype-mp4::before{content:"\f754"}.bi-filetype-otf::before{content:"\f755"}.bi-filetype-pdf::before{content:"\f756"}.bi-filetype-php::before{content:"\f757"}.bi-filetype-png::before{content:"\f758"}.bi-filetype-ppt::before{content:"\f75a"}.bi-filetype-psd::before{content:"\f75b"}.bi-filetype-py::before{content:"\f75c"}.bi-filetype-raw::before{content:"\f75d"}.bi-filetype-rb::before{content:"\f75e"}.bi-filetype-sass::before{content:"\f75f"}.bi-filetype-scss::before{content:"\f760"}.bi-filetype-sh::before{content:"\f761"}.bi-filetype-svg::before{content:"\f762"}.bi-filetype-tiff::before{content:"\f763"}.bi-filetype-tsx::before{content:"\f764"}.bi-filetype-ttf::before{content:"\f765"}.bi-filetype-txt::before{content:"\f766"}.bi-filetype-wav::before{content:"\f767"}.bi-filetype-woff::before{content:"\f768"}.bi-filetype-xls::before{content:"\f76a"}.bi-filetype-xml::before{content:"\f76b"}.bi-filetype-yml::before{content:"\f76c"}.bi-heart-arrow::before{content:"\f76d"}.bi-heart-pulse-fill::before{content:"\f76e"}.bi-heart-pulse::before{content:"\f76f"}.bi-heartbreak-fill::before{content:"\f770"}.bi-heartbreak::before{content:"\f771"}.bi-hearts::before{content:"\f772"}.bi-hospital-fill::before{content:"\f773"}.bi-hospital::before{content:"\f774"}.bi-house-heart-fill::before{content:"\f775"}.bi-house-heart::before{content:"\f776"}.bi-incognito::before{content:"\f777"}.bi-magnet-fill::before{content:"\f778"}.bi-magnet::before{content:"\f779"}.bi-person-heart::before{content:"\f77a"}.bi-person-hearts::before{content:"\f77b"}.bi-phone-flip::before{content:"\f77c"}.bi-plugin::before{content:"\f77d"}.bi-postage-fill::before{content:"\f77e"}.bi-postage-heart-fill::before{content:"\f77f"}.bi-postage-heart::before{content:"\f780"}.bi-postage::before{content:"\f781"}.bi-postcard-fill::before{content:"\f782"}.bi-postcard-heart-fill::before{content:"\f783"}.bi-postcard-heart::before{content:"\f784"}.bi-postcard::before{content:"\f785"}.bi-search-heart-fill::before{content:"\f786"}.bi-search-heart::before{content:"\f787"}.bi-sliders2-vertical::before{content:"\f788"}.bi-sliders2::before{content:"\f789"}.bi-trash3-fill::before{content:"\f78a"}.bi-trash3::before{content:"\f78b"}.bi-valentine::before{content:"\f78c"}.bi-valentine2::before{content:"\f78d"}.bi-wrench-adjustable-circle-fill::before{content:"\f78e"}.bi-wrench-adjustable-circle::before{content:"\f78f"}.bi-wrench-adjustable::before{content:"\f790"}.bi-filetype-json::before{content:"\f791"}.bi-filetype-pptx::before{content:"\f792"}.bi-filetype-xlsx::before{content:"\f793"}.bi-1-circle-fill::before{content:"\f796"}.bi-1-circle::before{content:"\f797"}.bi-1-square-fill::before{content:"\f798"}.bi-1-square::before{content:"\f799"}.bi-2-circle-fill::before{content:"\f79c"}.bi-2-circle::before{content:"\f79d"}.bi-2-square-fill::before{content:"\f79e"}.bi-2-square::before{content:"\f79f"}.bi-3-circle-fill::before{content:"\f7a2"}.bi-3-circle::before{content:"\f7a3"}.bi-3-square-fill::before{content:"\f7a4"}.bi-3-square::before{content:"\f7a5"}.bi-4-circle-fill::before{content:"\f7a8"}.bi-4-circle::before{content:"\f7a9"}.bi-4-square-fill::before{content:"\f7aa"}.bi-4-square::before{content:"\f7ab"}.bi-5-circle-fill::before{content:"\f7ae"}.bi-5-circle::before{content:"\f7af"}.bi-5-square-fill::before{content:"\f7b0"}.bi-5-square::before{content:"\f7b1"}.bi-6-circle-fill::before{content:"\f7b4"}.bi-6-circle::before{content:"\f7b5"}.bi-6-square-fill::before{content:"\f7b6"}.bi-6-square::before{content:"\f7b7"}.bi-7-circle-fill::before{content:"\f7ba"}.bi-7-circle::before{content:"\f7bb"}.bi-7-square-fill::before{content:"\f7bc"}.bi-7-square::before{content:"\f7bd"}.bi-8-circle-fill::before{content:"\f7c0"}.bi-8-circle::before{content:"\f7c1"}.bi-8-square-fill::before{content:"\f7c2"}.bi-8-square::before{content:"\f7c3"}.bi-9-circle-fill::before{content:"\f7c6"}.bi-9-circle::before{content:"\f7c7"}.bi-9-square-fill::before{content:"\f7c8"}.bi-9-square::before{content:"\f7c9"}.bi-airplane-engines-fill::before{content:"\f7ca"}.bi-airplane-engines::before{content:"\f7cb"}.bi-airplane-fill::before{content:"\f7cc"}.bi-airplane::before{content:"\f7cd"}.bi-alexa::before{content:"\f7ce"}.bi-alipay::before{content:"\f7cf"}.bi-android::before{content:"\f7d0"}.bi-android2::before{content:"\f7d1"}.bi-box-fill::before{content:"\f7d2"}.bi-box-seam-fill::before{content:"\f7d3"}.bi-browser-chrome::before{content:"\f7d4"}.bi-browser-edge::before{content:"\f7d5"}.bi-browser-firefox::before{content:"\f7d6"}.bi-browser-safari::before{content:"\f7d7"}.bi-c-circle-fill::before{content:"\f7da"}.bi-c-circle::before{content:"\f7db"}.bi-c-square-fill::before{content:"\f7dc"}.bi-c-square::before{content:"\f7dd"}.bi-capsule-pill::before{content:"\f7de"}.bi-capsule::before{content:"\f7df"}.bi-car-front-fill::before{content:"\f7e0"}.bi-car-front::before{content:"\f7e1"}.bi-cassette-fill::before{content:"\f7e2"}.bi-cassette::before{content:"\f7e3"}.bi-cc-circle-fill::before{content:"\f7e6"}.bi-cc-circle::before{content:"\f7e7"}.bi-cc-square-fill::before{content:"\f7e8"}.bi-cc-square::before{content:"\f7e9"}.bi-cup-hot-fill::before{content:"\f7ea"}.bi-cup-hot::before{content:"\f7eb"}.bi-currency-rupee::before{content:"\f7ec"}.bi-dropbox::before{content:"\f7ed"}.bi-escape::before{content:"\f7ee"}.bi-fast-forward-btn-fill::before{content:"\f7ef"}.bi-fast-forward-btn::before{content:"\f7f0"}.bi-fast-forward-circle-fill::before{content:"\f7f1"}.bi-fast-forward-circle::before{content:"\f7f2"}.bi-fast-forward-fill::before{content:"\f7f3"}.bi-fast-forward::before{content:"\f7f4"}.bi-filetype-sql::before{content:"\f7f5"}.bi-fire::before{content:"\f7f6"}.bi-google-play::before{content:"\f7f7"}.bi-h-circle-fill::before{content:"\f7fa"}.bi-h-circle::before{content:"\f7fb"}.bi-h-square-fill::before{content:"\f7fc"}.bi-h-square::before{content:"\f7fd"}.bi-indent::before{content:"\f7fe"}.bi-lungs-fill::before{content:"\f7ff"}.bi-lungs::before{content:"\f800"}.bi-microsoft-teams::before{content:"\f801"}.bi-p-circle-fill::before{content:"\f804"}.bi-p-circle::before{content:"\f805"}.bi-p-square-fill::before{content:"\f806"}.bi-p-square::before{content:"\f807"}.bi-pass-fill::before{content:"\f808"}.bi-pass::before{content:"\f809"}.bi-prescription::before{content:"\f80a"}.bi-prescription2::before{content:"\f80b"}.bi-r-circle-fill::before{content:"\f80e"}.bi-r-circle::before{content:"\f80f"}.bi-r-square-fill::before{content:"\f810"}.bi-r-square::before{content:"\f811"}.bi-repeat-1::before{content:"\f812"}.bi-repeat::before{content:"\f813"}.bi-rewind-btn-fill::before{content:"\f814"}.bi-rewind-btn::before{content:"\f815"}.bi-rewind-circle-fill::before{content:"\f816"}.bi-rewind-circle::before{content:"\f817"}.bi-rewind-fill::before{content:"\f818"}.bi-rewind::before{content:"\f819"}.bi-train-freight-front-fill::before{content:"\f81a"}.bi-train-freight-front::before{content:"\f81b"}.bi-train-front-fill::before{content:"\f81c"}.bi-train-front::before{content:"\f81d"}.bi-train-lightrail-front-fill::before{content:"\f81e"}.bi-train-lightrail-front::before{content:"\f81f"}.bi-truck-front-fill::before{content:"\f820"}.bi-truck-front::before{content:"\f821"}.bi-ubuntu::before{content:"\f822"}.bi-unindent::before{content:"\f823"}.bi-unity::before{content:"\f824"}.bi-universal-access-circle::before{content:"\f825"}.bi-universal-access::before{content:"\f826"}.bi-virus::before{content:"\f827"}.bi-virus2::before{content:"\f828"}.bi-wechat::before{content:"\f829"}.bi-yelp::before{content:"\f82a"}.bi-sign-stop-fill::before{content:"\f82b"}.bi-sign-stop-lights-fill::before{content:"\f82c"}.bi-sign-stop-lights::before{content:"\f82d"}.bi-sign-stop::before{content:"\f82e"}.bi-sign-turn-left-fill::before{content:"\f82f"}.bi-sign-turn-left::before{content:"\f830"}.bi-sign-turn-right-fill::before{content:"\f831"}.bi-sign-turn-right::before{content:"\f832"}.bi-sign-turn-slight-left-fill::before{content:"\f833"}.bi-sign-turn-slight-left::before{content:"\f834"}.bi-sign-turn-slight-right-fill::before{content:"\f835"}.bi-sign-turn-slight-right::before{content:"\f836"}.bi-sign-yield-fill::before{content:"\f837"}.bi-sign-yield::before{content:"\f838"}.bi-ev-station-fill::before{content:"\f839"}.bi-ev-station::before{content:"\f83a"}.bi-fuel-pump-diesel-fill::before{content:"\f83b"}.bi-fuel-pump-diesel::before{content:"\f83c"}.bi-fuel-pump-fill::before{content:"\f83d"}.bi-fuel-pump::before{content:"\f83e"}.bi-0-circle-fill::before{content:"\f83f"}.bi-0-circle::before{content:"\f840"}.bi-0-square-fill::before{content:"\f841"}.bi-0-square::before{content:"\f842"}.bi-rocket-fill::before{content:"\f843"}.bi-rocket-takeoff-fill::before{content:"\f844"}.bi-rocket-takeoff::before{content:"\f845"}.bi-rocket::before{content:"\f846"}.bi-stripe::before{content:"\f847"}.bi-subscript::before{content:"\f848"}.bi-superscript::before{content:"\f849"}.bi-trello::before{content:"\f84a"}.bi-envelope-at-fill::before{content:"\f84b"}.bi-envelope-at::before{content:"\f84c"}.bi-regex::before{content:"\f84d"}.bi-text-wrap::before{content:"\f84e"}.bi-sign-dead-end-fill::before{content:"\f84f"}.bi-sign-dead-end::before{content:"\f850"}.bi-sign-do-not-enter-fill::before{content:"\f851"}.bi-sign-do-not-enter::before{content:"\f852"}.bi-sign-intersection-fill::before{content:"\f853"}.bi-sign-intersection-side-fill::before{content:"\f854"}.bi-sign-intersection-side::before{content:"\f855"}.bi-sign-intersection-t-fill::before{content:"\f856"}.bi-sign-intersection-t::before{content:"\f857"}.bi-sign-intersection-y-fill::before{content:"\f858"}.bi-sign-intersection-y::before{content:"\f859"}.bi-sign-intersection::before{content:"\f85a"}.bi-sign-merge-left-fill::before{content:"\f85b"}.bi-sign-merge-left::before{content:"\f85c"}.bi-sign-merge-right-fill::before{content:"\f85d"}.bi-sign-merge-right::before{content:"\f85e"}.bi-sign-no-left-turn-fill::before{content:"\f85f"}.bi-sign-no-left-turn::before{content:"\f860"}.bi-sign-no-parking-fill::before{content:"\f861"}.bi-sign-no-parking::before{content:"\f862"}.bi-sign-no-right-turn-fill::before{content:"\f863"}.bi-sign-no-right-turn::before{content:"\f864"}.bi-sign-railroad-fill::before{content:"\f865"}.bi-sign-railroad::before{content:"\f866"}.bi-building-add::before{content:"\f867"}.bi-building-check::before{content:"\f868"}.bi-building-dash::before{content:"\f869"}.bi-building-down::before{content:"\f86a"}.bi-building-exclamation::before{content:"\f86b"}.bi-building-fill-add::before{content:"\f86c"}.bi-building-fill-check::before{content:"\f86d"}.bi-building-fill-dash::before{content:"\f86e"}.bi-building-fill-down::before{content:"\f86f"}.bi-building-fill-exclamation::before{content:"\f870"}.bi-building-fill-gear::before{content:"\f871"}.bi-building-fill-lock::before{content:"\f872"}.bi-building-fill-slash::before{content:"\f873"}.bi-building-fill-up::before{content:"\f874"}.bi-building-fill-x::before{content:"\f875"}.bi-building-fill::before{content:"\f876"}.bi-building-gear::before{content:"\f877"}.bi-building-lock::before{content:"\f878"}.bi-building-slash::before{content:"\f879"}.bi-building-up::before{content:"\f87a"}.bi-building-x::before{content:"\f87b"}.bi-buildings-fill::before{content:"\f87c"}.bi-buildings::before{content:"\f87d"}.bi-bus-front-fill::before{content:"\f87e"}.bi-bus-front::before{content:"\f87f"}.bi-ev-front-fill::before{content:"\f880"}.bi-ev-front::before{content:"\f881"}.bi-globe-americas::before{content:"\f882"}.bi-globe-asia-australia::before{content:"\f883"}.bi-globe-central-south-asia::before{content:"\f884"}.bi-globe-europe-africa::before{content:"\f885"}.bi-house-add-fill::before{content:"\f886"}.bi-house-add::before{content:"\f887"}.bi-house-check-fill::before{content:"\f888"}.bi-house-check::before{content:"\f889"}.bi-house-dash-fill::before{content:"\f88a"}.bi-house-dash::before{content:"\f88b"}.bi-house-down-fill::before{content:"\f88c"}.bi-house-down::before{content:"\f88d"}.bi-house-exclamation-fill::before{content:"\f88e"}.bi-house-exclamation::before{content:"\f88f"}.bi-house-gear-fill::before{content:"\f890"}.bi-house-gear::before{content:"\f891"}.bi-house-lock-fill::before{content:"\f892"}.bi-house-lock::before{content:"\f893"}.bi-house-slash-fill::before{content:"\f894"}.bi-house-slash::before{content:"\f895"}.bi-house-up-fill::before{content:"\f896"}.bi-house-up::before{content:"\f897"}.bi-house-x-fill::before{content:"\f898"}.bi-house-x::before{content:"\f899"}.bi-person-add::before{content:"\f89a"}.bi-person-down::before{content:"\f89b"}.bi-person-exclamation::before{content:"\f89c"}.bi-person-fill-add::before{content:"\f89d"}.bi-person-fill-check::before{content:"\f89e"}.bi-person-fill-dash::before{content:"\f89f"}.bi-person-fill-down::before{content:"\f8a0"}.bi-person-fill-exclamation::before{content:"\f8a1"}.bi-person-fill-gear::before{content:"\f8a2"}.bi-person-fill-lock::before{content:"\f8a3"}.bi-person-fill-slash::before{content:"\f8a4"}.bi-person-fill-up::before{content:"\f8a5"}.bi-person-fill-x::before{content:"\f8a6"}.bi-person-gear::before{content:"\f8a7"}.bi-person-lock::before{content:"\f8a8"}.bi-person-slash::before{content:"\f8a9"}.bi-person-up::before{content:"\f8aa"}.bi-scooter::before{content:"\f8ab"}.bi-taxi-front-fill::before{content:"\f8ac"}.bi-taxi-front::before{content:"\f8ad"}.bi-amd::before{content:"\f8ae"}.bi-database-add::before{content:"\f8af"}.bi-database-check::before{content:"\f8b0"}.bi-database-dash::before{content:"\f8b1"}.bi-database-down::before{content:"\f8b2"}.bi-database-exclamation::before{content:"\f8b3"}.bi-database-fill-add::before{content:"\f8b4"}.bi-database-fill-check::before{content:"\f8b5"}.bi-database-fill-dash::before{content:"\f8b6"}.bi-database-fill-down::before{content:"\f8b7"}.bi-database-fill-exclamation::before{content:"\f8b8"}.bi-database-fill-gear::before{content:"\f8b9"}.bi-database-fill-lock::before{content:"\f8ba"}.bi-database-fill-slash::before{content:"\f8bb"}.bi-database-fill-up::before{content:"\f8bc"}.bi-database-fill-x::before{content:"\f8bd"}.bi-database-fill::before{content:"\f8be"}.bi-database-gear::before{content:"\f8bf"}.bi-database-lock::before{content:"\f8c0"}.bi-database-slash::before{content:"\f8c1"}.bi-database-up::before{content:"\f8c2"}.bi-database-x::before{content:"\f8c3"}.bi-database::before{content:"\f8c4"}.bi-houses-fill::before{content:"\f8c5"}.bi-houses::before{content:"\f8c6"}.bi-nvidia::before{content:"\f8c7"}.bi-person-vcard-fill::before{content:"\f8c8"}.bi-person-vcard::before{content:"\f8c9"}.bi-sina-weibo::before{content:"\f8ca"}.bi-tencent-qq::before{content:"\f8cb"}.bi-wikipedia::before{content:"\f8cc"}.bi-alphabet-uppercase::before{content:"\f2a5"}.bi-alphabet::before{content:"\f68a"}.bi-amazon::before{content:"\f68d"}.bi-arrows-collapse-vertical::before{content:"\f690"}.bi-arrows-expand-vertical::before{content:"\f695"}.bi-arrows-vertical::before{content:"\f698"}.bi-arrows::before{content:"\f6a2"}.bi-ban-fill::before{content:"\f6a3"}.bi-ban::before{content:"\f6b6"}.bi-bing::before{content:"\f6c2"}.bi-cake::before{content:"\f6e0"}.bi-cake2::before{content:"\f6ed"}.bi-cookie::before{content:"\f6ee"}.bi-copy::before{content:"\f759"}.bi-crosshair::before{content:"\f769"}.bi-crosshair2::before{content:"\f794"}.bi-emoji-astonished-fill::before{content:"\f795"}.bi-emoji-astonished::before{content:"\f79a"}.bi-emoji-grimace-fill::before{content:"\f79b"}.bi-emoji-grimace::before{content:"\f7a0"}.bi-emoji-grin-fill::before{content:"\f7a1"}.bi-emoji-grin::before{content:"\f7a6"}.bi-emoji-surprise-fill::before{content:"\f7a7"}.bi-emoji-surprise::before{content:"\f7ac"}.bi-emoji-tear-fill::before{content:"\f7ad"}.bi-emoji-tear::before{content:"\f7b2"}.bi-envelope-arrow-down-fill::before{content:"\f7b3"}.bi-envelope-arrow-down::before{content:"\f7b8"}.bi-envelope-arrow-up-fill::before{content:"\f7b9"}.bi-envelope-arrow-up::before{content:"\f7be"}.bi-feather::before{content:"\f7bf"}.bi-feather2::before{content:"\f7c4"}.bi-floppy-fill::before{content:"\f7c5"}.bi-floppy::before{content:"\f7d8"}.bi-floppy2-fill::before{content:"\f7d9"}.bi-floppy2::before{content:"\f7e4"}.bi-gitlab::before{content:"\f7e5"}.bi-highlighter::before{content:"\f7f8"}.bi-marker-tip::before{content:"\f802"}.bi-nvme-fill::before{content:"\f803"}.bi-nvme::before{content:"\f80c"}.bi-opencollective::before{content:"\f80d"}.bi-pci-card-network::before{content:"\f8cd"}.bi-pci-card-sound::before{content:"\f8ce"}.bi-radar::before{content:"\f8cf"}.bi-send-arrow-down-fill::before{content:"\f8d0"}.bi-send-arrow-down::before{content:"\f8d1"}.bi-send-arrow-up-fill::before{content:"\f8d2"}.bi-send-arrow-up::before{content:"\f8d3"}.bi-sim-slash-fill::before{content:"\f8d4"}.bi-sim-slash::before{content:"\f8d5"}.bi-sourceforge::before{content:"\f8d6"}.bi-substack::before{content:"\f8d7"}.bi-threads-fill::before{content:"\f8d8"}.bi-threads::before{content:"\f8d9"}.bi-transparency::before{content:"\f8da"}.bi-twitter-x::before{content:"\f8db"}.bi-type-h4::before{content:"\f8dc"}.bi-type-h5::before{content:"\f8dd"}.bi-type-h6::before{content:"\f8de"}.bi-backpack-fill::before{content:"\f8df"}.bi-backpack::before{content:"\f8e0"}.bi-backpack2-fill::before{content:"\f8e1"}.bi-backpack2::before{content:"\f8e2"}.bi-backpack3-fill::before{content:"\f8e3"}.bi-backpack3::before{content:"\f8e4"}.bi-backpack4-fill::before{content:"\f8e5"}.bi-backpack4::before{content:"\f8e6"}.bi-brilliance::before{content:"\f8e7"}.bi-cake-fill::before{content:"\f8e8"}.bi-cake2-fill::before{content:"\f8e9"}.bi-duffle-fill::before{content:"\f8ea"}.bi-duffle::before{content:"\f8eb"}.bi-exposure::before{content:"\f8ec"}.bi-gender-neuter::before{content:"\f8ed"}.bi-highlights::before{content:"\f8ee"}.bi-luggage-fill::before{content:"\f8ef"}.bi-luggage::before{content:"\f8f0"}.bi-mailbox-flag::before{content:"\f8f1"}.bi-mailbox2-flag::before{content:"\f8f2"}.bi-noise-reduction::before{content:"\f8f3"}.bi-passport-fill::before{content:"\f8f4"}.bi-passport::before{content:"\f8f5"}.bi-person-arms-up::before{content:"\f8f6"}.bi-person-raised-hand::before{content:"\f8f7"}.bi-person-standing-dress::before{content:"\f8f8"}.bi-person-standing::before{content:"\f8f9"}.bi-person-walking::before{content:"\f8fa"}.bi-person-wheelchair::before{content:"\f8fb"}.bi-shadows::before{content:"\f8fc"}.bi-suitcase-fill::before{content:"\f8fd"}.bi-suitcase-lg-fill::before{content:"\f8fe"}.bi-suitcase-lg::before{content:"\f8ff"}.bi-suitcase::before{content:"\f900"}.bi-suitcase2-fill::before{content:"\f901"}.bi-suitcase2::before{content:"\f902"}.bi-vignette::before{content:"\f903"} \ No newline at end of file diff --git a/src/web/public/adminlte/bootstrap-icons/css/fonts/bootstrap-icons.woff b/src/web/public/adminlte/bootstrap-icons/css/fonts/bootstrap-icons.woff new file mode 100644 index 0000000..51204d2 Binary files /dev/null and b/src/web/public/adminlte/bootstrap-icons/css/fonts/bootstrap-icons.woff differ diff --git a/src/web/public/adminlte/bootstrap-icons/css/fonts/bootstrap-icons.woff2 b/src/web/public/adminlte/bootstrap-icons/css/fonts/bootstrap-icons.woff2 new file mode 100644 index 0000000..92c4830 Binary files /dev/null and b/src/web/public/adminlte/bootstrap-icons/css/fonts/bootstrap-icons.woff2 differ diff --git a/src/web/public/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff b/src/web/public/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff new file mode 100644 index 0000000..51204d2 Binary files /dev/null and b/src/web/public/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff differ diff --git a/src/web/public/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff2 b/src/web/public/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff2 new file mode 100644 index 0000000..92c4830 Binary files /dev/null and b/src/web/public/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff2 differ diff --git a/src/web/public/adminlte/css/adminlte.min.css b/src/web/public/adminlte/css/adminlte.min.css new file mode 100644 index 0000000..c314124 --- /dev/null +++ b/src/web/public/adminlte/css/adminlte.min.css @@ -0,0 +1,7 @@ +@charset "UTF-8";/*! + * AdminLTE v4.1.0 + * Author: Colorlib + * Website: AdminLTE.io + * License: Open source - MIT + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:rgb(5.2, 44, 101.2);--bs-secondary-text-emphasis:rgb(43.2, 46.8, 50);--bs-success-text-emphasis:rgb(10, 54, 33.6);--bs-info-text-emphasis:rgb(5.2, 80.8, 96);--bs-warning-text-emphasis:rgb(102, 77.2, 2.8);--bs-danger-text-emphasis:rgb(88, 21.2, 27.6);--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:rgb(206.6, 226, 254.6);--bs-secondary-bg-subtle:rgb(225.6, 227.4, 229);--bs-success-bg-subtle:rgb(209, 231, 220.8);--bs-info-bg-subtle:rgb(206.6, 244.4, 252);--bs-warning-bg-subtle:rgb(255, 242.6, 205.4);--bs-danger-bg-subtle:rgb(248, 214.6, 217.8);--bs-light-bg-subtle:rgb(251.5, 252, 252.5);--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:rgb(158.2, 197, 254.2);--bs-secondary-border-subtle:rgb(196.2, 199.8, 203);--bs-success-border-subtle:rgb(163, 207, 186.6);--bs-info-border-subtle:rgb(158.2, 233.8, 249);--bs-warning-border-subtle:rgb(255, 230.2, 155.8);--bs-danger-border-subtle:rgb(241, 174.2, 180.6);--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:"Source Sans 3",system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:rgb(10.4, 88, 202.4);--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:rgb(255, 242.6, 205.4);--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:rgb(42.5, 47.5, 52.5);--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:rgb(109.8, 168, 253.8);--bs-secondary-text-emphasis:rgb(166.8, 172.2, 177);--bs-success-text-emphasis:rgb(117, 183, 152.4);--bs-info-text-emphasis:rgb(109.8, 223.2, 246);--bs-warning-text-emphasis:rgb(255, 217.8, 106.2);--bs-danger-text-emphasis:rgb(234, 133.8, 143.4);--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:rgb(2.6, 22, 50.6);--bs-secondary-bg-subtle:rgb(21.6, 23.4, 25);--bs-success-bg-subtle:rgb(5, 27, 16.8);--bs-info-bg-subtle:rgb(2.6, 40.4, 48);--bs-warning-bg-subtle:rgb(51, 38.6, 1.4);--bs-danger-bg-subtle:rgb(44, 10.6, 13.8);--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:rgb(7.8, 66, 151.8);--bs-secondary-border-subtle:rgb(64.8, 70.2, 75);--bs-success-border-subtle:rgb(15, 81, 50.4);--bs-info-border-subtle:rgb(7.8, 121.2, 144);--bs-warning-border-subtle:rgb(153, 115.8, 4.2);--bs-danger-border-subtle:rgb(132, 31.8, 41.4);--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:rgb(109.8, 168, 253.8);--bs-link-hover-color:rgb(138.84, 185.4, 254.04);--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:rgb(230.4, 132.6, 181.2);--bs-highlight-color:#dee2e6;--bs-highlight-bg:rgb(102, 77.2, 2.8);--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:rgb(117, 183, 152.4);--bs-form-valid-border-color:rgb(117, 183, 152.4);--bs-form-invalid-color:rgb(234, 133.8, 143.4);--bs-form-invalid-border-color:rgb(234, 133.8, 143.4)}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;line-height:inherit;font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button{cursor:pointer;filter:grayscale(1)}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-weight:300;line-height:1.2;font-size:calc(1.625rem + 4.5vw)}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-weight:300;line-height:1.2;font-size:calc(1.575rem + 3.9vw)}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-weight:300;line-height:1.2;font-size:calc(1.525rem + 3.3vw)}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-weight:300;line-height:1.2;font-size:calc(1.475rem + 2.7vw)}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-weight:300;line-height:1.2;font-size:calc(1.425rem + 2.1vw)}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-weight:300;line-height:1.2;font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);box-shadow:var(--bs-box-shadow-sm);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:rgb(206.6, 226, 254.6);--bs-table-border-color:rgb(165.28, 180.8, 203.68);--bs-table-striped-bg:rgb(196.27, 214.7, 241.87);--bs-table-striped-color:#000;--bs-table-active-bg:rgb(185.94, 203.4, 229.14);--bs-table-active-color:#000;--bs-table-hover-bg:rgb(191.105, 209.05, 235.505);--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:rgb(225.6, 227.4, 229);--bs-table-border-color:rgb(180.48, 181.92, 183.2);--bs-table-striped-bg:rgb(214.32, 216.03, 217.55);--bs-table-striped-color:#000;--bs-table-active-bg:rgb(203.04, 204.66, 206.1);--bs-table-active-color:#000;--bs-table-hover-bg:rgb(208.68, 210.345, 211.825);--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:rgb(209, 231, 220.8);--bs-table-border-color:rgb(167.2, 184.8, 176.64);--bs-table-striped-bg:rgb(198.55, 219.45, 209.76);--bs-table-striped-color:#000;--bs-table-active-bg:rgb(188.1, 207.9, 198.72);--bs-table-active-color:#000;--bs-table-hover-bg:rgb(193.325, 213.675, 204.24);--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:rgb(206.6, 244.4, 252);--bs-table-border-color:rgb(165.28, 195.52, 201.6);--bs-table-striped-bg:rgb(196.27, 232.18, 239.4);--bs-table-striped-color:#000;--bs-table-active-bg:rgb(185.94, 219.96, 226.8);--bs-table-active-color:#000;--bs-table-hover-bg:rgb(191.105, 226.07, 233.1);--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:rgb(255, 242.6, 205.4);--bs-table-border-color:rgb(204, 194.08, 164.32);--bs-table-striped-bg:rgb(242.25, 230.47, 195.13);--bs-table-striped-color:#000;--bs-table-active-bg:rgb(229.5, 218.34, 184.86);--bs-table-active-color:#000;--bs-table-hover-bg:rgb(235.875, 224.405, 189.995);--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:rgb(248, 214.6, 217.8);--bs-table-border-color:rgb(198.4, 171.68, 174.24);--bs-table-striped-bg:rgb(235.6, 203.87, 206.91);--bs-table-striped-color:#000;--bs-table-active-bg:rgb(223.2, 193.14, 196.02);--bs-table-active-color:#000;--bs-table-hover-bg:rgb(229.4, 198.505, 201.465);--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:rgb(198.4, 199.2, 200);--bs-table-striped-bg:rgb(235.6, 236.55, 237.5);--bs-table-striped-color:#000;--bs-table-active-bg:rgb(223.2, 224.1, 225);--bs-table-active-color:#000;--bs-table-hover-bg:rgb(229.4, 230.325, 231.25);--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:rgb(77.4, 80.6, 83.8);--bs-table-striped-bg:rgb(44.1, 47.9, 51.7);--bs-table-striped-color:#fff;--bs-table-active-bg:rgb(55.2, 58.8, 62.4);--bs-table-active-color:#fff;--bs-table-hover-bg:rgb(49.65, 53.35, 57.05);--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);box-shadow:var(--bs-box-shadow-inset);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:rgb(134,182.5,254);outline:0;box-shadow:var(--bs-box-shadow-inset),0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);box-shadow:var(--bs-box-shadow-inset);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:rgb(134,182.5,254);outline:0;box-shadow:var(--bs-box-shadow-inset),0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:rgb(134,182.5,254);outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgb%28134, 182.5, 254%29'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;box-shadow:0 .1rem .25rem rgba(0,0,0,.1);-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:rgb(182.4,211.5,254.4)}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem;box-shadow:var(--bs-box-shadow-inset)}.form-range::-moz-range-thumb{width:1rem;height:1rem;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;box-shadow:0 .1rem .25rem rgba(0,0,0,.1);-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:rgb(182.4,211.5,254.4)}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem;box-shadow:var(--bs-box-shadow-inset)}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;max-width:100%;height:100%;padding:1rem .75rem;overflow:hidden;color:rgba(var(--bs-body-color-rgb),.65);text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem;padding-left:.75rem}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>textarea:focus~label::after,.form-floating>textarea:not(:placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>textarea:disabled~label::after{background-color:var(--bs-secondary-bg)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(-1 * var(--bs-border-width));border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:var(--bs-box-shadow-inset),0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:var(--bs-box-shadow-inset),0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:var(--bs-box-shadow-inset),0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:var(--bs-box-shadow-inset),0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);box-shadow:var(--bs-btn-box-shadow);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-box-shadow),var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-box-shadow),var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color);box-shadow:var(--bs-btn-active-shadow)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-active-shadow),var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-active-shadow),var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity);box-shadow:none}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:rgb(11.05, 93.5, 215.05);--bs-btn-hover-border-color:rgb(10.4, 88, 202.4);--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:rgb(10.4, 88, 202.4);--bs-btn-active-border-color:rgb(9.75, 82.5, 189.75);--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:rgb(91.8, 99.45, 106.25);--bs-btn-hover-border-color:rgb(86.4, 93.6, 100);--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:rgb(86.4, 93.6, 100);--bs-btn-active-border-color:rgb(81, 87.75, 93.75);--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:rgb(21.25, 114.75, 71.4);--bs-btn-hover-border-color:rgb(20, 108, 67.2);--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:rgb(20, 108, 67.2);--bs-btn-active-border-color:rgb(18.75, 101.25, 63);--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:rgb(49.3, 209.95, 242.25);--bs-btn-hover-border-color:rgb(37.2, 207.3, 241.5);--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:rgb(61.4, 212.6, 243);--bs-btn-active-border-color:rgb(37.2, 207.3, 241.5);--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:rgb(255, 202.3, 44.2);--bs-btn-hover-border-color:rgb(255, 199.2, 31.8);--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:rgb(255, 205.4, 56.6);--bs-btn-active-border-color:rgb(255, 199.2, 31.8);--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:rgb(187, 45.05, 58.65);--bs-btn-hover-border-color:rgb(176, 42.4, 55.2);--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:rgb(176, 42.4, 55.2);--bs-btn-active-border-color:rgb(165, 39.75, 51.75);--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:rgb(210.8, 211.65, 212.5);--bs-btn-hover-border-color:rgb(198.4, 199.2, 200);--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:rgb(198.4, 199.2, 200);--bs-btn-active-border-color:rgb(186, 186.75, 187.5);--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:rgb(66.3, 69.7, 73.1);--bs-btn-hover-border-color:rgb(55.2, 58.8, 62.4);--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:rgb(77.4, 80.6, 83.8);--bs-btn-active-border-color:rgb(55.2, 58.8, 62.4);--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius);box-shadow:var(--bs-dropdown-box-shadow)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(-1 * var(--bs-border-width))}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group.show .dropdown-toggle{box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.show .dropdown-toggle.btn-link{box-shadow:none}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(-1 * var(--bs-border-width))}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:nth-child(n+3),.btn-group-vertical>:not(.btn-check)+.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-grow:1;flex-basis:0;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:1rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-grow:1;flex-basis:100%;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius);box-shadow:var(--bs-card-box-shadow)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child)>.card-header,.card-group>.card:not(:last-child)>.card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child)>.card-footer,.card-group>.card:not(:last-child)>.card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child)>.card-header,.card-group>.card:not(:first-child)>.card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child)>.card-footer,.card-group>.card:not(:first-child)>.card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='rgb%285.2, 44, 101.2%29' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-collapse,.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='rgb%28109.8, 168, 253.8%29'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='rgb%28109.8, 168, 253.8%29'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(-1 * var(--bs-border-width))}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:var(--bs-progress-height)}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius);box-shadow:var(--bs-progress-box-shadow)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:not(.active):focus,.list-group-item-action:not(.active):hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:not(.active):active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;filter:var(--bs-btn-close-filter);border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}:root,[data-bs-theme=light]{--bs-btn-close-filter: }[data-bs-theme=dark]{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color:var(--bs-body-color);--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transform:translate(0,-50px);transition:transform .3s ease-out}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);box-shadow:var(--bs-modal-box-shadow);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin-top:calc(-.5 * var(--bs-modal-header-padding-y));margin-right:calc(-.5 * var(--bs-modal-header-padding-x));margin-bottom:calc(-.5 * var(--bs-modal-header-padding-y));margin-left:auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius);box-shadow:var(--bs-popover-box-shadow)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;filter:var(--bs-carousel-control-icon-filter);border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:var(--bs-carousel-indicator-active-bg);background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:var(--bs-carousel-caption-color);text-align:center}.carousel-dark{--bs-carousel-indicator-active-bg:#000;--bs-carousel-caption-color:#000;--bs-carousel-control-icon-filter:invert(1) grayscale(100)}:root,[data-bs-theme=light]{--bs-carousel-indicator-active-bg:#fff;--bs-carousel-caption-color:#fff;--bs-carousel-control-icon-filter: }[data-bs-theme=dark]{--bs-carousel-indicator-active-bg:#000;--bs-carousel-caption-color:#000;--bs-carousel-control-icon-filter:invert(1) grayscale(100)}.spinner-border,.spinner-grow{display:inline-block;flex-shrink:0;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--bs-offcanvas-box-shadow);transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--bs-offcanvas-box-shadow);transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--bs-offcanvas-box-shadow);transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--bs-offcanvas-box-shadow);transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--bs-offcanvas-box-shadow);transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--bs-offcanvas-box-shadow);transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin-top:calc(-.5 * var(--bs-offcanvas-padding-y));margin-right:calc(-.5 * var(--bs-offcanvas-padding-x));margin-bottom:calc(-.5 * var(--bs-offcanvas-padding-y));margin-left:auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:sticky;top:0;z-index:1020}.sticky-bottom{position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.visually-hidden *,.visually-hidden-focusable:not(:focus):not(:focus-within) *{overflow:hidden!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.m-n1{margin:-.25rem!important}.m-n2{margin:-.5rem!important}.m-n3{margin:-1rem!important}.m-n4{margin:-1.5rem!important}.m-n5{margin:-3rem!important}.mx-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-n5{margin-right:-3rem!important;margin-left:-3rem!important}.my-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-n5{margin-top:-3rem!important;margin-bottom:-3rem!important}.mt-n1{margin-top:-.25rem!important}.mt-n2{margin-top:-.5rem!important}.mt-n3{margin-top:-1rem!important}.mt-n4{margin-top:-1.5rem!important}.mt-n5{margin-top:-3rem!important}.me-n1{margin-right:-.25rem!important}.me-n2{margin-right:-.5rem!important}.me-n3{margin-right:-1rem!important}.me-n4{margin-right:-1.5rem!important}.me-n5{margin-right:-3rem!important}.mb-n1{margin-bottom:-.25rem!important}.mb-n2{margin-bottom:-.5rem!important}.mb-n3{margin-bottom:-1rem!important}.mb-n4{margin-bottom:-1.5rem!important}.mb-n5{margin-bottom:-3rem!important}.ms-n1{margin-left:-.25rem!important}.ms-n2{margin-left:-.5rem!important}.ms-n3{margin-left:-1rem!important}.ms-n4{margin-left:-1.5rem!important}.ms-n5{margin-left:-3rem!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fs-7{font-size:.875rem!important}.fs-8{font-size:.75rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.m-sm-n1{margin:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.m-sm-n3{margin:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mx-sm-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-sm-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-sm-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-sm-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-sm-n5{margin-right:-3rem!important;margin-left:-3rem!important}.my-sm-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-sm-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-sm-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-sm-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-sm-n5{margin-top:-3rem!important;margin-bottom:-3rem!important}.mt-sm-n1{margin-top:-.25rem!important}.mt-sm-n2{margin-top:-.5rem!important}.mt-sm-n3{margin-top:-1rem!important}.mt-sm-n4{margin-top:-1.5rem!important}.mt-sm-n5{margin-top:-3rem!important}.me-sm-n1{margin-right:-.25rem!important}.me-sm-n2{margin-right:-.5rem!important}.me-sm-n3{margin-right:-1rem!important}.me-sm-n4{margin-right:-1.5rem!important}.me-sm-n5{margin-right:-3rem!important}.mb-sm-n1{margin-bottom:-.25rem!important}.mb-sm-n2{margin-bottom:-.5rem!important}.mb-sm-n3{margin-bottom:-1rem!important}.mb-sm-n4{margin-bottom:-1.5rem!important}.mb-sm-n5{margin-bottom:-3rem!important}.ms-sm-n1{margin-left:-.25rem!important}.ms-sm-n2{margin-left:-.5rem!important}.ms-sm-n3{margin-left:-1rem!important}.ms-sm-n4{margin-left:-1.5rem!important}.ms-sm-n5{margin-left:-3rem!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.m-md-n1{margin:-.25rem!important}.m-md-n2{margin:-.5rem!important}.m-md-n3{margin:-1rem!important}.m-md-n4{margin:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mx-md-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-md-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-md-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-md-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-md-n5{margin-right:-3rem!important;margin-left:-3rem!important}.my-md-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-md-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-md-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-md-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-md-n5{margin-top:-3rem!important;margin-bottom:-3rem!important}.mt-md-n1{margin-top:-.25rem!important}.mt-md-n2{margin-top:-.5rem!important}.mt-md-n3{margin-top:-1rem!important}.mt-md-n4{margin-top:-1.5rem!important}.mt-md-n5{margin-top:-3rem!important}.me-md-n1{margin-right:-.25rem!important}.me-md-n2{margin-right:-.5rem!important}.me-md-n3{margin-right:-1rem!important}.me-md-n4{margin-right:-1.5rem!important}.me-md-n5{margin-right:-3rem!important}.mb-md-n1{margin-bottom:-.25rem!important}.mb-md-n2{margin-bottom:-.5rem!important}.mb-md-n3{margin-bottom:-1rem!important}.mb-md-n4{margin-bottom:-1.5rem!important}.mb-md-n5{margin-bottom:-3rem!important}.ms-md-n1{margin-left:-.25rem!important}.ms-md-n2{margin-left:-.5rem!important}.ms-md-n3{margin-left:-1rem!important}.ms-md-n4{margin-left:-1.5rem!important}.ms-md-n5{margin-left:-3rem!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.m-lg-n1{margin:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.m-lg-n3{margin:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mx-lg-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-lg-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-lg-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-lg-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-lg-n5{margin-right:-3rem!important;margin-left:-3rem!important}.my-lg-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-lg-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-lg-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-lg-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-lg-n5{margin-top:-3rem!important;margin-bottom:-3rem!important}.mt-lg-n1{margin-top:-.25rem!important}.mt-lg-n2{margin-top:-.5rem!important}.mt-lg-n3{margin-top:-1rem!important}.mt-lg-n4{margin-top:-1.5rem!important}.mt-lg-n5{margin-top:-3rem!important}.me-lg-n1{margin-right:-.25rem!important}.me-lg-n2{margin-right:-.5rem!important}.me-lg-n3{margin-right:-1rem!important}.me-lg-n4{margin-right:-1.5rem!important}.me-lg-n5{margin-right:-3rem!important}.mb-lg-n1{margin-bottom:-.25rem!important}.mb-lg-n2{margin-bottom:-.5rem!important}.mb-lg-n3{margin-bottom:-1rem!important}.mb-lg-n4{margin-bottom:-1.5rem!important}.mb-lg-n5{margin-bottom:-3rem!important}.ms-lg-n1{margin-left:-.25rem!important}.ms-lg-n2{margin-left:-.5rem!important}.ms-lg-n3{margin-left:-1rem!important}.ms-lg-n4{margin-left:-1.5rem!important}.ms-lg-n5{margin-left:-3rem!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.m-xl-n1{margin:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.m-xl-n3{margin:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mx-xl-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-xl-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-xl-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-xl-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-xl-n5{margin-right:-3rem!important;margin-left:-3rem!important}.my-xl-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-xl-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-xl-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-xl-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-xl-n5{margin-top:-3rem!important;margin-bottom:-3rem!important}.mt-xl-n1{margin-top:-.25rem!important}.mt-xl-n2{margin-top:-.5rem!important}.mt-xl-n3{margin-top:-1rem!important}.mt-xl-n4{margin-top:-1.5rem!important}.mt-xl-n5{margin-top:-3rem!important}.me-xl-n1{margin-right:-.25rem!important}.me-xl-n2{margin-right:-.5rem!important}.me-xl-n3{margin-right:-1rem!important}.me-xl-n4{margin-right:-1.5rem!important}.me-xl-n5{margin-right:-3rem!important}.mb-xl-n1{margin-bottom:-.25rem!important}.mb-xl-n2{margin-bottom:-.5rem!important}.mb-xl-n3{margin-bottom:-1rem!important}.mb-xl-n4{margin-bottom:-1.5rem!important}.mb-xl-n5{margin-bottom:-3rem!important}.ms-xl-n1{margin-left:-.25rem!important}.ms-xl-n2{margin-left:-.5rem!important}.ms-xl-n3{margin-left:-1rem!important}.ms-xl-n4{margin-left:-1.5rem!important}.ms-xl-n5{margin-left:-3rem!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.m-xxl-n1{margin:-.25rem!important}.m-xxl-n2{margin:-.5rem!important}.m-xxl-n3{margin:-1rem!important}.m-xxl-n4{margin:-1.5rem!important}.m-xxl-n5{margin:-3rem!important}.mx-xxl-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-xxl-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-xxl-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-xxl-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-xxl-n5{margin-right:-3rem!important;margin-left:-3rem!important}.my-xxl-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-xxl-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-xxl-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-xxl-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-xxl-n5{margin-top:-3rem!important;margin-bottom:-3rem!important}.mt-xxl-n1{margin-top:-.25rem!important}.mt-xxl-n2{margin-top:-.5rem!important}.mt-xxl-n3{margin-top:-1rem!important}.mt-xxl-n4{margin-top:-1.5rem!important}.mt-xxl-n5{margin-top:-3rem!important}.me-xxl-n1{margin-right:-.25rem!important}.me-xxl-n2{margin-right:-.5rem!important}.me-xxl-n3{margin-right:-1rem!important}.me-xxl-n4{margin-right:-1.5rem!important}.me-xxl-n5{margin-right:-3rem!important}.mb-xxl-n1{margin-bottom:-.25rem!important}.mb-xxl-n2{margin-bottom:-.5rem!important}.mb-xxl-n3{margin-bottom:-1rem!important}.mb-xxl-n4{margin-bottom:-1.5rem!important}.mb-xxl-n5{margin-bottom:-3rem!important}.ms-xxl-n1{margin-left:-.25rem!important}.ms-xxl-n2{margin-left:-.5rem!important}.ms-xxl-n3{margin-left:-1rem!important}.ms-xxl-n4{margin-left:-1.5rem!important}.ms-xxl-n5{margin-left:-3rem!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}}@keyframes flipInX{0%{opacity:0;transition-timing-function:ease-in;transform:perspective(400px) rotate3d(1,0,0,90deg)}40%{transition-timing-function:ease-in;transform:perspective(400px) rotate3d(1,0,0,-20deg)}60%{opacity:1;transform:perspective(400px) rotate3d(1,0,0,10deg)}80%{transform:perspective(400px) rotate3d(1,0,0,-5deg)}100%{transform:perspective(400px)}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes fadeOut{from{opacity:1}to{opacity:0}}@keyframes shake{0%{transform:translate(2px,1px) rotate(0)}10%{transform:translate(-1px,-2px) rotate(-2deg)}20%{transform:translate(-3px,0) rotate(3deg)}30%{transform:translate(0,2px) rotate(0)}40%{transform:translate(1px,-1px) rotate(1deg)}50%{transform:translate(-1px,2px) rotate(-1deg)}60%{transform:translate(-3px,1px) rotate(0)}70%{transform:translate(2px,1px) rotate(-2deg)}80%{transform:translate(-1px,-1px) rotate(4deg)}90%{transform:translate(2px,2px) rotate(0)}100%{transform:translate(1px,-2px) rotate(-1deg)}}@keyframes wobble{0%{transform:none}15%{transform:translate3d(-25%,0,0) rotate3d(0,0,1,-5deg)}30%{transform:translate3d(20%,0,0) rotate3d(0,0,1,3deg)}45%{transform:translate3d(-15%,0,0) rotate3d(0,0,1,-3deg)}60%{transform:translate3d(10%,0,0) rotate3d(0,0,1,2deg)}75%{transform:translate3d(-5%,0,0) rotate3d(0,0,1,-1deg)}100%{transform:none}}:root,[data-bs-theme=light]{--lte-sidebar-width:250px}.app-wrapper{position:relative;display:grid;grid-template-areas:"lte-app-sidebar lte-app-header" "lte-app-sidebar lte-app-main" "lte-app-sidebar lte-app-footer";grid-template-rows:min-content 1fr min-content;grid-template-columns:auto 1fr;grid-gap:0;align-content:stretch;align-items:stretch;max-width:100vw;min-height:100vh}.app-wrapper>*{min-width:0}.app-content{padding:0 .5rem}.app-header{z-index:1034;grid-area:lte-app-header;max-width:100vw;border-bottom:1px solid var(--bs-border-color);transition:.3s ease-in-out}@media (prefers-reduced-motion:reduce){.app-header{transition:none}}.app-header .nav-link{position:relative;height:2.5rem}.navbar-badge{position:absolute;top:9px;right:5px;padding:2px 4px;font-size:.6rem;font-weight:400}.fixed-header .app-header{position:sticky;top:0;z-index:1030}@media (min-width:576px){.fixed-header.sidebar-expand-sm .app-sidebar{position:sticky;top:0;max-height:100vh}.fixed-header.sidebar-expand-sm .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}}@media (min-width:768px){.fixed-header.sidebar-expand-md .app-sidebar{position:sticky;top:0;max-height:100vh}.fixed-header.sidebar-expand-md .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}}@media (min-width:992px){.fixed-header.sidebar-expand-lg .app-sidebar{position:sticky;top:0;max-height:100vh}.fixed-header.sidebar-expand-lg .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}}@media (min-width:1200px){.fixed-header.sidebar-expand-xl .app-sidebar{position:sticky;top:0;max-height:100vh}.fixed-header.sidebar-expand-xl .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}}@media (min-width:1400px){.fixed-header.sidebar-expand-xxl .app-sidebar{position:sticky;top:0;max-height:100vh}.fixed-header.sidebar-expand-xxl .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}}.fixed-header.sidebar-expand .app-sidebar{position:sticky;top:0;max-height:100vh}.fixed-header.sidebar-expand .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.app-sidebar{--lte-sidebar-hover-bg:rgba(0, 0, 0, 0.1);--lte-sidebar-color:#343a40;--lte-sidebar-hover-color:#212529;--lte-sidebar-active-color:#000;--lte-sidebar-menu-active-bg:rgba(0, 0, 0, 0.1);--lte-sidebar-menu-active-color:#000;--lte-sidebar-submenu-bg:transparent;--lte-sidebar-submenu-color:#777;--lte-sidebar-submenu-hover-color:#000;--lte-sidebar-submenu-hover-bg:rgba(0, 0, 0, 0.1);--lte-sidebar-submenu-active-color:#212529;--lte-sidebar-submenu-active-bg:rgba(0, 0, 0, 0.1);--lte-sidebar-header-color:rgb(49.4, 55.1, 60.8);z-index:1038;grid-area:lte-app-sidebar;min-width:var(--lte-sidebar-width);max-width:var(--lte-sidebar-width);transition:min-width .3s ease-in-out,max-width .3s ease-in-out,margin-left .3s ease-in-out,margin-right .3s ease-in-out}@media (prefers-reduced-motion:reduce){.app-sidebar{transition:none}}.sidebar-brand{display:flex;align-items:center;justify-content:center;height:3.5rem;padding:.8125rem .5rem;overflow:hidden;font-size:1.25rem;white-space:nowrap;border-bottom:1px solid var(--bs-border-color);transition:width .3s ease-in-out}@media (prefers-reduced-motion:reduce){.sidebar-brand{transition:none}}.sidebar-brand .brand-link{display:flex;align-items:center;text-decoration:none}.sidebar-brand .brand-link .brand-image{float:left;width:auto;max-height:33px;line-height:.8}.sidebar-brand .brand-link .brand-image-xs{float:left;width:auto;max-height:33px;margin-top:-.1rem;line-height:.8}.sidebar-brand .brand-link .brand-image-xl{width:auto;max-height:40px;line-height:.8}.sidebar-brand .brand-link .brand-image-xl.single{margin-top:-.3rem}.sidebar-brand .brand-text{margin-left:.5rem;color:rgba(var(--bs-emphasis-color-rgb),.8);transition:flex .3s ease-in-out,width .3s ease-in-out}@media (prefers-reduced-motion:reduce){.sidebar-brand .brand-text{transition:none}}.sidebar-brand .brand-text:hover{color:var(--bs-emphasis-color)}.sidebar-wrapper{padding-top:.5rem;padding-right:.5rem;padding-bottom:.5rem;padding-left:.5rem;overscroll-behavior:contain;scrollbar-color:var(--bs-secondary-bg) transparent}.sidebar-wrapper::-webkit-scrollbar-thumb{background-color:var(--bs-secondary-bg)}.sidebar-wrapper::-webkit-scrollbar-track{background-color:transparent}.sidebar-wrapper::-webkit-scrollbar-corner{background-color:transparent}.sidebar-wrapper{scrollbar-width:thin}.sidebar-wrapper::-webkit-scrollbar{width:.5rem;height:.5rem}.sidebar-wrapper .nav-item{max-width:100%}.sidebar-wrapper .nav-link{display:flex;justify-content:flex-start}.sidebar-wrapper .nav-link p{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar-wrapper .nav-icon{display:flex;align-items:center;justify-content:center;min-width:1.5rem;max-width:1.5rem}.sidebar-wrapper .sidebar-menu>.nav-item>.nav-link{color:var(--lte-sidebar-color)}.sidebar-wrapper .sidebar-menu>.nav-item>.nav-link:focus,.sidebar-wrapper .sidebar-menu>.nav-item>.nav-link:hover{color:var(--lte-sidebar-hover-color);background-color:var(--lte-sidebar-hover-bg)}.sidebar-wrapper .sidebar-menu>.nav-item>.nav-link.active{color:var(--lte-sidebar-menu-active-color);background-color:var(--lte-sidebar-menu-active-bg)}.sidebar-wrapper .sidebar-menu>.nav-item.menu-open>.nav-link{color:var(--lte-sidebar-hover-color);background-color:var(--lte-sidebar-hover-bg)}.sidebar-wrapper .sidebar-menu>.nav-item.menu-open>.nav-link.active{color:var(--lte-sidebar-menu-active-color);background-color:var(--lte-sidebar-menu-active-bg)}.sidebar-wrapper .sidebar-menu>.nav-item>.nav-treeview{background-color:var(--lte-sidebar-submenu-bg)}.sidebar-wrapper .nav-header{display:inline;overflow:hidden;color:var(--lte-sidebar-header-color);text-overflow:ellipsis;background-color:inherit}.sidebar-wrapper a{color:var(--lte-sidebar-color)}.sidebar-wrapper .nav-treeview>.nav-item>.nav-link{color:var(--lte-sidebar-submenu-color)}.sidebar-wrapper .nav-treeview>.nav-item>.nav-link:focus,.sidebar-wrapper .nav-treeview>.nav-item>.nav-link:hover{color:var(--lte-sidebar-submenu-hover-color);background-color:var(--lte-sidebar-submenu-hover-bg)}.sidebar-wrapper .nav-treeview>.nav-item>.nav-link.active{color:var(--lte-sidebar-submenu-active-color);background-color:var(--lte-sidebar-submenu-active-bg)}.sidebar-menu .nav-item>.nav-link{margin-bottom:.2rem}.sidebar-menu .nav-item>.nav-link .nav-arrow{transition:transform ease-in-out .3s}@media (prefers-reduced-motion:reduce){.sidebar-menu .nav-item>.nav-link .nav-arrow{transition:none}}.sidebar-menu .nav-item>.nav-link .nav-arrow{transform:translateY(-50%);animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both}.sidebar-menu .nav-link>.nav-badge,.sidebar-menu .nav-link>p>.nav-badge{position:absolute;top:50%;right:1rem;transform:translateY(-50%)}.sidebar-menu .nav-link>.nav-arrow,.sidebar-menu .nav-link>p>.nav-arrow{position:absolute;top:50%;right:1rem}.sidebar-menu .nav-link{position:relative;width:100%;transition:width ease-in-out .3s}@media (prefers-reduced-motion:reduce){.sidebar-menu .nav-link{transition:none}}.sidebar-menu .nav-link{border-radius:.375rem}.sidebar-menu .nav-link p{display:inline;padding-left:.5rem;margin:0}.sidebar-menu .nav-header{position:relative;width:100%;padding:.5rem .75rem;font-size:.9rem;transition:width ease-in-out .3s}@media (prefers-reduced-motion:reduce){.sidebar-menu .nav-header{transition:none}}.sidebar-menu .nav-treeview{display:none;padding:0;list-style:none}.sidebar-menu .menu-open>.nav-treeview{display:block}.sidebar-menu .menu-open>.nav-link .nav-arrow{transform:translateY(-50%) rotate(90deg)}.sidebar-menu .nav-link>.nav-arrow,.sidebar-menu .nav-link>.nav-badge,.sidebar-menu .nav-link>p>.nav-arrow,.sidebar-menu .nav-link>p>.nav-badge{right:1rem!important;left:auto!important}.nav-indent .nav-treeview{padding-left:.5rem}.nav-compact.nav-indent .nav-treeview{padding-left:0}.nav-compact.nav-indent .nav-treeview .nav-item{padding-left:.5rem}.sidebar-mini.sidebar-collapse.nav-indent .app-sidebar:hover .nav-treeview{padding-left:0}.sidebar-mini.sidebar-collapse.nav-indent .app-sidebar:hover .nav-treeview .nav-item{padding-left:.5rem}.sidebar-collapse.nav-compact.nav-indent .nav-treeview .nav-item{padding-left:0}.nav-compact .nav-link{border-radius:0;margin-bottom:0!important}.sidebar-menu,.sidebar-menu .nav-link,.sidebar-menu>.nav-header{white-space:nowrap}.logo-xl,.logo-xs{position:absolute;visibility:visible;opacity:1}.logo-xl.brand-image-xs,.logo-xs.brand-image-xs{top:12px;left:18px}.logo-xl.brand-image-xl,.logo-xs.brand-image-xl{top:6px;left:12px}.logo-xs{visibility:hidden;opacity:0}.logo-xs.brand-image-xl{top:8px;left:16px}.brand-link.logo-switch::before{content:" "}.sidebar-mini.sidebar-collapse .app-sidebar{min-width:4.6rem;max-width:4.6rem}.sidebar-mini.sidebar-collapse .sidebar-menu .nav-header{display:none}.sidebar-mini.sidebar-collapse .sidebar-menu .nav-link{width:3.6rem}.sidebar-mini.sidebar-collapse .sidebar-menu .nav-link p{display:inline-block;width:0;white-space:nowrap}.sidebar-mini.sidebar-collapse .sidebar-menu .nav-arrow,.sidebar-mini.sidebar-collapse .sidebar-menu .nav-badge{display:none;animation-name:fadeOut;animation-duration:.3s;animation-fill-mode:both}.sidebar-mini.sidebar-collapse .brand-text{display:inline-block;max-width:0;overflow:hidden}.sidebar-mini.sidebar-collapse .brand-text,.sidebar-mini.sidebar-collapse .logo-xl,.sidebar-mini.sidebar-collapse .nav-arrow,.sidebar-mini.sidebar-collapse .sidebar-menu .nav-link p{visibility:hidden;animation-name:fadeOut;animation-duration:.3s;animation-fill-mode:both}.sidebar-mini.sidebar-collapse .logo-xs{display:inline-block;visibility:visible;animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both}.sidebar-mini.sidebar-collapse:not(.sidebar-without-hover) .app-sidebar:hover{min-width:var(--lte-sidebar-width);max-width:var(--lte-sidebar-width)}.sidebar-mini.sidebar-collapse:not(.sidebar-without-hover) .app-sidebar:hover .sidebar-menu .nav-header{display:inline}.sidebar-mini.sidebar-collapse:not(.sidebar-without-hover) .app-sidebar:hover .sidebar-menu .nav-link{width:auto}.sidebar-mini.sidebar-collapse:not(.sidebar-without-hover) .app-sidebar:hover .brand-text,.sidebar-mini.sidebar-collapse:not(.sidebar-without-hover) .app-sidebar:hover .logo-xl,.sidebar-mini.sidebar-collapse:not(.sidebar-without-hover) .app-sidebar:hover .sidebar-menu .nav-link p{width:auto;margin-left:0;visibility:visible;animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both}.sidebar-mini.sidebar-collapse:not(.sidebar-without-hover) .app-sidebar:hover .brand-text{display:inline;max-width:inherit;margin-left:.5rem;animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both}.sidebar-mini.sidebar-collapse:not(.sidebar-without-hover) .app-sidebar:hover .nav-arrow,.sidebar-mini.sidebar-collapse:not(.sidebar-without-hover) .app-sidebar:hover .nav-badge{display:inline-block;visibility:visible;animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}.sidebar-mini.sidebar-collapse:not(.sidebar-without-hover) .app-sidebar:hover .nav-link p{padding-left:.5rem}.sidebar-mini.sidebar-collapse:not(.sidebar-without-hover) .app-sidebar:hover .logo-xs{visibility:hidden;animation-name:fadeOut;animation-duration:.3s;animation-fill-mode:both}.sidebar-collapse:not(.sidebar-mini) .app-sidebar{margin-left:calc(var(--lte-sidebar-width) * -1)}@media (min-width:576px){.sidebar-expand-sm.layout-fixed .app-main-wrapper{display:flex;flex-direction:column;min-height:100vh}.sidebar-expand-sm.layout-fixed .app-sidebar-wrapper{position:relative}.sidebar-expand-sm.layout-fixed .app-main{flex:1 1 auto;overflow:auto}.sidebar-expand-sm.layout-fixed .app-sidebar{position:sticky;top:0;bottom:0;max-height:100vh}.sidebar-expand-sm.layout-fixed .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand-sm.sidebar-open .nav-link>.nav-badge,.sidebar-expand-sm.sidebar-open .nav-link>p>.nav-badge{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}.sidebar-expand-sm.sidebar-open .nav-link>.nav-arrow,.sidebar-expand-sm.sidebar-open .nav-link>p>.nav-arrow{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}}@media (max-width:575.98px){.sidebar-expand-sm::before{display:none;content:"575.98px"}.sidebar-expand-sm .app-sidebar{position:fixed;top:0;bottom:0;max-height:100vh;margin-left:calc(var(--lte-sidebar-width) * -1)}.sidebar-expand-sm .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand-sm.sidebar-open .app-sidebar{margin-left:0}.sidebar-expand-sm.sidebar-open .sidebar-overlay{position:absolute;inset:0;z-index:1037;width:100%;height:100%;cursor:pointer;visibility:visible;background-color:rgba(0,0,0,.2);animation-name:fadeIn;animation-fill-mode:both}}@media (min-width:768px){.sidebar-expand-md.layout-fixed .app-main-wrapper{display:flex;flex-direction:column;min-height:100vh}.sidebar-expand-md.layout-fixed .app-sidebar-wrapper{position:relative}.sidebar-expand-md.layout-fixed .app-main{flex:1 1 auto;overflow:auto}.sidebar-expand-md.layout-fixed .app-sidebar{position:sticky;top:0;bottom:0;max-height:100vh}.sidebar-expand-md.layout-fixed .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand-md.sidebar-open .nav-link>.nav-badge,.sidebar-expand-md.sidebar-open .nav-link>p>.nav-badge{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}.sidebar-expand-md.sidebar-open .nav-link>.nav-arrow,.sidebar-expand-md.sidebar-open .nav-link>p>.nav-arrow{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}}@media (max-width:767.98px){.sidebar-expand-md::before{display:none;content:"767.98px"}.sidebar-expand-md .app-sidebar{position:fixed;top:0;bottom:0;max-height:100vh;margin-left:calc(var(--lte-sidebar-width) * -1)}.sidebar-expand-md .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand-md.sidebar-open .app-sidebar{margin-left:0}.sidebar-expand-md.sidebar-open .sidebar-overlay{position:absolute;inset:0;z-index:1037;width:100%;height:100%;cursor:pointer;visibility:visible;background-color:rgba(0,0,0,.2);animation-name:fadeIn;animation-fill-mode:both}}@media (min-width:992px){.sidebar-expand-lg.layout-fixed .app-main-wrapper{display:flex;flex-direction:column;min-height:100vh}.sidebar-expand-lg.layout-fixed .app-sidebar-wrapper{position:relative}.sidebar-expand-lg.layout-fixed .app-main{flex:1 1 auto;overflow:auto}.sidebar-expand-lg.layout-fixed .app-sidebar{position:sticky;top:0;bottom:0;max-height:100vh}.sidebar-expand-lg.layout-fixed .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand-lg.sidebar-open .nav-link>.nav-badge,.sidebar-expand-lg.sidebar-open .nav-link>p>.nav-badge{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}.sidebar-expand-lg.sidebar-open .nav-link>.nav-arrow,.sidebar-expand-lg.sidebar-open .nav-link>p>.nav-arrow{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}}@media (max-width:991.98px){.sidebar-expand-lg::before{display:none;content:"991.98px"}.sidebar-expand-lg .app-sidebar{position:fixed;top:0;bottom:0;max-height:100vh;margin-left:calc(var(--lte-sidebar-width) * -1)}.sidebar-expand-lg .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand-lg.sidebar-open .app-sidebar{margin-left:0}.sidebar-expand-lg.sidebar-open .sidebar-overlay{position:absolute;inset:0;z-index:1037;width:100%;height:100%;cursor:pointer;visibility:visible;background-color:rgba(0,0,0,.2);animation-name:fadeIn;animation-fill-mode:both}}@media (min-width:1200px){.sidebar-expand-xl.layout-fixed .app-main-wrapper{display:flex;flex-direction:column;min-height:100vh}.sidebar-expand-xl.layout-fixed .app-sidebar-wrapper{position:relative}.sidebar-expand-xl.layout-fixed .app-main{flex:1 1 auto;overflow:auto}.sidebar-expand-xl.layout-fixed .app-sidebar{position:sticky;top:0;bottom:0;max-height:100vh}.sidebar-expand-xl.layout-fixed .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand-xl.sidebar-open .nav-link>.nav-badge,.sidebar-expand-xl.sidebar-open .nav-link>p>.nav-badge{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}.sidebar-expand-xl.sidebar-open .nav-link>.nav-arrow,.sidebar-expand-xl.sidebar-open .nav-link>p>.nav-arrow{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}}@media (max-width:1199.98px){.sidebar-expand-xl::before{display:none;content:"1199.98px"}.sidebar-expand-xl .app-sidebar{position:fixed;top:0;bottom:0;max-height:100vh;margin-left:calc(var(--lte-sidebar-width) * -1)}.sidebar-expand-xl .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand-xl.sidebar-open .app-sidebar{margin-left:0}.sidebar-expand-xl.sidebar-open .sidebar-overlay{position:absolute;inset:0;z-index:1037;width:100%;height:100%;cursor:pointer;visibility:visible;background-color:rgba(0,0,0,.2);animation-name:fadeIn;animation-fill-mode:both}}@media (min-width:1400px){.sidebar-expand-xxl.layout-fixed .app-main-wrapper{display:flex;flex-direction:column;min-height:100vh}.sidebar-expand-xxl.layout-fixed .app-sidebar-wrapper{position:relative}.sidebar-expand-xxl.layout-fixed .app-main{flex:1 1 auto;overflow:auto}.sidebar-expand-xxl.layout-fixed .app-sidebar{position:sticky;top:0;bottom:0;max-height:100vh}.sidebar-expand-xxl.layout-fixed .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand-xxl.sidebar-open .nav-link>.nav-badge,.sidebar-expand-xxl.sidebar-open .nav-link>p>.nav-badge{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}.sidebar-expand-xxl.sidebar-open .nav-link>.nav-arrow,.sidebar-expand-xxl.sidebar-open .nav-link>p>.nav-arrow{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}}@media (max-width:1399.98px){.sidebar-expand-xxl::before{display:none;content:"1399.98px"}.sidebar-expand-xxl .app-sidebar{position:fixed;top:0;bottom:0;max-height:100vh;margin-left:calc(var(--lte-sidebar-width) * -1)}.sidebar-expand-xxl .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand-xxl.sidebar-open .app-sidebar{margin-left:0}.sidebar-expand-xxl.sidebar-open .sidebar-overlay{position:absolute;inset:0;z-index:1037;width:100%;height:100%;cursor:pointer;visibility:visible;background-color:rgba(0,0,0,.2);animation-name:fadeIn;animation-fill-mode:both}}.sidebar-expand.layout-fixed .app-main-wrapper{display:flex;flex-direction:column;min-height:100vh}.sidebar-expand.layout-fixed .app-sidebar-wrapper{position:relative}.sidebar-expand.layout-fixed .app-main{flex:1 1 auto;overflow:auto}.sidebar-expand.layout-fixed .app-sidebar{position:sticky;top:0;bottom:0;max-height:100vh}.sidebar-expand.layout-fixed .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand.sidebar-open .nav-link>.nav-badge,.sidebar-expand.sidebar-open .nav-link>p>.nav-badge{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}.sidebar-expand.sidebar-open .nav-link>.nav-arrow,.sidebar-expand.sidebar-open .nav-link>p>.nav-arrow{animation-name:fadeIn;animation-duration:.3s;animation-fill-mode:both;animation-delay:.3s}.sidebar-expand::before{display:none;content:""}.sidebar-expand .app-sidebar{position:fixed;top:0;bottom:0;max-height:100vh;margin-left:calc(var(--lte-sidebar-width) * -1)}.sidebar-expand .app-sidebar .sidebar-wrapper{height:calc(100vh - (calc(3.5rem + 1px)));overflow-x:hidden;overflow-y:auto}.sidebar-expand.sidebar-open .app-sidebar{margin-left:0}.sidebar-expand.sidebar-open .sidebar-overlay{position:absolute;inset:0;z-index:1037;width:100%;height:100%;cursor:pointer;visibility:visible;background-color:rgba(0,0,0,.2);animation-name:fadeIn;animation-fill-mode:both}.app-sidebar .brand-text,.app-sidebar .logo-xl,.app-sidebar .logo-xs,.sidebar-menu .nav-link p{transition:margin-left .3s linear,opacity .3s ease,visibility .3s ease}@media (prefers-reduced-motion:reduce){.app-sidebar .brand-text,.app-sidebar .logo-xl,.app-sidebar .logo-xs,.sidebar-menu .nav-link p{transition:none}}.app-loaded.sidebar-mini.sidebar-collapse .brand-text,.app-loaded.sidebar-mini.sidebar-collapse .sidebar-menu .nav-link p{animation-duration:.3s}body:not(.app-loaded) .app-footer,body:not(.app-loaded) .app-header,body:not(.app-loaded) .app-main,body:not(.app-loaded) .app-sidebar{transition:none!important}@media (prefers-reduced-motion:reduce){body:not(.app-loaded) .app-footer,body:not(.app-loaded) .app-header,body:not(.app-loaded) .app-main,body:not(.app-loaded) .app-sidebar{transition:none}}body:not(.app-loaded) .app-footer,body:not(.app-loaded) .app-header,body:not(.app-loaded) .app-main,body:not(.app-loaded) .app-sidebar{animation-duration:0s!important}.hold-transition .app-footer,.hold-transition .app-header,.hold-transition .app-main,.hold-transition .app-sidebar,.hold-transition .nav-arrow,.hold-transition .nav-badge{transition:none!important}@media (prefers-reduced-motion:reduce){.hold-transition .app-footer,.hold-transition .app-header,.hold-transition .app-main,.hold-transition .app-sidebar,.hold-transition .nav-arrow,.hold-transition .nav-badge{transition:none}}.hold-transition .app-footer,.hold-transition .app-header,.hold-transition .app-main,.hold-transition .app-sidebar,.hold-transition .nav-arrow,.hold-transition .nav-badge{animation-duration:0s!important}[data-bs-theme=dark] .app-sidebar,[data-bs-theme=dark].app-sidebar{--lte-sidebar-hover-bg:rgba(255, 255, 255, 0.1);--lte-sidebar-color:#c2c7d0;--lte-sidebar-hover-color:#fff;--lte-sidebar-active-color:#fff;--lte-sidebar-menu-active-bg:rgba(255, 255, 255, 0.1);--lte-sidebar-menu-active-color:#fff;--lte-sidebar-submenu-bg:transparent;--lte-sidebar-submenu-color:#c2c7d0;--lte-sidebar-submenu-hover-color:#fff;--lte-sidebar-submenu-hover-bg:rgba(255, 255, 255, 0.1);--lte-sidebar-submenu-active-color:#fff;--lte-sidebar-submenu-active-bg:rgba(255, 255, 255, 0.1);--lte-sidebar-header-color:rgb(197.05, 201.8, 210.35)}.app-main{position:relative;display:flex;flex-direction:column;grid-area:lte-app-main;max-width:100vw;padding-bottom:.75rem;transition:.3s ease-in-out}@media (prefers-reduced-motion:reduce){.app-main{transition:none}}.app-main .app-content-header{padding:1rem .5rem}.app-main .app-content-header .breadcrumb{padding:0;margin-bottom:0;line-height:2.5rem}.app-main .app-content-header .breadcrumb a{color:var(--bs-link-hover-color);text-decoration:none}.app-main .app-content-bottom-area,.app-main .app-content-top-area{color:var(--bs-secondary-color);background-color:var(--bs-body-bg)}.app-main .app-content-top-area{padding:1rem 0;border-bottom:1px solid var(--bs-border-color)}.app-main .app-content-bottom-area{padding:1rem 0;margin-top:auto;margin-bottom:-.75rem;border-top:1px solid var(--bs-border-color)}.app-footer{grid-area:lte-app-footer;width:inherit;max-width:100vw;min-height:3rem;padding:1rem;color:var(--bs-secondary-color);background-color:var(--bs-body-bg);border-top:1px solid var(--bs-border-color);transition:.3s ease-in-out}@media (prefers-reduced-motion:reduce){.app-footer{transition:none}}.fixed-footer .app-footer{position:sticky;bottom:0;z-index:1030}.fixed-footer.layout-fixed .app-main{min-height:0}.dropdown-menu{overflow:hidden}.fs-7 .dropdown-menu{font-size:.875rem!important}.fs-7 .dropdown-toggle::after{vertical-align:.2rem}.dropdown-item-title{margin:0;font-size:1rem}.dropdown-icon::after{margin-left:0}.dropdown-menu-lg{min-width:280px;max-width:300px;padding:0}.dropdown-menu-lg .dropdown-divider{margin:0}.dropdown-menu-lg .dropdown-item{padding:.5rem 1rem}.dropdown-menu-lg p{margin:0;word-wrap:break-word;white-space:normal}.dropdown-submenu{position:relative}.dropdown-submenu>a::after{border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid;float:right;margin-top:.5rem;margin-left:.5rem}.dropdown-submenu>.dropdown-menu{top:0;left:100%;margin-top:0;margin-left:0}.dropdown-hover .dropdown-submenu:hover>.dropdown-menu,.dropdown-hover.dropdown-submenu:hover>.dropdown-menu,.dropdown-hover.nav-item.dropdown:hover>.dropdown-menu,.dropdown-hover:hover>.dropdown-menu{display:block}.dropdown-menu-xl{min-width:360px;max-width:420px;padding:0}.dropdown-menu-xl .dropdown-divider{margin:0}.dropdown-menu-xl .dropdown-item{padding:.5rem 1rem}.dropdown-menu-xl p{margin:0;word-wrap:break-word;white-space:normal}.dropdown-footer,.dropdown-header{display:block;padding:.5rem 1rem;font-size:.875rem;text-align:center}.open:not(.dropup)>.animated-dropdown-menu{animation:flipInX .7s both;backface-visibility:visible!important}.navbar-custom-menu>.navbar-nav>li{position:relative}.navbar-custom-menu>.navbar-nav>li>.dropdown-menu{position:absolute;right:0;left:auto}@media (max-width:575.98px){.navbar-custom-menu>.navbar-nav{float:right}.navbar-custom-menu>.navbar-nav>li{position:static}.navbar-custom-menu>.navbar-nav>li>.dropdown-menu{position:absolute;right:5%;left:auto;background-color:var(--bs-body-bg);border:1px solid var(--bs-border-color)}}.navbar-nav>.user-menu>.nav-link::after{content:none}.navbar-nav>.user-menu>.dropdown-menu{width:280px;padding:0}.navbar-nav>.user-menu>.dropdown-menu>li.user-header{min-height:175px;padding:10px;text-align:center}.navbar-nav>.user-menu>.dropdown-menu>li.user-header>img{z-index:5;width:90px;height:90px;border:3px solid;border-color:transparent;border-color:var(--bs-border-color-translucent)}.navbar-nav>.user-menu>.dropdown-menu>li.user-header>p{z-index:5;margin-top:10px;font-size:17px;word-wrap:break-word}.navbar-nav>.user-menu>.dropdown-menu>li.user-header>p>.small,.navbar-nav>.user-menu>.dropdown-menu>li.user-header>p>small{display:block;font-size:12px}.navbar-nav>.user-menu>.dropdown-menu>.user-body::after{display:block;clear:both;content:""}.navbar-nav>.user-menu>.dropdown-menu>.user-body{padding:15px;border-top:1px solid var(--bs-border-color);border-bottom:1px solid var(--bs-border-color-translucent)}.navbar-nav>.user-menu>.dropdown-menu>.user-body a{text-decoration:none}.navbar-nav>.user-menu>.dropdown-menu>.user-footer::after{display:block;clear:both;content:""}.navbar-nav>.user-menu>.dropdown-menu>.user-footer{padding:10px;background-color:var(--bs-tertiary-bg)}@media (min-width:576px){.navbar-nav>.user-menu .user-image{float:none;margin-top:-8px;margin-right:.4rem;line-height:10px}}.navbar-nav>.user-menu .user-image{float:left;width:2rem;height:2rem;margin-top:-2px;border-radius:50%}.callout{padding:1.25rem;color:var(--lte-callout-color,inherit);background-color:var(--lte-callout-bg,var(--bs-gray-100));border-left:.25rem solid var(--lte-callout-border,var(--bs-gray-300))}.callout a{color:var(--lte-callout-link,var(--bs-link-color))}.callout code{color:var(--lte-callout-code-color,var(--bs-code-color))}.callout .callout-link{font-weight:700;color:var(--bs-callout-link-color)}.callout .h4,.callout h4{margin-bottom:.25rem}.callout>:last-child{margin-bottom:0}.callout+.callout{margin-top:-.25rem}.callout-primary{--lte-callout-color:var(--bs-primary-text-emphasis);--lte-callout-bg:var(--bs-primary-bg-subtle);--lte-callout-border:var(--bs-primary-border-subtle);--lte-callout-link:var(--bs-primary-text-emphasis);--lte-callout-code-color:var(--bs-primary-text-emphasis);--bs-callout-link-color:var(--bs-primary-text-emphasis)}.callout-secondary{--lte-callout-color:var(--bs-secondary-text-emphasis);--lte-callout-bg:var(--bs-secondary-bg-subtle);--lte-callout-border:var(--bs-secondary-border-subtle);--lte-callout-link:var(--bs-secondary-text-emphasis);--lte-callout-code-color:var(--bs-secondary-text-emphasis);--bs-callout-link-color:var(--bs-secondary-text-emphasis)}.callout-success{--lte-callout-color:var(--bs-success-text-emphasis);--lte-callout-bg:var(--bs-success-bg-subtle);--lte-callout-border:var(--bs-success-border-subtle);--lte-callout-link:var(--bs-success-text-emphasis);--lte-callout-code-color:var(--bs-success-text-emphasis);--bs-callout-link-color:var(--bs-success-text-emphasis)}.callout-info{--lte-callout-color:var(--bs-info-text-emphasis);--lte-callout-bg:var(--bs-info-bg-subtle);--lte-callout-border:var(--bs-info-border-subtle);--lte-callout-link:var(--bs-info-text-emphasis);--lte-callout-code-color:var(--bs-info-text-emphasis);--bs-callout-link-color:var(--bs-info-text-emphasis)}.callout-warning{--lte-callout-color:var(--bs-warning-text-emphasis);--lte-callout-bg:var(--bs-warning-bg-subtle);--lte-callout-border:var(--bs-warning-border-subtle);--lte-callout-link:var(--bs-warning-text-emphasis);--lte-callout-code-color:var(--bs-warning-text-emphasis);--bs-callout-link-color:var(--bs-warning-text-emphasis)}.callout-danger{--lte-callout-color:var(--bs-danger-text-emphasis);--lte-callout-bg:var(--bs-danger-bg-subtle);--lte-callout-border:var(--bs-danger-border-subtle);--lte-callout-link:var(--bs-danger-text-emphasis);--lte-callout-code-color:var(--bs-danger-text-emphasis);--bs-callout-link-color:var(--bs-danger-text-emphasis)}.callout-light{--lte-callout-color:var(--bs-light-text-emphasis);--lte-callout-bg:var(--bs-light-bg-subtle);--lte-callout-border:var(--bs-light-border-subtle);--lte-callout-link:var(--bs-light-text-emphasis);--lte-callout-code-color:var(--bs-light-text-emphasis);--bs-callout-link-color:var(--bs-light-text-emphasis)}.callout-dark{--lte-callout-color:var(--bs-dark-text-emphasis);--lte-callout-bg:var(--bs-dark-bg-subtle);--lte-callout-border:var(--bs-dark-border-subtle);--lte-callout-link:var(--bs-dark-text-emphasis);--lte-callout-code-color:var(--bs-dark-text-emphasis);--bs-callout-link-color:var(--bs-dark-text-emphasis)}.compact-mode .app-header{max-height:2.75rem}.compact-mode .app-header .nav-link{max-height:1.75rem}.compact-mode .nav-link{--bs-nav-link-padding-y:.25rem;--bs-nav-link-padding-x:.5rem}.compact-mode.sidebar-mini.sidebar-collapse .app-sidebar:not(:hover){min-width:3.1rem;max-width:3.1rem}.compact-mode.sidebar-mini.sidebar-collapse .app-sidebar:not(:hover) .sidebar-menu .nav-link{width:2.1rem!important}.compact-mode .logo-xl,.compact-mode .logo-xs{max-height:2.75rem}.compact-mode .brand-image{width:1.75rem;height:1.75rem}.compact-mode .sidebar-brand{height:2.75rem}.compact-mode .app-footer{padding:.5rem}.compact-mode .sidebar-wrapper .nav-icon{min-width:1.1rem;max-width:1.1rem}.progress{border-radius:1px}.progress.vertical{position:relative;display:inline-block;width:30px;height:200px;margin-right:10px}.progress.vertical>.progress-bar{position:absolute;bottom:0;width:100%}.progress.vertical.progress-sm,.progress.vertical.sm{width:20px}.progress.vertical.progress-xs,.progress.vertical.xs{width:10px}.progress.vertical.progress-xxs,.progress.vertical.xxs{width:3px}.progress-group{margin-bottom:.5rem}.progress-sm{height:10px}.progress-xs{height:7px}.progress-xxs{height:3px}.table tr>td .progress{margin:0}.card{box-shadow:0 0 1px rgba(var(--bs-body-color-rgb),.125),0 1px 3px rgba(var(--bs-body-color-rgb),.2)}.card[class*=card-]:not(.card-outline)>.card-header,.card[class*=text-bg-]:not(.card-outline)>.card-header{color:var(--lte-card-variant-color);background-color:var(--lte-card-variant-bg)}.card[class*=card-]:not(.card-outline)>.card-header .btn-tool,.card[class*=text-bg-]:not(.card-outline)>.card-header .btn-tool{--bs-btn-color:rgba(var(--lte-card-variant-color-rgb), .8);--bs-btn-hover-color:var(--lte-card-variant-color)}.card.card-outline{border-top:3px solid var(--lte-card-variant-bg)}.card.maximized-card{position:fixed;top:0;left:0;z-index:1050;width:100%!important;max-width:100%!important;height:100%!important;max-height:100%!important}.card.maximized-card.was-collapsed .card-body{display:block!important}.card.maximized-card .card-body{overflow:auto}.card.maximized-card [data-lte-toggle=card-collapse]{display:none}.card.maximized-card [data-lte-icon=maximize]{display:none}.card.maximized-card .card-footer,.card.maximized-card .card-header{border-radius:0!important}.card:not(.maximized-card) [data-lte-icon=minimize]{display:none}.card.collapsed-card>.card-header [data-lte-icon=collapse]{display:none}.card.collapsed-card>.card-body,.card.collapsed-card>.card-footer{display:none}.card:not(.collapsed-card)>.card-header [data-lte-icon=expand]{display:none}.card .nav.flex-column>li{margin:0;border-bottom:1px solid var(--bs-border-color-translucent)}.card .nav.flex-column>li:last-of-type{border-bottom:0}.card.height-control .card-body{max-height:300px;overflow:auto}.card .border-end{border-right:1px solid var(--bs-border-color-translucent)}.card .border-start{border-left:1px solid var(--bs-border-color-translucent)}.card.card-tabs:not(.card-outline)>.card-header{border-bottom:0}.card.card-tabs:not(.card-outline)>.card-header .nav-item:first-child .nav-link{border-left-color:transparent}.card.card-tabs.card-outline .nav-item{border-bottom:0}.card.card-tabs.card-outline .nav-item:first-child .nav-link{margin-left:0;border-left:0}.card.card-tabs .card-tools{margin:.3rem .5rem}.card.card-tabs:not(.expanding-card).collapsed-card .card-header{border-bottom:0}.card.card-tabs:not(.expanding-card).collapsed-card .card-header .nav-tabs{border-bottom:0}.card.card-tabs:not(.expanding-card).collapsed-card .card-header .nav-tabs .nav-item{margin-bottom:0}.card.card-tabs.expanding-card .card-header .nav-tabs .nav-item{margin-bottom:-1px}.card.card-outline-tabs{border-top:0}.card.card-outline-tabs .card-header .nav-item:first-child .nav-link{margin-left:0;border-left:0}.card.card-outline-tabs .card-header a{text-decoration:none;border-top:3px solid transparent}.card.card-outline-tabs .card-header a:hover{border-top:3px solid var(--bs-border-color)}.card.card-outline-tabs .card-header a.active:hover{margin-top:0}.card.card-outline-tabs .card-tools{margin:.5rem .5rem .3rem}.card.card-outline-tabs:not(.expanding-card).collapsed-card .card-header{border-bottom:0}.card.card-outline-tabs:not(.expanding-card).collapsed-card .card-header .nav-tabs{border-bottom:0}.card.card-outline-tabs:not(.expanding-card).collapsed-card .card-header .nav-tabs .nav-item{margin-bottom:0}.card.card-outline-tabs.expanding-card .card-header .nav-tabs .nav-item{margin-bottom:-1px}html.maximized-card{overflow:hidden}.card-body::after,.card-footer::after,.card-header::after{display:block;clear:both;content:""}.card-header{position:relative;padding:1rem 1rem;background-color:transparent;border-bottom:1px solid var(--bs-border-color-translucent);border-top-left-radius:.375rem;border-top-right-radius:.375rem}.collapsed-card .card-header{border-bottom:0}.card-header>.card-tools{float:right;margin-right:-.5rem}.card-header>.card-tools .input-group,.card-header>.card-tools .nav,.card-header>.card-tools .pagination{margin-top:-.4rem;margin-bottom:-.4rem}.card-header>.card-tools [data-bs-toggle=tooltip]{position:relative}.card-title{float:left;margin:0;font-size:1.1rem;font-weight:400}.btn-tool{--bs-btn-padding-x:.5rem;--bs-btn-padding-y:.25rem}.btn-tool:not(.btn-tool-custom){--bs-btn-color:var(--bs-tertiary-color);--bs-btn-bg:transparent;--bs-btn-box-shadow:none;--bs-btn-hover-color:var(--bs-secondary-color);--bs-btn-active-border-color:transparent}.btn-tool{margin:-1rem 0;font-size:.875rem}.bg-primary,.card-primary,.text-bg-primary{--lte-card-variant-bg:#0d6efd;--lte-card-variant-bg-rgb:13,110,253;--lte-card-variant-color:#fff;--lte-card-variant-color-rgb:255,255,255}.bg-secondary,.card-secondary,.text-bg-secondary{--lte-card-variant-bg:#6c757d;--lte-card-variant-bg-rgb:108,117,125;--lte-card-variant-color:#fff;--lte-card-variant-color-rgb:255,255,255}.bg-success,.card-success,.text-bg-success{--lte-card-variant-bg:#198754;--lte-card-variant-bg-rgb:25,135,84;--lte-card-variant-color:#fff;--lte-card-variant-color-rgb:255,255,255}.bg-info,.card-info,.text-bg-info{--lte-card-variant-bg:#0dcaf0;--lte-card-variant-bg-rgb:13,202,240;--lte-card-variant-color:#000;--lte-card-variant-color-rgb:0,0,0}.bg-warning,.card-warning,.text-bg-warning{--lte-card-variant-bg:#ffc107;--lte-card-variant-bg-rgb:255,193,7;--lte-card-variant-color:#000;--lte-card-variant-color-rgb:0,0,0}.bg-danger,.card-danger,.text-bg-danger{--lte-card-variant-bg:#dc3545;--lte-card-variant-bg-rgb:220,53,69;--lte-card-variant-color:#fff;--lte-card-variant-color-rgb:255,255,255}.bg-light,.card-light,.text-bg-light{--lte-card-variant-bg:#f8f9fa;--lte-card-variant-bg-rgb:248,249,250;--lte-card-variant-color:#000;--lte-card-variant-color-rgb:0,0,0}.bg-dark,.card-dark,.text-bg-dark{--lte-card-variant-bg:#212529;--lte-card-variant-bg-rgb:33,37,41;--lte-card-variant-color:#fff;--lte-card-variant-color-rgb:255,255,255}.card-body>.table{margin-bottom:0}.card-body>.table>thead>tr>td,.card-body>.table>thead>tr>th{border-top-width:0}.table:not(.table-dark){color:inherit}.table.table-head-fixed thead tr:first-child th{position:sticky;top:0;z-index:10;background-color:var(--bs-body-bg,#fff);border-bottom:0;box-shadow:inset 0 1px 0 var(--bs-border-color,var(--bs-border-color)),inset 0 -1px 0 var(--bs-border-color,var(--bs-border-color))}.table.no-border,.table.no-border td,.table.no-border th{border:0}.table.text-center,.table.text-center td,.table.text-center th{text-align:center}.table.table-valign-middle tbody>tr>td,.table.table-valign-middle tbody>tr>th,.table.table-valign-middle thead>tr>td,.table.table-valign-middle thead>tr>th{vertical-align:middle}.card-body.p-0 .table tbody>tr>td:first-of-type,.card-body.p-0 .table tbody>tr>th:first-of-type,.card-body.p-0 .table tfoot>tr>td:first-of-type,.card-body.p-0 .table tfoot>tr>th:first-of-type,.card-body.p-0 .table thead>tr>td:first-of-type,.card-body.p-0 .table thead>tr>th:first-of-type{padding-left:1.5rem}.card-body.p-0 .table tbody>tr>td:last-of-type,.card-body.p-0 .table tbody>tr>th:last-of-type,.card-body.p-0 .table tfoot>tr>td:last-of-type,.card-body.p-0 .table tfoot>tr>th:last-of-type,.card-body.p-0 .table thead>tr>td:last-of-type,.card-body.p-0 .table thead>tr>th:last-of-type{padding-right:1.5rem}.small-box{border-radius:.375rem;box-shadow:0 0 1px rgba(var(--bs-body-color-rgb),.125),0 1px 3px rgba(var(--bs-body-color-rgb),.2);position:relative;display:block;margin-bottom:1.25rem;--bs-link-color-rgb:none;--bs-link-hover-color-rgb:none;--bs-heading-color:none}.small-box>.inner{padding:10px}.small-box>.small-box-footer{position:relative;z-index:10;display:block;padding:3px 0;text-align:center;background-color:rgba(0,0,0,.07)}.small-box>.small-box-footer:hover{background-color:rgba(0,0,0,.1)}.small-box .h3,.small-box h3{font-size:calc(1.345rem + 1.14vw)}@media (min-width:1200px){.small-box .h3,.small-box h3{font-size:2.2rem}}.small-box .h3,.small-box h3{padding:0;margin:0 0 10px;font-weight:700;white-space:nowrap}@media (min-width:992px){.col-lg-2 .small-box .h3,.col-lg-2 .small-box h3,.col-md-2 .small-box .h3,.col-md-2 .small-box h3,.col-xl-2 .small-box .h3,.col-xl-2 .small-box h3{font-size:calc(1.285rem + .42vw)}}@media (min-width:992px) and (min-width:1200px){.col-lg-2 .small-box .h3,.col-lg-2 .small-box h3,.col-md-2 .small-box .h3,.col-md-2 .small-box h3,.col-xl-2 .small-box .h3,.col-xl-2 .small-box h3{font-size:1.6rem}}@media (min-width:992px){.col-lg-3 .small-box .h3,.col-lg-3 .small-box h3,.col-md-3 .small-box .h3,.col-md-3 .small-box h3,.col-xl-3 .small-box .h3,.col-xl-3 .small-box h3{font-size:calc(1.285rem + .42vw)}}@media (min-width:992px) and (min-width:1200px){.col-lg-3 .small-box .h3,.col-lg-3 .small-box h3,.col-md-3 .small-box .h3,.col-md-3 .small-box h3,.col-xl-3 .small-box .h3,.col-xl-3 .small-box h3{font-size:1.6rem}}@media (min-width:1200px){.col-lg-2 .small-box .h3,.col-lg-2 .small-box h3,.col-md-2 .small-box .h3,.col-md-2 .small-box h3,.col-xl-2 .small-box .h3,.col-xl-2 .small-box h3{font-size:calc(1.345rem + 1.14vw)}}@media (min-width:1200px) and (min-width:1200px){.col-lg-2 .small-box .h3,.col-lg-2 .small-box h3,.col-md-2 .small-box .h3,.col-md-2 .small-box h3,.col-xl-2 .small-box .h3,.col-xl-2 .small-box h3{font-size:2.2rem}}@media (min-width:1200px){.col-lg-3 .small-box .h3,.col-lg-3 .small-box h3,.col-md-3 .small-box .h3,.col-md-3 .small-box h3,.col-xl-3 .small-box .h3,.col-xl-3 .small-box h3{font-size:calc(1.345rem + 1.14vw)}}@media (min-width:1200px) and (min-width:1200px){.col-lg-3 .small-box .h3,.col-lg-3 .small-box h3,.col-md-3 .small-box .h3,.col-md-3 .small-box h3,.col-xl-3 .small-box .h3,.col-xl-3 .small-box h3{font-size:2.2rem}}.small-box p{font-size:1rem}.small-box p>.small,.small-box p>small{display:block;margin-top:5px;font-size:.9rem;color:#f8f9fa}.small-box .h3,.small-box h3,.small-box p{z-index:5}.small-box .small-box-icon{position:absolute;top:15px;right:15px;z-index:0;height:70px;font-size:70px;color:rgba(0,0,0,.15);transition:transform .3s linear}@media (prefers-reduced-motion:reduce){.small-box .small-box-icon{transition:none}}.small-box:hover .small-box-icon{transform:scale(1.1)}@media (max-width:575.98px){.small-box{text-align:center}.small-box .small-box-icon{display:none}.small-box p{font-size:12px}}.info-box{box-shadow:0 0 1px rgba(var(--bs-body-color-rgb),.125),0 1px 3px rgba(var(--bs-body-color-rgb),.2);border-radius:.375rem;position:relative;display:flex;width:100%;min-height:80px;padding:.5rem;margin-bottom:1rem;color:var(--bs-body-color);background-color:var(--bs-body-bg)}.info-box .progress{height:2px;margin:5px 0;background-color:rgba(var(--lte-card-variant-color-rgb),.125)}.info-box .progress .progress-bar{background-color:var(--lte-card-variant-color)}.info-box .info-box-icon{display:flex;align-items:center;justify-content:center;width:70px;font-size:1.875rem;text-align:center;border-radius:.375rem}.info-box .info-box-icon>img{max-width:100%}.info-box .info-box-content{display:flex;flex:1;flex-direction:column;justify-content:center;padding:0 10px;line-height:1.8}.info-box .info-box-number{display:block;margin-top:.25rem;font-weight:700}.info-box .info-box-text,.info-box .progress-description{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.info-box .info-box-more{display:block}.info-box .progress-description{margin:0}@media (min-width:768px){.col-lg-2 .info-box .progress-description,.col-md-2 .info-box .progress-description,.col-xl-2 .info-box .progress-description{display:none}.col-lg-3 .info-box .progress-description,.col-md-3 .info-box .progress-description,.col-xl-3 .info-box .progress-description{display:none}}@media (min-width:992px){.col-lg-2 .info-box .progress-description,.col-md-2 .info-box .progress-description,.col-xl-2 .info-box .progress-description{font-size:.75rem;display:block}.col-lg-3 .info-box .progress-description,.col-md-3 .info-box .progress-description,.col-xl-3 .info-box .progress-description{font-size:.75rem;display:block}}@media (min-width:1200px){.col-lg-2 .info-box .progress-description,.col-md-2 .info-box .progress-description,.col-xl-2 .info-box .progress-description{font-size:1rem;display:block}.col-lg-3 .info-box .progress-description,.col-md-3 .info-box .progress-description,.col-xl-3 .info-box .progress-description{font-size:1rem;display:block}}.timeline{position:relative;padding:0;margin:0 0 45px}.timeline::before{border-radius:.375rem;position:absolute;top:0;bottom:0;left:31px;width:4px;margin:0;content:"";background-color:var(--bs-border-color)}.timeline>div::after,.timeline>div::before{display:table;content:""}.timeline>div{position:relative;margin-right:10px;margin-bottom:15px}.timeline>div>.timeline-item{box-shadow:0 0 1px rgba(var(--bs-body-color-rgb),.125),0 1px 3px rgba(var(--bs-body-color-rgb),.2);border-radius:.375rem;position:relative;padding:0;margin-top:0;margin-right:15px;margin-left:60px;color:var(--bs-body-color);background-color:var(--bs-body-bg)}.timeline>div>.timeline-item>.time{float:right;padding:10px;font-size:12px;color:var(--bs-secondary-color)}.timeline>div>.timeline-item>.timeline-header{padding:10px;margin:0;font-size:16px;line-height:1.1;color:var(--bs-secondary-color);border-bottom:1px solid var(--bs-border-color)}.timeline>div>.timeline-item>.timeline-header>a{font-weight:600;text-decoration:none}.timeline>div>.timeline-item>.timeline-body,.timeline>div>.timeline-item>.timeline-footer{padding:10px}.timeline>div>.timeline-item>.timeline-body>img{margin:10px}.timeline>div>.timeline-item>.timeline-body ol,.timeline>div>.timeline-item>.timeline-body ul,.timeline>div>.timeline-item>.timeline-body>dl{margin:0}.timeline>div .timeline-icon{position:absolute;top:0;left:18px;width:30px;height:30px;font-size:16px;line-height:30px;text-align:center;background-color:var(--bs-secondary-bg);border-radius:50%}.timeline>.time-label>span{border-radius:4px;display:inline-block;padding:5px;font-weight:600;background-color:var(--bs-body-bg)}.timeline-inverse>div>.timeline-item{box-shadow:none;background-color:var(--bs-tertiary-bg);border:1px solid var(--bs-border-color)}.timeline-inverse>div>.timeline-item>.timeline-header{border-bottom-color:var(--bs-border-color)}.direct-chat .card-body{position:relative;padding:0;overflow-x:hidden}.direct-chat.chat-pane-open .direct-chat-contacts{transform:translate(0,0)}.direct-chat.timestamp-light .direct-chat-timestamp{color:rgba(var(--bs-body-color-rgb),.65)}.direct-chat.timestamp-dark .direct-chat-timestamp{color:rgba(var(--bs-body-color-rgb),.9)}.direct-chat-messages{height:250px;padding:10px;overflow:auto;transform:translate(0,0)}.direct-chat-msg,.direct-chat-text{display:block}.direct-chat-msg::after{display:block;clear:both;content:""}.direct-chat-msg{margin-bottom:10px}.direct-chat-contacts,.direct-chat-messages{transition:transform .5s ease-in-out}@media (prefers-reduced-motion:reduce){.direct-chat-contacts,.direct-chat-messages{transition:none}}.direct-chat-text{border-radius:.5rem;position:relative;padding:5px 10px;margin:5px 0 0 50px;color:var(--bs-emphasis-color);background-color:var(--bs-secondary-bg);border:1px solid var(--bs-border-color)}.direct-chat-text::after,.direct-chat-text::before{position:absolute;top:15px;right:100%;width:0;height:0;pointer-events:none;content:" ";border:solid transparent;border-right-color:var(--bs-border-color)}.direct-chat-text::after{margin-top:-5px;border-width:5px}.direct-chat-text::before{margin-top:-6px;border-width:6px}.end .direct-chat-text{margin-right:50px;margin-left:0}.end .direct-chat-text::after,.end .direct-chat-text::before{right:auto;left:100%;border-right-color:transparent;border-left-color:var(--bs-border-color)}.direct-chat-img{border-radius:50%;float:left;width:40px;height:40px}.end .direct-chat-img{float:right}.direct-chat-infos{display:block;margin-bottom:2px;font-size:.875rem}.direct-chat-name{font-weight:600}.direct-chat-timestamp{color:rgba(var(--bs-body-color-rgb),.75)}.direct-chat-contacts-open .direct-chat-contacts{transform:translate(0,0)}.direct-chat-contacts{position:absolute;top:0;bottom:0;width:100%;height:250px;overflow:auto;color:var(--bs-body-bg);background-color:var(--bs-body-color);transform:translate(101%,0)}.direct-chat-contacts-light{background-color:var(--bs-light-bg-subtle)}.direct-chat-contacts-light .contacts-list-name{color:var(--bs-body-color)}.direct-chat-contacts-light .contacts-list-date{color:var(--bs-secondary-color)}.direct-chat-contacts-light .contacts-list-msg{color:var(--bs-secondary-color)}.contacts-list{padding-left:0;list-style:none}.contacts-list>li::after{display:block;clear:both;content:""}.contacts-list>li{padding:10px;margin:0;text-decoration:none;border-bottom:1px solid rgba(0,0,0,.2)}.contacts-list>li:last-of-type{border-bottom:0}.contacts-list>li a{text-decoration:none}.contacts-list-img{border-radius:50%;float:left;width:40px}.contacts-list-info{margin-left:45px;color:var(--bs-body-bg)}.contacts-list-name,.contacts-list-status{display:block}.contacts-list-name{font-weight:600}.contacts-list-status{font-size:.875rem}.contacts-list-date{font-weight:400;color:var(--bs-secondary-bg)}.contacts-list-msg{color:var(--bs-secondary-bg)}.end>.direct-chat-text{color:var(--lte-direct-chat-color);background-color:var(--lte-direct-chat-bg);border-color:var(--lte-direct-chat-bg)}.end>.direct-chat-text::after,.end>.direct-chat-text::before{border-left-color:var(--lte-direct-chat-bg)}.direct-chat-primary{--lte-direct-chat-color:#fff;--lte-direct-chat-bg:#0d6efd}.direct-chat-secondary{--lte-direct-chat-color:#fff;--lte-direct-chat-bg:#6c757d}.direct-chat-success{--lte-direct-chat-color:#fff;--lte-direct-chat-bg:#198754}.direct-chat-info{--lte-direct-chat-color:#000;--lte-direct-chat-bg:#0dcaf0}.direct-chat-warning{--lte-direct-chat-color:#000;--lte-direct-chat-bg:#ffc107}.direct-chat-danger{--lte-direct-chat-color:#fff;--lte-direct-chat-bg:#dc3545}.direct-chat-light{--lte-direct-chat-color:#000;--lte-direct-chat-bg:#f8f9fa}.direct-chat-dark{--lte-direct-chat-color:#fff;--lte-direct-chat-bg:#212529}.toast-primary{--bs-toast-header-color:#fff;--bs-toast-header-bg:#0d6efd;--bs-toast-header-border-color:#0d6efd;--bs-toast-border-color:#0d6efd;--bs-toast-bg:var(--bs-primary-bg-subtle)}.toast-primary .btn-close{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}.toast-secondary{--bs-toast-header-color:#fff;--bs-toast-header-bg:#6c757d;--bs-toast-header-border-color:#6c757d;--bs-toast-border-color:#6c757d;--bs-toast-bg:var(--bs-secondary-bg-subtle)}.toast-secondary .btn-close{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}.toast-success{--bs-toast-header-color:#fff;--bs-toast-header-bg:#198754;--bs-toast-header-border-color:#198754;--bs-toast-border-color:#198754;--bs-toast-bg:var(--bs-success-bg-subtle)}.toast-success .btn-close{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}.toast-info{--bs-toast-header-color:#000;--bs-toast-header-bg:#0dcaf0;--bs-toast-header-border-color:#0dcaf0;--bs-toast-border-color:#0dcaf0;--bs-toast-bg:var(--bs-info-bg-subtle)}.toast-warning{--bs-toast-header-color:#000;--bs-toast-header-bg:#ffc107;--bs-toast-header-border-color:#ffc107;--bs-toast-border-color:#ffc107;--bs-toast-bg:var(--bs-warning-bg-subtle)}.toast-danger{--bs-toast-header-color:#fff;--bs-toast-header-bg:#dc3545;--bs-toast-header-border-color:#dc3545;--bs-toast-border-color:#dc3545;--bs-toast-bg:var(--bs-danger-bg-subtle)}.toast-danger .btn-close{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}.toast-light{--bs-toast-header-color:#000;--bs-toast-header-bg:#f8f9fa;--bs-toast-header-border-color:#f8f9fa;--bs-toast-border-color:#f8f9fa;--bs-toast-bg:var(--bs-light-bg-subtle)}.toast-dark{--bs-toast-header-color:#fff;--bs-toast-header-bg:#212529;--bs-toast-header-border-color:#212529;--bs-toast-border-color:#212529;--bs-toast-bg:var(--bs-dark-bg-subtle)}.toast-dark .btn-close{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}[data-bs-theme=dark] .toast-info .btn-close{--bs-btn-close-white-filter:none}[data-bs-theme=dark] .toast-warning .btn-close{--bs-btn-close-white-filter:none}[data-bs-theme=dark] .toast-light .btn-close{--bs-btn-close-white-filter:none}.login-logo,.register-logo{margin-bottom:.9rem;font-size:2.1rem;font-weight:300;text-align:center}.login-logo a,.register-logo a{color:var(--bs-secondary-color);text-decoration:none}.login-page,.register-page{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh}.login-box,.register-box{width:400px}@media (max-width:576px){.login-box,.register-box{width:90%;margin-top:.5rem}}.login-box .card,.register-box .card{margin-bottom:0}.login-card-body,.register-card-body{padding:20px;color:var(--bs-secondary-color);background-color:var(--bs-body-bg);border-top:0}.login-card-body .input-group .form-control:focus,.register-card-body .input-group .form-control:focus{box-shadow:none}.login-card-body .input-group .form-control:focus~.input-group-append .input-group-text,.login-card-body .input-group .form-control:focus~.input-group-prepend .input-group-text,.register-card-body .input-group .form-control:focus~.input-group-append .input-group-text,.register-card-body .input-group .form-control:focus~.input-group-prepend .input-group-text{border-color:rgb(134,182.5,254)}.login-card-body .input-group .form-control.is-valid:focus,.register-card-body .input-group .form-control.is-valid:focus{box-shadow:none}.login-card-body .input-group .form-control.is-valid~.input-group-append .input-group-text,.login-card-body .input-group .form-control.is-valid~.input-group-prepend .input-group-text,.register-card-body .input-group .form-control.is-valid~.input-group-append .input-group-text,.register-card-body .input-group .form-control.is-valid~.input-group-prepend .input-group-text{border-color:#198754}.login-card-body .input-group .form-control.is-invalid:focus,.register-card-body .input-group .form-control.is-invalid:focus{box-shadow:none}.login-card-body .input-group .form-control.is-invalid~.input-group-append .input-group-text,.register-card-body .input-group .form-control.is-invalid~.input-group-append .input-group-text{border-color:#dc3545}.login-card-body .input-group .input-group-text,.register-card-body .input-group .input-group-text{color:var(--bs-secondary-color);background-color:transparent;border-top-right-radius:.375rem;border-bottom-right-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.login-card-body .input-group .input-group-text,.register-card-body .input-group .input-group-text{transition:none}}.login-box-msg,.register-box-msg{padding:0 20px 20px;margin:0;text-align:center}.social-auth-links{margin:10px 0}.lockscreen .lockscreen-name{font-weight:600;text-align:center}.lockscreen .lockscreen-logo{margin-bottom:25px;font-size:35px;font-weight:300;text-align:center}.lockscreen .lockscreen-logo a{color:var(--bs-emphasis-color);text-decoration:none}.lockscreen .lockscreen-wrapper{max-width:400px;margin:0 auto;margin-top:10%}.lockscreen .lockscreen-item{position:relative;width:290px;padding:0;margin:10px auto 30px;background-color:var(--bs-body-bg);border-radius:4px}.lockscreen .lockscreen-image{position:absolute;top:-25px;left:-10px;z-index:10;padding:5px;background-color:var(--bs-body-bg);border-radius:50%}.lockscreen .lockscreen-image>img{border-radius:50%;width:70px;height:70px}.lockscreen .lockscreen-credentials{margin-left:70px}.lockscreen .lockscreen-credentials .form-control{border:0}.lockscreen .lockscreen-credentials .btn{padding:0 10px;border:0}.lockscreen .lockscreen-footer{margin-top:10px}.img-size-32,.img-size-50,.img-size-64{height:auto}.img-size-64{width:64px}.img-size-50{width:50px}.img-size-32{width:32px}.skip-link{position:absolute;top:-40px;left:6px;z-index:999999;padding:8px 16px;font-weight:600;color:var(--bs-white);text-decoration:none;background:var(--bs-primary)}.skip-link:focus{top:0;outline:3px solid var(--bs-warning);outline-offset:2px}.skip-link:hover{color:var(--bs-white);text-decoration:none;background:var(--bs-primary-emphasis)}.focus-enhanced:focus{outline:3px solid var(--bs-focus-ring-color,#0d6efd);outline-offset:2px;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}@media (prefers-contrast:high){.card{border:2px solid}.btn{border-width:2px}.nav-link{border:1px solid transparent}.nav-link:focus,.nav-link:hover{border-color:currentcolor}}@media (prefers-reduced-motion:reduce){*,::after,::before{transition-duration:0s!important;animation-duration:0s!important;animation-iteration-count:1!important;scroll-behavior:auto!important}.fade{opacity:1!important;transition:none!important}.collapse{transition:none!important}.modal.fade .modal-dialog{transform:none!important}}.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.sr-only-focusable:focus{position:static!important;width:auto!important;height:auto!important;padding:inherit!important;margin:inherit!important;overflow:visible!important;clip:auto!important;white-space:normal!important}.focus-trap:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.text-accessible-primary{color:#003d82}.text-accessible-success{color:#0f5132}.text-accessible-danger{color:#842029}.text-accessible-warning{color:#664d03}.live-region{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.live-region.live-region-visible{position:static;left:auto;width:auto;height:auto;overflow:visible}.form-control.is-invalid{border-color:var(--bs-danger)}.form-control.is-invalid:focus{border-color:var(--bs-danger);box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.invalid-feedback[role=alert]{font-weight:600}.touch-target{min-width:44px;min-height:44px}.touch-target.touch-target-small{min-width:24px;min-height:24px}.table-accessible th{font-weight:600;background-color:var(--bs-secondary-bg)}.table-accessible th[scope=col]{border-bottom:2px solid var(--bs-border-color)}.table-accessible th[scope=row]{border-right:2px solid var(--bs-border-color)}.table-accessible caption{padding:.75rem;font-weight:600;color:var(--bs-secondary);text-align:left;caption-side:top}nav[role=navigation]:not([aria-label]):not([aria-labelledby])::before{position:absolute;left:-10000px;content:"Navigation"}fieldset{padding:1rem;margin-bottom:1rem;border:1px solid var(--bs-border-color)}fieldset legend{padding:0 .5rem;margin-bottom:.5rem;font-size:1.1em;font-weight:600}.loading[aria-busy=true]{position:relative;pointer-events:none}.loading[aria-busy=true]::after{position:absolute;top:50%;left:50%;width:20px;height:20px;margin-top:-10px;margin-left:-10px;content:"";border:2px solid var(--bs-primary);border-top-color:transparent;animation:spin 1s linear infinite}@media (prefers-reduced-motion:reduce){.loading[aria-busy=true]::after{border-top-color:var(--bs-primary);animation:none}}@keyframes spin{to{transform:rotate(360deg)}}[data-bs-theme=dark] .text-accessible-primary{color:#6ea8fe}[data-bs-theme=dark] .text-accessible-success{color:#75b798}[data-bs-theme=dark] .text-accessible-danger{color:#f1aeb5}[data-bs-theme=dark] .text-accessible-warning{color:#ffda6a}@media print{.btn,.nav-link,.skip-link{color:#000!important;background:0 0!important;border:1px solid #000!important}a[href^=http]::after{font-size:.8em;content:" (" attr(href) ")"}.app-wrapper{display:grid!important;grid-template-rows:auto 1fr auto!important;grid-template-columns:auto 1fr!important}.sidebar-overlay{display:none!important}.app-sidebar{position:static!important;display:block!important;min-width:200px!important;max-width:200px!important;max-height:none!important;margin-left:0!important;overflow:visible!important}.sidebar-wrapper{height:auto!important;overflow:visible!important}.app-header{position:static!important}.app-main{width:auto!important;max-width:100%!important;overflow:visible!important}.app-content{overflow:visible!important}.app-footer{position:static!important}} +/*# sourceMappingURL=adminlte.min.css.map */ \ No newline at end of file diff --git a/src/web/public/adminlte/js/adminlte.min.js b/src/web/public/adminlte/js/adminlte.min.js new file mode 100644 index 0000000..6adef65 --- /dev/null +++ b/src/web/public/adminlte/js/adminlte.min.js @@ -0,0 +1,7 @@ +/*! + * AdminLTE v4.1.0 (https://adminlte.io) + * Copyright 2014-2026 Colorlib + * Licensed under MIT (https://github.com/ColorlibHQ/AdminLTE/blob/master/LICENSE) + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).adminlte={})}(this,function(e){"use strict";const t=[],n={controller:new AbortController,hasInitialized:!1},i=()=>n.controller.signal,s=()=>{if(!n.hasInitialized){n.hasInitialized=!0;for(const e of t)e()}},a=e=>{t.push(e),n.hasInitialized&&e()};"loading"===document.readyState?document.addEventListener("DOMContentLoaded",s,{once:!0}):s(),document.addEventListener("turbo:before-render",()=>{n.controller.abort(),n.controller=new AbortController,n.hasInitialized=!1}),document.addEventListener("turbo:load",s);const o=new WeakMap,r=e=>{const t=o.get(e)??[];for(const e of t)globalThis.clearTimeout(e);o.delete(e)},c=e=>{for(const t of["height","padding-top","padding-bottom","margin-top","margin-bottom","overflow","transition-duration","transition-property"])e.style.removeProperty(t)},l=(e,t=500)=>{if(r(e),t<=1)return e.style.display="none",void c(e);e.style.transitionProperty="height, margin, padding",e.style.transitionDuration=`${t}ms`,e.style.boxSizing="border-box",e.style.height=`${e.offsetHeight}px`,e.style.overflow="hidden";const n=globalThis.setTimeout(()=>{e.style.height="0",e.style.paddingTop="0",e.style.paddingBottom="0",e.style.marginTop="0",e.style.marginBottom="0"},1),i=globalThis.setTimeout(()=>{e.style.display="none",c(e),o.delete(e)},t);o.set(e,[n,i])},d=(e,t=500)=>{r(e),c(e),e.style.removeProperty("display");let{display:n}=globalThis.getComputedStyle(e);if("none"===n&&(n="block"),e.style.display=n,t<=1)return;const i=e.offsetHeight;e.style.overflow="hidden",e.style.height="0",e.style.paddingTop="0",e.style.paddingBottom="0",e.style.marginTop="0",e.style.marginBottom="0";const s=globalThis.setTimeout(()=>{e.style.boxSizing="border-box",e.style.transitionProperty="height, margin, padding",e.style.transitionDuration=`${t}ms`,e.style.height=`${i}px`,e.style.removeProperty("padding-top"),e.style.removeProperty("padding-bottom"),e.style.removeProperty("margin-top"),e.style.removeProperty("margin-bottom")},1),a=globalThis.setTimeout(()=>{c(e),o.delete(e)},t);o.set(e,[s,a])},u="hold-transition";class h{_element;_holdTransitionTimer;constructor(e){this._element=e,this._holdTransitionTimer=void 0}holdTransition(e=100){this._holdTransitionTimer&&clearTimeout(this._holdTransitionTimer),document.body.classList.add(u),this._holdTransitionTimer=setTimeout(()=>{document.body.classList.remove(u)},e)}}a(()=>{const e=new h(document.body);window.addEventListener("resize",()=>e.holdTransition(200),{signal:i()}),setTimeout(()=>{document.body.classList.add("app-loaded")},400)});const m=new WeakMap;class p{static get NAME(){throw new Error("Component subclasses must override the static NAME getter.")}static get DATA_KEY(){return`lte.${this.NAME}`}static _getInstance(e){return e?m.get(e)?.get(this.DATA_KEY)??null:null}_element;constructor(e){this._element=e;const t=m.get(e)??new Map;m.set(e,t),t.set(this.constructor.DATA_KEY,this)}dispose(){const e=m.get(this._element);e?.delete(this.constructor.DATA_KEY),0===e?.size&&m.delete(this._element)}}const g=(e,t,n={})=>{const i=new CustomEvent(t,{bubbles:!0,cancelable:n.cancelable??!1,detail:n.detail});return e.dispatchEvent(i),i},b="card-widget",f=`.lte.${b}`,v=`collapse${f}`,y=`expand${f}`,_=`remove${f}`,S=`collapsed${f}`,E=`expanded${f}`,L=`removed${f}`,A=`maximized${f}`,T=`minimized${f}`,k="card",x="collapsed-card",w="collapsing-card",M="expanding-card",q="was-collapsed",$="maximized-card",I='[data-lte-toggle="card-remove"]',C='[data-lte-toggle="card-collapse"]',N='[data-lte-toggle="card-maximize"]',P=`.${k}`,z=".card-body",D=".card-footer",R={animationSpeed:500,collapseTrigger:C,removeTrigger:I,maximizeTrigger:N};class B extends p{static get NAME(){return b}static getInstance(e){return this._getInstance(e)}static getOrCreateInstance(e,t={}){return this.getInstance(e)??new this(e,t)}_parent;_config;constructor(e,t={}){super(e),this._parent=e.closest(P),e.classList.contains(k)&&(this._parent=e),this._config={...R,...t}}collapse(){this._parent&&(g(this._parent,v,{cancelable:!0}).defaultPrevented||(this._parent.classList.add(w),this._parent.classList.remove(M),this._parent.querySelectorAll(`:scope > ${z}, :scope > ${D}`).forEach(e=>{e instanceof HTMLElement&&l(e,this._config.animationSpeed)}),setTimeout(()=>{this._parent?.classList.contains(w)&&(this._parent.classList.add(x),this._parent.classList.remove(w),g(this._parent,S))},this._config.animationSpeed)))}expand(){this._parent&&(g(this._parent,y,{cancelable:!0}).defaultPrevented||(this._parent.classList.add(M),this._parent.classList.remove(w,x),this._parent.querySelectorAll(`:scope > ${z}, :scope > ${D}`).forEach(e=>{e instanceof HTMLElement&&d(e,this._config.animationSpeed)}),setTimeout(()=>{this._parent?.classList.contains(M)&&(this._parent.classList.remove(M),g(this._parent,E))},this._config.animationSpeed)))}remove(){if(!this._parent)return;if(g(this._parent,_,{cancelable:!0}).defaultPrevented)return;const e=this._parent;l(e,this._config.animationSpeed),setTimeout(()=>{g(e,L),e.remove(),this.dispose()},this._config.animationSpeed)}toggle(){this._parent?.classList.contains(x)||this._parent?.classList.contains(w)?this.expand():this.collapse()}maximize(){this._parent&&(this._parent.style.height=`${this._parent.offsetHeight}px`,this._parent.style.width=`${this._parent.offsetWidth}px`,this._parent.style.transition="all .15s",setTimeout(()=>{const e=document.querySelector("html");e&&e.classList.add($),this._parent&&(this._parent.classList.add($),this._parent.classList.contains(x)&&this._parent.classList.add(q),g(this._parent,A))},150))}minimize(){this._parent&&(this._parent.style.height="auto",this._parent.style.width="auto",this._parent.style.transition="all .15s",setTimeout(()=>{const e=document.querySelector("html");e&&e.classList.remove($),this._parent&&(this._parent.classList.remove($),this._parent?.classList.contains(q)&&this._parent.classList.remove(q),g(this._parent,T),setTimeout(()=>{this._parent?.style.removeProperty("height"),this._parent?.style.removeProperty("width"),this._parent?.style.removeProperty("transition")},150))},10))}toggleMaximize(){this._parent?.classList.contains($)?this.minimize():this.maximize()}}document.addEventListener("click",e=>{const t=e.target;if(!(t instanceof Element))return;const n=t.closest(C),i=t.closest(I),s=t.closest(N),a=n??i??s;if(!a)return;e.preventDefault();const o=a.closest(P);if(!o)return;const r=B.getOrCreateInstance(o);n?r.toggle():i?r.remove():r.toggleMaximize()});const F="treeview",O=`.lte.${F}`,H=`expand${O}`,K=`collapse${O}`,W=`expanded${O}`,Y=`collapsed${O}`,j=`load${O}`,U="menu-open",V=".nav-item",G=".nav-link",J=".nav-treeview",Q='[data-lte-toggle="treeview"]',X={animationSpeed:300,accordion:!0},Z=(e,t)=>{const n=e.querySelector(`:scope > ${G}`);n?.setAttribute("aria-expanded",String(t))};class ee extends p{static get NAME(){return F}static getInstance(e){return this._getInstance(e)}static getOrCreateInstance(e,t={}){return this.getInstance(e)??new this(e,t)}_config;constructor(e,t={}){super(e),this._config={...X,...t}}open(){if(g(this._element,H,{cancelable:!0}).defaultPrevented)return;if(this._config.accordion){const e=this._element.parentElement?.querySelectorAll(`${V}.${U}`);e?.forEach(e=>{if(!this._element.contains(e)){e.classList.remove(U),Z(e,!1);const t=e?.querySelector(J);t&&l(t,this._config.animationSpeed)}})}this._element.classList.add(U),Z(this._element,!0);const e=this._element.querySelector(J);e&&d(e,this._config.animationSpeed),setTimeout(()=>{this._element.classList.contains(U)&&g(this._element,W)},this._config.animationSpeed)}close(){if(g(this._element,K,{cancelable:!0}).defaultPrevented)return;this._element.classList.remove(U),Z(this._element,!1);const e=this._element.querySelector(J);e&&l(e,this._config.animationSpeed),setTimeout(()=>{this._element.classList.contains(U)||g(this._element,Y)},this._config.animationSpeed)}toggle(){this._element.classList.contains(U)?this.close():this.open()}}document.addEventListener("click",e=>{const t=e.target;if(!(t instanceof Element))return;const n=t.closest(Q);if(!n)return;const i=t.closest(V),s=t.closest(G);if(!i?.querySelector(J))return;"#"!==t.getAttribute("href")&&"#"!==s?.getAttribute("href")||e.preventDefault();const a=n.dataset.accordion,o=n.dataset.animationSpeed,r={accordion:void 0===a?X.accordion:"true"===a,animationSpeed:void 0===o?X.animationSpeed:Number(o)};ee.getOrCreateInstance(i,r).toggle()}),a(()=>{document.querySelectorAll(`${V}.${U}`).forEach(e=>{const t=e.querySelector(J);if(t){d(t,0);const n=new Event(j);e.dispatchEvent(n)}}),document.querySelectorAll(Q).forEach(e=>{e.querySelectorAll(V).forEach(e=>{e.querySelector(`:scope > ${J}`)&&Z(e,e.classList.contains(U))})})});const te="direct-chat",ne=`.lte.${te}`,ie=`expanded${ne}`,se=`collapsed${ne}`,ae="direct-chat-contacts-open";class oe extends p{static get NAME(){return te}static getInstance(e){return this._getInstance(e)}static getOrCreateInstance(e){return this.getInstance(e)??new this(e)}toggle(){this._element.classList.contains(ae)?(this._element.classList.remove(ae),g(this._element,se)):(this._element.classList.add(ae),g(this._element,ie))}}document.addEventListener("click",e=>{const t=e.target;if(!(t instanceof Element))return;const n=t.closest('[data-lte-toggle="chat-pane"]');if(!n)return;e.preventDefault();const i=n.closest(".direct-chat");i&&oe.getOrCreateInstance(i).toggle()});const re="fullscreen",ce=`.lte.${re}`,le=`maximized${ce}`,de=`minimized${ce}`,ue='[data-lte-toggle="fullscreen"]';function he(){const e=document.querySelector('[data-lte-icon="maximize"]'),t=document.querySelector('[data-lte-icon="minimize"]'),n=Boolean(document.fullscreenElement);e?.classList.toggle("d-none",n),t?.classList.toggle("d-none",!n);const i=n?le:de;document.querySelectorAll(ue).forEach(e=>{g(e,i)})}class me extends p{static get NAME(){return re}static getInstance(e){return this._getInstance(e)}static getOrCreateInstance(e){return this.getInstance(e)??new this(e)}inFullScreen(){document.documentElement.requestFullscreen().catch(()=>{})}outFullscreen(){document.exitFullscreen().catch(()=>{})}toggleFullScreen(){document.fullscreenEnabled&&(document.fullscreenElement?this.outFullscreen():this.inFullScreen())}}document.addEventListener("click",e=>{const t=e.target;if(!(t instanceof Element))return;const n=t.closest(ue);n&&(e.preventDefault(),me.getOrCreateInstance(n).toggleFullScreen())}),a(()=>{document.addEventListener("fullscreenchange",he,{signal:i()})});const pe="push-menu",ge=`.lte.${pe}`,be=`open${ge}`,fe=`collapse${ge}`,ve=`opened${ge}`,ye=`collapsed${ge}`,_e="sidebar-overlay",Se="sidebar-collapse",Ee="sidebar-open",Le=".app-sidebar",Ae="lte.sidebar.state",Te={sidebarBreakpoint:991.98,enablePersistence:!1};class ke extends p{static get NAME(){return pe}static getInstance(e){return this._getInstance(e)}static getOrCreateInstance(e,t={}){return this.getInstance(e)??new this(e,t)}_config;constructor(e,t={}){super(e),this._config={...Te,...t}}isCollapsed(){return document.body.classList.contains(Se)}isExplicitlyOpen(){return document.body.classList.contains(Ee)}isMiniMode(){return document.body.classList.contains("sidebar-mini")}isMobileSize(){return globalThis.innerWidth<=this._config.sidebarBreakpoint}expand(){g(this._element,be,{cancelable:!0}).defaultPrevented||(document.body.classList.remove(Se),this.isMobileSize()&&document.body.classList.add(Ee),g(this._element,ve))}collapse(){g(this._element,fe,{cancelable:!0}).defaultPrevented||(document.body.classList.remove(Ee),document.body.classList.add(Se),g(this._element,ye))}toggle(){const e=this.isCollapsed();e?this.expand():this.collapse(),this._config.enablePersistence&&this.saveSidebarState(e?Ee:Se)}setupSidebarBreakPoint(){const e=document.querySelector('[class*="sidebar-expand"]');if(!e)return;const t=globalThis.getComputedStyle(e,"::before").getPropertyValue("content");if(!t||"none"===t)return;const n=Number(t.replace(/[^\d.-]/g,""));Number.isNaN(n)||(this._config={...this._config,sidebarBreakpoint:n})}updateStateByResponsiveLogic(){this.isMobileSize()?this.isExplicitlyOpen()||this.collapse():this.isMiniMode()&&this.isCollapsed()||this.expand()}saveSidebarState(e){if(void 0!==globalThis.localStorage)try{localStorage.setItem(Ae,e)}catch{}}loadSidebarState(){if(void 0!==globalThis.localStorage)try{const e=localStorage.getItem(Ae);e===Se?this.collapse():e===Ee?this.expand():this.updateStateByResponsiveLogic()}catch{this.updateStateByResponsiveLogic()}}clearSidebarState(){if(void 0!==globalThis.localStorage)try{localStorage.removeItem(Ae)}catch{}}init(){this.setupSidebarBreakPoint(),this._config.enablePersistence||this.clearSidebarState(),this._config.enablePersistence&&!this.isMobileSize()?this.loadSidebarState():this.updateStateByResponsiveLogic()}}document.addEventListener("click",e=>{const t=e.target;if(!(t instanceof Element))return;if(!t.closest('[data-lte-toggle="sidebar"]'))return;e.preventDefault();const n=document.querySelector(Le);n&&ke.getOrCreateInstance(n).toggle()}),a(()=>{const e=document.querySelector(Le);if(!e)return;const t=e.dataset.sidebarBreakpoint,n=e.dataset.enablePersistence,s={sidebarBreakpoint:void 0===t?Te.sidebarBreakpoint:Number(t),enablePersistence:void 0===n?Te.enablePersistence:"true"===n},a=ke.getOrCreateInstance(e,s);a.init(),globalThis.matchMedia(`(max-width: ${a._config.sidebarBreakpoint}px)`).addEventListener("change",()=>{a.updateStateByResponsiveLogic()},{signal:i()});const o=document.querySelector(".app-wrapper");let r=o?.querySelector(`:scope > .${_e}`);r||(r=document.createElement("div"),r.className=_e,o?.append(r));let c=!1;r.addEventListener("touchstart",()=>{c=!1},{passive:!0}),r.addEventListener("touchmove",()=>{c=!0},{passive:!0}),r.addEventListener("touchend",e=>{c||(e.preventDefault(),a.collapse()),c=!1},{passive:!1}),r.addEventListener("click",e=>{e.preventDefault(),a.collapse()})});const xe="lte-theme",we="[data-bs-theme-value]";class Me{getStoredTheme(){try{const e=localStorage.getItem(xe);return e&&["light","dark","auto"].includes(e)?e:null}catch{return null}}getPreferredTheme(){return this.getStoredTheme()||(this._prefersDark()?"dark":"light")}resolveTheme(e){return"auto"===e?this._prefersDark()?"dark":"light":e}setTheme(e){try{localStorage.setItem(xe,e)}catch{}this._applyTheme(e),this._showActiveTheme(e),document.dispatchEvent(new CustomEvent("changed.lte.color-mode",{detail:{theme:e,resolved:this.resolveTheme(e)}}))}_applyTheme(e){const t=this.resolveTheme(e);document.documentElement.setAttribute("data-bs-theme",t),document.documentElement.style.colorScheme=t}_prefersDark(){return globalThis.matchMedia("(prefers-color-scheme: dark)").matches}_showActiveTheme(e){document.querySelectorAll(we).forEach(t=>{const n=t.getAttribute("data-bs-theme-value")===e;t.classList.toggle("active",n),t.setAttribute("aria-pressed",String(n)),t.querySelector(".bi-check-lg")?.classList.toggle("d-none",!n)}),document.querySelectorAll("[data-lte-theme-icon]").forEach(t=>{t.classList.toggle("d-none",t.dataset.lteThemeIcon!==e)})}init(){const e=this.getPreferredTheme();this._applyTheme(e),this._showActiveTheme(e)}}document.addEventListener("click",e=>{const t=e.target;if(!(t instanceof Element))return;const n=t.closest(we),i=n?.getAttribute("data-bs-theme-value");i&&(new Me).setTheme(i)}),a(()=>{const e=new Me;e.init(),globalThis.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{const t=e.getStoredTheme();t&&"auto"!==t||(e._applyTheme("auto"),e._showActiveTheme(t??"auto"))},{signal:i()})});class qe{config;liveRegion=null;focusHistory=[];signal=i();constructor(e={}){this.config={announcements:!0,skipLinks:!0,focusManagement:!0,keyboardNavigation:!0,reducedMotion:!0,...e},this.init()}init(){this.config.announcements&&this.createLiveRegion(),this.config.skipLinks&&this.addSkipLinks(),this.config.focusManagement&&this.initFocusManagement(),this.config.keyboardNavigation&&this.initKeyboardNavigation(),this.config.reducedMotion&&this.respectReducedMotion(),this.initErrorAnnouncements(),this.initTableAccessibility(),this.initFormAccessibility()}createLiveRegion(){if(this.liveRegion)return;const e=document.getElementById("live-region");e?this.liveRegion=e:(this.liveRegion=document.createElement("div"),this.liveRegion.id="live-region",this.liveRegion.className="live-region",this.liveRegion.setAttribute("aria-live","polite"),this.liveRegion.setAttribute("aria-atomic","true"),this.liveRegion.setAttribute("role","status"),document.body.append(this.liveRegion))}addSkipLinks(){if(document.querySelector(".skip-links"))return void this.ensureSkipTargets();const e=document.createElement("div");e.className="skip-links";const t=document.createElement("a");t.href="#main",t.className="skip-link",t.textContent="Skip to main content";const n=document.createElement("a");n.href="#navigation",n.className="skip-link",n.textContent="Skip to navigation",e.append(t),e.append(n),document.body.insertBefore(e,document.body.firstChild),this.ensureSkipTargets()}ensureSkipTargets(){const e=document.querySelector('#main, main, [role="main"]');e&&!e.id&&(e.id="main"),e&&!e.hasAttribute("tabindex")&&e.setAttribute("tabindex","-1");const t=document.querySelector('#navigation, nav, [role="navigation"]');t&&!t.id&&(t.id="navigation"),t&&!t.hasAttribute("tabindex")&&t.setAttribute("tabindex","-1")}initFocusManagement(){document.addEventListener("keydown",e=>{"Escape"===e.key&&this.handleEscapeKey(e)},{signal:this.signal}),this.initModalFocusManagement(),this.initDropdownFocusManagement()}handleEscapeKey(e){if(!document.querySelector(".modal.show")&&document.querySelector(".dropdown-menu.show")){const t=document.querySelector('[data-bs-toggle="dropdown"][aria-expanded="true"]');t?.click(),e.preventDefault()}}initKeyboardNavigation(){document.addEventListener("keydown",e=>{const t=e.target;t.matches("input, textarea, select, [contenteditable], [contenteditable] *")||(t.closest(".nav, .navbar-nav, .dropdown-menu")&&this.handleMenuNavigation(e),"Enter"!==e.key&&" "!==e.key||!t.hasAttribute("role")||"button"!==t.getAttribute("role")||t.matches('button, input[type="button"], input[type="submit"]')||(e.preventDefault(),t.click()))},{signal:this.signal})}handleMenuNavigation(e){if(!["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End"].includes(e.key))return;const t=e.target,n=Array.from(t.closest(".nav, .navbar-nav, .dropdown-menu")?.querySelectorAll("a, button")||[]).filter(e=>null!==e.offsetParent),i=n.indexOf(t);if(-1===i)return;let s;switch(e.key){case"ArrowDown":case"ArrowRight":s=i0?i-1:n.length-1;break;case"Home":s=0;break;case"End":s=n.length-1;break;default:return}e.preventDefault(),n[s]?.focus()}respectReducedMotion(){if(globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches&&(document.body.classList.add("reduce-motion"),document.documentElement.style.scrollBehavior="auto",!document.getElementById("adminlte-reduce-motion"))){const e=document.createElement("style");e.id="adminlte-reduce-motion",e.textContent="\n *, *::before, *::after {\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0.01ms !important;\n }\n ",document.head.append(e)}}initErrorAnnouncements(){const e=new MutationObserver(e=>{e.forEach(e=>{e.addedNodes.forEach(e=>{if(e.nodeType===Node.ELEMENT_NODE){const t=e;t.matches(".alert-danger, .invalid-feedback, .error")&&this.announce(t.textContent||"Error occurred","assertive"),t.matches(".alert-success, .success")&&this.announce(t.textContent||"Success","polite")}})})});e.observe(document.body,{childList:!0,subtree:!0}),this.signal.addEventListener("abort",()=>{e.disconnect()},{once:!0})}initTableAccessibility(){document.querySelectorAll("table").forEach(e=>{if(e.hasAttribute("role")||e.setAttribute("role","table"),e.querySelectorAll("th").forEach(e=>{if(!e.hasAttribute("scope")){const t=e.closest("thead"),n=0===e.cellIndex;t?e.setAttribute("scope","col"):n&&e.setAttribute("scope","row")}}),!e.querySelector("caption")&&e.hasAttribute("title")){const t=document.createElement("caption");t.textContent=e.getAttribute("title")||"",e.insertBefore(t,e.firstChild)}})}initFormAccessibility(){document.querySelectorAll("input, select, textarea").forEach(e=>{const t=e;if(!t.labels?.length&&!t.hasAttribute("aria-label")&&!t.hasAttribute("aria-labelledby")){const e=t.getAttribute("placeholder");e&&t.setAttribute("aria-label",e)}if(t.hasAttribute("required")){const e=t.labels?.[0];if(e&&!e.querySelector(".required-indicator")){const t=document.createElement("span");t.className="required-indicator sr-only",t.textContent=" (required)",e.append(t)}}t.classList.contains("disable-adminlte-validations")||t.addEventListener("invalid",()=>{this.handleFormError(t)})})}handleFormError(e){e.id||e.name||(e.id=Ce.generateId("field"));const t=`${e.id||e.name}-error`;let n=document.getElementById(t);n||(n=document.createElement("div"),n.id=t,n.className="invalid-feedback",n.setAttribute("role","alert"),e.parentNode?.append(n)),n.textContent=e.validationMessage;const i=(e.getAttribute("aria-describedby")||"").split(/\s+/).filter(Boolean);i.includes(t)||i.push(t),e.setAttribute("aria-describedby",i.join(" ")),e.classList.add("is-invalid"),this.announce(`Error in ${e.labels?.[0]?.textContent||e.name}: ${e.validationMessage}`,"assertive")}initModalFocusManagement(){document.addEventListener("show.bs.modal",()=>{this.focusHistory.push(document.activeElement)},{signal:this.signal}),document.addEventListener("shown.bs.modal",e=>{const t=e.target,n=t.querySelector("[autofocus]")||t.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');n?.focus()},{signal:this.signal}),document.addEventListener("hidden.bs.modal",()=>{const e=this.focusHistory.pop();e?.isConnected&&e.focus()},{signal:this.signal})}initDropdownFocusManagement(){document.addEventListener("shown.bs.dropdown",e=>{const t=e.target.querySelector(".dropdown-menu"),n=t?.querySelector("a, button");n&&n.focus()},{signal:this.signal})}announce(e,t="polite"){this.liveRegion||this.createLiveRegion(),this.liveRegion&&(this.liveRegion.setAttribute("aria-live",t),this.liveRegion.textContent=e,setTimeout(()=>{this.liveRegion&&(this.liveRegion.textContent="")},1e3))}focusElement(e){const t=document.querySelector(e);t&&(t.focus(),t.scrollIntoView({behavior:"smooth",block:"center"}))}trapFocus(e){const t=e.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),n=Array.from(t),i=n[0],s=n.at(-1);e.addEventListener("keydown",e=>{"Tab"===e.key&&(e.shiftKey?document.activeElement===i&&(s?.focus(),e.preventDefault()):document.activeElement===s&&(i.focus(),e.preventDefault()))},{signal:this.signal})}addLandmarks(){if(!document.querySelector("main")){const e=document.querySelector(".app-main");e&&(e.setAttribute("role","main"),e.id||(e.id="main"))}document.querySelectorAll(".navbar-nav, .nav").forEach((e,t)=>{"UL"!==e.tagName&&"OL"!==e.tagName&&(e.hasAttribute("role")||e.setAttribute("role","navigation"),e.hasAttribute("aria-label")||e.setAttribute("aria-label",`Navigation ${t+1}`))});const e=document.querySelector('form[role="search"], .navbar-search');e&&!e.hasAttribute("role")&&e.setAttribute("role","search")}}const $e=e=>new qe(e),Ie=e=>{const[t,n,i]=(e=>{const t=/^#([\da-f]{3}|[\da-f]{6})$/i.exec(e.trim());if(t){let e=t[1];return 3===e.length&&(e=[...e].map(e=>e+e).join("")),[Number.parseInt(e.slice(0,2),16),Number.parseInt(e.slice(2,4),16),Number.parseInt(e.slice(4,6),16)]}return e.match(/\d+/g)?.map(Number)||[0,0,0]})(e).map(e=>(e/=255)<=.03928?e/12.92:(e+.055)**2.4/1.055**2.4);return.2126*t+.7152*n+.0722*i},Ce={checkColorContrast:(e,t)=>{const n=Ie(e),i=Ie(t),s=(Math.max(n,i)+.05)/(Math.min(n,i)+.05);return{ratio:Math.round(100*s)/100,passes:s>=4.5}},generateId:(e="a11y")=>`${e}-${Math.random().toString(36).slice(2,11)}`,isFocusable:e=>["a[href]","button:not([disabled])","input:not([disabled])","select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])','[contenteditable="true"]'].some(t=>e.matches(t))};a(()=>{$e({announcements:!0,skipLinks:!0,focusManagement:!0,keyboardNavigation:!0,reducedMotion:!0}).addLandmarks()}),e.CardWidget=B,e.ColorMode=Me,e.DirectChat=oe,e.FullScreen=me,e.Layout=h,e.PushMenu=ke,e.Treeview=ee,e.initAccessibility=$e}); +//# sourceMappingURL=adminlte.min.js.map \ No newline at end of file diff --git a/src/web/public/adminlte/js/bootstrap.bundle.min.js b/src/web/public/adminlte/js/bootstrap.bundle.min.js new file mode 100644 index 0000000..0b87369 --- /dev/null +++ b/src/web/public/adminlte/js/bootstrap.bundle.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.3.8 (https://getbootstrap.com/) + * Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,(t,e)=>`#${CSS.escape(e)}`)),t),s=t=>null==t?`${t}`:Object.prototype.toString.call(t).match(/\s([a-z]+)/i)[1].toLowerCase(),o=t=>{t.dispatchEvent(new Event(i))},r=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),a=t=>r(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,l=t=>{if(!r(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},c=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),h=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?h(t.parentNode):null},d=()=>{},u=t=>{t.offsetHeight},f=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,p=[],m=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(p.length||document.addEventListener("DOMContentLoaded",()=>{for(const t of p)t()}),p.push(e)):e()},_=(t,e=[],i=t)=>"function"==typeof t?t.call(...e):i,b=(t,e,n=!0)=>{if(!n)return void _(t);const s=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),_(t))};e.addEventListener(i,a),setTimeout(()=>{r||o(e)},s)},v=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},y=/[^.]*(?=\..*)\.|.*/,w=/\..*/,A=/::\d+$/,E={};let T=1;const C={mouseenter:"mouseover",mouseleave:"mouseout"},O=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function x(t,e){return e&&`${e}::${T++}`||t.uidEvent||T++}function k(t){const e=x(t);return t.uidEvent=e,E[e]=E[e]||{},E[e]}function L(t,e,i=null){return Object.values(t).find(t=>t.callable===e&&t.delegationSelector===i)}function S(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=N(t);return O.has(o)||(o=t),[n,s,o]}function D(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=S(e,i,n);if(e in C){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=k(t),c=l[a]||(l[a]={}),h=L(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=x(r,e.replace(y,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return j(s,{delegateTarget:r}),n.oneOff&&P.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return j(n,{delegateTarget:t}),i.oneOff&&P.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function $(t,e,i,n,s){const o=L(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function I(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&$(t,e,i,r.callable,r.delegationSelector)}function N(t){return t=t.replace(w,""),C[t]||t}const P={on(t,e,i,n){D(t,e,i,n,!1)},one(t,e,i,n){D(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=S(e,i,n),a=r!==e,l=k(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))I(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(A,"");a&&!e.includes(s)||$(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;$(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=f();let s=null,o=!0,r=!0,a=!1;e!==N(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=j(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function j(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function M(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function F(t){return t.replace(/[A-Z]/g,t=>`-${t.toLowerCase()}`)}const H={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${F(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${F(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter(t=>t.startsWith("bs")&&!t.startsWith("bsConfig"));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1),e[i]=M(t.dataset[n])}return e},getDataAttribute:(t,e)=>M(t.getAttribute(`data-bs-${F(e)}`))};class W{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=r(e)?H.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...r(e)?H.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[i,n]of Object.entries(e)){const e=t[i],o=r(e)?"element":s(e);if(!new RegExp(n).test(o))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${i}" provided type "${o}" but expected type "${n}".`)}}}class B extends W{constructor(t,i){super(),(t=a(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),P.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){b(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(a(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.8"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const z=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map(t=>n(t)).join(","):null},R={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter(t=>t.matches(e)),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map(t=>`${t}:not([tabindex^="-"])`).join(",");return this.find(e,t).filter(t=>!c(t)&&l(t))},getSelectorFromElement(t){const e=z(t);return e&&R.findOne(e)?e:null},getElementFromSelector(t){const e=z(t);return e?R.findOne(e):null},getMultipleElementsFromSelector(t){const e=z(t);return e?R.find(e):[]}},q=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;P.on(document,i,`[data-bs-dismiss="${n}"]`,function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),c(this))return;const s=R.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()})},V=".bs.alert",K=`close${V}`,Q=`closed${V}`;class X extends B{static get NAME(){return"alert"}close(){if(P.trigger(this._element,K).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback(()=>this._destroyElement(),this._element,t)}_destroyElement(){this._element.remove(),P.trigger(this._element,Q),this.dispose()}static jQueryInterface(t){return this.each(function(){const e=X.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}})}}q(X,"close"),g(X);const Y='[data-bs-toggle="button"]';class U extends B{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each(function(){const e=U.getOrCreateInstance(this);"toggle"===t&&e[t]()})}}P.on(document,"click.bs.button.data-api",Y,t=>{t.preventDefault();const e=t.target.closest(Y);U.getOrCreateInstance(e).toggle()}),g(U);const G=".bs.swipe",J=`touchstart${G}`,Z=`touchmove${G}`,tt=`touchend${G}`,et=`pointerdown${G}`,it=`pointerup${G}`,nt={endCallback:null,leftCallback:null,rightCallback:null},st={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class ot extends W{constructor(t,e){super(),this._element=t,t&&ot.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return nt}static get DefaultType(){return st}static get NAME(){return"swipe"}dispose(){P.off(this._element,G)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),_(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&_(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(P.on(this._element,et,t=>this._start(t)),P.on(this._element,it,t=>this._end(t)),this._element.classList.add("pointer-event")):(P.on(this._element,J,t=>this._start(t)),P.on(this._element,Z,t=>this._move(t)),P.on(this._element,tt,t=>this._end(t)))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const rt=".bs.carousel",at=".data-api",lt="ArrowLeft",ct="ArrowRight",ht="next",dt="prev",ut="left",ft="right",pt=`slide${rt}`,mt=`slid${rt}`,gt=`keydown${rt}`,_t=`mouseenter${rt}`,bt=`mouseleave${rt}`,vt=`dragstart${rt}`,yt=`load${rt}${at}`,wt=`click${rt}${at}`,At="carousel",Et="active",Tt=".active",Ct=".carousel-item",Ot=Tt+Ct,xt={[lt]:ft,[ct]:ut},kt={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Lt={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class St extends B{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=R.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===At&&this.cycle()}static get Default(){return kt}static get DefaultType(){return Lt}static get NAME(){return"carousel"}next(){this._slide(ht)}nextWhenVisible(){!document.hidden&&l(this._element)&&this.next()}prev(){this._slide(dt)}pause(){this._isSliding&&o(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval(()=>this.nextWhenVisible(),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?P.one(this._element,mt,()=>this.cycle()):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void P.one(this._element,mt,()=>this.to(t));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?ht:dt;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&P.on(this._element,gt,t=>this._keydown(t)),"hover"===this._config.pause&&(P.on(this._element,_t,()=>this.pause()),P.on(this._element,bt,()=>this._maybeEnableCycle())),this._config.touch&&ot.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of R.find(".carousel-item img",this._element))P.on(t,vt,t=>t.preventDefault());const t={leftCallback:()=>this._slide(this._directionToOrder(ut)),rightCallback:()=>this._slide(this._directionToOrder(ft)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout(()=>this._maybeEnableCycle(),500+this._config.interval))}};this._swipeHelper=new ot(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=xt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=R.findOne(Tt,this._indicatorsElement);e.classList.remove(Et),e.removeAttribute("aria-current");const i=R.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(Et),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===ht,s=e||v(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>P.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(pt).defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),u(s),i.classList.add(l),s.classList.add(l),this._queueCallback(()=>{s.classList.remove(l,c),s.classList.add(Et),i.classList.remove(Et,c,l),this._isSliding=!1,r(mt)},i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return R.findOne(Ot,this._element)}_getItems(){return R.find(Ct,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return m()?t===ut?dt:ht:t===ut?ht:dt}_orderToDirection(t){return m()?t===dt?ut:ft:t===dt?ft:ut}static jQueryInterface(t){return this.each(function(){const e=St.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)})}}P.on(document,wt,"[data-bs-slide], [data-bs-slide-to]",function(t){const e=R.getElementFromSelector(this);if(!e||!e.classList.contains(At))return;t.preventDefault();const i=St.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===H.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())}),P.on(window,yt,()=>{const t=R.find('[data-bs-ride="carousel"]');for(const e of t)St.getOrCreateInstance(e)}),g(St);const Dt=".bs.collapse",$t=`show${Dt}`,It=`shown${Dt}`,Nt=`hide${Dt}`,Pt=`hidden${Dt}`,jt=`click${Dt}.data-api`,Mt="show",Ft="collapse",Ht="collapsing",Wt=`:scope .${Ft} .${Ft}`,Bt='[data-bs-toggle="collapse"]',zt={parent:null,toggle:!0},Rt={parent:"(null|element)",toggle:"boolean"};class qt extends B{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=R.find(Bt);for(const t of i){const e=R.getSelectorFromElement(t),i=R.find(e).filter(t=>t===this._element);null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return zt}static get DefaultType(){return Rt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter(t=>t!==this._element).map(t=>qt.getOrCreateInstance(t,{toggle:!1}))),t.length&&t[0]._isTransitioning)return;if(P.trigger(this._element,$t).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Ft),this._element.classList.add(Ht),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback(()=>{this._isTransitioning=!1,this._element.classList.remove(Ht),this._element.classList.add(Ft,Mt),this._element.style[e]="",P.trigger(this._element,It)},this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(P.trigger(this._element,Nt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,u(this._element),this._element.classList.add(Ht),this._element.classList.remove(Ft,Mt);for(const t of this._triggerArray){const e=R.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback(()=>{this._isTransitioning=!1,this._element.classList.remove(Ht),this._element.classList.add(Ft),P.trigger(this._element,Pt)},this._element,!0)}_isShown(t=this._element){return t.classList.contains(Mt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=a(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Bt);for(const e of t){const t=R.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=R.find(Wt,this._config.parent);return R.find(t,this._config.parent).filter(t=>!e.includes(t))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each(function(){const i=qt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}})}}P.on(document,jt,Bt,function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of R.getMultipleElementsFromSelector(this))qt.getOrCreateInstance(t,{toggle:!1}).toggle()}),g(qt);var Vt="top",Kt="bottom",Qt="right",Xt="left",Yt="auto",Ut=[Vt,Kt,Qt,Xt],Gt="start",Jt="end",Zt="clippingParents",te="viewport",ee="popper",ie="reference",ne=Ut.reduce(function(t,e){return t.concat([e+"-"+Gt,e+"-"+Jt])},[]),se=[].concat(Ut,[Yt]).reduce(function(t,e){return t.concat([e,e+"-"+Gt,e+"-"+Jt])},[]),oe="beforeRead",re="read",ae="afterRead",le="beforeMain",ce="main",he="afterMain",de="beforeWrite",ue="write",fe="afterWrite",pe=[oe,re,ae,le,ce,he,de,ue,fe];function me(t){return t?(t.nodeName||"").toLowerCase():null}function ge(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function _e(t){return t instanceof ge(t).Element||t instanceof Element}function be(t){return t instanceof ge(t).HTMLElement||t instanceof HTMLElement}function ve(t){return"undefined"!=typeof ShadowRoot&&(t instanceof ge(t).ShadowRoot||t instanceof ShadowRoot)}const ye={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach(function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];be(s)&&me(s)&&(Object.assign(s.style,i),Object.keys(n).forEach(function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)}))})},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach(function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce(function(t,e){return t[e]="",t},{});be(n)&&me(n)&&(Object.assign(n.style,o),Object.keys(s).forEach(function(t){n.removeAttribute(t)}))})}},requires:["computeStyles"]};function we(t){return t.split("-")[0]}var Ae=Math.max,Ee=Math.min,Te=Math.round;function Ce(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map(function(t){return t.brand+"/"+t.version}).join(" "):navigator.userAgent}function Oe(){return!/^((?!chrome|android).)*safari/i.test(Ce())}function xe(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&be(t)&&(s=t.offsetWidth>0&&Te(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&Te(n.height)/t.offsetHeight||1);var r=(_e(t)?ge(t):window).visualViewport,a=!Oe()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function ke(t){var e=xe(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Le(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&ve(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function Se(t){return ge(t).getComputedStyle(t)}function De(t){return["table","td","th"].indexOf(me(t))>=0}function $e(t){return((_e(t)?t.ownerDocument:t.document)||window.document).documentElement}function Ie(t){return"html"===me(t)?t:t.assignedSlot||t.parentNode||(ve(t)?t.host:null)||$e(t)}function Ne(t){return be(t)&&"fixed"!==Se(t).position?t.offsetParent:null}function Pe(t){for(var e=ge(t),i=Ne(t);i&&De(i)&&"static"===Se(i).position;)i=Ne(i);return i&&("html"===me(i)||"body"===me(i)&&"static"===Se(i).position)?e:i||function(t){var e=/firefox/i.test(Ce());if(/Trident/i.test(Ce())&&be(t)&&"fixed"===Se(t).position)return null;var i=Ie(t);for(ve(i)&&(i=i.host);be(i)&&["html","body"].indexOf(me(i))<0;){var n=Se(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function je(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function Me(t,e,i){return Ae(t,Ee(e,i))}function Fe(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function He(t,e){return e.reduce(function(e,i){return e[i]=t,e},{})}const We={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=we(i.placement),l=je(a),c=[Xt,Qt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return Fe("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:He(t,Ut))}(s.padding,i),d=ke(o),u="y"===l?Vt:Xt,f="y"===l?Kt:Qt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=Pe(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=Me(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Le(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Be(t){return t.split("-")[1]}var ze={top:"auto",right:"auto",bottom:"auto",left:"auto"};function Re(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=Xt,y=Vt,w=window;if(c){var A=Pe(i),E="clientHeight",T="clientWidth";A===ge(i)&&"static"!==Se(A=$e(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===Vt||(s===Xt||s===Qt)&&o===Jt)&&(y=Kt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==Xt&&(s!==Vt&&s!==Kt||o!==Jt)||(v=Qt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&ze),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:Te(i*s)/s||0,y:Te(n*s)/s||0}}({x:f,y:m},ge(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const qe={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:we(e.placement),variation:Be(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,Re(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,Re(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var Ve={passive:!0};const Ke={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=ge(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach(function(t){t.addEventListener("scroll",i.update,Ve)}),a&&l.addEventListener("resize",i.update,Ve),function(){o&&c.forEach(function(t){t.removeEventListener("scroll",i.update,Ve)}),a&&l.removeEventListener("resize",i.update,Ve)}},data:{}};var Qe={left:"right",right:"left",bottom:"top",top:"bottom"};function Xe(t){return t.replace(/left|right|bottom|top/g,function(t){return Qe[t]})}var Ye={start:"end",end:"start"};function Ue(t){return t.replace(/start|end/g,function(t){return Ye[t]})}function Ge(t){var e=ge(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Je(t){return xe($e(t)).left+Ge(t).scrollLeft}function Ze(t){var e=Se(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function ti(t){return["html","body","#document"].indexOf(me(t))>=0?t.ownerDocument.body:be(t)&&Ze(t)?t:ti(Ie(t))}function ei(t,e){var i;void 0===e&&(e=[]);var n=ti(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=ge(n),r=s?[o].concat(o.visualViewport||[],Ze(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(ei(Ie(r)))}function ii(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function ni(t,e,i){return e===te?ii(function(t,e){var i=ge(t),n=$e(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Oe();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+Je(t),y:l}}(t,i)):_e(e)?function(t,e){var i=xe(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):ii(function(t){var e,i=$e(t),n=Ge(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=Ae(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=Ae(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Je(t),l=-n.scrollTop;return"rtl"===Se(s||i).direction&&(a+=Ae(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}($e(t)))}function si(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?we(s):null,r=s?Be(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case Vt:e={x:a,y:i.y-n.height};break;case Kt:e={x:a,y:i.y+i.height};break;case Qt:e={x:i.x+i.width,y:l};break;case Xt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?je(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case Gt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Jt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function oi(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?Zt:a,c=i.rootBoundary,h=void 0===c?te:c,d=i.elementContext,u=void 0===d?ee:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=Fe("number"!=typeof g?g:He(g,Ut)),b=u===ee?ie:ee,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=ei(Ie(t)),i=["absolute","fixed"].indexOf(Se(t).position)>=0&&be(t)?Pe(t):t;return _e(i)?e.filter(function(t){return _e(t)&&Le(t,i)&&"body"!==me(t)}):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce(function(e,i){var s=ni(t,i,n);return e.top=Ae(s.top,e.top),e.right=Ee(s.right,e.right),e.bottom=Ee(s.bottom,e.bottom),e.left=Ae(s.left,e.left),e},ni(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(_e(y)?y:y.contextElement||$e(t.elements.popper),l,h,r),A=xe(t.elements.reference),E=si({reference:A,element:v,placement:s}),T=ii(Object.assign({},v,E)),C=u===ee?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===ee&&x){var k=x[s];Object.keys(O).forEach(function(t){var e=[Qt,Kt].indexOf(t)>=0?1:-1,i=[Vt,Kt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e})}return O}function ri(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?se:l,h=Be(n),d=h?a?ne:ne.filter(function(t){return Be(t)===h}):Ut,u=d.filter(function(t){return c.indexOf(t)>=0});0===u.length&&(u=d);var f=u.reduce(function(e,i){return e[i]=oi(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[we(i)],e},{});return Object.keys(f).sort(function(t,e){return f[t]-f[e]})}const ai={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=we(g),b=l||(_!==g&&p?function(t){if(we(t)===Yt)return[];var e=Xe(t);return[Ue(t),e,Ue(e)]}(g):[Xe(g)]),v=[g].concat(b).reduce(function(t,i){return t.concat(we(i)===Yt?ri(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)},[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=oi(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),$=L?k?Qt:Xt:k?Kt:Vt;y[S]>w[S]&&($=Xe($));var I=Xe($),N=[];if(o&&N.push(D[x]<=0),a&&N.push(D[$]<=0,D[I]<=0),N.every(function(t){return t})){T=O,E=!1;break}A.set(O,N)}if(E)for(var P=function(t){var e=v.find(function(e){var i=A.get(e);if(i)return i.slice(0,t).every(function(t){return t})});if(e)return T=e,"break"},j=p?3:1;j>0&&"break"!==P(j);j--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function li(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function ci(t){return[Vt,Qt,Kt,Xt].some(function(e){return t[e]>=0})}const hi={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=oi(e,{elementContext:"reference"}),a=oi(e,{altBoundary:!0}),l=li(r,n),c=li(a,s,o),h=ci(l),d=ci(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},di={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=se.reduce(function(t,i){return t[i]=function(t,e,i){var n=we(t),s=[Xt,Vt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[Xt,Qt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t},{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},ui={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=si({reference:e.rects.reference,element:e.rects.popper,placement:e.placement})},data:{}},fi={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=oi(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=we(e.placement),b=Be(e.placement),v=!b,y=je(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?Vt:Xt,D="y"===y?Kt:Qt,$="y"===y?"height":"width",I=A[y],N=I+g[S],P=I-g[D],j=f?-T[$]/2:0,M=b===Gt?E[$]:T[$],F=b===Gt?-T[$]:-E[$],H=e.elements.arrow,W=f&&H?ke(H):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=Me(0,E[$],W[$]),V=v?E[$]/2-j-q-z-O.mainAxis:M-q-z-O.mainAxis,K=v?-E[$]/2+j+q+R+O.mainAxis:F+q+R+O.mainAxis,Q=e.elements.arrow&&Pe(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=I+K-Y,G=Me(f?Ee(N,I+V-Y-X):N,I,f?Ae(P,U):P);A[y]=G,k[y]=G-I}if(a){var J,Z="x"===y?Vt:Xt,tt="x"===y?Kt:Qt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[Vt,Xt].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=Me(t,e,i);return n>i?i:n}(at,et,lt):Me(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function pi(t,e,i){void 0===i&&(i=!1);var n,s,o=be(e),r=be(e)&&function(t){var e=t.getBoundingClientRect(),i=Te(e.width)/t.offsetWidth||1,n=Te(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=$e(e),l=xe(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==me(e)||Ze(a))&&(c=(n=e)!==ge(n)&&be(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:Ge(n)),be(e)?((h=xe(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=Je(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function mi(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach(function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}}),n.push(t)}return t.forEach(function(t){e.set(t.name,t)}),t.forEach(function(t){i.has(t.name)||s(t)}),n}var gi={placement:"bottom",modifiers:[],strategy:"absolute"};function _i(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10)):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(H.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,..._(this._config.popperConfig,[void 0,t])}}_selectMenuItem({key:t,target:e}){const i=R.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter(t=>l(t));i.length&&v(i,e,t===xi,!i.includes(e)).focus()}static jQueryInterface(t){return this.each(function(){const e=Qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}})}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=R.find(Mi);for(const i of e){const e=Qi.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Oi,xi].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(ji)?this:R.prev(this,ji)[0]||R.next(this,ji)[0]||R.findOne(ji,t.delegateTarget.parentNode),o=Qi.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}P.on(document,Ii,ji,Qi.dataApiKeydownHandler),P.on(document,Ii,Fi,Qi.dataApiKeydownHandler),P.on(document,$i,Qi.clearMenus),P.on(document,Ni,Qi.clearMenus),P.on(document,$i,ji,function(t){t.preventDefault(),Qi.getOrCreateInstance(this).toggle()}),g(Qi);const Xi="backdrop",Yi="show",Ui=`mousedown.bs.${Xi}`,Gi={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},Ji={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class Zi extends W{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return Gi}static get DefaultType(){return Ji}static get NAME(){return Xi}show(t){if(!this._config.isVisible)return void _(t);this._append();const e=this._getElement();this._config.isAnimated&&u(e),e.classList.add(Yi),this._emulateAnimation(()=>{_(t)})}hide(t){this._config.isVisible?(this._getElement().classList.remove(Yi),this._emulateAnimation(()=>{this.dispose(),_(t)})):_(t)}dispose(){this._isAppended&&(P.off(this._element,Ui),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=a(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),P.on(t,Ui,()=>{_(this._config.clickCallback)}),this._isAppended=!0}_emulateAnimation(t){b(t,this._getElement(),this._config.isAnimated)}}const tn=".bs.focustrap",en=`focusin${tn}`,nn=`keydown.tab${tn}`,sn="backward",on={autofocus:!0,trapElement:null},rn={autofocus:"boolean",trapElement:"element"};class an extends W{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return on}static get DefaultType(){return rn}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),P.off(document,tn),P.on(document,en,t=>this._handleFocusin(t)),P.on(document,nn,t=>this._handleKeydown(t)),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,P.off(document,tn))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=R.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===sn?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?sn:"forward")}}const ln=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",cn=".sticky-top",hn="padding-right",dn="margin-right";class un{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,hn,e=>e+t),this._setElementAttributes(ln,hn,e=>e+t),this._setElementAttributes(cn,dn,e=>e-t)}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,hn),this._resetElementAttributes(ln,hn),this._resetElementAttributes(cn,dn)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)})}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&H.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,t=>{const i=H.getDataAttribute(t,e);null!==i?(H.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)})}_applyManipulationCallback(t,e){if(r(t))e(t);else for(const i of R.find(t,this._element))e(i)}}const fn=".bs.modal",pn=`hide${fn}`,mn=`hidePrevented${fn}`,gn=`hidden${fn}`,_n=`show${fn}`,bn=`shown${fn}`,vn=`resize${fn}`,yn=`click.dismiss${fn}`,wn=`mousedown.dismiss${fn}`,An=`keydown.dismiss${fn}`,En=`click${fn}.data-api`,Tn="modal-open",Cn="show",On="modal-static",xn={backdrop:!0,focus:!0,keyboard:!0},kn={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Ln extends B{constructor(t,e){super(t,e),this._dialog=R.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new un,this._addEventListeners()}static get Default(){return xn}static get DefaultType(){return kn}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||P.trigger(this._element,_n,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(Tn),this._adjustDialog(),this._backdrop.show(()=>this._showElement(t)))}hide(){this._isShown&&!this._isTransitioning&&(P.trigger(this._element,pn).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(Cn),this._queueCallback(()=>this._hideModal(),this._element,this._isAnimated())))}dispose(){P.off(window,fn),P.off(this._dialog,fn),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new Zi({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new an({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=R.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),u(this._element),this._element.classList.add(Cn),this._queueCallback(()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,P.trigger(this._element,bn,{relatedTarget:t})},this._dialog,this._isAnimated())}_addEventListeners(){P.on(this._element,An,t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())}),P.on(window,vn,()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()}),P.on(this._element,wn,t=>{P.one(this._element,yn,e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())})})}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide(()=>{document.body.classList.remove(Tn),this._resetAdjustments(),this._scrollBar.reset(),P.trigger(this._element,gn)})}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(P.trigger(this._element,mn).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(On)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(On),this._queueCallback(()=>{this._element.classList.remove(On),this._queueCallback(()=>{this._element.style.overflowY=e},this._dialog)},this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=m()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=m()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each(function(){const i=Ln.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}})}}P.on(document,En,'[data-bs-toggle="modal"]',function(t){const e=R.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),P.one(e,_n,t=>{t.defaultPrevented||P.one(e,gn,()=>{l(this)&&this.focus()})});const i=R.findOne(".modal.show");i&&Ln.getInstance(i).hide(),Ln.getOrCreateInstance(e).toggle(this)}),q(Ln),g(Ln);const Sn=".bs.offcanvas",Dn=".data-api",$n=`load${Sn}${Dn}`,In="show",Nn="showing",Pn="hiding",jn=".offcanvas.show",Mn=`show${Sn}`,Fn=`shown${Sn}`,Hn=`hide${Sn}`,Wn=`hidePrevented${Sn}`,Bn=`hidden${Sn}`,zn=`resize${Sn}`,Rn=`click${Sn}${Dn}`,qn=`keydown.dismiss${Sn}`,Vn={backdrop:!0,keyboard:!0,scroll:!1},Kn={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class Qn extends B{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return Vn}static get DefaultType(){return Kn}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||P.trigger(this._element,Mn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new un).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Nn),this._queueCallback(()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(In),this._element.classList.remove(Nn),P.trigger(this._element,Fn,{relatedTarget:t})},this._element,!0))}hide(){this._isShown&&(P.trigger(this._element,Hn).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(Pn),this._backdrop.hide(),this._queueCallback(()=>{this._element.classList.remove(In,Pn),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new un).reset(),P.trigger(this._element,Bn)},this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new Zi({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():P.trigger(this._element,Wn)}:null})}_initializeFocusTrap(){return new an({trapElement:this._element})}_addEventListeners(){P.on(this._element,qn,t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():P.trigger(this._element,Wn))})}static jQueryInterface(t){return this.each(function(){const e=Qn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}})}}P.on(document,Rn,'[data-bs-toggle="offcanvas"]',function(t){const e=R.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),c(this))return;P.one(e,Bn,()=>{l(this)&&this.focus()});const i=R.findOne(jn);i&&i!==e&&Qn.getInstance(i).hide(),Qn.getOrCreateInstance(e).toggle(this)}),P.on(window,$n,()=>{for(const t of R.find(jn))Qn.getOrCreateInstance(t).show()}),P.on(window,zn,()=>{for(const t of R.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&Qn.getOrCreateInstance(t).hide()}),q(Qn),g(Qn);const Xn={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Yn=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Un=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Gn=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!Yn.has(i)||Boolean(Un.test(t.nodeValue)):e.filter(t=>t instanceof RegExp).some(t=>t.test(i))},Jn={allowList:Xn,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
    "},Zn={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},ts={entry:"(string|element|function|null)",selector:"(string|element)"};class es extends W{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Jn}static get DefaultType(){return Zn}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map(t=>this._resolvePossibleFunction(t)).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},ts)}_setContent(t,e,i){const n=R.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?r(e)?this._putElementInTemplate(a(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Gn(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return _(t,[void 0,this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const is=new Set(["sanitize","allowList","sanitizeFn"]),ns="fade",ss="show",os=".tooltip-inner",rs=".modal",as="hide.bs.modal",ls="hover",cs="focus",hs="click",ds={AUTO:"auto",TOP:"top",RIGHT:m()?"left":"right",BOTTOM:"bottom",LEFT:m()?"right":"left"},us={allowList:Xn,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},fs={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class ps extends B{constructor(t,e){if(void 0===Ai)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org/docs/v2/)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return us}static get DefaultType(){return fs}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),P.off(this._element.closest(rs),as,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=P.trigger(this._element,this.constructor.eventName("show")),e=(h(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),P.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(ss),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))P.on(t,"mouseover",d);this._queueCallback(()=>{P.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1},this.tip,this._isAnimated())}hide(){if(this._isShown()&&!P.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(ss),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))P.off(t,"mouseover",d);this._activeTrigger[hs]=!1,this._activeTrigger[cs]=!1,this._activeTrigger[ls]=!1,this._isHovered=null,this._queueCallback(()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),P.trigger(this._element,this.constructor.eventName("hidden")))},this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(ns,ss),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(ns),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new es({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{[os]:this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(ns)}_isShown(){return this.tip&&this.tip.classList.contains(ss)}_createPopper(t){const e=_(this._config.placement,[this,t,this._element]),i=ds[e.toUpperCase()];return wi(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map(t=>Number.parseInt(t,10)):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return _(t,[this._element,this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,..._(this._config.popperConfig,[void 0,e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)P.on(this._element,this.constructor.eventName("click"),this._config.selector,t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger[hs]=!(e._isShown()&&e._activeTrigger[hs]),e.toggle()});else if("manual"!==e){const t=e===ls?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===ls?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");P.on(this._element,t,this._config.selector,t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?cs:ls]=!0,e._enter()}),P.on(this._element,i,this._config.selector,t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?cs:ls]=e._element.contains(t.relatedTarget),e._leave()})}this._hideModalHandler=()=>{this._element&&this.hide()},P.on(this._element.closest(rs),as,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout(()=>{this._isHovered&&this.show()},this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout(()=>{this._isHovered||this.hide()},this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=H.getDataAttributes(this._element);for(const t of Object.keys(e))is.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:a(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each(function(){const e=ps.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}})}}g(ps);const ms=".popover-header",gs=".popover-body",_s={...ps.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},bs={...ps.DefaultType,content:"(null|string|element|function)"};class vs extends ps{static get Default(){return _s}static get DefaultType(){return bs}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{[ms]:this._getTitle(),[gs]:this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each(function(){const e=vs.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}})}}g(vs);const ys=".bs.scrollspy",ws=`activate${ys}`,As=`click${ys}`,Es=`load${ys}.data-api`,Ts="active",Cs="[href]",Os=".nav-link",xs=`${Os}, .nav-item > ${Os}, .list-group-item`,ks={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},Ls={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Ss extends B{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return ks}static get DefaultType(){return Ls}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=a(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map(t=>Number.parseFloat(t))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(P.off(this._config.target,As),P.on(this._config.target,As,Cs,t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}}))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver(t=>this._observerCallback(t),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=R.find(Cs,this._config.target);for(const e of t){if(!e.hash||c(e))continue;const t=R.findOne(decodeURI(e.hash),this._element);l(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(Ts),this._activateParents(t),P.trigger(this._element,ws,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))R.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(Ts);else for(const e of R.parents(t,".nav, .list-group"))for(const t of R.prev(e,xs))t.classList.add(Ts)}_clearActiveClass(t){t.classList.remove(Ts);const e=R.find(`${Cs}.${Ts}`,t);for(const t of e)t.classList.remove(Ts)}static jQueryInterface(t){return this.each(function(){const e=Ss.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}})}}P.on(window,Es,()=>{for(const t of R.find('[data-bs-spy="scroll"]'))Ss.getOrCreateInstance(t)}),g(Ss);const Ds=".bs.tab",$s=`hide${Ds}`,Is=`hidden${Ds}`,Ns=`show${Ds}`,Ps=`shown${Ds}`,js=`click${Ds}`,Ms=`keydown${Ds}`,Fs=`load${Ds}`,Hs="ArrowLeft",Ws="ArrowRight",Bs="ArrowUp",zs="ArrowDown",Rs="Home",qs="End",Vs="active",Ks="fade",Qs="show",Xs=".dropdown-toggle",Ys=`:not(${Xs})`,Us='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',Gs=`.nav-link${Ys}, .list-group-item${Ys}, [role="tab"]${Ys}, ${Us}`,Js=`.${Vs}[data-bs-toggle="tab"], .${Vs}[data-bs-toggle="pill"], .${Vs}[data-bs-toggle="list"]`;class Zs extends B{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),P.on(this._element,Ms,t=>this._keydown(t)))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?P.trigger(e,$s,{relatedTarget:t}):null;P.trigger(t,Ns,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(Vs),this._activate(R.getElementFromSelector(t)),this._queueCallback(()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),P.trigger(t,Ps,{relatedTarget:e})):t.classList.add(Qs)},t,t.classList.contains(Ks)))}_deactivate(t,e){t&&(t.classList.remove(Vs),t.blur(),this._deactivate(R.getElementFromSelector(t)),this._queueCallback(()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),P.trigger(t,Is,{relatedTarget:e})):t.classList.remove(Qs)},t,t.classList.contains(Ks)))}_keydown(t){if(![Hs,Ws,Bs,zs,Rs,qs].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter(t=>!c(t));let i;if([Rs,qs].includes(t.key))i=e[t.key===Rs?0:e.length-1];else{const n=[Ws,zs].includes(t.key);i=v(e,t.target,n,!0)}i&&(i.focus({preventScroll:!0}),Zs.getOrCreateInstance(i).show())}_getChildren(){return R.find(Gs,this._parent)}_getActiveElem(){return this._getChildren().find(t=>this._elemIsActive(t))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=R.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=R.findOne(t,i);s&&s.classList.toggle(n,e)};n(Xs,Vs),n(".dropdown-menu",Qs),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(Vs)}_getInnerElement(t){return t.matches(Gs)?t:R.findOne(Gs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each(function(){const e=Zs.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}})}}P.on(document,js,Us,function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),c(this)||Zs.getOrCreateInstance(this).show()}),P.on(window,Fs,()=>{for(const t of R.find(Js))Zs.getOrCreateInstance(t)}),g(Zs);const to=".bs.toast",eo=`mouseover${to}`,io=`mouseout${to}`,no=`focusin${to}`,so=`focusout${to}`,oo=`hide${to}`,ro=`hidden${to}`,ao=`show${to}`,lo=`shown${to}`,co="hide",ho="show",uo="showing",fo={animation:"boolean",autohide:"boolean",delay:"number"},po={animation:!0,autohide:!0,delay:5e3};class mo extends B{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return po}static get DefaultType(){return fo}static get NAME(){return"toast"}show(){P.trigger(this._element,ao).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(co),u(this._element),this._element.classList.add(ho,uo),this._queueCallback(()=>{this._element.classList.remove(uo),P.trigger(this._element,lo),this._maybeScheduleHide()},this._element,this._config.animation))}hide(){this.isShown()&&(P.trigger(this._element,oo).defaultPrevented||(this._element.classList.add(uo),this._queueCallback(()=>{this._element.classList.add(co),this._element.classList.remove(uo,ho),P.trigger(this._element,ro)},this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(ho),super.dispose()}isShown(){return this._element.classList.contains(ho)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout(()=>{this.hide()},this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){P.on(this._element,eo,t=>this._onInteraction(t,!0)),P.on(this._element,io,t=>this._onInteraction(t,!1)),P.on(this._element,no,t=>this._onInteraction(t,!0)),P.on(this._element,so,t=>this._onInteraction(t,!1))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each(function(){const e=mo.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}})}}return q(mo),g(mo),{Alert:X,Button:U,Carousel:St,Collapse:qt,Dropdown:Qi,Modal:Ln,Offcanvas:Qn,Popover:vs,ScrollSpy:Ss,Tab:Zs,Toast:mo,Tooltip:ps}}); +//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/src/web/public/css/admin.css b/src/web/public/css/admin.css deleted file mode 100644 index 97773d2..0000000 --- a/src/web/public/css/admin.css +++ /dev/null @@ -1,2704 +0,0 @@ -:root { - color-scheme: light; - --bg: #eef2f7; - --bg-accent: #f8fbff; - --surface: #ffffff; - --surface-alt: #f6f8fc; - --text: #102033; - --muted: #66758a; - --border: rgba(16, 32, 51, 0.1); - --border-strong: rgba(16, 32, 51, 0.16); - --primary: #2563eb; - --primary-dark: #1d4ed8; - --primary-soft: rgba(37, 99, 235, 0.12); - --success: #0f9d58; - --danger: #dc2626; - --warning: #b45309; - --focus-ring: rgba(37, 99, 235, 0.18); - --page-html-bg: radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 30%), linear-gradient(180deg, #f9fbff 0%, var(--bg) 100%); - --page-body-bg: radial-gradient(circle at top right, rgba(14, 165, 233, 0.16), transparent 24%), radial-gradient(circle at 20% 0%, rgba(37, 99, 235, 0.12), transparent 22%), linear-gradient(180deg, #f8fbff 0%, var(--bg) 100%); - --auth-body-bg: radial-gradient(circle at top left, rgba(34, 197, 94, 0.16), transparent 24%), radial-gradient(circle at 80% 20%, rgba(37, 99, 235, 0.18), transparent 28%), linear-gradient(180deg, #eef5fb 0%, #e6edf6 100%); - --sidebar-background: radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 30%), linear-gradient(180deg, #0f172a 0%, #111c32 100%); - --sidebar-border: rgba(255, 255, 255, 0.08); - --sidebar-text: #dbe7ff; - --sidebar-text-strong: #ffffff; - --sidebar-muted: rgba(219, 231, 255, 0.7); - --sidebar-surface: rgba(255, 255, 255, 0.06); - --sidebar-hover: rgba(255, 255, 255, 0.12); - --sidebar-divider: rgba(219, 231, 255, 0.12); - --sidebar-link: #93c5fd; - --table-border: rgba(16, 32, 51, 0.08); - --table-hover: rgba(37, 99, 235, 0.04); - --table-row-odd: rgba(15, 23, 42, 0.02); - --table-row-even: rgba(255, 255, 255, 0.82); - --stat-background: linear-gradient(180deg, var(--surface), var(--surface-alt)); - --button-secondary-bg: #334155; - --button-secondary-hover: #1f2937; - --theme-toggle-bg: linear-gradient(180deg, var(--surface), var(--surface-alt)); - --theme-toggle-hover: linear-gradient(180deg, var(--surface-alt), var(--surface)); - --editor-paper: #ffffff; - --editor-text: #000000; - --shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.16); - --shadow-md: 0 16px 28px rgba(15, 23, 42, 0.12); - --shadow-sm: 0 8px 20px rgba(15, 23, 42, 0.08); -} - -html[data-theme='dark'] { - color-scheme: dark; - --bg: #0b1220; - --bg-accent: #111827; - --surface: #111b2d; - --surface-alt: #172337; - --text: #e6eefb; - --muted: #9ba9be; - --border: rgba(148, 163, 184, 0.16); - --border-strong: rgba(148, 163, 184, 0.26); - --primary: #60a5fa; - --primary-dark: #3b82f6; - --primary-soft: rgba(96, 165, 250, 0.16); - --success: #34d399; - --danger: #f87171; - --warning: #f59e0b; - --focus-ring: rgba(96, 165, 250, 0.28); - --page-html-bg: radial-gradient(circle at top left, rgba(96, 165, 250, 0.16), transparent 26%), linear-gradient(180deg, #060b14 0%, #0b1220 100%); - --page-body-bg: radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 24%), radial-gradient(circle at 20% 0%, rgba(96, 165, 250, 0.12), transparent 22%), linear-gradient(180deg, #08111d 0%, #0b1220 100%); - --auth-body-bg: radial-gradient(circle at top left, rgba(34, 197, 94, 0.12), transparent 24%), radial-gradient(circle at 80% 20%, rgba(96, 165, 250, 0.16), transparent 28%), linear-gradient(180deg, #070d18 0%, #0b1220 100%); - --sidebar-background: radial-gradient(circle at top right, rgba(59, 130, 246, 0.14), transparent 30%), linear-gradient(180deg, #09101d 0%, #0e1728 100%); - --sidebar-border: rgba(148, 163, 184, 0.1); - --sidebar-text: #dce8fb; - --sidebar-text-strong: #ffffff; - --sidebar-muted: rgba(220, 232, 251, 0.66); - --sidebar-surface: rgba(255, 255, 255, 0.08); - --sidebar-hover: rgba(255, 255, 255, 0.14); - --sidebar-divider: rgba(148, 163, 184, 0.12); - --sidebar-link: #bfdbfe; - --table-border: rgba(148, 163, 184, 0.14); - --table-hover: rgba(96, 165, 250, 0.08); - --table-row-odd: rgba(255, 255, 255, 0.03); - --table-row-even: rgba(15, 23, 42, 0.28); - --stat-background: linear-gradient(180deg, var(--surface), var(--surface-alt)); - --button-secondary-bg: #334155; - --button-secondary-hover: #475569; - --theme-toggle-bg: linear-gradient(180deg, #162235, #111b2d); - --theme-toggle-hover: linear-gradient(180deg, #1b2a41, #162235); - --editor-paper: #ffffff; - --editor-text: #000000; - --shadow-lg: 0 28px 64px rgba(2, 6, 23, 0.45); - --shadow-md: 0 18px 30px rgba(2, 6, 23, 0.28); - --shadow-sm: 0 10px 22px rgba(2, 6, 23, 0.24); -} - -* { - box-sizing: border-box; -} - -html { - min-height: 100%; - -webkit-text-size-adjust: 100%; - background: var(--page-html-bg); -} - -body { - margin: 0; - min-height: 100vh; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - color: var(--text); - background: var(--page-body-bg); -} - -a { - color: var(--primary); -} - -/* Shared admin shell and global UI used across most admin pages. */ -.admin-shell-body { - overflow-x: hidden; -} - -.app-shell { - min-height: 100vh; - display: grid; - grid-template-columns: 280px minmax(0, 1fr); -} - -.sidebar { - position: sticky; - top: 0; - align-self: start; - min-height: 100vh; - display: flex; - flex-direction: column; - padding: 16px 12px 12px; - color: var(--sidebar-text); - background: var(--sidebar-background); - border-right: 1px solid var(--sidebar-border); -} - -.brand { - display: flex; - align-items: center; - gap: 12px; - padding: 6px 8px 16px; -} - -.brand-mark { - width: 44px; - height: 44px; - border-radius: 14px; - display: grid; - place-items: center; - background: linear-gradient(135deg, #f97316, #eab308 24%, #22c55e 48%, #06b6d4 72%, #8b5cf6); - background-size: 220% 220%; - animation: logo-rainbow 8s ease-in-out infinite; - color: #fff; - font-weight: 800; - letter-spacing: 0.08em; - box-shadow: 0 12px 30px rgba(37, 99, 235, 0.4); -} - -.brand-copy { - display: grid; - gap: 2px; -} - -.brand-copy strong { - font-size: 16px; - letter-spacing: 0.01em; -} - -.brand-copy span { - color: var(--sidebar-muted); - font-size: 12px; -} - -.sidebar-actions { - padding: 4px 8px 12px; -} - -.nav { - display: grid; - gap: 8px; -} - -.nav-label { - padding: 10px 12px 4px; - color: var(--sidebar-muted); - text-transform: uppercase; - letter-spacing: 0.12em; - font-size: 11px; -} - -.nav-item { - display: flex; - align-items: center; - gap: 10px; - color: var(--sidebar-text); - text-decoration: none; - padding: 12px 14px; - border-radius: 14px; - background: transparent; - transition: background-color 160ms ease, color 160ms ease, transform 160ms ease; -} - -.nav-item::before { - content: ''; - width: 8px; - height: 8px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.24); - flex: none; -} - -.nav-item.active, -.nav-item:hover { - background: var(--sidebar-surface); - color: var(--sidebar-text-strong); - transform: translateX(2px); -} - -.nav-item.active::before, -.nav-item:hover::before { - background: #60a5fa; -} - -.sidebar-footer { - padding: 16px 14px 8px; - display: flex; - align-items: center; - gap: 12px; - color: var(--sidebar-text); -} - -.sidebar-footer--bottom { - margin-top: auto; - padding: 0; - justify-content: space-between; - gap: 16px; -} - -.sidebar-footer strong, -.sidebar-footer > div > span, -.sidebar-footer > .sidebar-version { - display: block; -} - -.sidebar-footer strong { - color: var(--sidebar-text-strong); - font-size: 14px; -} - -.sidebar-account-name { - color: var(--sidebar-text-strong); - font-size: 14px; - font-weight: 800; -} - -.sidebar-footer > div > span { - color: var(--sidebar-muted); - font-size: 12px; - line-height: 1.35; -} - -.sidebar-footer--account { - align-items: flex-start; - gap: 10px; - justify-content: flex-start; -} - -.sidebar-divider { - height: 1px; - margin: 10px 14px 4px; - background: var(--sidebar-divider); -} - -.user-avatar { - width: 34px; - height: 34px; - min-width: 34px; - border-radius: 999px; - display: grid; - place-items: center; - flex: none; - background: linear-gradient(180deg, #60a5fa, #2563eb); - color: #fff; - font-size: 14px; - font-weight: 800; - letter-spacing: 0.02em; - box-shadow: 0 10px 22px rgba(37, 99, 235, 0.28); -} - -.user-avatar--fallback { - background: linear-gradient(180deg, #94a3b8, #64748b); - box-shadow: 0 10px 22px rgba(100, 116, 139, 0.2); -} - -.sidebar-footer-links { - display: flex; - gap: 12px; - margin-top: 8px; - flex-wrap: wrap; -} - -.sidebar-footer-links a { - color: var(--sidebar-link); - text-decoration: none; - font-size: 12px; - font-weight: 700; -} - -.sidebar-footer-links a:hover { - color: var(--sidebar-text-strong); -} - -.sidebar-version { - color: var(--sidebar-muted); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: lowercase; -} - -.theme-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0; - width: auto; - min-height: 0; - margin-top: 0; - padding: 6px; - border: 0; - border-radius: 0; - background: transparent; - color: var(--text); - box-shadow: none; -} - -.theme-toggle:hover { - background: transparent; - filter: none; -} - -.theme-toggle:active { - transform: translateY(1px); - filter: none; -} - -.theme-toggle__label { - display: block; - text-align: left; - line-height: 1.1; -} - -.theme-toggle__icon { - position: relative; - display: inline-block; - width: 16px; - height: 16px; - flex: none; -} - -.theme-toggle__icon--moon { - border-radius: 50%; - background: var(--theme-toggle-bg); - color: var(--text); -} - -.login-page-body .theme-toggle__icon--moon { - background: currentColor; -} - -.theme-toggle__icon--moon::after { - content: ''; - position: absolute; - top: -1px; - right: -3px; - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--sidebar-background); -} - -.login-page-body .theme-toggle__icon--moon::after { - background: var(--auth-body-bg); -} - -.theme-toggle__icon--sun { - display: none; - color: var(--text); -} - -.theme-toggle__icon--sun::before, -.theme-toggle__icon--sun::after { - content: ''; - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - border-radius: 50%; -} - -.theme-toggle__icon--sun::before { - width: 2px; - height: 2px; - background: currentColor; - box-shadow: - 0 -8px 0 0 currentColor, - 0 8px 0 0 currentColor, - 8px 0 0 0 currentColor, - -8px 0 0 0 currentColor, - 6px 6px 0 0 currentColor, - -6px 6px 0 0 currentColor, - 6px -6px 0 0 currentColor, - -6px -6px 0 0 currentColor; -} - -.theme-toggle__icon--sun::after { - width: 7px; - height: 7px; - background: var(--theme-toggle-bg); - box-shadow: inset 0 0 0 2px currentColor; -} - -html[data-theme='dark'] .theme-toggle__icon--sun { - display: inline-block; -} - -.theme-toggle--sidebar { - width: auto; - padding: 0; - justify-content: center; - margin: 0; - color: var(--sidebar-text-strong); -} - -.theme-toggle--sidebar .theme-toggle__icon--moon, -.theme-toggle--sidebar .theme-toggle__icon--sun { - color: var(--sidebar-text-strong); -} - -.theme-toggle--sidebar .theme-toggle__label { - display: none; -} - -.theme-toggle--sidebar .theme-toggle__icon { - width: 18px; - height: 18px; -} - -.theme-toggle--floating { - width: auto; - padding: 0; - justify-content: center; - position: fixed; - top: 16px; - right: 16px; - z-index: 90; -} - -.theme-toggle--floating .theme-toggle__label { - display: none; -} - -.theme-toggle--floating .theme-toggle__icon { - width: 18px; - height: 18px; -} - -.auth-shell-body .theme-toggle--floating { - top: auto; - bottom: 16px; -} - -.status-dot { - width: 12px; - height: 12px; - border-radius: 999px; - background: #22c55e; - box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.14); - flex: none; -} - -.status-dot--unknown { - background: #94a3b8; - box-shadow: 0 0 0 6px rgba(148, 163, 184, 0.14); -} - -.status-dot--online { - background: #22c55e; - box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.14); -} - -.status-dot--offline { - background: #ef4444; - box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.14); -} - -.status-pill--unknown { - color: #334155; - background: rgba(148, 163, 184, 0.18); -} - -.status-pill--online { - color: #14532d; - background: rgba(34, 197, 94, 0.16); -} - -.status-pill--offline { - color: #7f1d1d; - background: rgba(239, 68, 68, 0.14); -} - -.shell-main { - min-width: 0; -} - -.status-pill { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 38px; - padding: 0 14px; - border-radius: 999px; - font-size: 13px; - font-weight: 700; - text-decoration: none; - color: #0f5132; - background: rgba(16, 185, 129, 0.14); -} - -.content { - padding: 28px; -} - -/* Dashboard and list-style admin pages. */ -.page-shell { - display: grid; - gap: 18px; -} - -.page-shell > * { - animation: fade-up 240ms ease-out both; -} - -.page-header { - display: flex; - align-items: end; - justify-content: space-between; - gap: 16px; - margin-bottom: 4px; -} - -.page-header h2 { - margin: 0; - font-size: 22px; - line-height: 1.2; -} - -.page-header p { - margin: 0; - color: var(--muted); -} - -.toast { - position: fixed; - top: auto; - bottom: 20px; - right: 20px; - z-index: 1000; - display: flex; - align-items: center; - gap: 12px; - max-width: min(360px, calc(100vw - 40px)); - padding: 10px 14px; - border-radius: 16px; - background: linear-gradient(180deg, var(--surface), var(--surface-alt)); - border: 1px solid var(--border-strong); - border-left: 4px solid var(--primary); - color: var(--text); - box-shadow: var(--shadow-md); -} - -.toast-body { - flex: 1; - line-height: 1.4; -} - -.toast-close { - width: 30px; - min-width: 30px; - height: 30px; - margin: 0; - padding: 0; - border-radius: 999px; - background: var(--bg); - color: var(--muted); - font-size: 20px; - line-height: 1; -} - -.toast-close:hover { - background: var(--surface-alt); - color: var(--text); -} - -.toast-hide { - opacity: 0; - transform: translateY(-8px); - transition: opacity 180ms ease, transform 180ms ease; -} - -.grid { - display: grid; - gap: 16px; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); -} - -.card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 20px; - padding: 18px; - box-shadow: var(--shadow-sm); - margin-bottom: 0; -} - -/* Authentication pages. */ -.auth-shell-body { - background: var(--auth-body-bg); -} - -.auth-page { - min-height: 100vh; - display: grid; - place-items: center; - padding: 24px; -} - -.auth-card { - width: min(100%, 480px); - padding: 28px; -} - -.auth-brand { - display: flex; - gap: 16px; - align-items: center; - margin-bottom: 18px; -} - -.auth-brand h1 { - margin: 4px 0 6px; - font-size: 28px; -} - -.auth-brand p, -.auth-hint { - margin: 0; - color: var(--muted); -} - -.eyebrow { - display: block; - color: var(--primary-dark); - text-transform: uppercase; - letter-spacing: 0.12em; - font-size: 11px; - font-weight: 800; -} - -.auth-message { - margin: 18px 0 0; - padding: 12px 14px; - border-radius: 14px; - background: var(--primary-soft); - color: var(--primary-dark); - font-weight: 700; -} - -.auth-form { - display: grid; - gap: 16px; - margin-top: 18px; -} - -.auth-form button { - margin-top: 4px; -} - -.auth-hint { - margin-top: 16px; - font-size: 13px; - line-height: 1.5; -} - -/* Account and user management pages. */ -.account-card { - max-width: 720px; -} - -.user-create-card { - max-width: 980px; -} - -.user-create-form { - display: grid; - gap: 20px; -} - -.user-create-section { - display: grid; - gap: 14px; -} - -.user-create-section h3 { - margin: 0; -} - -.user-create-fields { - display: grid; - gap: 14px; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); -} - -.user-create-fields label { - display: grid; - gap: 8px; -} - -.user-create-actions { - margin-top: 4px; - flex-wrap: wrap; -} - -.user-create-button { - min-height: 42px; - padding-left: 18px; - padding-right: 18px; -} - -.button-link.secondary { - background: var(--button-secondary-bg); -} - -.button-link.secondary:hover { - background: var(--button-secondary-hover); -} - -.user-edit-card { - max-width: 980px; -} - -.user-edit-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 16px; -} - -.user-edit-grid { - display: grid; - gap: 16px; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); -} - -.user-edit-panel { - display: grid; - gap: 14px; - margin-bottom: 0; -} - -.user-edit-panel h3 { - margin: 0; -} - -.user-edit-button { - width: 100%; - min-height: 42px; - display: flex; - align-items: center; - justify-content: center; - white-space: nowrap; -} - -.template-field-head { - display: flex; - justify-content: space-between; - gap: 8px; - align-items: center; - margin-bottom: 8px; -} - -.users-table th:last-child { - width: 220px; -} - -.users-row-actions { - justify-content: flex-start; -} - -.user-cell { - display: flex; - align-items: center; - gap: 12px; -} - -.user-avatar--small { - width: 30px; - height: 30px; - min-width: 30px; - font-size: 12px; -} - -.user-inline-form, -.user-password-form { - display: grid; - gap: 8px; -} - -.user-password-form { - grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr) auto; - align-items: start; -} - -/* Playlist, canvas size, template, and screen edit pages. */ -.user-inline-form .button-link, -.user-password-form .button-link { - width: fit-content; -} - -.account-form { - display: grid; - gap: 16px; -} - -.account-form .actions { - margin-top: 4px; -} - -.account-form .button-link.secondary { - background: var(--button-secondary-bg); -} - -.account-form .button-link.secondary:hover { - background: var(--button-secondary-hover); -} - -.playlist-name-card { - margin-bottom: 16px; -} - -.slide-template-card { - margin-bottom: 16px; -} - -.template-meta-card { - margin-bottom: 16px; -} - -.canvas-size-form { - display: grid; - gap: 16px; -} - -.canvas-size-form .row { - align-items: stretch; -} - -.canvas-size-form button { - margin-top: 0; -} - -.screen-edit-form { - display: grid; - gap: 14px; -} - -.screen-edit-form label { - display: grid; - gap: 8px; -} - -.screen-edit-form input, -.screen-edit-form select, -.screen-edit-form button { - margin-top: 0; -} - -.screen-edit-form button { - width: fit-content; -} - -.screen-edit-actions { - display: flex; - padding-top: 14px; - gap: 10px; - flex-wrap: wrap; - align-items: center; -} - -.screen-edit-actions .inline-form { - display: inline-flex; -} - -.screen-edit-actions button, -.screen-edit-actions .inline-form button { - width: fit-content; - margin-top: 0; -} - -/* Shared table, form, and widget styling used across admin pages. */ -.card h3 { - margin: 0 0 14px; - font-size: 18px; -} - -.stats { - display: grid; - gap: 12px; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - margin-bottom: 0; -} - -.stat { - position: relative; - overflow: hidden; - background: var(--stat-background); - border: 1px solid var(--border); - border-radius: 12px; - padding: 16px; - box-shadow: var(--shadow-sm); - margin-bottom: 0; -} - -.stat::after { - content: ''; - position: absolute; - inset: auto -16px -18px auto; - width: 88px; - height: 88px; - background-repeat: no-repeat; - background-position: center; - background-size: 64% 64%; - opacity: 0.18; - pointer-events: none; -} - -.stats .stat:nth-child(1)::after { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232563eb' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h10'/%3E%3C/svg%3E"); -} - -.stats .stat:nth-child(2)::after { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232563eb' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='4' width='16' height='16' rx='2'/%3E%3Cpath d='M8 13l2-2 3 3 2-2 3 3'/%3E%3Cpath d='M8 9h.01'/%3E%3C/svg%3E"); -} - -.stats .stat:nth-child(3)::after { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232563eb' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='5' width='18' height='12' rx='2'/%3E%3Cpath d='M8 19h8'/%3E%3Cpath d='M12 17v2'/%3E%3C/svg%3E"); -} - -.stats .stat:nth-child(4)::after { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232563eb' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z'/%3E%3Cpath d='M16 12a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z'/%3E%3Cpath d='M4.5 19a5.5 5.5 0 0 1 11 0'/%3E%3Cpath d='M14 19a4.5 4.5 0 0 1 5.5-4.4'/%3E%3C/svg%3E"); -} - -.stat strong { - position: relative; - display: block; - font-size: 32px; - line-height: 1; - margin-bottom: 8px; -} - -.stat span { - position: relative; - color: var(--muted); - font-size: 13px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.stat span { - position: relative; - color: var(--muted); - font-size: 13px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.row { - display: flex; - gap: 12px; - flex-wrap: wrap; - align-items: center; -} - -.switch-field { - display: inline-flex; - align-items: center; - gap: 12px; - width: fit-content; - margin-top: 5px; - align-self: center; -} - -.switch-control { - position: relative; - display: inline-flex; - align-items: center; - cursor: pointer; - flex: none; -} - -.switch-control input { - position: absolute; - width: 1px; - height: 1px; - margin: 0; - padding: 0; - border: 0; - clip: rect(0, 0, 0, 0); - clip-path: inset(50%); - overflow: hidden; - white-space: nowrap; -} - -.switch-ui { - position: relative; - width: 54px; - height: 32px; - border-radius: 999px; - background: #cbd5e1; - border: 1px solid rgba(16, 32, 51, 0.14); - box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.12); - flex: none; - transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease; -} - -.switch-ui::after { - content: ''; - position: absolute; - top: 3px; - left: 3px; - width: 24px; - height: 24px; - border-radius: 50%; - background: #fff; - box-shadow: 0 6px 16px rgba(15, 23, 42, 0.18); - transition: transform 160ms ease; -} - -.switch-label { - font-size: 14px; - font-weight: 700; - color: var(--text); - line-height: 1; -} - -.switch-control input:checked + .switch-ui { - background: linear-gradient(180deg, var(--primary), var(--primary-dark)); - border-color: rgba(37, 99, 235, 0.55); - box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08), 0 0 0 4px rgba(37, 99, 235, 0.12); -} - -.switch-control input:checked + .switch-ui::after { - transform: translateX(22px); -} - -.switch-field:focus-within .switch-ui { - outline: 3px solid rgba(37, 99, 235, 0.18); - outline-offset: 2px; -} - -.actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; -} - -.page-toolbar { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; -} - -/* Shared form controls. */ -label { - display: block; - font-size: 14px; - font-weight: 700; - color: var(--text); -} - -label > input, -label > textarea, -label > select, -label > button { - margin-top: 6px; -} - -input, -textarea, -select, -button { - width: 100%; - box-sizing: border-box; - padding: 10px 12px; - border: 1px solid var(--border-strong); - border-radius: 12px; - font: inherit; - background: var(--surface); - color: var(--text); -} - -input:focus, -textarea:focus, -button:focus-visible, -a.button-link:focus-visible, -button.sidebar-toggle:focus-visible { - outline: 3px solid var(--focus-ring); - outline-offset: 2px; -} - -select:focus-visible { - outline: 3px solid var(--focus-ring); - outline-offset: 2px; -} - -select:focus { - outline: none; -} - -button:focus, -a.button-link:focus, -button.sidebar-toggle:focus { - outline: none; -} - -button { - background: linear-gradient(180deg, var(--primary), var(--primary-dark)); - color: #fff; - border: none; - cursor: pointer; - font-weight: 800; - box-shadow: 0 10px 20px rgba(37, 99, 235, 0.18); -} - -button:hover { - filter: brightness(0.92); -} - -button:active, -a.button-link:active, -button.sidebar-toggle:active { - filter: brightness(1.08); -} - -button.secondary, -a.button-link, -.status-pill { - border: 1px solid transparent; -} - -button.secondary { - background: var(--button-secondary-bg); -} - -button.secondary:hover { - background: var(--button-secondary-hover); -} - -button.danger, -button.is-danger { - background: linear-gradient(180deg, #ef4444, #dc2626); - box-shadow: 0 10px 18px rgba(220, 38, 38, 0.18); -} - -button.danger:hover, -button.is-danger:hover { - background: linear-gradient(180deg, #dc2626, #b91c1c); -} - -button.is-paused, -button.is-blackout { - background: linear-gradient(180deg, #f59e0b, #d97706); -} - -.ce-inline-toolbar__buttons button, -.ce-inline-toolbar__actions button { - background: transparent; - color: inherit; - box-shadow: none; -} - -.ce-inline-toolbar__buttons button:hover, -.ce-inline-toolbar__actions button:hover { - background: rgba(255, 255, 255, 0.08); - filter: none; -} - -a.button-link { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 42px; - background: linear-gradient(180deg, var(--primary), var(--primary-dark)); - color: #fff; - text-decoration: none; - padding: 10px 14px; - border-radius: 12px; - font-weight: 800; - box-shadow: 0 10px 20px rgba(37, 99, 235, 0.18); -} - -a.button-link:hover { - color: #fff; - filter: brightness(0.92); -} - -.list-action { - display: inline-flex; - align-items: center; - justify-content: center; - width: 104px; - min-width: 104px; - box-sizing: border-box; - font: inherit; - font-size: 14px; - line-height: 1.2; - font-weight: 800; - text-align: center; - box-shadow: none; -} - -.actions a.button-link.list-action, -.actions button.list-action { - min-height: 38px; - padding: 8px 12px; - border-radius: 11px; - font-size: 13px; - line-height: 1.1; -} - -.actions a.button-link.list-action { - min-width: 96px; -} - -.actions button.list-action { - min-width: 96px; -} - -.list-action--nav, -a.button-link.list-action.list-action--nav, -button.list-action.list-action--nav { - width: 36.8px; - min-width: 36.8px; - height: 36.8px; - padding: 0; - display: inline-grid; - place-items: center; - font-size: 15px; - line-height: 1; - border-radius: 12px; - background: linear-gradient(180deg, #f59e0b, #d97706); - color: #fff; -} - -table .inline-form button.list-action--nav { - width: 36.8px; - min-width: 36.8px; - height: 36.8px; - padding: 0; -} - -.list-action--nav:hover { - filter: brightness(0.92); -} - -a.button-link.list-action, -button.list-action { - padding: 10px 14px; -} - -table { - width: 100%; - border-collapse: collapse; - border-spacing: 0; - overflow: hidden; - border-radius: 16px; -} - -th, -td { - text-align: left; - padding: 13px 14px; - border-bottom: 1px solid var(--table-border); - vertical-align: middle; - line-height: 1.4; -} - -thead th { - font-size: 13px; - font-weight: 800; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.06em; - background: var(--surface-alt); -} - -tbody tr:hover { - background: rgba(37, 99, 235, 0.07); -} - -tbody tr:last-child td { - border-bottom: none; -} - -.inline-form { - display: inline-flex; - margin: 0; -} - -.subtle { - color: var(--muted); - font-size: 13px; -} - -.connection-count { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - background: rgba(37, 99, 235, 0.1); - color: #1d4ed8; - font-size: 12px; - font-weight: 800; - margin-bottom: 8px; -} - -.connection-id { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - background: rgba(15, 118, 110, 0.12); - color: #0f766e; - font-size: 12px; - font-weight: 700; -} - -#dashboard-screens-table th:last-child, -#dashboard-screens-table td:last-child { - width: auto; - white-space: normal; -} - -.sidebar-toggle { - display: none; - align-items: center; - justify-content: center; - gap: 5px; - width: 46px; - height: 46px; - margin: 0; - padding: 0; - border-radius: 14px; - background: var(--surface); - border: 1px solid var(--border); - box-shadow: var(--shadow-sm); - flex: none; -} - -.sidebar-toggle--shell { - position: fixed; - top: 16px; - right: 16px; - left: auto; - z-index: 70; -} - -.sidebar-toggle-icon { - position: relative; - display: block; - width: 18px; - height: 2px; - border-radius: 999px; - background: #334155; - box-shadow: 0 -6px 0 #334155, 0 6px 0 #334155; -} - -.sidebar-backdrop { - display: none; -} - -.app-shell--frame { - grid-template-columns: 1fr; -} - -.shell-main--frame { - min-height: 100vh; -} - -.topbar--frame { - position: sticky; - top: 0; -} - -.content--frame { - padding: 20px; -} - -.page-shell--frame { - gap: 0; -} - -.admin-shell-body--frame-plain { - background: var(--surface); -} - -.admin-shell-body--frame-plain .content--frame { - padding: 0; - background: var(--surface); -} - -.admin-shell-body--frame-plain .page-shell--frame { - padding-top: 12px; -} - -.admin-shell-body--frame-plain .card { - box-shadow: none; -} - -.admin-shell-body--frame-plain button[type="submit"] { - padding: 8px 12px; -} - -@keyframes fade-up { - from { - opacity: 0; - transform: translateY(8px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes logo-rainbow { - 0% { - background-position: 0% 50%; - } - - 50% { - background-position: 100% 50%; - } - - 100% { - background-position: 0% 50%; - } -} - -/* Responsive shell behavior. */ -@media (max-width: 1100px) { - .app-shell { - grid-template-columns: 1fr; - } - - .sidebar { - position: fixed; - z-index: 60; - inset: 0 auto 0 0; - width: min(88vw, 320px); - transform: translateX(-104%); - transition: transform 220ms ease; - box-shadow: var(--shadow-lg); - } - - .admin-shell-body.is-sidebar-open .sidebar { - transform: translateX(0); - } - - .sidebar-toggle { - display: inline-flex; - } - - .sidebar-toggle--shell { - display: inline-flex; - } - - .sidebar-backdrop { - display: block; - position: fixed; - inset: 0; - z-index: 50; - width: 100%; - height: 100%; - margin: 0; - padding: 0; - border: 0; - border-radius: 0; - background: rgba(15, 23, 42, 0.42); - opacity: 0; - pointer-events: none; - transition: opacity 220ms ease; - } - - .admin-shell-body.is-sidebar-open .sidebar-backdrop { - opacity: 1; - pointer-events: auto; - } - - .content { - padding: 18px; - } -} - -@media (max-width: 720px) { - .toast { - top: auto; - bottom: 12px; - left: auto; - right: 12px; - max-width: none; - } - - .content { - padding: 12px; - } - - .page-shell { - gap: 12px; - } - - .page-header { - align-items: flex-start; - flex-direction: column; - padding-right: 72px; - } - - .card { - padding: 14px; - border-radius: 16px; - } - - .actions, - .playlist-item-actions, - .page-toolbar, - .playlist-edit-actions { - align-items: stretch; - flex-direction: row; - flex-wrap: wrap; - } - - .actions > *, - .playlist-item-actions > *, - .page-toolbar > *, - .playlist-edit-actions > * { - flex: 1 1 calc(50% - 6px); - min-width: 140px; - } - - .page-toolbar > *, - .playlist-edit-actions > * { - min-width: 160px; - } - - .stats { - grid-template-columns: 1fr; - } - - table.table-cards { - display: block; - width: 100%; - border-collapse: separate; - border-spacing: 0 12px; - overflow: visible; - } - - table.table-cards thead { - display: none; - } - - table.table-cards tbody { - display: grid; - gap: 12px; - } - - table.table-cards tbody tr { - display: grid; - gap: 8px; - padding: 14px; - border: 1px solid var(--border); - border-radius: 16px; - background: var(--surface); - box-shadow: var(--shadow-sm); - } - - table.table-cards th, - table.table-cards td { - display: flex; - flex-direction: column; - gap: 6px; - padding: 0; - border: 0; - white-space: normal; - } - - table.table-cards td::before { - content: attr(data-label); - color: var(--muted); - font-size: 11px; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.06em; - } - - table.table-cards td.empty::before { - content: ''; - } - - table.table-cards td[data-label="Actions"] { - width: 1%; - white-space: nowrap; - } - - table.table-cards td .actions { - gap: 6px; - justify-content: flex-start; - } - - table.table-cards td[data-label="Actions"] .actions { - display: inline-flex; - flex-wrap: nowrap; - align-items: center; - justify-content: flex-start; - width: auto; - } - - table.table-cards td[data-label="Actions"] .actions > * { - flex: 0 0 auto; - min-width: 0; - } - - table.table-cards td[data-label="Actions"] .actions > * { - width: auto; - align-self: flex-start; - } - - .inline-form { - display: flex; - flex: 0 0 auto; - min-width: 0; - } - - table.table-cards td[data-label="Actions"] .inline-form { - display: inline-flex; - min-width: 0; - width: auto; - } - - table.table-cards td[data-label="Actions"] .inline-form button, - table.table-cards td[data-label="Actions"] .button-link, - table.table-cards td[data-label="Actions"] .list-action { - display: inline-flex; - width: auto; - min-width: 0; - white-space: nowrap; - } - - table.table-cards td[data-label="Actions"] .inline-form button.list-action--nav, - table.table-cards td[data-label="Actions"] .list-action--nav { - flex: 0 0 36.8px; - min-width: 36.8px; - width: 36.8px; - height: 36.8px; - padding: 0; - } - - .inline-form button { - min-width: 0; - width: auto; - } -} - -thead th.sortable { - cursor: pointer; - user-select: none; - position: relative; - padding-right: 36px; -} - -thead th.sortable::after { - content: ''; - position: absolute; - right: 10px; - top: 50%; - display: grid; - place-items: center; - width: 18px; - height: 18px; - margin-top: -9px; - border-radius: 999px; - background: rgba(37, 99, 235, 0.08); - background-repeat: no-repeat; - background-position: center; - background-size: 10px 10px; - color: var(--primary); - font-size: 0; - font-weight: 900; - line-height: 1; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M6 1 L2.5 4.5 H9.5 Z' fill='%232563eb'/%3E%3Cpath d='M6 11 L2.5 7.5 H9.5 Z' fill='%232563eb'/%3E%3C/svg%3E"); -} - -thead th.sortable.sort-asc::after { - background: rgba(37, 99, 235, 0.16); - background-repeat: no-repeat; - background-position: center; - background-size: 10px 10px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M6 1 L2.5 4.5 H9.5 Z' fill='%232563eb'/%3E%3C/svg%3E"); -} - -thead th.sortable.sort-asc, -thead th.sortable.sort-desc { - color: var(--text); -} - -thead th.sortable.sort-desc::after { - background: rgba(37, 99, 235, 0.16); - background-repeat: no-repeat; - background-position: center; - background-size: 10px 10px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M6 11 L2.5 7.5 H9.5 Z' fill='%232563eb'/%3E%3C/svg%3E"); -} - -thead th.unsortable { - cursor: default; -} - -tbody tr:nth-child(odd) { - background: var(--table-row-odd); -} - -tbody tr:nth-child(even) { - background: var(--table-row-even); -} - -table input, -table select, -table textarea, -table button { - margin-top: 0; -} - -td .actions { - align-items: center; -} - -table th:last-child, -table td:last-child { - width: 1%; - white-space: nowrap; -} - -table td .actions { - width: auto; - justify-content: flex-end; - flex-wrap: nowrap; -} - -#dashboard-clients-table th:nth-child(1), -#dashboard-clients-table td:nth-child(1) { - width: 24%; - min-width: 240px; -} - -#dashboard-clients-table th:nth-child(2), -#dashboard-clients-table td:nth-child(2) { - width: 16%; - min-width: 120px; -} - -#dashboard-clients-table th:nth-child(3), -#dashboard-clients-table td:nth-child(3) { - width: 14%; - min-width: 110px; -} - -#dashboard-clients-table th:nth-child(4), -#dashboard-clients-table td:nth-child(4) { - width: 18%; - min-width: 150px; -} - -#dashboard-clients-table th:nth-child(5), -#dashboard-clients-table td:nth-child(5) { - width: 20%; - min-width: 180px; -} - -#dashboard-clients-table th:nth-child(6), -#dashboard-clients-table td:nth-child(6) { - width: 8%; - min-width: 220px; -} - -table td .inline-form, -table td .actions form { - flex: 0 0 auto; -} - -.inline-form button { - width: auto; - min-width: 112px; -} - -.list-action.is-paused { - background: #b45309; - color: #fff; - border-color: rgba(180, 83, 9, 0.24); - animation: action-flash 1.5s ease-in-out infinite alternate; -} - -.list-action.is-paused:hover { - background: #92400e; -} - -.list-action.is-danger { - background: #dc2626; - color: #fff; -} - -.list-action.is-danger:hover { - background: #b91c1c; -} - -.list-action.is-blackout { - background: #1f2937; - color: #fff; - border-color: rgba(31, 41, 55, 0.24); - animation: action-flash-blackout 0.75s ease-in-out infinite alternate; -} - -.list-action.is-blackout:hover { - background: #111827; -} - -@keyframes action-flash { - from { - background: #b45309; - filter: brightness(1); - } - - to { - background: #f59e0b; - filter: brightness(1.08); - } -} - -@keyframes action-flash-blackout { - from { - background: #235ce8; - filter: brightness(1); - } - - to { - background: #dd0b0b; - filter: brightness(1.08); - } -} - -.muted { - color: var(--muted); -} - -.playlist-item-actions { - flex-wrap: nowrap; - align-items: center; -} - -.playlist-order-cell { - width: 88px; - min-width: 88px; - white-space: nowrap; - vertical-align: middle; -} - -.playlist-order-cell-inner { - display: flex; - align-items: center; - gap: 8px; - min-height: 100%; -} - -.playlist-order-number { - min-width: 1.5em; - font-weight: 800; - color: var(--muted); -} - -.playlist-drag-handle { - flex: 0 0 auto; - width: 28px; - height: 28px; - padding: 0; - border: 0; - margin: 0; - border-radius: 0; - color: var(--muted); - background: currentColor; - -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M12 4L8 8H16L12 4Z'/%3E%3Cpath d='M4 11H20V13H4V11Z'/%3E%3Cpath d='M4 15H20V17H4V15Z'/%3E%3Cpath d='M4 7H20V9H4V7Z'/%3E%3Cpath d='M12 20L8 16H16L12 20Z'/%3E%3C/svg%3E") center / contain no-repeat; - mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M12 4L8 8H16L12 4Z'/%3E%3Cpath d='M4 11H20V13H4V11Z'/%3E%3Cpath d='M4 15H20V17H4V15Z'/%3E%3Cpath d='M4 7H20V9H4V7Z'/%3E%3Cpath d='M12 20L8 16H16L12 20Z'/%3E%3C/svg%3E") center / contain no-repeat; - cursor: grab; - transition: color 160ms ease; -} - -.playlist-drag-handle { - color: var(--muted); -} - -.playlist-drag-handle:hover, -.playlist-drag-handle:focus-visible { - color: var(--primary); -} - -html[data-theme='dark'] .playlist-drag-handle { - color: var(--sidebar-text); -} - -html[data-theme='dark'] .playlist-drag-handle:hover, -html[data-theme='dark'] .playlist-drag-handle:focus-visible { - color: var(--primary); -} - -.playlist-drag-handle:active, -tr.is-dragging .playlist-drag-handle { - cursor: grabbing; -} - -tr.sortable-chosen { - opacity: 0.55; -} - -tr.sortable-ghost { - opacity: 0.32; - background: var(--primary-soft); -} - -tr.sortable-drag { - box-shadow: var(--shadow-md); - background: var(--surface); -} - -#playlist-items-body tr.sortable-drag { - display: flex; - align-items: stretch; -} - -#playlist-items-body tr.sortable-drag > td { - display: flex; - flex: 0 0 auto; - align-items: center; -} - -#playlist-items-body tr.sortable-drag .playlist-order-cell-inner { - height: 100%; -} - -#playlist-items-body tr.sortable-drag .playlist-drag-handle { - align-self: center; -} - -@media (min-width: 721px) { -} - -.actions form { - display: inline; -} - -.playlist-edit-actions button { - width: auto; -} - -#playlist-add-slide-button { - margin-top: 12px; -} - -.playlist-edit-header { - align-items: flex-end; -} - -.playlist-name-field { - flex: 1; -} - -.empty { - color: var(--muted); - padding: 12px 0 12px 24px; -} - -.chip { - display: inline-block; - background: #e5e7eb; - color: #374151; - border-radius: 999px; - padding: 4px 10px; - font-size: 12px; - margin-left: 6px; -} - -html[data-theme='dark'] .chip { - background: rgba(148, 163, 184, 0.16); - color: var(--sidebar-text); -} - -.slide-editor-top { - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); - gap: 16px; - align-items: start; - margin-bottom: 16px; -} - -.slide-image-file { - margin-top: 8px; -} - -.slide-preview-card { - display: flex; - flex-direction: column; - gap: 0; - padding: 0; -} - -.slide-preview-header { - margin-bottom: 0; - padding: 16px 16px 8px; -} - -.slide-preview-header h3 { - margin: 0; -} - -.slide-preview-stage { - position: relative; - width: 100%; - aspect-ratio: 16 / 9; - overflow: hidden; - border-radius: 0; - background: linear-gradient(135deg, #111827, #1f2937); -} - -.slide-preview-scroll { - min-height: 0; - padding: 0 16px 16px; - margin-top: 0; -} - -.slide-preview-scroll button { - margin-bottom: 8px; -} - -.slide-preview-background { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; -} - -.slide-preview-empty { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - padding: 20px; - color: #cbd5e1; - background: rgba(17, 24, 39, 0.35); - z-index: 1; -} - -.slide-preview-overlay { - position: absolute; - inset: 0; - z-index: 2; -} - -.slide-preview-region { - position: absolute; - box-sizing: border-box; - padding: 0; - overflow: hidden; - color: #fff; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(15, 23, 42, 0.2); -} - -.slide-preview-region h1, -.slide-preview-region h2, -.slide-preview-region h3, -.slide-preview-region h4, -.slide-preview-region h5, -.slide-preview-region h6, -.slide-preview-region p, -.slide-preview-region blockquote, -.slide-preview-region ul, -.slide-preview-region ol, -.slide-preview-region pre { - margin: 0; -} - -.slide-preview-region ul, -.slide-preview-region ol { - padding-left: 18px; -} - -.slide-preview-region code { - white-space: pre-wrap; -} - -.slide-preview-text-content { - transform-origin: top left; - display: block; -} - -.slide-preview-image-region { - background: rgba(15, 23, 42, 0.25); -} - -.slide-preview-image { - width: 100%; - height: 100%; - object-fit: contain; - display: block; -} - -.slide-preview-webpage-region { - background: rgba(15, 23, 42, 0.35); - overflow: hidden; -} - -.slide-preview-webpage-frame { - width: 100%; - height: 100%; - border: 0; - display: block; - background: #fff; - overflow: hidden; -} - -.slide-preview-html-region { - background: rgba(15, 23, 42, 0.35); - overflow: hidden; -} - -.slide-preview-html-frame { - width: 100%; - height: 100%; - border: 0; - display: block; - background: transparent; - overflow: hidden; -} - -.slide-schedule-dialog { - width: min(900px, calc(100vw - 32px)); - max-width: 900px; - border: none; - border-radius: 12px; - padding: 16px; - background: var(--surface); - color: var(--text); - box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35); -} - -.slide-schedule-dialog::backdrop { - background: rgba(15, 23, 42, 0.55); -} - -.slide-schedule-dialog iframe { - width: 100%; - height: auto; - min-height: 120px; - border: 0; - margin-top: 0; - background: var(--surface); -} - -.slide-schedule-dialog-head { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: center; -} - -.slide-schedule-dialog button { - width: auto; - margin: 0; -.theme-toggle--floating .theme-toggle__label { - display: none; -} -.theme-toggle--floating .theme-toggle__icon { - width: 18px; - height: 18px; -} -} - -.slide-schedule-dialog button.secondary { - box-shadow: none; -} - -.slide-schedule-dialog button.secondary:hover { - filter: brightness(0.92); -} - -.slide-schedule-dialog form { - display: grid; - gap: 16px; -} - -.slide-schedule-dialog .card { - display: grid; - gap: 12px; -} - -.schedule-type-field { - display: block; - margin-bottom: 20px; -} - -.schedule-day-option { - margin-top: 0; - justify-content: flex-start; - width: 100%; - gap: 14px; -} - -.schedule-day-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); - gap: 8px; - margin-top: 16px; -} - -.schedule-day-label { - font-size: 13px; - font-weight: 700; - color: var(--text); - line-height: 1.1; -} - -.schedule-day-option .switch-ui { - flex: none; -} - -.schedule-day-option input:focus-visible + .switch-ui { - outline: 3px solid var(--focus-ring); - outline-offset: 2px; -} - -.schedule-day-option input:checked + .switch-ui { - background: linear-gradient(180deg, var(--primary), var(--primary-dark)); - border-color: rgba(37, 99, 235, 0.55); - box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08), 0 0 0 4px rgba(37, 99, 235, 0.12); -} - -.schedule-save-actions { - margin-top: 16px; -} - -.schedule-save-actions button { - margin-top: 0; -} - -.slide-preview-placeholder { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: #cbd5e1; - font-size: 12px; -} - -.slide-preview-table { - width: 100%; - border-collapse: collapse; - border-spacing: 0; - background: none; -} - -.slide-preview-table tbody tr { - background: none; - border: 0; -} - -.slide-preview-table th, -.slide-preview-table td { - border: 1px solid rgba(255, 255, 255, 0.35); - padding: 6px 8px; - text-align: left; - vertical-align: top; -} - -.slide-preview-table th { - font-weight: 700; -} - -.ckeditor-holder { - min-height: 360px; - background: var(--editor-paper); - color: var(--editor-text); - margin-top: 8px; -} - -html[data-theme='dark'] .ckeditor-holder { - color-scheme: dark; - --editor-paper: #111b2d; - --editor-text: #e6eefb; - --ck-color-base-foreground: #111b2d; - --ck-color-base-background: #111b2d; - --ck-color-base-border: rgba(148, 163, 184, 0.22); - --ck-color-base-text: #e6eefb; - --ck-color-text: #e6eefb; - --ck-color-base-active: #60a5fa; - --ck-color-base-active-focus: #93c5fd; - --ck-color-base-focus: #60a5fa; - --ck-color-focus-border: rgba(96, 165, 250, 0.92); - --ck-color-focus-outer-shadow: rgba(96, 165, 250, 0.28); - --ck-color-toolbar-background: #111b2d; - --ck-color-toolbar-border: rgba(148, 163, 184, 0.2); - --ck-color-dropdown-panel-background: #111b2d; - --ck-color-dropdown-panel-border: rgba(148, 163, 184, 0.22); - --ck-color-panel-background: #111b2d; - --ck-color-panel-border: rgba(148, 163, 184, 0.22); - --ck-color-dialog-background: #111b2d; - --ck-color-dialog-form-header-border: rgba(148, 163, 184, 0.18); - --ck-color-input-background: #0e1728; - --ck-color-input-border: rgba(148, 163, 184, 0.22); - --ck-color-input-text: #e6eefb; - --ck-color-list-background: #111b2d; - --ck-color-list-button-hover-background: #162235; - --ck-color-list-button-on-background: #1b2a41; - --ck-color-list-button-on-background-focus: #22324a; - --ck-color-list-button-on-text: #e6eefb; - --ck-color-button-default-hover-background: #162235; - --ck-color-button-default-active-background: #1b2a41; - --ck-color-button-on-background: #1b2a41; - --ck-color-button-on-hover-background: #22324a; - --ck-color-button-on-active-background: #22324a; - --ck-color-button-on-color: #93c5fd; - --ck-color-button-on-disabled-background: #162235; - --ck-color-button-action-background: #2563eb; - --ck-color-button-action-hover-background: #1d4ed8; - --ck-color-button-action-active-background: #1d4ed8; - --ck-color-button-action-disabled-background: #3b82f6; - --ck-color-button-action-text: #ffffff; - --ck-color-switch-button-off-background: #475569; - --ck-color-switch-button-off-hover-background: #64748b; - --ck-color-switch-button-inner-background: #111b2d; - --ck-color-switch-button-inner-shadow: rgba(2, 6, 23, 0.45); - --ck-color-engine-placeholder-text: #94a3b8; - --ck-powered-by-background: #111b2d; - --ck-powered-by-text-color: #cbd5e1; - --ck-evaluation-badge-background: #111b2d; - --ck-evaluation-badge-text-color: #cbd5e1; -} - -.ckeditor-holder .ckeditor-source { - display: none; -} - -.ckeditor-holder .ck-editor, -.ckeditor-holder .ck-editor__main, -.ckeditor-holder .ck-editor__editable, -.ckeditor-holder .ck-content, -.ckeditor-holder .ck-editor__editable * { - color: var(--editor-text); -} - -.ckeditor-holder .ck button, -.ckeditor-holder .ck button:hover, -.ckeditor-holder .ck button:active, -.ckeditor-holder .ck button:focus, -.ckeditor-holder .ck button:focus-visible { - - box-shadow: none; -} - -.ckeditor-holder .ck-font-size-input, -.ckeditor-holder .ck-font-size-input.ck-input-text, -.ckeditor-holder .ck-font-size-input.ck-input-number, -.ckeditor-holder .ck-font-size-input .ck-input__field, -.ckeditor-holder .ck-font-size-input input { - width: 72px !important; - min-width: 72px !important; - max-width: 72px !important; - flex: 0 0 72px !important; - box-sizing: border-box; -} - -.ckeditor-holder .ck-font-size-input, -.ckeditor-holder .ck-font-size-input .ck-input__field, -.ckeditor-holder .ck-font-size-input input { - text-align: center; -} - -.ckeditor-holder .ck-font-size-input input::-webkit-outer-spin-button, -.ckeditor-holder .ck-font-size-input input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -.ckeditor-holder .ck-font-size-input input[type='number'] { - appearance: textfield; - -moz-appearance: textfield; -} - -.ckeditor-holder .ck-content { - font-family: Arial, Helvetica, sans-serif; - font-size: 16px; - line-height: 1.5; - letter-spacing: normal; - text-transform: none; -} - -.ckeditor-holder .ck-editor__editable { - caret-color: var(--editor-text); -} - -.ckeditor-holder .ck-editor__editable.ck-focused { - border-color: var(--primary); - box-shadow: none; -} - -.ckeditor-holder .ck-editor__editable:not(.ck-editor__nested-editable) { - min-height: 360px; -} - -.ckeditor-holder .ck-placeholder:before { - color: rgba(0, 0, 0, 0.45); -} - -@media (max-width: 1100px) { - .slide-editor-top { - grid-template-columns: 1fr; - } -} - -/* Template designer pages. */ -.template-designer-layout { - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); - gap: 12px; - align-items: start; -} - -.template-designer-main { - display: grid; - gap: 8px; -} - -.template-meta-row { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); - gap: 12px; - align-items: stretch; -} - -.template-meta-row > label { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 0; -} - -.template-background-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: center; -} - -.template-background-row input[type="file"] { - flex: 1 1 auto; - min-width: 0; -} - -.template-background-row button { - flex: 0 0 auto; - white-space: nowrap; -} - -.template-designer-sidebar { - display: grid; - gap: 8px; -} - -.template-designer-actions { - display: flex; - flex-wrap: nowrap; - gap: 8px; - margin: 0 0 8px 0; -} - -#template-form .template-designer-actions button { - flex: 1 1 0; - width: auto; - margin-top: 0; -} - -.region-select { - width: 100%; - margin-bottom: 8px; -} - -#canvas-size-select:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -#canvas-size-summary.is-locked { - color: #94a3b8; -} - -.designer-stage { - position: relative; - width: 100%; - background: #0f172a; - border-radius: 0; - overflow: hidden; - border: 1px solid #1f2937; - cursor: crosshair; - user-select: none; - box-sizing: border-box; -} - -.designer-stage img { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: fill; - pointer-events: none; -} - -.designer-stage .empty-bg { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - color: #94a3b8; - font-size: 14px; - pointer-events: none; -} - -.designer-overlay { - position: absolute; - inset: 0; -} - -.designer-rect { - position: absolute; - border: 2px solid #22c55e; - background: rgba(34, 197, 94, 0.14); - color: #fff; - font-size: 12px; - overflow: hidden; - box-sizing: border-box; -} - -.designer-rect.selected { - border-color: #f59e0b; - background: rgba(245, 158, 11, 0.18); -} - -.designer-rect-label { - position: absolute; - left: 12px; - top: 0; - padding: 2px 6px; - background: rgba(15, 23, 42, 0.8); - font-size: 11px; - pointer-events: none; -} - -.designer-rect .resize-handle { - position: absolute; - width: 24px; - height: 24px; - background: var(--surface); - border: 1px solid var(--border-strong); - box-sizing: border-box; -} - -.designer-rect .resize-handle.nw { - left: -13px; - top: -13px; - cursor: nwse-resize; -} - -.designer-rect .resize-handle.ne { - right: -13px; - top: -13px; - cursor: nesw-resize; -} - -.designer-rect .resize-handle.sw { - left: -13px; - bottom: -13px; - cursor: nesw-resize; -} - -.designer-rect .resize-handle.se { - right: -13px; - bottom: -13px; - cursor: nwse-resize; -} - -.designer-draft { - border-style: dashed; - background: rgba(59, 130, 246, 0.12); - border-color: #60a5fa; -} - -.region-list { - display: grid; - gap: 8px; -} - -#template-form { - display: grid; -} - -#template-form label { - margin-top: 8px; -} - -#template-form input, -#template-form textarea, -#template-form select, -#template-form button { - margin-top: 4px; -} - -#template-form .card { - padding: 12px; -} - -.template-field-card + .template-field-card { - margin-top: 12px; -} - -.template-designer-sidebar .row.template-designer-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.template-save-button { - margin-bottom: 0; -} - -@media (max-width: 720px) { - .template-designer-sidebar .row.template-designer-actions { - grid-template-columns: 1fr; - } -} - -.image-filename { - display: block; - margin-top: 8px; -} - -.region-item { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 12px; - padding: 8px; - display: grid; - gap: 8px; -} - -.region-item.selected { - border-color: #f59e0b; - box-shadow: 0 0 0 1px #f59e0b inset; -} - -.region-item .row { - align-items: end; - gap: 6px; -} - -.region-item[hidden] { - display: none !important; -} - diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css new file mode 100644 index 0000000..b695e6c --- /dev/null +++ b/src/web/public/css/theme-custom.css @@ -0,0 +1,1251 @@ +.status-dot { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 0.75rem; + height: 0.75rem; + border-radius: 999px; + flex: 0 0 auto; +} + +.status-dot::after { + content: ''; + position: absolute; + inset: -0.28rem; + border-radius: inherit; + opacity: 0; + transform: scale(0.7); +} + +.status-dot--online { + background: #28a745; + box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.2); +} + +.status-dot--online::after { + background: rgba(40, 167, 69, 0.32); + animation: status-dot-pulse 1.8s ease-out infinite; +} + +.status-dot--offline { + background: #dc3545; + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.2); +} + +.status-dot--unknown { + background: #adb5bd; + box-shadow: 0 0 0 0.2rem rgba(173, 181, 189, 0.18); +} + +.status-dot--unknown::after { + background: rgba(173, 181, 189, 0.26); + animation: status-dot-pulse 1.8s ease-out infinite; +} + +@keyframes status-dot-pulse { + 0% { + opacity: 0.55; + transform: scale(0.7); + } + + 70% { + opacity: 0; + transform: scale(1.75); + } + + 100% { + opacity: 0; + transform: scale(1.75); + } +} + +.user-menu-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 999px; + background: linear-gradient(135deg, #0d6efd, #6610f2); + color: #fff; + font-size: 0.875rem; + font-weight: 700; + flex: 0 0 auto; +} + +.user-menu-avatar--lg { + width: 3rem; + height: 3rem; + font-size: 1rem; +} + +.user-menu-name { + line-height: 1; +} + +.user-menu-dropdown .user-header { + padding: 1rem; + text-align: center; +} + +.user-menu-dropdown .user-body { + padding: 0.75rem 1rem; +} + +.user-menu-dropdown .user-body a { + color: inherit; + text-decoration: none; +} + +.user-menu-dropdown .user-header p { + margin: 0.75rem 0 0; + line-height: 1.2; +} + +.user-menu-dropdown .user-header small { + display: block; + margin-top: 0.25rem; + opacity: 0.85; +} + +.user-menu-dropdown .user-footer { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem 1rem; +} + +.user-menu-dropdown .user-footer .btn { + flex: 1 1 0; +} + +.app-header .navbar-nav { + --bs-navbar-nav-link-padding-x: 10px; +} + +.sidebar-brand .brand-text.fw-light { + margin: 0; +} + +.card { + box-shadow: none; +} + +.admin-form-card { + box-shadow: none; +} + +.admin-form-card .card-header { + background: var(--bs-tertiary-bg); +} + +.dashboard-hero { + overflow: hidden; + background: linear-gradient(135deg, rgba(13, 110, 253, 0.08), rgba(32, 201, 151, 0.08)); +} + +.dashboard-hero-body { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; +} + +.dashboard-hero-kicker { + display: inline-flex; + align-items: center; + padding: 0.3rem 0.65rem; + border-radius: 999px; + background: var(--bs-body-bg); + color: var(--bs-primary); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.dashboard-hero-title { + margin: 0.75rem 0 0; + font-size: clamp(1.4rem, 1.1rem + 1vw, 2.2rem); + line-height: 1.08; + max-width: 28rem; +} + +.dashboard-hero-copy { + margin: 0.75rem 0 0; + max-width: 42rem; + color: var(--bs-secondary-color); +} + +.dashboard-hero-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + min-width: min(100%, 22rem); +} + +.dashboard-hero-stat { + display: grid; + gap: 0.15rem; + min-width: 0; + padding: 0.85rem 1rem; + border-radius: 1rem; + background: var(--bs-body-bg); +} + +.dashboard-hero-stat-value { + font-size: 1.5rem; + font-weight: 700; + line-height: 1; +} + +.dashboard-hero-stat-label { + color: var(--bs-secondary-color); + font-size: 0.8rem; + line-height: 1.2; +} + +.dashboard-actions-card .card-header, +.dashboard-table-card .card-header { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.dashboard-card-heading { + display: grid; + gap: 0.2rem; +} + +.dashboard-card-heading .card-title { + float: none; + margin: 0; +} + +.dashboard-card-subtitle { + margin: 0; + color: var(--bs-secondary-color); + font-size: 0.875rem; +} + +.dashboard-action-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.dashboard-action-form { + display: grid; + gap: 0.5rem; + margin: 0; +} + +.dashboard-action-button { + justify-content: center; +} + +.dashboard-action-help { + color: var(--bs-secondary-color); + font-size: 0.875rem; +} + +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + padding: 0 0 0.5rem; +} + +.page-header h2 { + margin: 0; + font-size: clamp(1.45rem, 1.2rem + 0.6vw, 2rem); + line-height: 1.1; +} + +.page-header p { + margin: 0.35rem 0 0; + color: var(--bs-secondary-color); +} + +.page-header > div { + min-width: 0; +} + +.card.card-outline > .card-header { + align-items: center; + min-height: 3.25rem; +} + +.card-body.table-responsive { + border-bottom-left-radius: calc(var(--bs-border-radius) - 1px); + border-bottom-right-radius: calc(var(--bs-border-radius) - 1px); +} + +.card-body.table-responsive > table.table > thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--bs-body-bg); + box-shadow: inset 0 -1px 0 var(--bs-border-color); +} + +.card-body.table-responsive > table.table > tbody > tr:last-child > * { + border-bottom-width: 0; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.625rem; + border-radius: 999px; + background: var(--bs-secondary-bg); + color: var(--bs-secondary-color); + font-size: 0.875rem; + line-height: 1.2; + white-space: nowrap; +} + +.empty { + color: var(--bs-secondary-color); + font-style: italic; +} + +.subtle { + color: var(--bs-secondary-color); +} + +.connection-count, +td[data-label="Name"], +td[data-label="Title"], +td[data-label="Client"], +td[data-label="Current Screen"], +td[data-label="Current Slide"], +td[data-label="IP"], +td[data-label="Viewport"], +td[data-label="Connected/Updated"], +td[data-label="Template"], +td[data-label="Slides"] { + overflow-wrap: anywhere; +} + +.slide-editor-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem); + gap: 1rem; + align-items: start; + position: relative; +} + +.slide-form-stack { + display: grid; + gap: 1rem; + min-width: 0; +} + +.slide-editor-shell .slide-preview-card { + width: 100%; + max-width: 24rem; + justify-self: end; +} + +.slide-editor-shell .slide-preview-card .card-body { + padding: 0.875rem; +} + +.slide-editor-shell .slide-preview-scroll { + max-height: calc(100vh - 8rem); + overflow: auto; +} + +.slide-preview-header h3 { + margin: 0; +} + +.slide-preview-header .muted { + margin-top: 0.25rem; +} + +.slide-form-stack .ck.ck-editor, +.template-field-card .ck.ck-editor { + --ck-custom-background: var(--bs-body-bg); + --ck-custom-foreground: var(--bs-body-color); + --ck-custom-border: var(--bs-border-color); + --ck-color-base-foreground: var(--bs-tertiary-bg); + --ck-color-base-background: var(--bs-body-bg); + --ck-color-base-border: var(--bs-border-color); + --ck-color-base-text: var(--bs-body-color); + --ck-color-base-action: var(--bs-primary); + --ck-color-base-focus: var(--bs-primary); + --ck-color-base-active: var(--bs-primary); + --ck-color-base-active-focus: var(--bs-primary); + --ck-color-base-error: var(--bs-danger); + --ck-color-focus-border: rgba(var(--bs-primary-rgb), 1); + --ck-color-focus-outer-shadow: rgba(var(--bs-primary-rgb), 0.18); + --ck-color-focus-disabled-shadow: rgba(var(--bs-secondary-rgb), 0.14); + --ck-color-focus-error-shadow: rgba(var(--bs-danger-rgb), 0.18); + --ck-color-shadow-drop: rgba(0, 0, 0, 0.16); + --ck-color-shadow-drop-active: rgba(0, 0, 0, 0.22); + --ck-color-shadow-inner: rgba(0, 0, 0, 0.08); + --ck-color-button-default-background: transparent; + --ck-color-button-default-hover-background: var(--bs-secondary-bg); + --ck-color-button-default-active-background: var(--bs-secondary-bg); + --ck-color-button-default-disabled-background: transparent; + --ck-color-button-on-background: rgba(var(--bs-primary-rgb), 0.12); + --ck-color-button-on-hover-background: rgba(var(--bs-primary-rgb), 0.16); + --ck-color-button-on-active-background: rgba(var(--bs-primary-rgb), 0.16); + --ck-color-button-on-disabled-background: var(--bs-secondary-bg); + --ck-color-button-on-color: var(--bs-primary); + --ck-color-button-action-background: var(--bs-primary); + --ck-color-button-action-hover-background: var(--bs-primary); + --ck-color-button-action-active-background: var(--bs-primary); + --ck-color-button-action-disabled-background: var(--bs-primary); + --ck-color-button-action-text: var(--bs-body-bg); + --ck-color-switch-button-off-background: var(--bs-secondary-color); + --ck-color-switch-button-off-hover-background: var(--bs-secondary-color); + --ck-color-switch-button-on-background: var(--bs-primary); + --ck-color-switch-button-on-hover-background: var(--bs-primary); + --ck-color-switch-button-inner-background: var(--bs-body-bg); + --ck-color-switch-button-inner-shadow: rgba(0, 0, 0, 0.08); + --ck-color-dropdown-panel-background: var(--bs-body-bg); + --ck-color-dropdown-panel-border: var(--bs-border-color); + --ck-color-dialog-background: var(--bs-body-bg); + --ck-color-dialog-form-header-border: var(--bs-border-color); + --ck-color-list-button-on-background: rgba(var(--bs-primary-rgb), 0.12); + --ck-color-list-button-on-text: var(--bs-primary); + --ck-color-input-background: var(--bs-body-bg); + --ck-color-input-border: var(--bs-border-color); + --ck-color-input-text: var(--bs-body-color); + --ck-color-input-disabled-background: var(--bs-secondary-bg); + --ck-color-input-disabled-border: var(--bs-border-color); + --ck-color-input-disabled-text: var(--bs-secondary-color); + --ck-color-toolbar-background: var(--bs-tertiary-bg); + --ck-color-toolbar-border: var(--bs-border-color); + --ck-color-toolbar-box-shadow: none; + --ck-color-image-caption-background: var(--bs-secondary-bg); + --ck-color-image-caption-text: var(--bs-secondary-color); + --ck-color-resizer: var(--bs-primary); + --ck-color-resizer-tooltip-background: var(--bs-dark); + --ck-color-resizer-tooltip-text: var(--bs-light); + border-radius: var(--bs-border-radius); + overflow: hidden; + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); +} + +.slide-form-stack .ck.ck-editor__top .ck-sticky-panel__content, +.template-field-card .ck.ck-editor__top .ck-sticky-panel__content { + border-bottom: 1px solid var(--bs-border-color); +} + +.slide-form-stack .ck.ck-toolbar, +.template-field-card .ck.ck-toolbar { + background: var(--bs-tertiary-bg); + border: 0; + border-radius: 0; +} + +.slide-form-stack .ck.ck-toolbar .ck-toolbar__items, +.template-field-card .ck.ck-toolbar .ck-toolbar__items { + gap: 0.15rem; +} + +.slide-form-stack .ck.ck-toolbar .ck-button, +.slide-form-stack .ck.ck-toolbar .ck-dropdown__button, +.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action, +.template-field-card .ck.ck-toolbar .ck-button, +.template-field-card .ck.ck-toolbar .ck-dropdown__button, +.template-field-card .ck.ck-toolbar .ck-splitbutton__action { + border-radius: 0.5rem; + color: var(--bs-body-color); +} + +.slide-form-stack .ck.ck-toolbar .ck-button:hover, +.slide-form-stack .ck.ck-toolbar .ck-dropdown__button:hover, +.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action:hover, +.template-field-card .ck.ck-toolbar .ck-button:hover, +.template-field-card .ck.ck-toolbar .ck-dropdown__button:hover, +.template-field-card .ck.ck-toolbar .ck-splitbutton__action:hover { + background: var(--bs-secondary-bg); +} + +.slide-form-stack .ck.ck-toolbar .ck-button.ck-on, +.slide-form-stack .ck.ck-toolbar .ck-dropdown__button.ck-on, +.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action.ck-on, +.template-field-card .ck.ck-toolbar .ck-button.ck-on, +.template-field-card .ck.ck-toolbar .ck-dropdown__button.ck-on, +.template-field-card .ck.ck-toolbar .ck-splitbutton__action.ck-on { + background: rgba(var(--bs-primary-rgb), 0.12); + color: var(--bs-primary); +} + +.slide-form-stack .ck.ck-toolbar .ck-button.ck-disabled, +.slide-form-stack .ck.ck-toolbar .ck-dropdown__button.ck-disabled, +.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action.ck-disabled, +.template-field-card .ck.ck-toolbar .ck-button.ck-disabled, +.template-field-card .ck.ck-toolbar .ck-dropdown__button.ck-disabled, +.template-field-card .ck.ck-toolbar .ck-splitbutton__action.ck-disabled { + color: var(--bs-secondary-color); +} + +.slide-form-stack .ck.ck-dropdown__panel, +.slide-form-stack .ck.ck-list__panel, +.slide-form-stack .ck.ck-balloon-panel, +.template-field-card .ck.ck-dropdown__panel, +.template-field-card .ck.ck-list__panel, +.template-field-card .ck.ck-balloon-panel { + border-color: var(--bs-border-color); + box-shadow: var(--bs-box-shadow); +} + +.slide-form-stack .ck.ck-list__item .ck-button, +.template-field-card .ck.ck-list__item .ck-button { + color: var(--bs-body-color); +} + +.slide-form-stack .ck.ck-list__item .ck-button:hover, +.template-field-card .ck.ck-list__item .ck-button:hover { + background: var(--bs-secondary-bg); +} + +.slide-form-stack .ck.ck-dropdown__panel, +.slide-form-stack .ck.ck-list__panel, +.slide-form-stack .ck.ck-balloon-panel, +.slide-form-stack .ck.ck-toolbar .ck-dropdown__panel, +.template-field-card .ck.ck-dropdown__panel, +.template-field-card .ck.ck-list__panel, +.template-field-card .ck.ck-balloon-panel, +.template-field-card .ck.ck-toolbar .ck-dropdown__panel { + background: var(--bs-body-bg); +} + +.slide-form-stack .ck.ck-input-text, +.slide-form-stack .ck.ck-list__item-text, +.slide-form-stack .ck.ck-list__item-label, +.slide-form-stack .ck.ck-dropdown__panel .ck-button__label, +.template-field-card .ck.ck-input-text, +.template-field-card .ck.ck-list__item-text, +.template-field-card .ck.ck-list__item-label, +.template-field-card .ck.ck-dropdown__panel .ck-button__label { + color: var(--bs-body-color); +} + +.slide-form-stack .ck.ck-input-text, +.template-field-card .ck.ck-input-text { + background: var(--bs-body-bg); + border-color: var(--bs-border-color); +} + +.slide-form-stack .ck.ck-input-text:focus, +.template-field-card .ck.ck-input-text:focus { + border-color: var(--bs-primary); + box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.18); +} + +.slide-form-stack .ck.ck-editor__main > .ck-editor__editable, +.slide-form-stack .ck.ck-editor__editable_inline, +.template-field-card .ck.ck-editor__main > .ck-editor__editable, +.template-field-card .ck.ck-editor__editable_inline { + background: var(--bs-body-bg); + color: var(--bs-body-color); + border: 0; +} + +.slide-form-stack .ck.ck-editor__main > .ck-editor__editable.ck-focused, +.slide-form-stack .ck.ck-editor__editable_inline.ck-focused, +.template-field-card .ck.ck-editor__main > .ck-editor__editable.ck-focused, +.template-field-card .ck.ck-editor__editable_inline.ck-focused { + box-shadow: inset 0 0 0 1px var(--bs-primary), 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.18); +} + +.slide-form-stack .ck-content, +.template-field-card .ck-content { + font-family: inherit; + font-size: 1rem; + line-height: 1.55; +} + +.slide-form-stack .ck-content h1, +.slide-form-stack .ck-content h2, +.slide-form-stack .ck-content h3, +.slide-form-stack .ck-content h4, +.slide-form-stack .ck-content h5, +.slide-form-stack .ck-content h6, +.template-field-card .ck-content h1, +.template-field-card .ck-content h2, +.template-field-card .ck-content h3, +.template-field-card .ck-content h4, +.template-field-card .ck-content h5, +.template-field-card .ck-content h6 { + color: var(--bs-body-color); +} + +.slide-form-stack .ck-content p, +.template-field-card .ck-content p { + margin-bottom: 0.75rem; +} + + +.slide-preview-dimensions-chip { + display: inline-flex; + align-items: center; + margin-left: auto; + flex-shrink: 0; + padding: 0.2rem 0.5rem; + border-radius: 999px; + background: var(--bs-secondary-bg); + color: var(--bs-secondary-color); + font-size: 0.75rem; + line-height: 1.2; +} + +.slide-preview-stage { + position: relative; + width: 100%; + overflow: hidden; + border-radius: 0; + background: #111; +} + +.slide-preview-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: block; + transform-origin: top left; + will-change: transform; +} + +.slide-preview-background, +.slide-preview-overlay, +.slide-preview-empty { + position: absolute; + inset: 0; +} + +.slide-preview-region { + position: absolute; + overflow: hidden; + box-sizing: border-box; +} + +.slide-preview-image { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.slide-preview-background { + width: 100%; + height: 100%; + object-fit: contain; +} + +.slide-preview-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + color: rgba(255, 255, 255, 0.75); + text-align: center; + background: linear-gradient(135deg, rgba(13, 110, 253, 0.15), rgba(255, 255, 255, 0.04)); +} + +.slide-preview-overlay { + z-index: 2; +} + +.slide-preview-background { + z-index: 1; +} + +.slide-preview-empty { + z-index: 0; +} + +.template-designer-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 20rem; + gap: 1rem; + align-items: start; +} + +.template-designer-main, +.template-designer-sidebar { + min-width: 0; +} + +.designer-stage { + position: relative; + width: 100%; + min-height: 18rem; + overflow: hidden; + border: 1px solid var(--bs-border-color); + border-radius: 0; + background: var(--bs-body-bg); +} + +.designer-stage > img, +.designer-stage > .empty-bg, +.designer-stage > .designer-overlay { + position: absolute; + inset: 0; +} + +.designer-stage > img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.designer-stage > .empty-bg { + display: flex; + align-items: center; + justify-content: center; + color: var(--bs-secondary-color); + background: linear-gradient(135deg, rgba(13, 110, 253, 0.08), rgba(108, 117, 125, 0.05)); +} + +.designer-overlay { + z-index: 2; + cursor: crosshair; +} + +.designer-rect { + position: absolute; + border: 2px solid rgba(13, 110, 253, 0.95); + background: rgba(13, 110, 253, 0.12); + box-sizing: border-box; + border-radius: 0.35rem; + min-width: 12px; + min-height: 12px; +} + +.designer-rect.selected { + border-color: rgba(25, 135, 84, 1); + background: rgba(25, 135, 84, 0.14); +} + +.designer-rect-label { + position: absolute; + left: 0; + top: -1.55rem; + max-width: 100%; + padding: 0.15rem 0.45rem; + border-radius: 999px; + background: rgba(33, 37, 41, 0.88); + color: #fff; + font-size: 0.75rem; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.designer-rect.selected .designer-rect-label { + background: rgba(25, 135, 84, 0.95); +} + +.resize-handle { + position: absolute; + width: 0.8rem; + height: 0.8rem; + border-radius: 999px; + border: 2px solid #fff; + background: rgba(13, 110, 253, 1); + box-shadow: 0 0 0 1px rgba(13, 110, 253, 0.35); +} + +.resize-handle.nw { + left: -0.4rem; + top: -0.4rem; + cursor: nwse-resize; +} + +.resize-handle.ne { + right: -0.4rem; + top: -0.4rem; + cursor: nesw-resize; +} + +.resize-handle.sw { + left: -0.4rem; + bottom: -0.4rem; + cursor: nesw-resize; +} + +.resize-handle.se { + right: -0.4rem; + bottom: -0.4rem; + cursor: nwse-resize; +} + +.designer-draft { + border-style: dashed; + background: rgba(13, 110, 253, 0.08); +} + +.template-designer-sidebar { + position: sticky; + top: 1rem; + display: grid; + gap: 1rem; +} + +.template-designer-sidebar .card-body.d-grid { + padding: 1rem; +} + +.template-designer-sidebar .btn-group { + width: 100%; +} + +.template-actions-group { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: 0; + width: 100%; +} + +.template-actions-group > * { + width: 100%; +} + +.template-actions-group > .btn-warning, +.template-actions-group > .btn-danger, +.playlist-actions-group > .btn-warning, +.playlist-actions-group > .btn-danger, +.screen-actions-group > .btn-warning, +.screen-actions-group > .btn-danger { + flex: 1 1 0; +} + +.template-actions-group > .btn-warning, +.template-actions-group > .btn-danger, +.playlist-actions-group > .btn-warning, +.playlist-actions-group > .btn-danger, +.screen-actions-group > .btn-warning, +.screen-actions-group > .btn-danger { + text-align: center; +} + +.save-action-group > .dropdown-toggle-split { + flex: 0 0 2.35rem; + width: 2.35rem; + min-width: 2.35rem; + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.save-action-group > .dropdown-toggle-split::after { + margin-left: 0; +} + +.slide-actions-group .save-action-group > .dropdown-toggle-split { + flex-basis: 3rem; + width: 3rem; + min-width: 3rem; +} + +.template-designer-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.template-designer-actions .btn { + width: 100%; +} + +.region-info-card .card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.region-info-card .card-title { + margin-bottom: 0.15rem; +} + +.region-info-card .card-subtitle { + margin-bottom: 0; +} + +.region-selector-block .form-label { + font-weight: 600; +} + +.region-list { + display: grid; + gap: 0.75rem; +} + +.region-item { + overflow: hidden; +} + +.region-item label { + display: grid; + gap: 0.35rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--bs-body-color); +} + +.region-item input, +.region-item select { + width: 100%; +} + +.region-field-grid { + display: grid; + width: 100%; + gap: 0.75rem; +} + +.region-field-grid--identity { + grid-template-columns: minmax(0, 1fr); +} + +.region-field-grid--position { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.region-field-grid--size { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.region-field-grid > label { + display: grid; + gap: 0.35rem; + min-width: 0; +} + +.region-field-grid > label > .form-control, +.region-field-grid > label > .form-select { + min-width: 0; +} + +.template-field-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + min-height: 3.25rem; +} + +.template-field-actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +.template-field-head strong { + font-size: 0.95rem; +} + +.template-field-head .chip { + display: inline-flex; + align-items: center; + margin-left: auto; + flex-shrink: 0; + padding: 0.2rem 0.5rem; + border-radius: 999px; + background: var(--bs-secondary-bg); + color: var(--bs-secondary-color); + font-size: 0.75rem; + line-height: 1.2; +} + +.template-field-card { + overflow: hidden; +} + +.template-field-card .card-header, +.region-item .card-header { + background: var(--bs-tertiary-bg); + border-bottom: 1px solid var(--bs-border-color-translucent); +} + +.template-field-card .card-body, +.region-item .card-body { + background: var(--bs-body-bg); +} + +.template-field-card label, +.region-item label { + margin-bottom: 0; +} + +.template-field-card .form-control, +.template-field-card .form-select, +.template-field-card textarea, +.template-field-card input[type='file'], +.region-item .form-control, +.region-item .form-select, +.region-item input[type='number'], +.region-item input[type='text'] { + width: 100%; +} + +.template-field-card .ckeditor-holder { + min-width: 0; +} + +.template-field-card .ck-editor__editable_inline { + min-height: 18rem; +} + +.template-field-card.is-expanded .ck-editor__editable_inline { + min-height: 32rem; +} + +.template-field-card.is-expanded .card-body { + min-height: 36rem; +} + +.template-field-card .slide-image-file, +.region-item .slide-image-file { + margin: 0; +} + +.region-item .remove-region { + justify-self: end; +} + +.region-item .form-control:focus, +.region-item .form-select:focus, +.region-item input[type='number']:focus, +.region-item input[type='text']:focus { + border-color: var(--bs-primary); + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15); +} + +@media (max-width: 575.98px) { + .region-info-card .card-header { + align-items: stretch; + flex-direction: column; + } + + .region-field-grid--identity, + .region-field-grid--position, + .region-field-grid--size { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1199.98px) { + .template-designer-layout { + grid-template-columns: minmax(0, 1fr); + } + + .template-designer-sidebar, + .slide-editor-shell, + .slide-editor-shell .slide-preview-card { + position: static; + } + + .slide-editor-shell { + grid-template-columns: minmax(0, 1fr); + } + + .slide-editor-shell .slide-preview-card { + max-width: none; + justify-self: stretch; + } + + .template-designer-actions { + grid-template-columns: 1fr; + } +} + +.frame-popup-card-body { + margin: 0; + padding: 0; +} + +.frame-popup-card-body .slide-schedule-card { + width: min(100%, 40rem); + margin: 0 auto; +} + +.toast-container { + z-index: 1085; +} + +.is-hidden { + display: none !important; +} + +.playlist-add-slide-form { + display: flex; +} + +.playlist-add-slide-form.is-hidden, +.playlist-add-slide-empty.is-hidden, +.schedule-panel.is-hidden { + display: none !important; +} + +.schedule-day-grid .schedule-day-button { + flex: 1 1 0; +} + +.slide-schedule-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border: 0; + padding: 0; + background: transparent; + max-width: none; + overflow: hidden; + width: min(calc(100vw - 1rem), 42rem); + max-height: calc(100vh - 1rem); + height: auto; + margin: 0; +} + +.slide-schedule-dialog::backdrop { + background: rgba(0, 0, 0, 0.35); +} + +.slide-schedule-content { + width: 100%; +} + +.actions, +.playlist-item-actions, +.users-row-actions { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 0.5rem; + white-space: nowrap; +} + +td[data-label="Actions"] { + text-align: right; +} + +td[data-label="Actions"] > div { + justify-content: flex-end; +} + +.actions .inline-form, +.playlist-item-actions .inline-form, +.users-row-actions .inline-form { + margin: 0; + flex: 0 0 auto; +} + +table.table > :not(caption) > * > * { + vertical-align: middle; +} + +.playlist-order-cell { + width: 1%; + white-space: nowrap; + padding-bottom: 0 !important; +} + +.playlist-order-cell-inner { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.playlist-drag-handle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.35rem; + height: 1.9rem; + line-height: 1; + cursor: grab; + text-decoration: none; +} + +.playlist-drag-handle-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.1rem; + height: 1.9rem; +} + +.playlist-drag-handle-svg { + display: block; + width: 100%; + height: 100%; + fill: currentColor; +} + +.playlist-drag-handle:active, +.playlist-drag-handle:hover, +.playlist-drag-handle:focus { + text-decoration: none; +} + +.playlist-drag-handle:active { + cursor: grabbing; +} + +table.table thead th.sortable { + cursor: pointer; + white-space: nowrap; +} + +table.table thead th.sortable .table-sort-indicator { + font-size: 0.8em; + opacity: 0.55; +} + +table.table thead th.sort-asc .table-sort-indicator, +table.table thead th.sort-desc .table-sort-indicator { + opacity: 1; +} + +.navbar-nav>.user-menu>.dropdown-menu>li.user-header { + min-height: 100px; +} + +@media (max-width: 767.98px) { + .dashboard-hero-body { + flex-direction: column; + align-items: stretch; + } + + .dashboard-hero-stats { + grid-template-columns: 1fr; + min-width: 0; + } + + .dashboard-action-grid { + grid-template-columns: 1fr; + } + + .dashboard-hero-body { + flex-direction: column; + align-items: stretch; + } + + .dashboard-hero-stats { + grid-template-columns: 1fr; + min-width: 0; + } + + .page-header { + align-items: flex-start; + flex-direction: column; + } + + .card-body.table-responsive > table.table > thead th { + position: static; + } +} \ No newline at end of file diff --git a/src/web/public/js/admin-page.js b/src/web/public/js/admin-page.js index 23a54a7..7ddbb58 100644 --- a/src/web/public/js/admin-page.js +++ b/src/web/public/js/admin-page.js @@ -31,299 +31,55 @@ return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim(); } - function renderClientActionCell(client) { - var paused = Boolean(client.paused); - var pauseButtonClass = 'button-link list-action' + (paused ? ' is-paused' : ''); - var pauseButtonLabel = paused ? 'Resume' : 'Pause'; - var blackout = Boolean(client.blackout); - var blackoutButtonClass = 'button-link list-action' + (blackout ? ' is-blackout' : ''); - var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout'; - var reloadConfirmMessage = 'Reloading will restart the player page. Continue?'; - var blackoutCommandValue = blackout ? 'false' : 'true'; - - return '
    '; + function getClientDisplayName(client) { + if (client && client.client_name) { + return String(client.client_name).trim(); + } + var clientId = String(client && client.clientId ? client.clientId : '').trim(); + if (clientId) { + return clientId; + } + return ''; } - function updateClientActionCell(cell, client) { - if (!cell) { + function setButtonVariant(button, classesToRemove, classToAdd) { + if (!button) { return; } - var pauseButton = cell.querySelector('button[data-action="pause"]'); - if (!pauseButton) { - cell.innerHTML = renderClientActionCell(client); + if (button.classList) { + classesToRemove.forEach(function (className) { + button.classList.remove(className); + }); + if (classToAdd) { + button.classList.add(classToAdd); + } return; } - var paused = Boolean(client.paused); - pauseButton.className = 'button-link list-action' + (paused ? ' is-paused' : ''); - pauseButton.textContent = paused ? 'Resume' : 'Pause'; - - var pauseForm = pauseButton.form; - if (pauseForm) { - var commandInput = pauseForm.querySelector('input[name="command"]'); - if (commandInput) { - commandInput.value = 'pause'; - } - var connectionInput = pauseForm.querySelector('input[name="connectionId"]'); - if (connectionInput) { - connectionInput.value = client.id || ''; - } - pauseForm.action = '/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands'; - } - - var reloadButton = cell.querySelector('button[data-action="reload"]'); - if (reloadButton) { - reloadButton.textContent = 'Reload'; - reloadButton.className = 'button-link list-action is-danger'; - var reloadForm = reloadButton.form; - if (reloadForm) { - var reloadInput = reloadForm.querySelector('input[name="connectionId"]'); - if (reloadInput) { - reloadInput.value = client.id || ''; - } - reloadForm.action = '/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands'; - reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?'); - } - } - - var blackoutButton = cell.querySelector('button[data-action="blackout"]'); - if (!blackoutButton) { - cell.innerHTML = renderClientActionCell(client); - return; - } - - var blackout = Boolean(client.blackout); - blackoutButton.className = 'button-link list-action' + (blackout ? ' is-blackout' : ''); - blackoutButton.textContent = blackout ? 'Restore' : 'Blackout'; - - var blackoutForm = blackoutButton.form; - if (blackoutForm) { - var blackoutCommandInput = blackoutForm.querySelector('input[name="command"]'); - if (blackoutCommandInput) { - blackoutCommandInput.value = 'blackout'; - } - var blackoutStateInput = blackoutForm.querySelector('input[name="blackout"]'); - if (blackoutStateInput) { - blackoutStateInput.value = blackout ? 'false' : 'true'; - } - var blackoutConnectionInput = blackoutForm.querySelector('input[name="connectionId"]'); - if (blackoutConnectionInput) { - blackoutConnectionInput.value = client.id || ''; - } - blackoutForm.action = '/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands'; - } - - var previousButton = cell.querySelector('button[data-action="previous"]'); - if (!previousButton) { - cell.innerHTML = renderClientActionCell(client); - return; - } - previousButton.className = 'button-link list-action list-action--nav'; - previousButton.setAttribute('aria-label', 'Previous slide'); - var previousForm = previousButton.form; - if (previousForm) { - var previousCommandInput = previousForm.querySelector('input[name="command"]'); - if (previousCommandInput) { - previousCommandInput.value = 'previous'; - } - var previousConnectionInput = previousForm.querySelector('input[name="connectionId"]'); - if (previousConnectionInput) { - previousConnectionInput.value = client.id || ''; - } - previousForm.action = '/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands'; - } - - var nextButton = cell.querySelector('button[data-action="next"]'); - if (!nextButton) { - cell.innerHTML = renderClientActionCell(client); - return; - } - nextButton.className = 'button-link list-action list-action--nav'; - nextButton.setAttribute('aria-label', 'Next slide'); - var nextForm = nextButton.form; - if (nextForm) { - var nextCommandInput = nextForm.querySelector('input[name="command"]'); - if (nextCommandInput) { - nextCommandInput.value = 'next'; - } - var nextConnectionInput = nextForm.querySelector('input[name="connectionId"]'); - if (nextConnectionInput) { - nextConnectionInput.value = client.id || ''; - } - nextForm.action = '/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands'; - } - } - - function renderClientRow(client) { - var connectedAt = client.connectedAt ? '
    ' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '
    ' + (client.lastSeenAt ? '
    ' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '
    ' : '') : 'Unknown'; - var clientIp = client.clientIp ? escapeHtml(client.clientIp) : (client.remoteAddress ? escapeHtml(client.remoteAddress) : 'Unknown'); - var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : 'Unknown'; - var clientId = client.clientId ? escapeHtml(client.clientId) : 'Unknown'; - var connectionId = client.id ? escapeHtml(client.id) : 'Unknown'; - var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : 'No slide currently showing'; - - return [ - '', - '
    ' + clientId + '

    ' + connectionId + '
    ', - '' + clientIp + '', - '' + viewport + '', - '' + connectedAt + '', - '
    ' + escapeHtml(client.screen_name) + '
    Showing: ' + currentSlide + '
    ', - '' + renderClientActionCell(client) + '', - '' - ].join(''); - } - - function renderScreenRow(screen) { - var clientCount = Number(screen.player_connection_count || 0); - return [ - '', - '' + escapeHtml(screen.name) + '', - '' + escapeHtml(screen.player_url || '') + '', - '' + escapeHtml(screen.playlist_name || '') + '', - '' + (clientCount ? '
    ' + clientCount + ' connected
    ' : 'No clients connected.') + '', - '' - ].join(''); - } - - function updateStats(state) { - var clientCount = document.getElementById('dashboard-client-count'); - var screenCount = document.getElementById('dashboard-screen-count'); - var slideCount = document.getElementById('dashboard-slide-count'); - var playlistCount = document.getElementById('dashboard-playlist-count'); - - if (playlistCount && Array.isArray(state.playlists)) { - playlistCount.textContent = String(state.playlists.length); - } - if (slideCount && Array.isArray(state.slides)) { - slideCount.textContent = String(state.slides.length); - } - if (screenCount && Array.isArray(state.screens)) { - screenCount.textContent = String(state.screens.length); - } - if (clientCount) { - clientCount.textContent = String(Number(state.connectedClientsCount || 0)); - } - } - - function updateClientTable(state) { - var tbody = document.getElementById('dashboard-clients-table-body'); - if (!tbody || !Array.isArray(state.clients)) { - return; - } - if (!state.clients.length) { - tbody.innerHTML = 'No connected clients yet.'; - return; - } - var existingRows = {}; - Array.prototype.slice.call(tbody.querySelectorAll('tr[data-client-key]')).forEach(function (row) { - existingRows[row.getAttribute('data-client-key')] = row; + var className = String(button.className || ''); + classesToRemove.forEach(function (removeClass) { + className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' '); }); - - Array.prototype.slice.call(tbody.querySelectorAll('tr')).forEach(function (row) { - if (!row.hasAttribute('data-client-key')) { - row.parentNode.removeChild(row); - } - }); - - state.clients.forEach(function (client, index) { - var rowKey = getClientRowKey(client); - var row = existingRows[rowKey]; - if (!row) { - var tempBody = document.createElement('tbody'); - tempBody.innerHTML = renderClientRow(client); - row = tempBody.firstElementChild; - } - - if (!row) { - return; - } - - row.setAttribute('data-client-key', rowKey); - if (row.cells && row.cells.length >= 6) { - var connectedAt = client.connectedAt ? '
    ' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '
    ' + (client.lastSeenAt ? '
    ' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '
    ' : '') : 'Unknown'; - var clientIp = client.clientIp ? escapeHtml(client.clientIp) : (client.remoteAddress ? escapeHtml(client.remoteAddress) : 'Unknown'); - var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : 'Unknown'; - var clientId = client.clientId ? escapeHtml(client.clientId) : 'Unknown'; - var connectionId = client.id ? escapeHtml(client.id) : 'Unknown'; - var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : 'No slide currently showing'; - - row.cells[0].innerHTML = '
    ' + clientId + '

    ' + connectionId + '
    '; - row.cells[1].innerHTML = clientIp; - row.cells[2].innerHTML = viewport; - row.cells[3].innerHTML = connectedAt; - row.cells[4].innerHTML = '
    ' + escapeHtml(client.screen_name) + '
    Showing: ' + currentSlide + '
    '; - updateClientActionCell(row.cells[5], client); - } - - var referenceNode = tbody.children[index] || null; - if (referenceNode !== row) { - tbody.insertBefore(row, referenceNode); - } - }); - - while (tbody.children.length > state.clients.length) { - tbody.removeChild(tbody.lastElementChild); + if (classToAdd) { + className += ' ' + classToAdd; } - - window.applyTableSort(document.getElementById('dashboard-clients-table')); + button.className = className.replace(/\s+/g, ' ').trim(); } - function updateScreenTable(state) { - var table = document.getElementById('dashboard-screens-table'); - if (!table || !Array.isArray(state.screens)) { - return; + function normalizeDisplayIp(value) { + var ip = String(value || '').trim(); + if (!ip) { + return ''; } - var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null; - if (!tbody) { - return; + + if (ip.toLowerCase().indexOf('::ffff:') === 0) { + return ip.slice(7).trim(); } - if (!state.screens.length) { - tbody.innerHTML = 'No screens yet.'; - return; - } - tbody.innerHTML = state.screens.map(renderScreenRow).join(''); - window.applyTableSort(table); + + return ip; } - function updateDashboardQuickActions(state) { - var blackoutButton = document.getElementById('dashboard-blackout-all-button'); - if (!blackoutButton || !state || !Array.isArray(state.clients)) { - return; - } - - var hasClients = state.clients.length > 0; - var allBlackout = hasClients && state.clients.every(function (client) { - return Boolean(client && client.blackout); - }); - var label = allBlackout ? 'Restore all clients' : 'Blackout all clients'; - var blackoutForm = blackoutButton.form; - var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null; - - blackoutButton.textContent = label; - blackoutButton.className = 'button-link list-action' + (allBlackout ? ' is-blackout' : ''); - if (blackoutInput) { - blackoutInput.value = allBlackout ? 'false' : 'true'; - } - if (blackoutForm) { - blackoutForm.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients?' : 'Blackout all connected clients?'); - } - blackoutButton.setAttribute('aria-label', label); - } - - function handleDashboardState(state) { - if (!state) { - return; - } - updateStats(state); - updateScreenTable(state); - updateClientTable(state); - updateDashboardQuickActions(state); - } - - window.webHandleDashboardState = handleDashboardState; - function initConfirmForms() { document.addEventListener('submit', function (event) { var form = event.target; @@ -340,6 +96,62 @@ }); } + function markFormDirty(form) { + if (!form || !form.hasAttribute || form.hasAttribute('data-clean-on-load')) { + return; + } + form.dataset.dirty = 'true'; + } + + function clearFormDirty(form) { + if (!form) { + return; + } + form.dataset.dirty = 'false'; + } + + function isFormDirty(form) { + return Boolean(form && form.dataset && form.dataset.dirty === 'true'); + } + + function initDirtyTracking() { + document.addEventListener('input', function (event) { + var target = event.target; + if (!target || !target.form) { + return; + } + markFormDirty(target.form); + }, true); + + document.addEventListener('change', function (event) { + var target = event.target; + if (!target || !target.form) { + return; + } + markFormDirty(target.form); + }, true); + } + + function initCancelConfirm() { + document.addEventListener('click', function (event) { + var cancelTarget = event.target.closest('[data-confirm-unsaved]'); + if (!cancelTarget) { + return; + } + + var form = cancelTarget.form || cancelTarget.closest('form') || document.querySelector('form[data-dirty="true"]'); + if (!isFormDirty(form)) { + return; + } + + var message = cancelTarget.getAttribute('data-confirm-unsaved') || 'You have unsaved changes. Leave this page?'; + if (!window.confirm(message)) { + event.preventDefault(); + event.stopPropagation(); + } + }, true); + } + function initAsyncCommandForms() { document.addEventListener('submit', function (event) { var form = event.target; @@ -384,6 +196,42 @@ } function initAsyncSaveForms() { + function setSaveActionValue(form, value) { + if (!form) { + return; + } + + var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]'); + if (!hiddenInput) { + hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = 'save_action'; + form.appendChild(hiddenInput); + } + + hiddenInput.value = String(value || '').trim().toLowerCase(); + } + + document.addEventListener('click', function (event) { + var target = event.target; + if (!target || !target.closest) { + return; + } + + var button = target.closest('button[name="save_action"]'); + if (!button) { + return; + } + + var form = button.form || button.closest('form'); + if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) { + return; + } + + setSaveActionValue(form, button.value || ''); + form.dataset.submitterValue = String(button.value || '').trim().toLowerCase(); + }, true); + document.addEventListener('submit', function (event) { var form = event.target; if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) { @@ -404,7 +252,13 @@ event.preventDefault(); form.dataset.busy = 'true'; + var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]'); var formData = new FormData(form); + var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase(); + if (event.submitter && event.submitter.name) { + submitterValue = String(event.submitter.value || '').trim().toLowerCase(); + formData.set(event.submitter.name, event.submitter.value || ''); + } var hasFileValue = false; formData.forEach(function (value) { if (value && typeof value === 'object' && typeof value.name === 'string') { @@ -437,10 +291,14 @@ throw new Error(text || 'Unable to save changes.'); }); } - if (form.hasAttribute && form.hasAttribute('data-async-save-reload')) { - window.location.replace(response.url || window.location.href); + if (submitterValue === 'close' || submitterValue === 'new') { + var redirectUrl = submitterValue === 'close' + ? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href) + : String(form.dataset.asyncSaveNewUrl || response.url || window.location.href); + window.location.replace(redirectUrl); return; } + clearFormDirty(form); return response.text().then(function (text) { var savedMessage = ''; try { @@ -452,12 +310,16 @@ } catch (_error) { savedMessage = ''; } - showToast(savedMessage || 'Saved.'); + showToast(savedMessage || 'Saved.', 'success'); }); }).catch(function (error) { window.alert(error.message || 'Unable to save changes.'); }).finally(function () { delete form.dataset.busy; + delete form.dataset.submitterValue; + if (hiddenSaveAction) { + hiddenSaveAction.value = ''; + } }); }, true); } @@ -479,6 +341,8 @@ } initConfirmForms(); + initDirtyTracking(); + initCancelConfirm(); initAsyncCommandForms(); initAsyncSaveForms(); initSubmitOnChange(); diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js new file mode 100644 index 0000000..f180508 --- /dev/null +++ b/src/web/public/js/dashboard/dashboard-page.js @@ -0,0 +1,462 @@ +(function () { + function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function formatDashboardDate(value) { + if (!value) { + return ''; + } + var date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: '2-digit', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit' + }).format(date); + } + + function getClientRowKey(client) { + return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim(); + } + + function getClientDisplayName(client) { + if (client && client.client_name) { + return String(client.client_name).trim(); + } + var clientId = String(client && client.clientId ? client.clientId : '').trim(); + if (clientId) { + return clientId; + } + return ''; + } + + function setButtonVariant(button, classesToRemove, classToAdd) { + if (!button) { + return; + } + + if (button.classList) { + classesToRemove.forEach(function (className) { + button.classList.remove(className); + }); + if (classToAdd) { + button.classList.add(classToAdd); + } + return; + } + + var className = String(button.className || ''); + classesToRemove.forEach(function (removeClass) { + className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' '); + }); + if (classToAdd) { + className += ' ' + classToAdd; + } + button.className = className.replace(/\s+/g, ' ').trim(); + } + + function normalizeDisplayIp(value) { + var ip = String(value || '').trim(); + if (!ip) { + return ''; + } + + if (ip.toLowerCase().indexOf('::ffff:') === 0) { + return ip.slice(7).trim(); + } + + return ip; + } + + function renderClientActionCell(client) { + var paused = Boolean(client.paused); + var pauseButtonClass = 'btn btn-sm btn-info'; + var pauseButtonLabel = paused ? 'Resume' : 'Pause'; + var blackout = Boolean(client.blackout); + var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary'); + var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout'; + var reloadConfirmMessage = 'Reloading will restart the player page. Continue?'; + var blackoutCommandValue = blackout ? 'false' : 'true'; + + return '
    '; + } + + function updateClientActionCell(cell, client) { + if (!cell) { + return; + } + + var pauseButton = cell.querySelector('button[data-action="pause"]'); + if (!pauseButton) { + cell.innerHTML = renderClientActionCell(client); + return; + } + + var paused = Boolean(client.paused); + setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info'); + pauseButton.innerHTML = '' + (paused ? 'Resume' : 'Pause'); + + var pauseForm = pauseButton.form; + if (pauseForm) { + var commandInput = pauseForm.querySelector('input[name="command"]'); + if (commandInput) { + commandInput.value = 'pause'; + } + var connectionInput = pauseForm.querySelector('input[name="connectionId"]'); + if (connectionInput) { + connectionInput.value = client.id || ''; + } + pauseForm.action = '/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands'; + } + + var reloadButton = cell.querySelector('button[data-action="reload"]'); + if (reloadButton) { + reloadButton.innerHTML = 'Reload'; + setButtonVariant(reloadButton, ['btn-danger', 'btn-success', 'btn-outline-secondary', 'btn-outline-dark', 'btn-outline-primary', 'btn-secondary'], 'btn-danger'); + var reloadForm = reloadButton.form; + if (reloadForm) { + var reloadInput = reloadForm.querySelector('input[name="connectionId"]'); + if (reloadInput) { + reloadInput.value = client.id || ''; + } + reloadForm.action = '/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands'; + reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?'); + } + } + + var blackoutButton = cell.querySelector('button[data-action="blackout"]'); + if (!blackoutButton) { + cell.innerHTML = renderClientActionCell(client); + return; + } + + var blackout = Boolean(client.blackout); + setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary'); + blackoutButton.innerHTML = '' + (blackout ? 'Restore' : 'Blackout'); + + var blackoutForm = blackoutButton.form; + if (blackoutForm) { + var blackoutCommandInput = blackoutForm.querySelector('input[name="command"]'); + if (blackoutCommandInput) { + blackoutCommandInput.value = 'blackout'; + } + var blackoutStateInput = blackoutForm.querySelector('input[name="blackout"]'); + if (blackoutStateInput) { + blackoutStateInput.value = blackout ? 'false' : 'true'; + } + var blackoutConnectionInput = blackoutForm.querySelector('input[name="connectionId"]'); + if (blackoutConnectionInput) { + blackoutConnectionInput.value = client.id || ''; + } + blackoutForm.action = '/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands'; + } + + var previousButton = cell.querySelector('button[data-action="previous"]'); + if (!previousButton) { + cell.innerHTML = renderClientActionCell(client); + return; + } + setButtonVariant(previousButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning'); + previousButton.setAttribute('aria-label', 'Previous slide'); + var previousForm = previousButton.form; + if (previousForm) { + var previousCommandInput = previousForm.querySelector('input[name="command"]'); + if (previousCommandInput) { + previousCommandInput.value = 'previous'; + } + var previousConnectionInput = previousForm.querySelector('input[name="connectionId"]'); + if (previousConnectionInput) { + previousConnectionInput.value = client.id || ''; + } + previousForm.action = '/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands'; + } + + var nextButton = cell.querySelector('button[data-action="next"]'); + if (!nextButton) { + cell.innerHTML = renderClientActionCell(client); + return; + } + setButtonVariant(nextButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning'); + nextButton.setAttribute('aria-label', 'Next slide'); + var nextForm = nextButton.form; + if (nextForm) { + var nextCommandInput = nextForm.querySelector('input[name="command"]'); + if (nextCommandInput) { + nextCommandInput.value = 'next'; + } + var nextConnectionInput = nextForm.querySelector('input[name="connectionId"]'); + if (nextConnectionInput) { + nextConnectionInput.value = client.id || ''; + } + nextForm.action = '/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands'; + } + } + + function renderClientRow(client) { + var connectedAt = client.connectedAt ? '
    ' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '
    ' + (client.lastSeenAt ? '
    ' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '
    ' : '') : 'Unknown'; + var clientIpValue = normalizeDisplayIp(client.clientIp); + var clientIp = clientIpValue ? escapeHtml(clientIpValue) : 'Unknown'; + var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : 'Unknown'; + var clientNameValue = getClientDisplayName(client); + var clientName = clientNameValue ? escapeHtml(clientNameValue) : 'Unknown'; + var screenName = client.screen_name ? escapeHtml(client.screen_name) : 'Unknown'; + var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : 'No slide currently showing'; + + return [ + '', + '
    ' + clientName + '
    ', + '
    ' + screenName + '
    ', + '' + currentSlide + '', + '' + clientIp + '', + '' + viewport + '', + '' + connectedAt + '', + '' + renderClientActionCell(client) + '', + '' + ].join(''); + } + + function renderScreenRow(screen) { + var clientCount = Number(screen.player_connection_count || 0); + return [ + '', + '' + escapeHtml(screen.name) + '', + '' + escapeHtml(screen.player_url || '') + '', + '' + escapeHtml(screen.playlist_name || '') + '', + '' + (clientCount ? '
    ' + clientCount + ' connected
    ' : 'No clients connected.') + '', + '' + ].join(''); + } + + function updateStats(state) { + var clientCount = document.getElementById('dashboard-client-count'); + var screenCount = document.getElementById('dashboard-screen-count'); + var slideCount = document.getElementById('dashboard-slide-count'); + var playlistCount = document.getElementById('dashboard-playlist-count'); + + if (playlistCount && Array.isArray(state.playlists)) { + playlistCount.textContent = String(state.playlists.length); + } + if (slideCount && Array.isArray(state.slides)) { + slideCount.textContent = String(state.slides.length); + } + if (screenCount && Array.isArray(state.screens)) { + screenCount.textContent = String(state.screens.length); + } + if (clientCount) { + clientCount.textContent = String(Number(state.connectedClientsCount || 0)); + } + } + + function updateClientTable(state) { + var tbody = document.getElementById('dashboard-clients-table-body'); + if (!tbody || !Array.isArray(state.clients)) { + return; + } + if (!state.clients.length) { + tbody.innerHTML = 'No connected clients yet.'; + return; + } + var existingRows = {}; + Array.prototype.slice.call(tbody.querySelectorAll('tr[data-client-key]')).forEach(function (row) { + existingRows[row.getAttribute('data-client-key')] = row; + }); + + Array.prototype.slice.call(tbody.querySelectorAll('tr')).forEach(function (row) { + if (!row.hasAttribute('data-client-key')) { + row.parentNode.removeChild(row); + } + }); + + state.clients.forEach(function (client, index) { + var rowKey = getClientRowKey(client); + var row = existingRows[rowKey]; + if (!row) { + var tempBody = document.createElement('tbody'); + tempBody.innerHTML = renderClientRow(client); + row = tempBody.firstElementChild; + } + + if (!row) { + return; + } + + row.setAttribute('data-client-key', rowKey); + row.setAttribute('data-client-id', client.clientId || ''); + row.setAttribute('data-client-device-id', client.deviceId || ''); + row.setAttribute('data-client-screen-slug', client.screen_slug || ''); + if (row.cells && row.cells.length >= 7) { + var connectedAt = client.connectedAt ? '
    ' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '
    ' + (client.lastSeenAt ? '
    ' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '
    ' : '') : 'Unknown'; + var clientIpValue = normalizeDisplayIp(client.clientIp); + var clientIp = clientIpValue ? escapeHtml(clientIpValue) : 'Unknown'; + var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : 'Unknown'; + var clientNameValue = getClientDisplayName(client); + var clientName = clientNameValue ? escapeHtml(clientNameValue) : 'Unknown'; + var screenName = client.screen_name ? escapeHtml(client.screen_name) : 'Unknown'; + var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : 'No slide currently showing'; + + row.cells[0].innerHTML = '
    ' + clientName + '
    '; + row.cells[1].innerHTML = '
    ' + screenName + '
    '; + row.cells[2].innerHTML = currentSlide; + row.cells[3].innerHTML = clientIp; + row.cells[4].innerHTML = viewport; + row.cells[5].innerHTML = connectedAt; + updateClientActionCell(row.cells[6], client); + } + + var referenceNode = tbody.children[index] || null; + if (referenceNode !== row) { + tbody.insertBefore(row, referenceNode); + } + }); + + while (tbody.children.length > state.clients.length) { + tbody.removeChild(tbody.lastElementChild); + } + + window.applyTableSort(document.getElementById('dashboard-clients-table')); + } + + function updateScreenTable(state) { + var table = document.getElementById('dashboard-screens-table'); + if (!table || !Array.isArray(state.screens)) { + return; + } + var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null; + if (!tbody) { + return; + } + if (!state.screens.length) { + tbody.innerHTML = 'No screens yet.'; + return; + } + tbody.innerHTML = state.screens.map(renderScreenRow).join(''); + window.applyTableSort(table); + } + + function updateDashboardQuickActions(state) { + var blackoutButton = document.getElementById('dashboard-blackout-all-button'); + if (!blackoutButton || !state || !Array.isArray(state.clients)) { + return; + } + + var hasClients = state.clients.length > 0; + var allBlackout = hasClients && state.clients.every(function (client) { + return Boolean(client && client.blackout); + }); + var label = allBlackout ? 'Restore all clients' : 'Blackout all clients'; + var blackoutForm = blackoutButton.form; + var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null; + + blackoutButton.innerHTML = (allBlackout ? '' : '') + escapeHtml(label); + setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], 'btn-secondary'); + if (blackoutInput) { + blackoutInput.value = allBlackout ? 'false' : 'true'; + } + if (blackoutForm) { + blackoutForm.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients?' : 'Blackout all connected clients?'); + } + blackoutButton.setAttribute('aria-label', label); + } + + function handleDashboardState(state) { + if (!state) { + return; + } + updateStats(state); + updateScreenTable(state); + updateClientTable(state); + updateDashboardQuickActions(state); + } + + function sendClientRename(screenSlug, connectionId, clientId, deviceId, clientName) { + var body = new URLSearchParams(); + body.append('command', 'setClientName'); + body.append('connectionId', String(connectionId || '').trim()); + body.append('clientId', String(clientId || '').trim()); + body.append('deviceId', String(deviceId || '').trim()); + body.append('clientName', String(clientName || '').trim()); + + return fetch('/admin/screens/' + encodeURIComponent(String(screenSlug || '').trim()) + '/commands', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json, text/plain, */*' + }, + body: body.toString(), + credentials: 'same-origin' + }).then(function (response) { + if (!response.ok) { + return response.text().then(function (text) { + throw new Error(text || 'Unable to rename client.'); + }); + } + return response.json().catch(function () { + return { ok: true }; + }); + }); + } + + function initClientRenameHandler() { + var table = document.getElementById('dashboard-clients-table'); + var tbody = document.getElementById('dashboard-clients-table-body'); + if (!table || !tbody) { + return; + } + + tbody.addEventListener('dblclick', function (event) { + var cell = event.target && event.target.closest ? event.target.closest('td[data-label="Client"]') : null; + if (!cell || !tbody.contains(cell)) { + return; + } + + var row = cell.parentElement; + if (!row) { + return; + } + + var connectionId = String(row.getAttribute('data-client-key') || '').trim(); + var clientId = String(row.getAttribute('data-client-id') || '').trim(); + var deviceId = String(row.getAttribute('data-client-device-id') || '').trim(); + var screenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim(); + var currentName = String(cell.textContent || '').trim(); + var nextName = window.prompt('Rename connected client', currentName && currentName !== 'Unknown' ? currentName : ''); + if (nextName === null) { + return; + } + + nextName = String(nextName || '').trim(); + if (!nextName) { + window.alert('Client name is required.'); + return; + } + if (!connectionId || !screenSlug) { + window.alert('Unable to rename this client right now.'); + return; + } + + sendClientRename(screenSlug, connectionId, clientId, deviceId, nextName).then(function () { + if (row && row.cells && row.cells[0]) { + row.cells[0].innerHTML = '
    ' + escapeHtml(nextName) + '
    '; + } + }).catch(function (error) { + window.alert(error && error.message ? error.message : 'Unable to rename client.'); + }); + }); + } + + window.webHandleDashboardState = handleDashboardState; + + initClientRenameHandler(); +}()); \ No newline at end of file diff --git a/src/web/public/js/playlist-schedule.js b/src/web/public/js/playlists/playlist-schedule.js similarity index 69% rename from src/web/public/js/playlist-schedule.js rename to src/web/public/js/playlists/playlist-schedule.js index 5952d68..b7cb70f 100644 --- a/src/web/public/js/playlist-schedule.js +++ b/src/web/public/js/playlists/playlist-schedule.js @@ -23,43 +23,69 @@ function initPlaylistScheduleModal() { var dialog = document.getElementById('slide-schedule-dialog'); - var frame = document.getElementById('slide-schedule-frame'); - var closeButton = document.getElementById('slide-schedule-close'); + var content = document.getElementById('slide-schedule-content'); var triggers = document.querySelectorAll('[data-schedule-config]'); - if (!dialog || !frame || !closeButton || !triggers.length) { + if (!dialog || !content || !triggers.length) { return; } + function initInjectedContent() { + if (typeof window.initPlaylistScheduleForm === 'function') { + window.initPlaylistScheduleForm(content); + } + } + + function collectScheduleParams(row, rowKey) { + var params = new URLSearchParams(); + var scheduleFields = [ + 'schedule_mode[]', + 'schedule_start_datetime[]', + 'schedule_end_datetime[]', + 'schedule_start_time[]', + 'schedule_end_time[]', + 'schedule_days_json[]' + ]; + + params.set('row_key', String(rowKey || '')); + scheduleFields.forEach(function (fieldName) { + var field = row && row.querySelector ? row.querySelector('[name="' + fieldName + '"]') : null; + var value = field && typeof field.value !== 'undefined' ? String(field.value || '') : ''; + if (value) { + params.set(fieldName.replace(/\[\]$/, ''), value); + } + }); + + return params; + } + function openScheduleModal(url) { - frame.src = url; + content.innerHTML = '
    Loading schedule...
    '; if (typeof dialog.showModal === 'function') { dialog.showModal(); } else { dialog.setAttribute('open', 'open'); } - } - function resizeScheduleFrame() { - try { - if (!frame.contentWindow || !frame.contentWindow.document) { - return; + fetch(url, { + credentials: 'same-origin' + }).then(function (response) { + if (!response.ok) { + return response.text().then(function (text) { + throw new Error(text || 'Unable to open schedule editor.'); + }); } - var doc = frame.contentWindow.document; - var height = Math.max( - doc.body.scrollHeight, - doc.documentElement.scrollHeight, - doc.body.offsetHeight, - doc.documentElement.offsetHeight - ); - frame.style.height = height + 'px'; - } catch (_error) { - frame.style.height = '70vh'; - } + return response.text(); + }).then(function (html) { + content.innerHTML = html; + initInjectedContent(); + }).catch(function (error) { + content.innerHTML = '
    ' + String(error && error.message ? error.message : 'Unable to open schedule editor.') + '
    '; + }); } function closeScheduleModal() { - frame.src = 'about:blank'; + content.innerHTML = ''; if (typeof dialog.close === 'function') { dialog.close(); } else { @@ -68,22 +94,23 @@ } window.openScheduleModal = openScheduleModal; - window.resizeScheduleFrame = resizeScheduleFrame; window.closeScheduleModal = closeScheduleModal; Array.prototype.forEach.call(triggers, function (trigger) { trigger.addEventListener('click', function () { var url = trigger.getAttribute('data-schedule-config'); var rowKey = trigger.getAttribute('data-schedule-config-row') || ''; - if (rowKey && url.indexOf('row_key=') === -1) { + var row = trigger.closest('tr[data-playlist-slide-row]'); + if (row) { + var params = collectScheduleParams(row, rowKey || row.getAttribute('data-row-key') || ''); + url += (url.indexOf('?') === -1 ? '?' : '&') + params.toString(); + } else if (rowKey && url.indexOf('row_key=') === -1) { url += (url.indexOf('?') === -1 ? '?' : '&') + 'row_key=' + encodeURIComponent(rowKey); } openScheduleModal(url); }); }); - frame.addEventListener('load', resizeScheduleFrame); - closeButton.addEventListener('click', closeScheduleModal); } function scheduleSummary(values) { @@ -104,15 +131,19 @@ return 'Always visible'; } - function initPlaylistScheduleForm() { - var form = document.querySelector('form[action*="/config"]'); - var select = document.getElementById('schedule-mode-select'); + function initPlaylistScheduleForm(root) { + var scope = root || document; + var form = scope.querySelector('form[action*="/config"]'); + var select = scope.querySelector('#schedule-mode-select'); if (!form || !select) { return; } - var datesPanel = document.getElementById('schedule-dates-panel'); - var timesPanel = document.getElementById('schedule-times-panel'); + var DEFAULT_START_TIME = '00:00'; + var DEFAULT_END_TIME = '23:59'; + + var datesPanel = scope.querySelector('#schedule-dates-panel'); + var timesPanel = scope.querySelector('#schedule-times-panel'); var startDateInput = form.querySelector('[name="schedule_start_datetime"]'); var endDateInput = form.querySelector('[name="schedule_end_datetime"]'); var startTimeInput = form.querySelector('[name="schedule_start_time"]'); @@ -120,16 +151,89 @@ var rowKeyInput = form.querySelector('[name="row_key"]'); var dayCheckboxes = form.querySelectorAll('[name="schedule_days"]'); - function notifyParentResize() { - if (window.parent && typeof window.parent.resizeScheduleFrame === 'function') { - window.parent.resizeScheduleFrame(); + function clearScheduleValidity() { + [startDateInput, endDateInput, startTimeInput, endTimeInput].forEach(function (input) { + if (input) { + input.setCustomValidity(''); + } + }); + } + + function setScheduleError(input, message) { + if (!input) { + return; } + input.setCustomValidity(message); + } + + function validateScheduleForm() { + var mode = select.value; + var firstDayCheckbox = dayCheckboxes.length ? dayCheckboxes[0] : null; + + clearScheduleValidity(); + + if (mode === 'dates') { + if (!startDateInput.value) { + setScheduleError(startDateInput, 'Start datetime is required for this schedule mode.'); + } + if (!endDateInput.value) { + setScheduleError(endDateInput, 'End datetime is required for this schedule mode.'); + } + if (startDateInput.value && endDateInput.value && new Date(endDateInput.value) < new Date(startDateInput.value)) { + setScheduleError(endDateInput, 'End datetime must be after start datetime.'); + } + } + + if (mode === 'times') { + if (!startTimeInput.value) { + setScheduleError(startTimeInput, 'Start time is required for this schedule mode.'); + } + if (!endTimeInput.value) { + setScheduleError(endTimeInput, 'End time is required for this schedule mode.'); + } + if (startTimeInput.value && endTimeInput.value && endTimeInput.value < startTimeInput.value) { + setScheduleError(endTimeInput, 'End time must be after start time.'); + } + if (!Array.prototype.some.call(dayCheckboxes, function (checkbox) { + return checkbox.checked; + })) { + setScheduleError(firstDayCheckbox, 'Select at least one day.'); + } + } + + return form.checkValidity(); + } + + [startDateInput, endDateInput, startTimeInput, endTimeInput].forEach(function (input) { + if (input) { + input.addEventListener('input', clearScheduleValidity); + } + }); + Array.prototype.forEach.call(dayCheckboxes, function (checkbox) { + checkbox.addEventListener('change', clearScheduleValidity); + }); + select.addEventListener('change', clearScheduleValidity); + + function notifyParentResize() { + return; } function updateVisibility() { var mode = select.value; - datesPanel.style.display = mode === 'dates' ? '' : 'none'; - timesPanel.style.display = mode === 'times' ? '' : 'none'; + if (datesPanel) { + datesPanel.classList.toggle('is-hidden', mode !== 'dates'); + } + if (timesPanel) { + timesPanel.classList.toggle('is-hidden', mode !== 'times'); + } + if (mode === 'times') { + if (!startTimeInput.value) { + startTimeInput.value = DEFAULT_START_TIME; + } + if (!endTimeInput.value) { + endTimeInput.value = DEFAULT_END_TIME; + } + } window.requestAnimationFrame(function () { notifyParentResize(); }); @@ -141,37 +245,12 @@ form.addEventListener('submit', function (event) { event.preventDefault(); + if (!validateScheduleForm()) { + form.reportValidity(); + return; + } + var mode = select.value; - var datesIncomplete = mode === 'dates' && (!startDateInput.value || !endDateInput.value); - var timesIncomplete = mode === 'times' && (!startTimeInput.value || !endTimeInput.value || !Array.prototype.some.call(dayCheckboxes, function (checkbox) { - return checkbox.checked; - })); - - if (mode === 'dates' && !datesIncomplete) { - if (new Date(endDateInput.value) < new Date(startDateInput.value)) { - alert('End datetime must be after start datetime.'); - return; - } - } - if (mode === 'times' && !timesIncomplete) { - if (endTimeInput.value < startTimeInput.value) { - alert('End time must be after start time.'); - return; - } - } - - if (datesIncomplete || timesIncomplete) { - mode = 'always'; - select.value = 'always'; - startDateInput.value = ''; - endDateInput.value = ''; - startTimeInput.value = ''; - endTimeInput.value = ''; - Array.prototype.forEach.call(dayCheckboxes, function (checkbox) { - checkbox.checked = false; - }); - updateVisibility(); - } var selectedDays = []; Array.prototype.forEach.call(dayCheckboxes, function (checkbox) { @@ -264,6 +343,12 @@ } } + function markPlaylistDirty() { + if (form && form.dataset) { + form.dataset.dirty = 'true'; + } + } + function scheduleSummaryForRow(row) { var modeInput = row.querySelector('[name="schedule_mode[]"]'); var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]'); @@ -351,15 +436,15 @@ addSlideSelect.disabled = availableCount === 0; } if (addSlideEmpty) { - addSlideEmpty.style.display = availableCount === 0 ? '' : 'none'; + addSlideEmpty.classList.toggle('is-hidden', availableCount !== 0); } if (addSection && addSlideForm && addSlideSelect && addSlideButton) { var controlsVisible = availableCount > 0; - addSlideForm.style.display = controlsVisible ? '' : 'none'; + addSlideForm.classList.toggle('is-hidden', !controlsVisible); } } - function updateRowOrder() { + function updateRowOrder(markDirty) { var rows = getRows(); rows.forEach(function (row, index) { var orderNumber = row.querySelector('.playlist-order-number'); @@ -369,6 +454,9 @@ }); syncAddSlideOptions(); updateEmptyState(); + if (markDirty) { + markPlaylistDirty(); + } } function lockDraggedRowWidths(row) { @@ -435,11 +523,13 @@ if (summary) { summary.textContent = values.summary || scheduleSummaryForRow(row); } + markPlaylistDirty(); } function createRow(values) { var row = document.createElement('tr'); var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2)); + var playlistId = tbody.getAttribute('data-playlist-id') || ''; row.setAttribute('data-playlist-slide-row', ''); row.setAttribute('data-row-key', rowKey); row.setAttribute('data-slide-id', String(values.slide_id)); @@ -447,24 +537,24 @@ row.innerHTML = '' + '' + '
    ' + - '' + + '' + '' + '
    ' + '' + - '' + values.title + '' + + '' + values.title + '' + '' + '
    ' + values.summary + '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + + '' + + '' + '' + - '' + + '' + '
    ' + - '' + - '' + + '' + + '' + '
    '; return row; } @@ -481,7 +571,7 @@ if (removeButton) { event.preventDefault(); row.remove(); - updateRowOrder(); + updateRowOrder(true); return; } @@ -514,7 +604,7 @@ }, onEnd: function () { unlockDraggedRowWidths(tbody.querySelector('.sortable-drag')); - updateRowOrder(); + updateRowOrder(true); } }); } @@ -544,7 +634,7 @@ summary: 'Always visible' }); tbody.appendChild(row); - updateRowOrder(); + updateRowOrder(true); }); } @@ -559,6 +649,8 @@ updateRowOrder(); } + window.initPlaylistScheduleForm = initPlaylistScheduleForm; + initPlaylistScheduleModal(); initPlaylistScheduleForm(); initPlaylistEditStaging(); diff --git a/src/web/public/js/slide-form.js b/src/web/public/js/slide-form.js index 104568e..efcc0d2 100644 --- a/src/web/public/js/slide-form.js +++ b/src/web/public/js/slide-form.js @@ -21,7 +21,8 @@ import { Subscript, Superscript, Underline -} from '/assets/js/ckeditor5/ckeditor5.js'; +} from '/assets/js/vendor/ckeditor5/ckeditor5.js'; +import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js'; (function () { var dataElement = document.getElementById('slide-editor-data'); @@ -42,13 +43,25 @@ import { var templateSelect = document.getElementById('template-select'); var templateFields = document.getElementById('template-fields'); var slideForm = document.getElementById('slide-form'); + var slideEditorShell = document.querySelector('.slide-editor-shell'); + var slideEditorSidebar = document.querySelector('.slide-editor-sidebar'); var slidePreviewMeta = document.getElementById('slide-preview-meta'); var slidePreviewStage = document.getElementById('slide-preview-stage'); + var slidePreviewCanvas = document.getElementById('slide-preview-canvas'); var slidePreviewBackground = document.getElementById('slide-preview-background'); var slidePreviewEmpty = document.getElementById('slide-preview-empty'); var slidePreviewOverlay = document.getElementById('slide-preview-overlay'); + var openPreviewPopupButton = document.getElementById('open-preview-popup'); var editorInstances = new Map(); var submitting = false; + var previewRenderFrame = 0; + var previewPopupWindow = null; + var previewPopupRenderFrame = 0; + var currentPreviewCanvasWidth = 0; + var currentPreviewCanvasHeight = 0; + var sidebarSyncFrame = 0; + var sidebarTopOffset = 16; + var templateSelectorLock = createTemplateSelectorLockController(templateSelect); class FontSizeInputUI extends Plugin { static get pluginName() { @@ -211,6 +224,21 @@ import { return String(value || ''); } + function requestPreviewRender() { + if (previewRenderFrame) { + return; + } + + previewRenderFrame = window.requestAnimationFrame(function () { + previewRenderFrame = 0; + renderPreview(); + }); + } + + function getTemplateFieldControls() { + return templateFields ? templateFields.querySelectorAll('input, textarea, select') : []; + } + function sanitizePreviewHtml(html) { var output = String(html || ''); output = output.replace(//gi, ''); @@ -445,30 +473,221 @@ import { }; } + function getPreviewCanvasDimensions(template) { + return { + width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)), + height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080)) + }; + } + + function buildPreviewPopupHtml() { + return '' + + '' + + '' + + '' + + '' + + 'Slide preview' + + '' + + '' + + '' + + '' + + '' + + ''; + } + + function syncPopupPreview() { + if (!previewPopupWindow || previewPopupWindow.closed) { + previewPopupWindow = null; + return; + } + + var popupDocument = previewPopupWindow.document; + if (!popupDocument) { + return; + } + + var popupStage = popupDocument.getElementById('popup-preview-stage'); + var popupCanvas = popupDocument.getElementById('popup-preview-canvas'); + var template = getSelectedTemplate(); + + if (!popupStage || !popupCanvas) { + if (previewPopupRenderFrame) { + cancelAnimationFrame(previewPopupRenderFrame); + } + previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview); + return; + } + + if (!template) { + popupStage.style.backgroundColor = '#111111'; + popupCanvas.style.width = ''; + popupCanvas.style.height = ''; + popupCanvas.style.transform = ''; + popupCanvas.innerHTML = slidePreviewCanvas.innerHTML; + var popupEmpty = popupCanvas.querySelector('#slide-preview-empty'); + var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay'); + var popupBackground = popupCanvas.querySelector('#slide-preview-background'); + if (popupBackground) { + popupBackground.style.display = 'none'; + } + if (popupEmpty) { + popupEmpty.style.display = 'flex'; + popupEmpty.textContent = 'Select a template to preview the slide.'; + } + if (popupOverlay) { + popupOverlay.innerHTML = ''; + } + return; + } + + var canvasWidth = currentPreviewCanvasWidth || Math.max(1, Number(template.canvas_size_width || 1920)); + var canvasHeight = currentPreviewCanvasHeight || Math.max(1, Number(template.canvas_size_height || 1080)); + popupStage.style.backgroundColor = template.background_color || '#111111'; + popupCanvas.style.width = canvasWidth + 'px'; + popupCanvas.style.height = canvasHeight + 'px'; + popupCanvas.innerHTML = slidePreviewCanvas.innerHTML; + var popupBackground = popupCanvas.querySelector('#slide-preview-background'); + var popupEmpty = popupCanvas.querySelector('#slide-preview-empty'); + var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay'); + + if (popupBackground) { + popupBackground.style.display = template.background_image_path ? 'block' : 'none'; + popupBackground.src = template.background_image_path || ''; + } + if (popupEmpty) { + popupEmpty.style.display = 'none'; + } + + var stageRect = popupStage.getBoundingClientRect(); + if (!stageRect.width || !stageRect.height) { + if (previewPopupRenderFrame) { + cancelAnimationFrame(previewPopupRenderFrame); + } + previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview); + return; + } + + var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1; + popupCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')'; + } + + function openPreviewPopup() { + if (previewPopupWindow && !previewPopupWindow.closed) { + previewPopupWindow.focus(); + syncPopupPreview(); + return; + } + + var popupWidth = 1280; + var popupHeight = 900; + var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2)); + var top = Math.max(0, Math.round((window.screen.height - popupHeight) / 2)); + var popupFeatures = [ + 'popup=yes', + 'toolbar=no', + 'location=no', + 'menubar=no', + 'directories=no', + 'status=no', + 'scrollbars=yes', + 'resizable=yes', + 'width=' + popupWidth, + 'height=' + popupHeight, + 'left=' + left, + 'top=' + top + ].join(','); + + previewPopupWindow = window.open('', 'slide-preview-popup', popupFeatures); + if (!previewPopupWindow) { + return; + } + + previewPopupWindow.document.open(); + previewPopupWindow.document.write(buildPreviewPopupHtml()); + previewPopupWindow.document.close(); + previewPopupWindow.addEventListener('resize', syncPopupPreview); + previewPopupWindow.focus(); + syncPopupPreview(); + } + + function syncSlideEditorSidebar() { + sidebarSyncFrame = 0; + + if (!slideEditorShell || !slideEditorSidebar) { + return; + } + + if (window.matchMedia && window.matchMedia('(max-width: 1199.98px)').matches) { + slideEditorSidebar.style.transform = ''; + return; + } + + var shellRect = slideEditorShell.getBoundingClientRect(); + var sidebarHeight = slideEditorSidebar.offsetHeight; + var shellTop = window.scrollY + shellRect.top; + var shellBottom = shellTop + slideEditorShell.offsetHeight; + var maxTranslate = Math.max(0, shellBottom - shellTop - sidebarHeight - sidebarTopOffset); + var desiredTranslate = Math.max(0, Math.min(window.scrollY + sidebarTopOffset - shellTop, maxTranslate)); + + slideEditorSidebar.style.transform = desiredTranslate ? 'translate3d(0, ' + desiredTranslate + 'px, 0)' : ''; + } + + function requestSidebarSync() { + if (sidebarSyncFrame) { + return; + } + + sidebarSyncFrame = window.requestAnimationFrame(syncSlideEditorSidebar); + } + function renderPreview() { var template = getSelectedTemplate(); if (!template) { - slidePreviewMeta.textContent = 'Select a template to preview the slide.'; + slidePreviewMeta.textContent = '--'; slidePreviewBackground.style.display = 'none'; slidePreviewEmpty.style.display = 'flex'; slidePreviewEmpty.textContent = 'Select a template to preview the slide.'; + slidePreviewCanvas.style.width = ''; + slidePreviewCanvas.style.height = ''; slidePreviewOverlay.innerHTML = ''; slidePreviewOverlay.style.width = ''; slidePreviewOverlay.style.height = ''; slidePreviewOverlay.style.transform = ''; slidePreviewStage.style.aspectRatio = '16 / 9'; + slidePreviewStage.style.backgroundColor = '#111111'; return; } - var canvasWidth = Math.max(1, Number(template.canvas_size_width || 1920)); - var canvasHeight = Math.max(1, Number(template.canvas_size_height || 1080)); + var canvasSize = getPreviewCanvasDimensions(template); + var canvasWidth = canvasSize.width; + var canvasHeight = canvasSize.height; + currentPreviewCanvasWidth = canvasWidth; + currentPreviewCanvasHeight = canvasHeight; slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight; - slidePreviewMeta.textContent = template.name + ' • ' + canvasWidth + 'x' + canvasHeight; + slidePreviewStage.style.backgroundColor = template.background_color || '#111111'; + slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight; if (!(template.regions || []).length) { slidePreviewEmpty.style.display = 'flex'; slidePreviewEmpty.textContent = 'This template has no regions.'; slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none'; slidePreviewBackground.src = template.background_image_path || ''; + slidePreviewCanvas.style.width = canvasWidth + 'px'; + slidePreviewCanvas.style.height = canvasHeight + 'px'; + slidePreviewCanvas.style.transform = ''; slidePreviewOverlay.innerHTML = ''; slidePreviewOverlay.style.width = ''; slidePreviewOverlay.style.height = ''; @@ -481,11 +700,21 @@ import { slidePreviewBackground.src = template.background_image_path || ''; var stageRect = slidePreviewStage.getBoundingClientRect(); + if (!stageRect.width || !stageRect.height) { + if (previewRenderFrame) { + cancelAnimationFrame(previewRenderFrame); + } + previewRenderFrame = requestAnimationFrame(function () { + previewRenderFrame = 0; + renderPreview(); + }); + return; + } var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1; - slidePreviewOverlay.style.width = canvasWidth + 'px'; - slidePreviewOverlay.style.height = canvasHeight + 'px'; - slidePreviewOverlay.style.transform = 'scale(' + scale + ')'; - slidePreviewOverlay.style.transformOrigin = 'top left'; + slidePreviewCanvas.style.width = canvasWidth + 'px'; + slidePreviewCanvas.style.height = canvasHeight + 'px'; + slidePreviewCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')'; + slidePreviewCanvas.style.transformOrigin = 'top left'; var regions = template.regions || []; slidePreviewOverlay.innerHTML = regions.map(function (region) { var box = getRegionBox(region, canvasWidth, canvasHeight); @@ -525,79 +754,93 @@ import { : ' slide-preview-text-region'; return '
    ' + content + '
    '; }).join(''); + + syncPopupPreview(); } function renderTextRegion(region) { var current = getCurrentRegionValue(region); return '' + - '
    ' + - '
    ' + + '
    ' + + '
    ' + '' + escapeHtml(region.label) + '' + - 'Text' + + '
    ' + + '' + + 'Text' + + '
    ' + '
    ' + - '
    ' + - '' + + '
    ' + + '
    ' + + '' + + '
    ' + + '' + + '' + '
    ' + - '' + - '' + '
    '; } function renderWebpageRegion(region) { var current = getCurrentRegionValue(region); return '' + - '
    ' + - '
    ' + + '
    ' + + '
    ' + '' + escapeHtml(region.label) + '' + 'Webpage' + '
    ' + - '' + - '
    The webpage will be loaded in an iframe.
    ' + + '
    ' + + '' + + '
    The webpage will be loaded in an iframe.
    ' + + '
    ' + '
    '; } function renderHtmlRegion(region) { var current = getCurrentRegionValue(region); return '' + - '
    ' + - '
    ' + + '
    ' + + '
    ' + '' + escapeHtml(region.label) + '' + 'HTML' + '
    ' + - '' + - '
    HTML is rendered in a sandboxed iframe preview.
    ' + + '
    ' + + '' + + '
    HTML is rendered in a sandboxed iframe preview.
    ' + + '
    ' + '
    '; } function renderImageRegion(region) { var current = getCurrentRegionValue(region); return '' + - '
    ' + - '
    ' + + '
    ' + + '
    ' + '' + escapeHtml(region.label) + '' + 'Image' + '
    ' + - '' + - '' + - (current - ? '' - : '
    No image selected.
    ') + + '
    ' + + '' + + '' + + (current + ? '' + : '
    No image selected.
    ') + + '
    ' + '
    '; } function renderTemplate() { + templateSelectorLock.reset(); clearImagePreviewUrls(); destroyEditors(); var template = getTemplateById(templateSelect.value); if (!template) { templateFields.innerHTML = ''; - renderPreview(); + requestPreviewRender(); return; } @@ -712,14 +955,28 @@ import { if (hidden) { hidden.value = editor.getData(); } - renderPreview(); + templateSelectorLock.markEdited(); + requestPreviewRender(); }); - renderPreview(); + requestPreviewRender(); }).catch(function (error) { console.error('Failed to initialize CKEditor.', error); }); }); + templateFields.querySelectorAll('[data-toggle-editor-height]').forEach(function (button) { + button.addEventListener('click', function () { + var regionId = button.getAttribute('data-toggle-editor-height'); + var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); + if (!card) { + return; + } + + var expanded = card.classList.toggle('is-expanded'); + button.textContent = expanded ? 'Collapse' : 'Expand'; + }); + }); + templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) { input.addEventListener('change', function () { if (input.dataset.previewUrl) { @@ -729,10 +986,14 @@ import { if (input.files && input.files.length) { input.dataset.previewUrl = URL.createObjectURL(input.files[0]); } - renderPreview(); + templateSelectorLock.markEdited(); + requestPreviewRender(); }); }); - renderPreview(); + window.requestAnimationFrame(function () { + templateSelectorLock.arm(); + }); + requestPreviewRender(); } slideForm.addEventListener('submit', async function (event) { @@ -740,6 +1001,9 @@ import { return; } event.preventDefault(); + var submitterValue = event.submitter && event.submitter.name === 'save_action' + ? String(event.submitter.value || '').trim().toLowerCase() + : ''; var saves = Array.prototype.map.call(templateFields.querySelectorAll('.ckeditor-holder'), function (holder) { var regionId = holder.getAttribute('data-region-id'); var editor = editorInstances.get(regionId); @@ -753,13 +1017,17 @@ import { submitting = true; try { await Promise.all(saves); + var formData = new FormData(slideForm); + if (event.submitter && event.submitter.name) { + formData.set(event.submitter.name, event.submitter.value || ''); + } var response = await fetch(slideForm.action, { method: (slideForm.method || 'POST').toUpperCase(), headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/html, application/json, text/plain, */*' }, - body: new FormData(slideForm), + body: formData, credentials: 'same-origin' }); @@ -767,6 +1035,14 @@ import { throw new Error(await response.text() || 'Unable to save slide.'); } + if (submitterValue === 'close' || submitterValue === 'new') { + var redirectUrl = submitterValue === 'close' + ? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action) + : String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action); + window.location.replace(redirectUrl); + return; + } + var responseText = await response.text(); var savedMessage = ''; try { @@ -779,19 +1055,53 @@ import { savedMessage = ''; } - showToast(savedMessage || 'Saved slide.'); - window.location.href = response.url || slideForm.action; + showToast(savedMessage || 'Saved slide.', 'success'); + slideForm.dataset.dirty = 'false'; } finally { submitting = false; } }); + slideForm.addEventListener('input', function () { + slideForm.dataset.dirty = 'true'; + }, true); + + slideForm.addEventListener('change', function () { + slideForm.dataset.dirty = 'true'; + }, true); + + if (openPreviewPopupButton) { + openPreviewPopupButton.addEventListener('click', function () { + openPreviewPopup(); + }); + } + templateSelect.addEventListener('change', renderTemplate); - templateFields.addEventListener('change', renderPreview); - templateFields.addEventListener('input', renderPreview); + templateFields.addEventListener('change', function () { + templateSelectorLock.markEdited(); + requestPreviewRender(); + }); + templateFields.addEventListener('input', function () { + templateSelectorLock.markEdited(); + requestPreviewRender(); + }); if (existingTemplateId) { templateSelect.value = String(existingTemplateId); } renderTemplate(); - window.addEventListener('resize', renderPreview); + if (window.ResizeObserver && slidePreviewStage) { + var previewResizeObserver = new ResizeObserver(function () { + requestPreviewRender(); + }); + previewResizeObserver.observe(slidePreviewStage); + } + requestSidebarSync(); + window.addEventListener('scroll', requestSidebarSync, { passive: true }); + window.addEventListener('resize', requestPreviewRender); + window.addEventListener('resize', requestSidebarSync); + window.addEventListener('beforeunload', function () { + if (previewPopupWindow && !previewPopupWindow.closed) { + previewPopupWindow.close(); + } + }); })(); diff --git a/src/web/public/js/slides/slide-form-template-lock.js b/src/web/public/js/slides/slide-form-template-lock.js new file mode 100644 index 0000000..c0e2099 --- /dev/null +++ b/src/web/public/js/slides/slide-form-template-lock.js @@ -0,0 +1,86 @@ +export function createTemplateSelectorLockController(templateSelect) { + var locked = false; + var armed = false; + var lockInput = null; + + function clearLockInput() { + if (lockInput && lockInput.parentNode) { + lockInput.parentNode.removeChild(lockInput); + } + lockInput = null; + } + + function reset() { + locked = false; + armed = false; + clearLockInput(); + if (!templateSelect) { + return; + } + + templateSelect.disabled = false; + templateSelect.removeAttribute('aria-disabled'); + templateSelect.removeAttribute('title'); + templateSelect.classList.remove('is-locked'); + } + + function arm() { + armed = true; + } + + function syncLockedValue() { + if (lockInput) { + lockInput.value = templateSelect.value; + } + } + + function lock() { + if (!templateSelect || locked) { + return; + } + + locked = true; + lockInput = lockInput || document.createElement('input'); + lockInput.type = 'hidden'; + lockInput.name = templateSelect.name; + lockInput.value = templateSelect.value; + templateSelect.insertAdjacentElement('afterend', lockInput); + templateSelect.disabled = true; + templateSelect.setAttribute('aria-disabled', 'true'); + templateSelect.setAttribute('title', 'Template is locked after you edit its content.'); + templateSelect.classList.add('is-locked'); + } + + function markEdited() { + if (!armed) { + return false; + } + + lock(); + syncLockedValue(); + return locked; + } + + function sync() { + if (!templateSelect) { + return false; + } + + if (locked) { + syncLockedValue(); + return true; + } + + return false; + } + + return { + reset: reset, + arm: arm, + markEdited: markEdited, + sync: sync, + isLocked: function () { + return locked; + } + }; +} diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js new file mode 100644 index 0000000..efcc0d2 --- /dev/null +++ b/src/web/public/js/slides/slide-form.js @@ -0,0 +1,1107 @@ +import { + Alignment, + BlockQuote, + Bold, + ClassicEditor, + ClipboardPipeline, + Essentials, + FontBackgroundColor, + FontColor, + FontFamily, + FontSize, + Heading, + HorizontalLine, + Indent, + IndentBlock, + Italic, + InputTextView, + Plugin, + Paragraph, + Strikethrough, + Subscript, + Superscript, + Underline +} from '/assets/js/vendor/ckeditor5/ckeditor5.js'; +import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js'; + +(function () { + var dataElement = document.getElementById('slide-editor-data'); + if (!dataElement) { + return; + } + + var slideEditorData = {}; + try { + slideEditorData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {}; + } catch (_error) { + slideEditorData = {}; + } + + var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : []; + var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null; + var existingContent = slideEditorData.existingContent || {}; + var templateSelect = document.getElementById('template-select'); + var templateFields = document.getElementById('template-fields'); + var slideForm = document.getElementById('slide-form'); + var slideEditorShell = document.querySelector('.slide-editor-shell'); + var slideEditorSidebar = document.querySelector('.slide-editor-sidebar'); + var slidePreviewMeta = document.getElementById('slide-preview-meta'); + var slidePreviewStage = document.getElementById('slide-preview-stage'); + var slidePreviewCanvas = document.getElementById('slide-preview-canvas'); + var slidePreviewBackground = document.getElementById('slide-preview-background'); + var slidePreviewEmpty = document.getElementById('slide-preview-empty'); + var slidePreviewOverlay = document.getElementById('slide-preview-overlay'); + var openPreviewPopupButton = document.getElementById('open-preview-popup'); + var editorInstances = new Map(); + var submitting = false; + var previewRenderFrame = 0; + var previewPopupWindow = null; + var previewPopupRenderFrame = 0; + var currentPreviewCanvasWidth = 0; + var currentPreviewCanvasHeight = 0; + var sidebarSyncFrame = 0; + var sidebarTopOffset = 16; + var templateSelectorLock = createTemplateSelectorLockController(templateSelect); + + class FontSizeInputUI extends Plugin { + static get pluginName() { + return 'FontSizeInputUI'; + } + + init() { + var editor = this.editor; + var fontSizeCommand = editor.commands.get('fontSize'); + var plugin = this; + + if (!fontSizeCommand) { + return; + } + + editor.ui.componentFactory.add('fontSizeInput', function (locale) { + var view = new InputTextView(locale); + + view.extendTemplate({ + attributes: { + class: ['ck-font-size-input'] + } + }); + + view.set({ + placeholder: 'Font size', + ariaLabel: 'Font size' + }); + + function getCurrentFontSizeValue() { + var value = fontSizeCommand.value; + if (value === null || value === undefined || value === '') { + return ''; + } + + var parsed = Math.round(Number(String(value).replace(/[^0-9.]/g, ''))); + return Number.isFinite(parsed) && parsed > 0 ? String(parsed) : ''; + } + + function syncValue() { + if (view.element && document.activeElement === view.element) { + return; + } + + view.value = getCurrentFontSizeValue(); + } + + view.on('render', function () { + if (!view.element) { + return; + } + + view.element.type = 'text'; + view.element.inputMode = 'numeric'; + view.element.autocomplete = 'off'; + view.element.spellcheck = false; + syncValue(); + }); + + function applyValue() { + if (!view.element) { + return; + } + + var size = Math.max(1, Math.round(Number(view.element.value || 0))); + if (!size) { + return; + } + + editor.execute('fontSize', { value: size + 'px' }); + } + + view.on('input', applyValue); + view.on('blur', applyValue); + + view.on('keydown', function (_eventInfo, domEvent) { + if (domEvent.key === 'Enter') { + domEvent.preventDefault(); + if (view.element) { + view.element.blur(); + } + } + }); + + plugin.listenTo(editor.model.document.selection, 'change:range', syncValue); + plugin.listenTo(fontSizeCommand, 'change:value', syncValue); + + syncValue(); + + return view; + }); + } + } + + class ForcePlainTextPaste extends Plugin { + static get pluginName() { + return 'ForcePlainTextPaste'; + } + + static get requires() { + return [ClipboardPipeline]; + } + + init() { + var editor = this.editor; + var clipboardPipeline = editor.plugins.get(ClipboardPipeline); + + this.listenTo(clipboardPipeline, 'inputTransformation', function (_event, data) { + var plainText = data.dataTransfer.getData('text/plain'); + data.content = editor.data.processor.toView(plainTextToHtml(plainText)); + }, { priority: 'high' }); + } + } + + function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function plainTextToHtml(value) { + var lines = String(value ?? '').replace(/\r\n?/g, '\n').split('\n'); + var paragraphs = []; + var currentParagraph = []; + + lines.forEach(function (line) { + if (line === '') { + if (currentParagraph.length) { + paragraphs.push('

    ' + currentParagraph.join('
    ') + '

    '); + currentParagraph = []; + } else { + paragraphs.push('

    '); + } + return; + } + + currentParagraph.push(escapeHtml(line)); + }); + + if (currentParagraph.length) { + paragraphs.push('

    ' + currentParagraph.join('
    ') + '

    '); + } + + return paragraphs.length ? paragraphs.join('') : '

    '; + } + + function getTemplateById(id) { + return templates.find(function (item) { return Number(item.id) === Number(id); }) || null; + } + + function getCurrentRegionValue(region) { + var current = existingContent[region.region_key]; + return current && current.value !== undefined ? current.value : ''; + } + + function normalizeEditorData(value) { + return String(value || ''); + } + + function requestPreviewRender() { + if (previewRenderFrame) { + return; + } + + previewRenderFrame = window.requestAnimationFrame(function () { + previewRenderFrame = 0; + renderPreview(); + }); + } + + function getTemplateFieldControls() { + return templateFields ? templateFields.querySelectorAll('input, textarea, select') : []; + } + + function sanitizePreviewHtml(html) { + var output = String(html || ''); + output = output.replace(//gi, ''); + output = output.replace(//gi, ''); + return output.replace(/<[^>]+>/g, function (tag) { + var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i); + if (!match) { + return ''; + } + var closing = Boolean(match[1]); + var name = String(match[2] || '').toLowerCase(); + var attrText = String(match[3] || ''); + var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr'; + var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; + if (allowed.indexOf(name) === -1) { + return ''; + } + if (closing) { + return ''; + } + if (selfClosing) { + return '<' + name + sanitizeTagAttributes(name, attrText) + '>'; + } + return '<' + name + sanitizeTagAttributes(name, attrText) + '>'; + }); + } + + function sanitizeTagAttributes(tagName, attrText) { + var allowedAttributes = { + a: ['href', 'title', 'target', 'rel', 'class', 'style'], + blockquote: ['class', 'style'], + div: ['class', 'style'], + figure: ['class', 'style'], + figcaption: ['class', 'style'], + h1: ['class', 'style'], + h2: ['class', 'style'], + h3: ['class', 'style'], + h4: ['class', 'style'], + h5: ['class', 'style'], + h6: ['class', 'style'], + li: ['class', 'style'], + ol: ['class', 'style', 'start'], + p: ['class', 'style'], + pre: ['class', 'style'], + span: ['class', 'style'], + table: ['class', 'style'], + td: ['class', 'style', 'colspan', 'rowspan'], + th: ['class', 'style', 'colspan', 'rowspan', 'scope'], + tr: ['class', 'style'], + ul: ['class', 'style'] + }; + var allowed = allowedAttributes[tagName] || []; + if (!allowed.length) { + return ''; + } + + var attrs = []; + attrText.replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) { + var lowerKey = String(key || '').toLowerCase(); + if (allowed.indexOf(lowerKey) === -1) { + return ''; + } + var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; + if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { + return ''; + } + if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) { + return ''; + } + if (lowerKey === 'target') { + var targetValue = String(value || '').trim(); + if (targetValue === '_blank') { + attrs.push(' target="_blank"'); + if (attrs.indexOf(' rel="noreferrer noopener"') === -1) { + attrs.push(' rel="noreferrer noopener"'); + } + return ''; + } + } + attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"'); + return ''; + }); + + return attrs.join(''); + } + + function renderPreviewListItem(item, tag) { + if (item && typeof item === 'object') { + var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : ''); + var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : []; + var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderPreviewListItem(child, tag); }).join('') + '' : ''; + return '
  • ' + sanitizePreviewHtml(content || '') + nested + '
  • '; + } + return '
  • ' + sanitizePreviewHtml(item || '') + '
  • '; + } + + function renderPreviewTable(data) { + var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : []; + if (!rows.length) { + return ''; + } + var hasHeadings = Boolean(data.withHeadings); + var body = rows.map(function (row, rowIndex) { + var cells = Array.isArray(row) ? row : []; + var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td'; + var cellAttrs = cellTag === 'th' ? ' scope="col"' : ''; + return '' + cells.map(function (cell) { + return '<' + cellTag + cellAttrs + '>' + sanitizePreviewHtml(cell || '') + ''; + }).join('') + ''; + }).join(''); + return '' + body + '
    '; + } + + function renderPreviewBlock(block) { + if (!block || !block.type || !block.data) { + return ''; + } + if (block.type === 'header') { + var level = Math.max(1, Math.min(6, Number(block.data.level || 2))); + return '' + sanitizePreviewHtml(block.data.text || '') + ''; + } + if (block.type === 'list') { + var tag = block.data.style === 'ordered' ? 'ol' : 'ul'; + var items = Array.isArray(block.data.items) ? block.data.items : []; + return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderPreviewListItem(item, tag); }).join('') + ''; + } + if (block.type === 'delimiter') { + return '
    '; + } + if (block.type === 'code') { + return '
    ' + sanitizePreviewHtml(block.data.code || '') + '
    '; + } + if (block.type === 'table') { + return renderPreviewTable(block.data); + } + if (block.type === 'paragraph') { + return '

    ' + sanitizePreviewHtml(block.data.text || '') + '

    '; + } + return ''; + } + + function renderPreviewText(value) { + var raw = String(value || ''); + if (!raw) { + return '
    Empty text
    '; + } + return sanitizePreviewHtml(raw); + } + + function renderPreviewTextRegion(region, value, style, scale) { + var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : ''; + var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : ''; + var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : ''; + var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1)); + var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1)); + var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color; + return '
    ' + renderPreviewText(value) + '
    '; + } + + function renderPreviewWebpageRegion(value) { + var src = String(value || '').trim(); + if (!src) { + return '
    Webpage
    '; + } + return ''; + } + + function renderPreviewHtmlRegion(value) { + var html = String(value || '').trim(); + if (!html) { + return '
    HTML
    '; + } + return ''; + } + + function destroyEditors() { + editorInstances.forEach(function (editor) { + if (editor && typeof editor.destroy === 'function') { + editor.destroy(); + } + }); + editorInstances.clear(); + } + + function getEditorHiddenInput(regionId) { + var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); + return card ? card.querySelector('input[type="hidden"][name="region_text_' + regionId + '"]') : null; + } + + function getEditorFontSizeHiddenInput(regionId) { + var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); + return card ? card.querySelector('input[name="region_font_size_' + regionId + '"]') : null; + } + + function normalizeFontSizeValue(value) { + var size = Math.max(1, Math.round(Number(String(value || '').replace(/[^0-9.]/g, '')))); + return size ? String(size) : ''; + } + + function getCurrentTextStyle(region) { + var current = existingContent[region.region_key] || {}; + return { + font_family: String(current.font_family || region.font_family || 'Arial').trim() || 'Arial', + font_size: Math.max(8, Number(current.font_size || 24)), + font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000' + }; + } + + function getTextStyleFromCard(_card, region) { + return getCurrentTextStyle(region); + } + + function getSelectedTemplate() { + return templates.find(function (item) { return Number(item.id) === Number(templateSelect.value); }) || null; + } + + function clearImagePreviewUrls() { + templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) { + if (input.dataset.previewUrl) { + URL.revokeObjectURL(input.dataset.previewUrl); + delete input.dataset.previewUrl; + } + }); + } + + function getRegionBox(region, canvasWidth, canvasHeight) { + return { + left: Math.max(0, Math.round(Number(region.x || 0))), + top: Math.max(0, Math.round(Number(region.y || 0))), + width: Math.max(1, Math.round(Number(region.width || 0) || 1)), + height: Math.max(1, Math.round(Number(region.height || 0) || 1)) + }; + } + + function getPreviewCanvasDimensions(template) { + return { + width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)), + height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080)) + }; + } + + function buildPreviewPopupHtml() { + return '' + + '' + + '' + + '' + + '' + + 'Slide preview' + + '' + + '' + + '' + + '' + + '' + + ''; + } + + function syncPopupPreview() { + if (!previewPopupWindow || previewPopupWindow.closed) { + previewPopupWindow = null; + return; + } + + var popupDocument = previewPopupWindow.document; + if (!popupDocument) { + return; + } + + var popupStage = popupDocument.getElementById('popup-preview-stage'); + var popupCanvas = popupDocument.getElementById('popup-preview-canvas'); + var template = getSelectedTemplate(); + + if (!popupStage || !popupCanvas) { + if (previewPopupRenderFrame) { + cancelAnimationFrame(previewPopupRenderFrame); + } + previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview); + return; + } + + if (!template) { + popupStage.style.backgroundColor = '#111111'; + popupCanvas.style.width = ''; + popupCanvas.style.height = ''; + popupCanvas.style.transform = ''; + popupCanvas.innerHTML = slidePreviewCanvas.innerHTML; + var popupEmpty = popupCanvas.querySelector('#slide-preview-empty'); + var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay'); + var popupBackground = popupCanvas.querySelector('#slide-preview-background'); + if (popupBackground) { + popupBackground.style.display = 'none'; + } + if (popupEmpty) { + popupEmpty.style.display = 'flex'; + popupEmpty.textContent = 'Select a template to preview the slide.'; + } + if (popupOverlay) { + popupOverlay.innerHTML = ''; + } + return; + } + + var canvasWidth = currentPreviewCanvasWidth || Math.max(1, Number(template.canvas_size_width || 1920)); + var canvasHeight = currentPreviewCanvasHeight || Math.max(1, Number(template.canvas_size_height || 1080)); + popupStage.style.backgroundColor = template.background_color || '#111111'; + popupCanvas.style.width = canvasWidth + 'px'; + popupCanvas.style.height = canvasHeight + 'px'; + popupCanvas.innerHTML = slidePreviewCanvas.innerHTML; + var popupBackground = popupCanvas.querySelector('#slide-preview-background'); + var popupEmpty = popupCanvas.querySelector('#slide-preview-empty'); + var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay'); + + if (popupBackground) { + popupBackground.style.display = template.background_image_path ? 'block' : 'none'; + popupBackground.src = template.background_image_path || ''; + } + if (popupEmpty) { + popupEmpty.style.display = 'none'; + } + + var stageRect = popupStage.getBoundingClientRect(); + if (!stageRect.width || !stageRect.height) { + if (previewPopupRenderFrame) { + cancelAnimationFrame(previewPopupRenderFrame); + } + previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview); + return; + } + + var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1; + popupCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')'; + } + + function openPreviewPopup() { + if (previewPopupWindow && !previewPopupWindow.closed) { + previewPopupWindow.focus(); + syncPopupPreview(); + return; + } + + var popupWidth = 1280; + var popupHeight = 900; + var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2)); + var top = Math.max(0, Math.round((window.screen.height - popupHeight) / 2)); + var popupFeatures = [ + 'popup=yes', + 'toolbar=no', + 'location=no', + 'menubar=no', + 'directories=no', + 'status=no', + 'scrollbars=yes', + 'resizable=yes', + 'width=' + popupWidth, + 'height=' + popupHeight, + 'left=' + left, + 'top=' + top + ].join(','); + + previewPopupWindow = window.open('', 'slide-preview-popup', popupFeatures); + if (!previewPopupWindow) { + return; + } + + previewPopupWindow.document.open(); + previewPopupWindow.document.write(buildPreviewPopupHtml()); + previewPopupWindow.document.close(); + previewPopupWindow.addEventListener('resize', syncPopupPreview); + previewPopupWindow.focus(); + syncPopupPreview(); + } + + function syncSlideEditorSidebar() { + sidebarSyncFrame = 0; + + if (!slideEditorShell || !slideEditorSidebar) { + return; + } + + if (window.matchMedia && window.matchMedia('(max-width: 1199.98px)').matches) { + slideEditorSidebar.style.transform = ''; + return; + } + + var shellRect = slideEditorShell.getBoundingClientRect(); + var sidebarHeight = slideEditorSidebar.offsetHeight; + var shellTop = window.scrollY + shellRect.top; + var shellBottom = shellTop + slideEditorShell.offsetHeight; + var maxTranslate = Math.max(0, shellBottom - shellTop - sidebarHeight - sidebarTopOffset); + var desiredTranslate = Math.max(0, Math.min(window.scrollY + sidebarTopOffset - shellTop, maxTranslate)); + + slideEditorSidebar.style.transform = desiredTranslate ? 'translate3d(0, ' + desiredTranslate + 'px, 0)' : ''; + } + + function requestSidebarSync() { + if (sidebarSyncFrame) { + return; + } + + sidebarSyncFrame = window.requestAnimationFrame(syncSlideEditorSidebar); + } + + function renderPreview() { + var template = getSelectedTemplate(); + if (!template) { + slidePreviewMeta.textContent = '--'; + slidePreviewBackground.style.display = 'none'; + slidePreviewEmpty.style.display = 'flex'; + slidePreviewEmpty.textContent = 'Select a template to preview the slide.'; + slidePreviewCanvas.style.width = ''; + slidePreviewCanvas.style.height = ''; + slidePreviewOverlay.innerHTML = ''; + slidePreviewOverlay.style.width = ''; + slidePreviewOverlay.style.height = ''; + slidePreviewOverlay.style.transform = ''; + slidePreviewStage.style.aspectRatio = '16 / 9'; + slidePreviewStage.style.backgroundColor = '#111111'; + return; + } + + var canvasSize = getPreviewCanvasDimensions(template); + var canvasWidth = canvasSize.width; + var canvasHeight = canvasSize.height; + currentPreviewCanvasWidth = canvasWidth; + currentPreviewCanvasHeight = canvasHeight; + slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight; + slidePreviewStage.style.backgroundColor = template.background_color || '#111111'; + slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight; + if (!(template.regions || []).length) { + slidePreviewEmpty.style.display = 'flex'; + slidePreviewEmpty.textContent = 'This template has no regions.'; + slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none'; + slidePreviewBackground.src = template.background_image_path || ''; + slidePreviewCanvas.style.width = canvasWidth + 'px'; + slidePreviewCanvas.style.height = canvasHeight + 'px'; + slidePreviewCanvas.style.transform = ''; + slidePreviewOverlay.innerHTML = ''; + slidePreviewOverlay.style.width = ''; + slidePreviewOverlay.style.height = ''; + slidePreviewOverlay.style.transform = ''; + return; + } + + slidePreviewEmpty.style.display = 'none'; + slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none'; + slidePreviewBackground.src = template.background_image_path || ''; + + var stageRect = slidePreviewStage.getBoundingClientRect(); + if (!stageRect.width || !stageRect.height) { + if (previewRenderFrame) { + cancelAnimationFrame(previewRenderFrame); + } + previewRenderFrame = requestAnimationFrame(function () { + previewRenderFrame = 0; + renderPreview(); + }); + return; + } + var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1; + slidePreviewCanvas.style.width = canvasWidth + 'px'; + slidePreviewCanvas.style.height = canvasHeight + 'px'; + slidePreviewCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')'; + slidePreviewCanvas.style.transformOrigin = 'top left'; + var regions = template.regions || []; + slidePreviewOverlay.innerHTML = regions.map(function (region) { + var box = getRegionBox(region, canvasWidth, canvasHeight); + var card = templateFields.querySelector('[data-region-id="' + region.id + '"]'); + var hiddenInput = card && card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]'); + var htmlInput = card && card.querySelector('textarea[name="region_html_' + region.id + '"]'); + var imageHidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]'); + var imageFileInput = card && card.querySelector('input[type="file"][name="region_image_' + region.id + '"]'); + var webpageInput = card && card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]'); + var value = region.region_type === 'image' + ? ((imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : '')) + : region.region_type === 'webpage' + ? (webpageInput ? webpageInput.value : '') + : region.region_type === 'html' + ? (htmlInput ? htmlInput.value : '') + : (hiddenInput ? hiddenInput.value : ''); + var style = region.region_type === 'image' + ? null + : region.region_type === 'webpage' + ? null + : region.region_type === 'html' + ? null + : getTextStyleFromCard(card, region); + var content = region.region_type === 'image' + ? (value ? '' : '
    Image
    ') + : region.region_type === 'webpage' + ? renderPreviewWebpageRegion(value) + : region.region_type === 'html' + ? renderPreviewHtmlRegion(value) + : renderPreviewTextRegion(region, value, style, scale); + var selected = region.region_type === 'image' + ? ' slide-preview-image-region' + : region.region_type === 'webpage' + ? ' slide-preview-webpage-region' + : region.region_type === 'html' + ? ' slide-preview-html-region' + : ' slide-preview-text-region'; + return '
    ' + content + '
    '; + }).join(''); + + syncPopupPreview(); + } + + function renderTextRegion(region) { + var current = getCurrentRegionValue(region); + return '' + + '
    ' + + '
    ' + + '' + escapeHtml(region.label) + '' + + '
    ' + + '' + + 'Text' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + '' + + '' + + '
    ' + + '
    '; + } + + function renderWebpageRegion(region) { + var current = getCurrentRegionValue(region); + return '' + + '
    ' + + '
    ' + + '' + escapeHtml(region.label) + '' + + 'Webpage' + + '
    ' + + '
    ' + + '' + + '
    The webpage will be loaded in an iframe.
    ' + + '
    ' + + '
    '; + } + + function renderHtmlRegion(region) { + var current = getCurrentRegionValue(region); + return '' + + '
    ' + + '
    ' + + '' + escapeHtml(region.label) + '' + + 'HTML' + + '
    ' + + '
    ' + + '' + + '
    HTML is rendered in a sandboxed iframe preview.
    ' + + '
    ' + + '
    '; + } + + function renderImageRegion(region) { + var current = getCurrentRegionValue(region); + return '' + + '
    ' + + '
    ' + + '' + escapeHtml(region.label) + '' + + 'Image' + + '
    ' + + '
    ' + + '' + + '' + + (current + ? '' + : '
    No image selected.
    ') + + '
    ' + + '
    '; + } + + function renderTemplate() { + templateSelectorLock.reset(); + clearImagePreviewUrls(); + destroyEditors(); + var template = getTemplateById(templateSelect.value); + if (!template) { + templateFields.innerHTML = ''; + requestPreviewRender(); + return; + } + + templateFields.innerHTML = template.regions.length ? template.regions.map(function (region) { + if (region.region_type === 'image') { + return renderImageRegion(region); + } + if (region.region_type === 'webpage') { + return renderWebpageRegion(region); + } + if (region.region_type === 'html') { + return renderHtmlRegion(region); + } + return renderTextRegion(region); + }).join('') : '
    This template has no regions.
    '; + + templateFields.querySelectorAll('.ckeditor-holder').forEach(function (holder) { + var regionId = holder.getAttribute('data-region-id'); + var source = holder.querySelector('.ckeditor-source'); + var hidden = getEditorHiddenInput(regionId); + var editorConfig = { + licenseKey: 'GPL', + placeholder: 'Write content...', + toolbar: { + items: [ + 'undo', + 'redo', + '|', + 'heading', + '|', + 'fontSizeInput', + 'fontFamily', + 'fontColor', + 'fontBackgroundColor', + '|', + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'subscript', + 'superscript', + '|', + 'horizontalLine', + 'blockQuote', + '|', + 'alignment', + '|', + 'outdent', + 'indent' + ], + shouldNotGroupWhenFull: false + }, + plugins: [ + Alignment, + BlockQuote, + Bold, + Essentials, + ForcePlainTextPaste, + FontBackgroundColor, + FontColor, + FontFamily, + FontSize, + FontSizeInputUI, + Heading, + HorizontalLine, + Indent, + IndentBlock, + Italic, + Paragraph, + Strikethrough, + Subscript, + Superscript, + Underline + ], + fontFamily: { + supportAllValues: true + }, + fontSize: { + options: [20, 24, 28, 32, 36, 40, 44], + supportAllValues: true + }, + heading: { + options: [ + { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' }, + { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' }, + { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' }, + { model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' }, + { model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' } + ] + }, + initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : '')) + }; + + ClassicEditor.create(source || holder, editorConfig).then(function (editor) { + editorInstances.set(regionId, editor); + var fontSizeHidden = getEditorFontSizeHiddenInput(regionId); + var fontSizeCommand = editor.commands.get('fontSize'); + if (hidden) { + hidden.value = editor.getData(); + } + if (fontSizeHidden && fontSizeCommand) { + fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value; + fontSizeCommand.on('change:value', function () { + fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value; + }); + } + editor.model.document.on('change:data', function () { + var instance = editorInstances.get(regionId); + if (instance !== editor) { + return; + } + if (hidden) { + hidden.value = editor.getData(); + } + templateSelectorLock.markEdited(); + requestPreviewRender(); + }); + requestPreviewRender(); + }).catch(function (error) { + console.error('Failed to initialize CKEditor.', error); + }); + }); + + templateFields.querySelectorAll('[data-toggle-editor-height]').forEach(function (button) { + button.addEventListener('click', function () { + var regionId = button.getAttribute('data-toggle-editor-height'); + var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); + if (!card) { + return; + } + + var expanded = card.classList.toggle('is-expanded'); + button.textContent = expanded ? 'Collapse' : 'Expand'; + }); + }); + + templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) { + input.addEventListener('change', function () { + if (input.dataset.previewUrl) { + URL.revokeObjectURL(input.dataset.previewUrl); + delete input.dataset.previewUrl; + } + if (input.files && input.files.length) { + input.dataset.previewUrl = URL.createObjectURL(input.files[0]); + } + templateSelectorLock.markEdited(); + requestPreviewRender(); + }); + }); + window.requestAnimationFrame(function () { + templateSelectorLock.arm(); + }); + requestPreviewRender(); + } + + slideForm.addEventListener('submit', async function (event) { + if (submitting) { + return; + } + event.preventDefault(); + var submitterValue = event.submitter && event.submitter.name === 'save_action' + ? String(event.submitter.value || '').trim().toLowerCase() + : ''; + var saves = Array.prototype.map.call(templateFields.querySelectorAll('.ckeditor-holder'), function (holder) { + var regionId = holder.getAttribute('data-region-id'); + var editor = editorInstances.get(regionId); + var hidden = getEditorHiddenInput(regionId); + if (!editor || !hidden) { + return Promise.resolve(); + } + hidden.value = editor.getData(); + return Promise.resolve(); + }); + submitting = true; + try { + await Promise.all(saves); + var formData = new FormData(slideForm); + if (event.submitter && event.submitter.name) { + formData.set(event.submitter.name, event.submitter.value || ''); + } + var response = await fetch(slideForm.action, { + method: (slideForm.method || 'POST').toUpperCase(), + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'text/html, application/json, text/plain, */*' + }, + body: formData, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(await response.text() || 'Unable to save slide.'); + } + + if (submitterValue === 'close' || submitterValue === 'new') { + var redirectUrl = submitterValue === 'close' + ? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action) + : String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action); + window.location.replace(redirectUrl); + return; + } + + var responseText = await response.text(); + var savedMessage = ''; + try { + var doc = new DOMParser().parseFromString(responseText || '', 'text/html'); + var toastBody = doc.querySelector('.toast-body'); + if (toastBody && toastBody.textContent) { + savedMessage = toastBody.textContent.trim(); + } + } catch (_error) { + savedMessage = ''; + } + + showToast(savedMessage || 'Saved slide.', 'success'); + slideForm.dataset.dirty = 'false'; + } finally { + submitting = false; + } + }); + + slideForm.addEventListener('input', function () { + slideForm.dataset.dirty = 'true'; + }, true); + + slideForm.addEventListener('change', function () { + slideForm.dataset.dirty = 'true'; + }, true); + + if (openPreviewPopupButton) { + openPreviewPopupButton.addEventListener('click', function () { + openPreviewPopup(); + }); + } + + templateSelect.addEventListener('change', renderTemplate); + templateFields.addEventListener('change', function () { + templateSelectorLock.markEdited(); + requestPreviewRender(); + }); + templateFields.addEventListener('input', function () { + templateSelectorLock.markEdited(); + requestPreviewRender(); + }); + if (existingTemplateId) { + templateSelect.value = String(existingTemplateId); + } + renderTemplate(); + if (window.ResizeObserver && slidePreviewStage) { + var previewResizeObserver = new ResizeObserver(function () { + requestPreviewRender(); + }); + previewResizeObserver.observe(slidePreviewStage); + } + requestSidebarSync(); + window.addEventListener('scroll', requestSidebarSync, { passive: true }); + window.addEventListener('resize', requestPreviewRender); + window.addEventListener('resize', requestSidebarSync); + window.addEventListener('beforeunload', function () { + if (previewPopupWindow && !previewPopupWindow.closed) { + previewPopupWindow.close(); + } + }); +})(); diff --git a/src/web/public/js/system-status.js b/src/web/public/js/system-status.js index 22c95d0..c908768 100644 --- a/src/web/public/js/system-status.js +++ b/src/web/public/js/system-status.js @@ -64,8 +64,10 @@ socket.onmessage = function (event) { try { var payload = JSON.parse(String(event.data || '{}')); - if (payload && payload.type === 'dashboard-state' && typeof window.webHandleDashboardState === 'function') { - window.webHandleDashboardState(payload.state); + if (payload && payload.type === 'dashboard-state') { + if (typeof window.webHandleDashboardState === 'function') { + window.webHandleDashboardState(payload.state); + } updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed'); } } catch (_error) { diff --git a/src/web/public/js/table-sort.js b/src/web/public/js/table-sort.js index 3cc135f..92da331 100644 --- a/src/web/public/js/table-sort.js +++ b/src/web/public/js/table-sort.js @@ -69,6 +69,45 @@ return table._sortableState; } + function ensureSortableHeaderIndicator(headerCell) { + var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator'); + if (indicator) { + return indicator; + } + + indicator = document.createElement('i'); + indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1'; + indicator.setAttribute('aria-hidden', 'true'); + headerCell.appendChild(indicator); + return indicator; + } + + function updateSortableHeaderIndicator(headerCell, isSortable, isActive, direction) { + if (!isSortable) { + var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator'); + if (hiddenIndicator) { + hiddenIndicator.style.display = 'none'; + } + return; + } + + var indicator = ensureSortableHeaderIndicator(headerCell); + indicator.style.display = ''; + indicator.className = 'table-sort-indicator bi ms-1'; + + if (isActive && direction === 'desc') { + indicator.classList.add('bi-caret-down-fill'); + return; + } + + if (isActive && direction === 'asc') { + indicator.classList.add('bi-caret-up-fill'); + return; + } + + indicator.classList.add('bi-arrow-down-up'); + } + function isSortableTableColumn(table, columnIndex) { var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null; if (!headerCell) { @@ -100,13 +139,15 @@ if (!headerCell) { return; } + var sortable = isSortableTableColumn(table, index); headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable'); headerCell.removeAttribute('aria-sort'); headerCell.removeAttribute('role'); headerCell.removeAttribute('tabindex'); - if (isSortableTableColumn(table, index)) { + if (sortable) { headerCell.classList.add('sortable'); + updateSortableHeaderIndicator(headerCell, true, state.columnIndex === index, state.direction); headerCell.setAttribute('role', 'button'); headerCell.setAttribute('tabindex', '0'); if (state.columnIndex === index) { @@ -117,6 +158,7 @@ } } else { headerCell.classList.add('unsortable'); + updateSortableHeaderIndicator(headerCell, false); } }); } @@ -168,6 +210,8 @@ return; } + ensureSortableHeaderIndicator(headerCell); + headerCell.addEventListener('click', function () { var state = getSortableTableState(table); var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc'; diff --git a/src/web/public/js/template-designer.js b/src/web/public/js/template-designer.js index 60c1d3b..bd07748 100644 --- a/src/web/public/js/template-designer.js +++ b/src/web/public/js/template-designer.js @@ -4,6 +4,8 @@ return; } + var utils = window.templateDesignerUtils || {}; + var templateData = {}; try { templateData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {}; @@ -24,27 +26,26 @@ var canvasWidthInput = document.getElementById('canvas-width'); var canvasHeightInput = document.getElementById('canvas-height'); var backgroundInput = document.getElementById('background-image'); + var backgroundColorInput = document.getElementById('background-color'); var backgroundPreview = document.getElementById('background-preview'); var backgroundEmpty = document.getElementById('background-empty'); var removeBackgroundButton = document.getElementById('remove-background-image'); var removeBackgroundFlag = document.getElementById('remove-background-image-flag'); - var addTextRegionButton = document.getElementById('add-text-region'); - var addImageRegionButton = document.getElementById('add-image-region'); + var addRegionButton = document.getElementById('add-region-button'); + var regionAddModal = document.getElementById('region-add-modal'); + var regionCardTemplate = document.getElementById('region-card-template'); var regionsJsonInput = document.getElementById('regions-json'); + var templateForm = document.getElementById('template-form'); var draft = null; var selectedIndex = -1; + var overlayRenderFrame = 0; function escapeHtml(value) { - return String(value === undefined || value === null ? '' : value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value); } function valueOrDefault(value, fallback) { - return value === undefined || value === null || value === '' ? fallback : value; + return utils.valueOrDefault ? utils.valueOrDefault(value, fallback) : (value === undefined || value === null || value === '' ? fallback : value); } function getCanvasSize() { @@ -55,7 +56,7 @@ } function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); + return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value)); } function getCards() { @@ -67,21 +68,62 @@ } function getRegionName(card) { - return String(card.querySelector('[name="region_name[]"]').value || '').trim(); + return utils.getRegionName ? utils.getRegionName(card) : String(card.querySelector('[name="region_name[]"]').value || '').trim(); } function syncRegionIdentity(card, value) { + if (utils.syncRegionIdentity) { + utils.syncRegionIdentity(card, value); + return; + } var next = String(value || '').trim(); card.querySelector('[name="region_name[]"]').value = next; card.querySelector('[name="region_key[]"]').value = next; card.querySelector('[name="region_label[]"]').value = next; } + function validateRegionNames() { + var cards = getCards(); + var names = {}; + var hasDuplicate = false; + + cards.forEach(function (card) { + var input = card.querySelector('[name="region_name[]"]'); + if (!input) { + return; + } + + var normalized = String(input.value || '').trim().toLowerCase(); + if (!normalized) { + input.setCustomValidity('Region name is required.'); + return; + } + + if (!names[normalized]) { + names[normalized] = []; + } + names[normalized].push(input); + }); + + Object.keys(names).forEach(function (key) { + var inputs = names[key]; + if (inputs.length > 1) { + hasDuplicate = true; + inputs.forEach(function (input) { + input.setCustomValidity('Region names must be unique on this template.'); + }); + } else { + inputs[0].setCustomValidity(''); + } + }); + + return !hasDuplicate; + } + function readCard(card) { - var name = getRegionName(card); - return { - region_key: name, - label: name, + return utils.readCard ? utils.readCard(card) : { + region_key: getRegionName(card), + label: getRegionName(card), region_type: card.querySelector('[name="region_type[]"]').value, font_family: card.querySelector('[name="font_family[]"]').value, x: Number(card.querySelector('[name="region_x[]"]').value || 0), @@ -93,6 +135,10 @@ } function writeCard(card, values) { + if (utils.writeCard) { + utils.writeCard(card, values); + return; + } if (values.region_name !== undefined) { syncRegionIdentity(card, values.region_name); } else if (values.region_key !== undefined) { @@ -101,7 +147,7 @@ syncRegionIdentity(card, values.label); } if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; } - if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; updateRegionFieldVisibility(card); } + if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; } if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); } if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); } if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); } @@ -110,28 +156,22 @@ } function getOverlayRect() { - return overlay.getBoundingClientRect(); + return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect(); } function toCanvasPoint(event) { - var rect = getOverlayRect(); - var size = getCanvasSize(); - var x = clamp(event.clientX - rect.left, 0, rect.width); - var y = clamp(event.clientY - rect.top, 0, rect.height); - return { - x: Math.round((x / Math.max(rect.width, 1)) * size.width), - y: Math.round((y / Math.max(rect.height, 1)) * size.height) + return utils.toCanvasPoint ? utils.toCanvasPoint(event, overlay, getCanvasSize()) : { + x: 0, + y: 0 }; } function canvasRectToPixels(region) { - var rect = getOverlayRect(); - var size = getCanvasSize(); - return { - left: (region.x / size.width) * rect.width, - top: (region.y / size.height) * rect.height, - width: (region.width / size.width) * rect.width, - height: (region.height / size.height) * rect.height + return utils.canvasRectToPixels ? utils.canvasRectToPixels(region, overlay, getCanvasSize()) : { + left: 0, + top: 0, + width: 0, + height: 0 }; } @@ -182,40 +222,84 @@ reader.readAsDataURL(file); } - function updateRegionFieldVisibility(card) { - var typeSelect = card.querySelector('[name="region_type[]"]'); - var fontField = card.querySelector('[name="font_family[]"]'); - if (!typeSelect || !fontField) { + function updateStageBackgroundColor() { + if (!stage) { return; } - fontField.value = typeSelect.value === 'text' || typeSelect.value === 'html' ? 'Arial' : ''; + stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111'; + } + + function getRegionChipLabel(regionType) { + return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : 'Text'; + } + + function populateRegionCard(card, region) { + var chip = card.querySelector('[data-region-chip]'); + var title = card.querySelector('[data-region-title]'); + var nameInput = card.querySelector('[name="region_name[]"]'); + var fontFamilyInput = card.querySelector('[name="font_family[]"]'); + var regionTypeInput = card.querySelector('[name="region_type[]"]'); + var regionKeyInput = card.querySelector('[name="region_key[]"]'); + var regionLabelInput = card.querySelector('[name="region_label[]"]'); + + if (title) { + title.textContent = region.label || region.region_key || 'Region'; + } + if (chip) { + chip.textContent = getRegionChipLabel(region.region_type); + } + if (nameInput) { + nameInput.value = region.region_key || region.label || ''; + } + if (fontFamilyInput) { + fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial'); + } + if (regionTypeInput) { + regionTypeInput.value = region.region_type || 'text'; + } + if (regionKeyInput) { + regionKeyInput.value = region.region_key || region.label || ''; + } + if (regionLabelInput) { + regionLabelInput.value = region.label || region.region_key || ''; + } + card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80); + card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80); + card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1); + card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300); + card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120); + } + + function updateRegionLabel(card) { + var label = getRegionName(card) || 'Region'; + var cards = getCards(); + var index = cards.indexOf(card); + var title = card.querySelector('.template-field-head strong'); + if (title) { + title.textContent = label; + } + if (index >= 0 && regionSelect.options[index]) { + regionSelect.options[index].textContent = label; + } } function makeRegionCard(region) { - var card = document.createElement('div'); - card.className = 'region-item'; - var chipLabel = region.region_type === 'image' ? 'Image' : region.region_type === 'webpage' ? 'Webpage' : region.region_type === 'html' ? 'HTML' : 'Text'; - card.innerHTML = '' + - '' + - '' + - '' + - '' + - '' + - '
    ' + escapeHtml(region.label || region.region_key || '') + '' + escapeHtml(chipLabel) + '
    ' + - '
    ' + - '
    ' + - '
    '; + var card; + if (regionCardTemplate && regionCardTemplate.content) { + card = regionCardTemplate.content.firstElementChild.cloneNode(true); + } else { + card = document.createElement('div'); + card.className = 'card card-outline card-secondary admin-form-card region-item mb-3'; + } + populateRegionCard(card, region); var nameInput = card.querySelector('[name="region_name[]"]'); - var typeSelect = card.querySelector('[name="region_type[]"]'); nameInput.addEventListener('input', function () { syncRegionIdentity(card, nameInput.value); + updateRegionLabel(card); + validateRegionNames(); renderRegionSidebar(); renderOverlay(); }); - typeSelect.addEventListener('change', function () { - updateRegionFieldVisibility(card); - renderOverlay(); - }); card.addEventListener('click', function (event) { if (event.target && event.target.classList && event.target.classList.contains('remove-region')) { return; @@ -233,7 +317,6 @@ renderRegionSidebar(); renderOverlay(); }); - updateRegionFieldVisibility(card); return card; } @@ -269,6 +352,7 @@ }); regionSelect.value = String(selectedIndex); updateCanvasSizeLock(); + validateRegionNames(); } function renderOverlay() { @@ -290,6 +374,17 @@ } } + function requestOverlayRender() { + if (overlayRenderFrame) { + return; + } + + overlayRenderFrame = window.requestAnimationFrame(function () { + overlayRenderFrame = 0; + renderOverlay(); + }); + } + function render() { updateAspectRatio(); renderRegionSidebar(); @@ -318,6 +413,13 @@ setSelected(getCards().length - 1); } + function openAddRegionModal() { + if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) { + return; + } + window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show(); + } + function createDefaultRegion(type) { var count = getCards().length + 1; var name = 'region_' + count; @@ -356,7 +458,7 @@ renderOverlay(); function moveHandler(moveEvent) { draft.end = toCanvasPoint(moveEvent); - renderOverlay(); + requestOverlayRender(); } function upHandler(upEvent) { draft.end = toCanvasPoint(upEvent); @@ -367,7 +469,7 @@ addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 }); } draft = null; - renderOverlay(); + requestOverlayRender(); document.removeEventListener('mousemove', moveHandler); document.removeEventListener('mouseup', upHandler); } @@ -384,7 +486,7 @@ var dy = currentPoint.y - startPoint.y; var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height }); writeCard(cardAt(index), { x: next.x, y: next.y }); - renderOverlay(); + requestOverlayRender(); } function upHandler() { document.removeEventListener('mousemove', moveHandler); @@ -410,7 +512,7 @@ if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; } next = clampRegion(next); writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height }); - renderOverlay(); + requestOverlayRender(); } function upHandler() { document.removeEventListener('mousemove', moveHandler); @@ -420,15 +522,20 @@ document.addEventListener('mouseup', upHandler); } - addTextRegionButton.addEventListener('click', function () { addRegion(createDefaultRegion('text')); }); - var addHtmlRegionButton = document.getElementById('add-html-region'); - if (addHtmlRegionButton) { - addHtmlRegionButton.addEventListener('click', function () { addRegion(createDefaultRegion('html')); }); - } - addImageRegionButton.addEventListener('click', function () { addRegion(createDefaultRegion('image')); }); - var addWebpageRegionButton = document.getElementById('add-webpage-region'); - if (addWebpageRegionButton) { - addWebpageRegionButton.addEventListener('click', function () { addRegion(createDefaultRegion('webpage')); }); + if (addRegionButton && regionAddModal) { + var addRegionTypeButtons = regionAddModal.querySelectorAll('[data-add-region-type]'); + addRegionButton.addEventListener('click', function () { + openAddRegionModal(); + }); + Array.prototype.forEach.call(addRegionTypeButtons, function (button) { + button.addEventListener('click', function () { + var regionType = button.getAttribute('data-add-region-type'); + addRegion(createDefaultRegion(regionType)); + if (window.bootstrap && window.bootstrap.Modal) { + window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide(); + } + }); + }); } backgroundInput.addEventListener('change', function () { var file = backgroundInput.files && backgroundInput.files[0]; @@ -445,6 +552,9 @@ backgroundEmpty.style.display = 'block'; }); } + if (backgroundColorInput) { + backgroundColorInput.addEventListener('input', updateStageBackgroundColor); + } canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); }); canvasWidthInput.addEventListener('input', render); canvasHeightInput.addEventListener('input', render); @@ -470,12 +580,27 @@ setSelected(-1); startDraw(event); }); - document.getElementById('template-form').addEventListener('submit', function () { - syncCanvasSizeSelection(); - regionsJsonInput.value = JSON.stringify(getCards().map(readCard)); - }); + if (templateForm) { + templateForm.addEventListener('formdata', function (event) { + if (!validateRegionNames()) { + event.preventDefault(); + return; + } + syncCanvasSizeSelection(); + event.formData.set('regions_json', JSON.stringify(getCards().map(readCard))); + }); + + templateForm.addEventListener('submit', function () { + if (!validateRegionNames()) { + return; + } + syncCanvasSizeSelection(); + regionsJsonInput.value = JSON.stringify(getCards().map(readCard)); + }); + } renderRegionList(existingRegions); syncCanvasSizeSelection(); + updateStageBackgroundColor(); render(); })(); diff --git a/src/web/public/js/templates/template-designer-utils.js b/src/web/public/js/templates/template-designer-utils.js new file mode 100644 index 0000000..8dc167d --- /dev/null +++ b/src/web/public/js/templates/template-designer-utils.js @@ -0,0 +1,117 @@ +(function () { + function escapeHtml(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function valueOrDefault(value, fallback) { + return value === undefined || value === null || value === '' ? fallback : value; + } + + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + function getRegionName(card) { + return String(card.querySelector('[name="region_name[]"]').value || '').trim(); + } + + function syncRegionIdentity(card, value) { + var next = String(value || '').trim(); + card.querySelector('[name="region_name[]"]').value = next; + card.querySelector('[name="region_key[]"]').value = next; + card.querySelector('[name="region_label[]"]').value = next; + } + + function readCard(card) { + var name = getRegionName(card); + return { + region_key: name, + label: name, + region_type: card.querySelector('[name="region_type[]"]').value, + font_family: card.querySelector('[name="font_family[]"]').value, + x: Number(card.querySelector('[name="region_x[]"]').value || 0), + y: Number(card.querySelector('[name="region_y[]"]').value || 0), + width: Number(card.querySelector('[name="region_width[]"]').value || 0), + height: Number(card.querySelector('[name="region_height[]"]').value || 0), + z_index: Number(card.querySelector('[name="region_z[]"]').value || 0) + }; + } + + function writeCard(card, values) { + if (values.region_name !== undefined) { + syncRegionIdentity(card, values.region_name); + } else if (values.region_key !== undefined) { + syncRegionIdentity(card, values.region_key); + } else if (values.label !== undefined) { + syncRegionIdentity(card, values.label); + } + if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; } + if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; } + if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); } + if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); } + if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); } + if (values.height !== undefined) { card.querySelector('[name="region_height[]"]').value = Math.round(values.height); } + if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.round(values.z_index); } + } + + function clampRegion(region, size, minSize) { + var bounds = size || { width: 1920, height: 1080 }; + var minimum = minSize || 12; + var x = clamp(region.x, 0, bounds.width - minimum); + var y = clamp(region.y, 0, bounds.height - minimum); + var width = Math.max(minimum, region.width); + var height = Math.max(minimum, region.height); + if (x + width > bounds.width) { + width = bounds.width - x; + } + if (y + height > bounds.height) { + height = bounds.height - y; + } + return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(minimum, width)), height: Math.round(Math.max(minimum, height)) }; + } + + function getOverlayRect(overlay) { + return overlay.getBoundingClientRect(); + } + + function toCanvasPoint(event, overlay, size) { + var rect = getOverlayRect(overlay); + var canvasSize = size || { width: 1920, height: 1080 }; + var x = clamp(event.clientX - rect.left, 0, rect.width); + var y = clamp(event.clientY - rect.top, 0, rect.height); + return { + x: Math.round((x / Math.max(rect.width, 1)) * canvasSize.width), + y: Math.round((y / Math.max(rect.height, 1)) * canvasSize.height) + }; + } + + function canvasRectToPixels(region, overlay, size) { + var rect = getOverlayRect(overlay); + var canvasSize = size || { width: 1920, height: 1080 }; + return { + left: (region.x / canvasSize.width) * rect.width, + top: (region.y / canvasSize.height) * rect.height, + width: (region.width / canvasSize.width) * rect.width, + height: (region.height / canvasSize.height) * rect.height + }; + } + + window.templateDesignerUtils = { + escapeHtml: escapeHtml, + valueOrDefault: valueOrDefault, + clamp: clamp, + getRegionName: getRegionName, + syncRegionIdentity: syncRegionIdentity, + readCard: readCard, + writeCard: writeCard, + clampRegion: clampRegion, + getOverlayRect: getOverlayRect, + toCanvasPoint: toCanvasPoint, + canvasRectToPixels: canvasRectToPixels + }; +}()); diff --git a/src/web/public/js/templates/template-designer.js b/src/web/public/js/templates/template-designer.js new file mode 100644 index 0000000..bd07748 --- /dev/null +++ b/src/web/public/js/templates/template-designer.js @@ -0,0 +1,606 @@ +(function () { + var dataElement = document.getElementById('template-editor-data'); + if (!dataElement) { + return; + } + + var utils = window.templateDesignerUtils || {}; + + var templateData = {}; + try { + templateData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {}; + } catch (_error) { + templateData = {}; + } + + var existingRegions = Array.isArray(templateData.regions) + ? templateData.regions + : (Array.isArray(templateData) ? templateData : []); + var stage = document.getElementById('designer-stage'); + var overlay = document.getElementById('designer-overlay'); + var regionList = document.getElementById('region-list'); + var regionSelect = document.getElementById('region-select'); + var canvasSizeSelect = document.getElementById('canvas-size-select'); + var canvasSizeIdInput = document.getElementById('canvas-size-id'); + var canvasSizeSummary = document.getElementById('canvas-size-summary'); + var canvasWidthInput = document.getElementById('canvas-width'); + var canvasHeightInput = document.getElementById('canvas-height'); + var backgroundInput = document.getElementById('background-image'); + var backgroundColorInput = document.getElementById('background-color'); + var backgroundPreview = document.getElementById('background-preview'); + var backgroundEmpty = document.getElementById('background-empty'); + var removeBackgroundButton = document.getElementById('remove-background-image'); + var removeBackgroundFlag = document.getElementById('remove-background-image-flag'); + var addRegionButton = document.getElementById('add-region-button'); + var regionAddModal = document.getElementById('region-add-modal'); + var regionCardTemplate = document.getElementById('region-card-template'); + var regionsJsonInput = document.getElementById('regions-json'); + var templateForm = document.getElementById('template-form'); + var draft = null; + var selectedIndex = -1; + var overlayRenderFrame = 0; + + function escapeHtml(value) { + return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value); + } + + function valueOrDefault(value, fallback) { + return utils.valueOrDefault ? utils.valueOrDefault(value, fallback) : (value === undefined || value === null || value === '' ? fallback : value); + } + + function getCanvasSize() { + return { + width: Math.max(1, Number(canvasWidthInput.value || 1920)), + height: Math.max(1, Number(canvasHeightInput.value || 1080)) + }; + } + + function clamp(value, min, max) { + return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value)); + } + + function getCards() { + return Array.prototype.slice.call(regionList.querySelectorAll('.region-item')); + } + + function cardAt(index) { + return getCards()[index] || null; + } + + function getRegionName(card) { + return utils.getRegionName ? utils.getRegionName(card) : String(card.querySelector('[name="region_name[]"]').value || '').trim(); + } + + function syncRegionIdentity(card, value) { + if (utils.syncRegionIdentity) { + utils.syncRegionIdentity(card, value); + return; + } + var next = String(value || '').trim(); + card.querySelector('[name="region_name[]"]').value = next; + card.querySelector('[name="region_key[]"]').value = next; + card.querySelector('[name="region_label[]"]').value = next; + } + + function validateRegionNames() { + var cards = getCards(); + var names = {}; + var hasDuplicate = false; + + cards.forEach(function (card) { + var input = card.querySelector('[name="region_name[]"]'); + if (!input) { + return; + } + + var normalized = String(input.value || '').trim().toLowerCase(); + if (!normalized) { + input.setCustomValidity('Region name is required.'); + return; + } + + if (!names[normalized]) { + names[normalized] = []; + } + names[normalized].push(input); + }); + + Object.keys(names).forEach(function (key) { + var inputs = names[key]; + if (inputs.length > 1) { + hasDuplicate = true; + inputs.forEach(function (input) { + input.setCustomValidity('Region names must be unique on this template.'); + }); + } else { + inputs[0].setCustomValidity(''); + } + }); + + return !hasDuplicate; + } + + function readCard(card) { + return utils.readCard ? utils.readCard(card) : { + region_key: getRegionName(card), + label: getRegionName(card), + region_type: card.querySelector('[name="region_type[]"]').value, + font_family: card.querySelector('[name="font_family[]"]').value, + x: Number(card.querySelector('[name="region_x[]"]').value || 0), + y: Number(card.querySelector('[name="region_y[]"]').value || 0), + width: Number(card.querySelector('[name="region_width[]"]').value || 0), + height: Number(card.querySelector('[name="region_height[]"]').value || 0), + z_index: Number(card.querySelector('[name="region_z[]"]').value || 0) + }; + } + + function writeCard(card, values) { + if (utils.writeCard) { + utils.writeCard(card, values); + return; + } + if (values.region_name !== undefined) { + syncRegionIdentity(card, values.region_name); + } else if (values.region_key !== undefined) { + syncRegionIdentity(card, values.region_key); + } else if (values.label !== undefined) { + syncRegionIdentity(card, values.label); + } + if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; } + if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; } + if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); } + if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); } + if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); } + if (values.height !== undefined) { card.querySelector('[name="region_height[]"]').value = Math.round(values.height); } + if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.round(values.z_index); } + } + + function getOverlayRect() { + return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect(); + } + + function toCanvasPoint(event) { + return utils.toCanvasPoint ? utils.toCanvasPoint(event, overlay, getCanvasSize()) : { + x: 0, + y: 0 + }; + } + + function canvasRectToPixels(region) { + return utils.canvasRectToPixels ? utils.canvasRectToPixels(region, overlay, getCanvasSize()) : { + left: 0, + top: 0, + width: 0, + height: 0 + }; + } + + function updateAspectRatio() { + var size = getCanvasSize(); + stage.style.aspectRatio = size.width + ' / ' + size.height; + } + + function updateCanvasSizeSummary() { + var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex]; + canvasSizeSummary.textContent = option ? option.textContent : ''; + } + + function updateCanvasSizeLock() { + var lockOnExistingTemplate = canvasSizeSelect.dataset.lockOnExistingTemplate === 'true'; + var locked = lockOnExistingTemplate && getCards().length > 0; + canvasSizeSelect.disabled = locked; + canvasSizeSummary.classList.toggle('is-locked', locked); + } + + function syncCanvasSizeSelection() { + var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex]; + if (!option) { + return; + } + if (canvasSizeIdInput) { + canvasSizeIdInput.value = option.value; + } + canvasWidthInput.value = Math.max(1, Number(option.dataset.width || canvasWidthInput.value || 1920)); + canvasHeightInput.value = Math.max(1, Number(option.dataset.height || canvasHeightInput.value || 1080)); + updateAspectRatio(); + updateCanvasSizeSummary(); + } + + function updateBackgroundPreview(file) { + if (!file) { + return; + } + if (removeBackgroundFlag) { + removeBackgroundFlag.checked = false; + } + var reader = new FileReader(); + reader.onload = function () { + backgroundPreview.src = reader.result; + backgroundPreview.style.display = 'block'; + backgroundEmpty.style.display = 'none'; + }; + reader.readAsDataURL(file); + } + + function updateStageBackgroundColor() { + if (!stage) { + return; + } + stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111'; + } + + function getRegionChipLabel(regionType) { + return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : 'Text'; + } + + function populateRegionCard(card, region) { + var chip = card.querySelector('[data-region-chip]'); + var title = card.querySelector('[data-region-title]'); + var nameInput = card.querySelector('[name="region_name[]"]'); + var fontFamilyInput = card.querySelector('[name="font_family[]"]'); + var regionTypeInput = card.querySelector('[name="region_type[]"]'); + var regionKeyInput = card.querySelector('[name="region_key[]"]'); + var regionLabelInput = card.querySelector('[name="region_label[]"]'); + + if (title) { + title.textContent = region.label || region.region_key || 'Region'; + } + if (chip) { + chip.textContent = getRegionChipLabel(region.region_type); + } + if (nameInput) { + nameInput.value = region.region_key || region.label || ''; + } + if (fontFamilyInput) { + fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial'); + } + if (regionTypeInput) { + regionTypeInput.value = region.region_type || 'text'; + } + if (regionKeyInput) { + regionKeyInput.value = region.region_key || region.label || ''; + } + if (regionLabelInput) { + regionLabelInput.value = region.label || region.region_key || ''; + } + card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80); + card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80); + card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1); + card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300); + card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120); + } + + function updateRegionLabel(card) { + var label = getRegionName(card) || 'Region'; + var cards = getCards(); + var index = cards.indexOf(card); + var title = card.querySelector('.template-field-head strong'); + if (title) { + title.textContent = label; + } + if (index >= 0 && regionSelect.options[index]) { + regionSelect.options[index].textContent = label; + } + } + + function makeRegionCard(region) { + var card; + if (regionCardTemplate && regionCardTemplate.content) { + card = regionCardTemplate.content.firstElementChild.cloneNode(true); + } else { + card = document.createElement('div'); + card.className = 'card card-outline card-secondary admin-form-card region-item mb-3'; + } + populateRegionCard(card, region); + var nameInput = card.querySelector('[name="region_name[]"]'); + nameInput.addEventListener('input', function () { + syncRegionIdentity(card, nameInput.value); + updateRegionLabel(card); + validateRegionNames(); + renderRegionSidebar(); + renderOverlay(); + }); + card.addEventListener('click', function (event) { + if (event.target && event.target.classList && event.target.classList.contains('remove-region')) { + return; + } + setSelected(getCards().indexOf(card)); + }); + card.querySelector('.remove-region').addEventListener('click', function (event) { + event.preventDefault(); + card.remove(); + if (!getCards().length) { + selectedIndex = -1; + } else if (selectedIndex >= getCards().length) { + selectedIndex = getCards().length - 1; + } + renderRegionSidebar(); + renderOverlay(); + }); + return card; + } + + function renderRegionList(initialRegions) { + regionList.innerHTML = ''; + initialRegions.forEach(function (region) { + regionList.appendChild(makeRegionCard(region)); + }); + } + + function renderRegionSidebar() { + var cards = getCards(); + regionSelect.innerHTML = ''; + if (!cards.length) { + regionSelect.disabled = true; + regionList.innerHTML = ''; + updateCanvasSizeLock(); + return; + } + regionSelect.disabled = false; + if (selectedIndex < 0 || selectedIndex >= cards.length) { + selectedIndex = 0; + } + cards.forEach(function (card, index) { + var option = document.createElement('option'); + option.value = String(index); + option.textContent = getRegionName(card) || ('Region ' + (index + 1)); + if (index === selectedIndex) { + option.selected = true; + } + regionSelect.appendChild(option); + card.hidden = index !== selectedIndex; + }); + regionSelect.value = String(selectedIndex); + updateCanvasSizeLock(); + validateRegionNames(); + } + + function renderOverlay() { + var cards = getCards(); + var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null; + var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1; + var regions = cards.map(readCard); + regionsJsonInput.value = JSON.stringify(regions); + overlay.innerHTML = regions.map(function (region, index) { + var box = canvasRectToPixels(region); + var selected = index === selectedNow ? ' selected' : ''; + return '
    ' + escapeHtml(region.label || region.region_key || 'Region') + '
    '; + }).join(''); + if (draft) { + var rect = getOverlayRect(); + var size = getCanvasSize(); + var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) }; + overlay.innerHTML += '
    '; + } + } + + function requestOverlayRender() { + if (overlayRenderFrame) { + return; + } + + overlayRenderFrame = window.requestAnimationFrame(function () { + overlayRenderFrame = 0; + renderOverlay(); + }); + } + + function render() { + updateAspectRatio(); + renderRegionSidebar(); + renderOverlay(); + } + + function setSelected(index) { + var cards = getCards(); + if (!cards.length) { + selectedIndex = -1; + } else if (index < 0) { + selectedIndex = 0; + } else { + selectedIndex = clamp(index, 0, cards.length - 1); + } + renderRegionSidebar(); + renderOverlay(); + } + + function addRegion(region) { + var hint = regionList.querySelector('.muted'); + if (hint) { + hint.remove(); + } + regionList.appendChild(makeRegionCard(region)); + setSelected(getCards().length - 1); + } + + function openAddRegionModal() { + if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) { + return; + } + window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show(); + } + + function createDefaultRegion(type) { + var count = getCards().length + 1; + var name = 'region_' + count; + return { + region_key: name, + label: name, + region_type: type, + font_family: type === 'text' || type === 'html' ? 'Arial' : '', + x: 80, + y: 80, + width: type === 'image' || type === 'webpage' || type === 'html' ? 420 : 300, + height: type === 'image' || type === 'webpage' || type === 'html' ? 240 : 120, + z_index: 1 + }; + } + + function clampRegion(region) { + var size = getCanvasSize(); + var minSize = 12; + var x = clamp(region.x, 0, size.width - minSize); + var y = clamp(region.y, 0, size.height - minSize); + var width = Math.max(minSize, region.width); + var height = Math.max(minSize, region.height); + if (x + width > size.width) { + width = size.width - x; + } + if (y + height > size.height) { + height = size.height - y; + } + return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(minSize, width)), height: Math.round(Math.max(minSize, height)) }; + } + + function startDraw(event) { + var start = toCanvasPoint(event); + draft = { start: start, end: start }; + renderOverlay(); + function moveHandler(moveEvent) { + draft.end = toCanvasPoint(moveEvent); + requestOverlayRender(); + } + function upHandler(upEvent) { + draft.end = toCanvasPoint(upEvent); + var width = Math.abs(draft.end.x - draft.start.x); + var height = Math.abs(draft.end.y - draft.start.y); + if (width >= 8 && height >= 8) { + var region = clampRegion({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height }); + addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 }); + } + draft = null; + requestOverlayRender(); + document.removeEventListener('mousemove', moveHandler); + document.removeEventListener('mouseup', upHandler); + } + document.addEventListener('mousemove', moveHandler); + document.addEventListener('mouseup', upHandler); + } + + function startMove(index, event) { + var startPoint = toCanvasPoint(event); + var startRegion = readCard(cardAt(index)); + function moveHandler(moveEvent) { + var currentPoint = toCanvasPoint(moveEvent); + var dx = currentPoint.x - startPoint.x; + var dy = currentPoint.y - startPoint.y; + var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height }); + writeCard(cardAt(index), { x: next.x, y: next.y }); + requestOverlayRender(); + } + function upHandler() { + document.removeEventListener('mousemove', moveHandler); + document.removeEventListener('mouseup', upHandler); + } + document.addEventListener('mousemove', moveHandler); + document.addEventListener('mouseup', upHandler); + } + + function resizeFromHandle(index, dir, event) { + var startPoint = toCanvasPoint(event); + var startRegion = readCard(cardAt(index)); + function moveHandler(moveEvent) { + var currentPoint = toCanvasPoint(moveEvent); + var dx = currentPoint.x - startPoint.x; + var dy = currentPoint.y - startPoint.y; + var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height }; + if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; } + if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; } + if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; } + if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; } + if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; } + if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; } + next = clampRegion(next); + writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height }); + requestOverlayRender(); + } + function upHandler() { + document.removeEventListener('mousemove', moveHandler); + document.removeEventListener('mouseup', upHandler); + } + document.addEventListener('mousemove', moveHandler); + document.addEventListener('mouseup', upHandler); + } + + if (addRegionButton && regionAddModal) { + var addRegionTypeButtons = regionAddModal.querySelectorAll('[data-add-region-type]'); + addRegionButton.addEventListener('click', function () { + openAddRegionModal(); + }); + Array.prototype.forEach.call(addRegionTypeButtons, function (button) { + button.addEventListener('click', function () { + var regionType = button.getAttribute('data-add-region-type'); + addRegion(createDefaultRegion(regionType)); + if (window.bootstrap && window.bootstrap.Modal) { + window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide(); + } + }); + }); + } + backgroundInput.addEventListener('change', function () { + var file = backgroundInput.files && backgroundInput.files[0]; + if (file) { + updateBackgroundPreview(file); + } + }); + if (removeBackgroundButton && removeBackgroundFlag) { + removeBackgroundButton.addEventListener('click', function () { + removeBackgroundFlag.checked = true; + backgroundInput.value = ''; + backgroundPreview.removeAttribute('src'); + backgroundPreview.style.display = 'none'; + backgroundEmpty.style.display = 'block'; + }); + } + if (backgroundColorInput) { + backgroundColorInput.addEventListener('input', updateStageBackgroundColor); + } + canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); }); + canvasWidthInput.addEventListener('input', render); + canvasHeightInput.addEventListener('input', render); + regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); }); + overlay.addEventListener('mousedown', function (event) { + var rect = event.target.closest('.designer-rect'); + if (rect) { + var index = Number(rect.getAttribute('data-index')); + var handle = event.target.closest('.resize-handle'); + event.preventDefault(); + setSelected(index); + if (handle) { + resizeFromHandle(index, handle.getAttribute('data-dir'), event); + } else { + startMove(index, event); + } + return; + } + if (event.target !== overlay && !event.target.classList.contains('designer-overlay')) { + return; + } + event.preventDefault(); + setSelected(-1); + startDraw(event); + }); + if (templateForm) { + templateForm.addEventListener('formdata', function (event) { + if (!validateRegionNames()) { + event.preventDefault(); + return; + } + syncCanvasSizeSelection(); + event.formData.set('regions_json', JSON.stringify(getCards().map(readCard))); + }); + + templateForm.addEventListener('submit', function () { + if (!validateRegionNames()) { + return; + } + syncCanvasSizeSelection(); + regionsJsonInput.value = JSON.stringify(getCards().map(readCard)); + }); + } + + renderRegionList(existingRegions); + syncCanvasSizeSelection(); + updateStageBackgroundColor(); + render(); +})(); diff --git a/src/web/public/js/theme-init.js b/src/web/public/js/theme-init.js index f867351..5859470 100644 --- a/src/web/public/js/theme-init.js +++ b/src/web/public/js/theme-init.js @@ -1,20 +1,16 @@ (function () { - var storageKey = 'web-theme'; - var theme = 'light'; + var storageKey = 'lte-theme'; + var theme = 'auto'; try { var storedTheme = window.localStorage.getItem(storageKey); - if (storedTheme === 'dark' || storedTheme === 'light') { + if (storedTheme === 'dark' || storedTheme === 'light' || storedTheme === 'auto') { theme = storedTheme; - } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - theme = 'dark'; } } catch (error) { - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - theme = 'dark'; - } + theme = 'auto'; } - document.documentElement.dataset.theme = theme; + document.documentElement.dataset.bsTheme = theme; document.documentElement.style.colorScheme = theme; }()); \ No newline at end of file diff --git a/src/web/public/js/theme-sidebar.js b/src/web/public/js/theme-sidebar.js index c6a8252..f4704a5 100644 --- a/src/web/public/js/theme-sidebar.js +++ b/src/web/public/js/theme-sidebar.js @@ -33,7 +33,7 @@ var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark'; var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode'; - document.documentElement.dataset.theme = normalizedTheme; + document.documentElement.dataset.bsTheme = normalizedTheme; document.documentElement.style.colorScheme = normalizedTheme; Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) { @@ -62,7 +62,7 @@ Array.prototype.forEach.call(toggleButtons, function (toggleButton) { toggleButton.addEventListener('click', function () { - var nextTheme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark'; setStoredTheme(nextTheme); applyTheme(nextTheme); }); diff --git a/src/web/public/js/toast.js b/src/web/public/js/toast.js index b3b850d..6cc268a 100644 --- a/src/web/public/js/toast.js +++ b/src/web/public/js/toast.js @@ -1,57 +1,102 @@ (function () { + function getBootstrapToast(toast) { + if (!toast || !window.bootstrap || !window.bootstrap.Toast) { + return null; + } + + return window.bootstrap.Toast.getOrCreateInstance(toast, { + autohide: true, + delay: 4000 + }); + } + + function removeToast(toast) { + if (toast && toast.parentNode) { + toast.parentNode.removeChild(toast); + } + } + function dismissToast(toast) { - if (!toast) { + var instance = getBootstrapToast(toast); + if (instance) { + instance.hide(); + return; + } + removeToast(toast); + } + + function setToastVariant(toast, variant) { + if (!toast || !toast.classList) { return; } - toast.classList.add('toast-hide'); - window.setTimeout(function () { - if (toast && toast.parentNode) { - toast.parentNode.removeChild(toast); - } - }, 220); + var nextVariant = String(variant || 'success').trim().toLowerCase(); + var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark']; + variants.forEach(function (value) { + toast.classList.remove('text-bg-' + value); + }); + toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'success' : nextVariant)); } - function showToast(message) { + function getMessageVariant(message, fallbackVariant) { + var text = String(message || '').trim(); + if (/^(unable to delete|cannot delete|can't delete)/i.test(text)) { + return 'danger'; + } + return String(fallbackVariant || 'success').trim().toLowerCase() || 'success'; + } + + function showToast(message, variant) { var text = String(message || '').trim(); if (!text) { return; } + var container = document.getElementById('app-toast-container'); + if (!container) { + return; + } + var existingToast = document.getElementById('app-toast'); - var existingBody = existingToast ? existingToast.querySelector('.toast-body') : null; - if (existingToast && existingBody) { - existingBody.textContent = text; - existingToast.classList.remove('toast-hide'); - window.clearTimeout(existingToast._dismissTimer); - existingToast._dismissTimer = window.setTimeout(function () { - dismissToast(existingToast); - }, 4000); + if (existingToast) { + var existingBody = existingToast.querySelector('.toast-body'); + if (existingBody) { + existingBody.textContent = text; + } + setToastVariant(existingToast, getMessageVariant(text, variant)); + var existingInstance = getBootstrapToast(existingToast); + if (existingInstance) { + existingInstance.show(); + } return; } var toast = document.createElement('div'); - toast.className = 'toast'; + toast.className = 'toast align-items-center border-0'; + toast.id = 'app-toast'; toast.setAttribute('role', 'status'); toast.setAttribute('aria-live', 'polite'); - toast.innerHTML = '
    '; + toast.setAttribute('aria-atomic', 'true'); + toast.setAttribute('data-bs-autohide', 'true'); + toast.setAttribute('data-bs-delay', '4000'); + toast.innerHTML = '
    '; + setToastVariant(toast, getMessageVariant(text, variant)); toast.querySelector('.toast-body').textContent = text; - var closeButton = toast.querySelector('.toast-close'); - closeButton.addEventListener('click', function () { - dismissToast(toast); + toast.addEventListener('hidden.bs.toast', function () { + removeToast(toast); }); - document.body.appendChild(toast); - toast._dismissTimer = window.setTimeout(function () { - dismissToast(toast); - }, 4000); + container.appendChild(toast); + var instance = getBootstrapToast(toast); + if (instance) { + instance.show(); + } } function initToast() { var toast = document.getElementById('app-toast'); - var closeButton = document.getElementById('app-toast-close'); - if (!toast || !closeButton) { + if (!toast) { return; } @@ -65,13 +110,12 @@ // ignore URL cleanup failures } - closeButton.addEventListener('click', function () { - dismissToast(toast); - }); + setToastVariant(toast, getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success')); - window.setTimeout(function () { - dismissToast(toast); - }, 4000); + var instance = getBootstrapToast(toast); + if (instance) { + instance.show(); + } } window.dismissToast = dismissToast; diff --git a/src/web/public/js/ckeditor5/LICENSE-ckeditor5.md b/src/web/public/js/vendor/ckeditor5/LICENSE-ckeditor5.md similarity index 100% rename from src/web/public/js/ckeditor5/LICENSE-ckeditor5.md rename to src/web/public/js/vendor/ckeditor5/LICENSE-ckeditor5.md diff --git a/src/web/public/js/ckeditor5/ckeditor5-content.css b/src/web/public/js/vendor/ckeditor5/ckeditor5-content.css similarity index 100% rename from src/web/public/js/ckeditor5/ckeditor5-content.css rename to src/web/public/js/vendor/ckeditor5/ckeditor5-content.css diff --git a/src/web/public/js/ckeditor5/ckeditor5-editor.css b/src/web/public/js/vendor/ckeditor5/ckeditor5-editor.css similarity index 100% rename from src/web/public/js/ckeditor5/ckeditor5-editor.css rename to src/web/public/js/vendor/ckeditor5/ckeditor5-editor.css diff --git a/src/web/public/js/ckeditor5/ckeditor5.css b/src/web/public/js/vendor/ckeditor5/ckeditor5.css similarity index 100% rename from src/web/public/js/ckeditor5/ckeditor5.css rename to src/web/public/js/vendor/ckeditor5/ckeditor5.css diff --git a/src/web/public/js/ckeditor5/ckeditor5.css.map b/src/web/public/js/vendor/ckeditor5/ckeditor5.css.map similarity index 100% rename from src/web/public/js/ckeditor5/ckeditor5.css.map rename to src/web/public/js/vendor/ckeditor5/ckeditor5.css.map diff --git a/src/web/public/js/ckeditor5/ckeditor5.js b/src/web/public/js/vendor/ckeditor5/ckeditor5.js similarity index 100% rename from src/web/public/js/ckeditor5/ckeditor5.js rename to src/web/public/js/vendor/ckeditor5/ckeditor5.js diff --git a/src/web/public/js/ckeditor5/ckeditor5.js.map b/src/web/public/js/vendor/ckeditor5/ckeditor5.js.map similarity index 100% rename from src/web/public/js/ckeditor5/ckeditor5.js.map rename to src/web/public/js/vendor/ckeditor5/ckeditor5.js.map diff --git a/src/web/public/js/ckeditor5/ckeditor5.umd.js b/src/web/public/js/vendor/ckeditor5/ckeditor5.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/ckeditor5.umd.js rename to src/web/public/js/vendor/ckeditor5/ckeditor5.umd.js diff --git a/src/web/public/js/ckeditor5/ckeditor5.umd.js.map b/src/web/public/js/vendor/ckeditor5/ckeditor5.umd.js.map similarity index 100% rename from src/web/public/js/ckeditor5/ckeditor5.umd.js.map rename to src/web/public/js/vendor/ckeditor5/ckeditor5.umd.js.map diff --git a/src/web/public/js/ckeditor5/translations/af.d.ts b/src/web/public/js/vendor/ckeditor5/translations/af.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/af.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/af.d.ts diff --git a/src/web/public/js/ckeditor5/translations/af.js b/src/web/public/js/vendor/ckeditor5/translations/af.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/af.js rename to src/web/public/js/vendor/ckeditor5/translations/af.js diff --git a/src/web/public/js/ckeditor5/translations/af.umd.js b/src/web/public/js/vendor/ckeditor5/translations/af.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/af.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/af.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ar.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ar.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ar.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ar.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ar.js b/src/web/public/js/vendor/ckeditor5/translations/ar.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ar.js rename to src/web/public/js/vendor/ckeditor5/translations/ar.js diff --git a/src/web/public/js/ckeditor5/translations/ar.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ar.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ar.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ar.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ast.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ast.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ast.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ast.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ast.js b/src/web/public/js/vendor/ckeditor5/translations/ast.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ast.js rename to src/web/public/js/vendor/ckeditor5/translations/ast.js diff --git a/src/web/public/js/ckeditor5/translations/ast.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ast.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ast.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ast.umd.js diff --git a/src/web/public/js/ckeditor5/translations/az.d.ts b/src/web/public/js/vendor/ckeditor5/translations/az.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/az.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/az.d.ts diff --git a/src/web/public/js/ckeditor5/translations/az.js b/src/web/public/js/vendor/ckeditor5/translations/az.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/az.js rename to src/web/public/js/vendor/ckeditor5/translations/az.js diff --git a/src/web/public/js/ckeditor5/translations/az.umd.js b/src/web/public/js/vendor/ckeditor5/translations/az.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/az.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/az.umd.js diff --git a/src/web/public/js/ckeditor5/translations/be.d.ts b/src/web/public/js/vendor/ckeditor5/translations/be.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/be.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/be.d.ts diff --git a/src/web/public/js/ckeditor5/translations/be.js b/src/web/public/js/vendor/ckeditor5/translations/be.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/be.js rename to src/web/public/js/vendor/ckeditor5/translations/be.js diff --git a/src/web/public/js/ckeditor5/translations/be.umd.js b/src/web/public/js/vendor/ckeditor5/translations/be.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/be.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/be.umd.js diff --git a/src/web/public/js/ckeditor5/translations/bg.d.ts b/src/web/public/js/vendor/ckeditor5/translations/bg.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/bg.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/bg.d.ts diff --git a/src/web/public/js/ckeditor5/translations/bg.js b/src/web/public/js/vendor/ckeditor5/translations/bg.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/bg.js rename to src/web/public/js/vendor/ckeditor5/translations/bg.js diff --git a/src/web/public/js/ckeditor5/translations/bg.umd.js b/src/web/public/js/vendor/ckeditor5/translations/bg.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/bg.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/bg.umd.js diff --git a/src/web/public/js/ckeditor5/translations/bn.d.ts b/src/web/public/js/vendor/ckeditor5/translations/bn.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/bn.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/bn.d.ts diff --git a/src/web/public/js/ckeditor5/translations/bn.js b/src/web/public/js/vendor/ckeditor5/translations/bn.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/bn.js rename to src/web/public/js/vendor/ckeditor5/translations/bn.js diff --git a/src/web/public/js/ckeditor5/translations/bn.umd.js b/src/web/public/js/vendor/ckeditor5/translations/bn.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/bn.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/bn.umd.js diff --git a/src/web/public/js/ckeditor5/translations/bs.d.ts b/src/web/public/js/vendor/ckeditor5/translations/bs.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/bs.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/bs.d.ts diff --git a/src/web/public/js/ckeditor5/translations/bs.js b/src/web/public/js/vendor/ckeditor5/translations/bs.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/bs.js rename to src/web/public/js/vendor/ckeditor5/translations/bs.js diff --git a/src/web/public/js/ckeditor5/translations/bs.umd.js b/src/web/public/js/vendor/ckeditor5/translations/bs.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/bs.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/bs.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ca.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ca.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ca.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ca.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ca.js b/src/web/public/js/vendor/ckeditor5/translations/ca.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ca.js rename to src/web/public/js/vendor/ckeditor5/translations/ca.js diff --git a/src/web/public/js/ckeditor5/translations/ca.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ca.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ca.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ca.umd.js diff --git a/src/web/public/js/ckeditor5/translations/cs.d.ts b/src/web/public/js/vendor/ckeditor5/translations/cs.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/cs.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/cs.d.ts diff --git a/src/web/public/js/ckeditor5/translations/cs.js b/src/web/public/js/vendor/ckeditor5/translations/cs.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/cs.js rename to src/web/public/js/vendor/ckeditor5/translations/cs.js diff --git a/src/web/public/js/ckeditor5/translations/cs.umd.js b/src/web/public/js/vendor/ckeditor5/translations/cs.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/cs.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/cs.umd.js diff --git a/src/web/public/js/ckeditor5/translations/da.d.ts b/src/web/public/js/vendor/ckeditor5/translations/da.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/da.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/da.d.ts diff --git a/src/web/public/js/ckeditor5/translations/da.js b/src/web/public/js/vendor/ckeditor5/translations/da.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/da.js rename to src/web/public/js/vendor/ckeditor5/translations/da.js diff --git a/src/web/public/js/ckeditor5/translations/da.umd.js b/src/web/public/js/vendor/ckeditor5/translations/da.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/da.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/da.umd.js diff --git a/src/web/public/js/ckeditor5/translations/de-ch.d.ts b/src/web/public/js/vendor/ckeditor5/translations/de-ch.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/de-ch.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/de-ch.d.ts diff --git a/src/web/public/js/ckeditor5/translations/de-ch.js b/src/web/public/js/vendor/ckeditor5/translations/de-ch.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/de-ch.js rename to src/web/public/js/vendor/ckeditor5/translations/de-ch.js diff --git a/src/web/public/js/ckeditor5/translations/de-ch.umd.js b/src/web/public/js/vendor/ckeditor5/translations/de-ch.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/de-ch.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/de-ch.umd.js diff --git a/src/web/public/js/ckeditor5/translations/de.d.ts b/src/web/public/js/vendor/ckeditor5/translations/de.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/de.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/de.d.ts diff --git a/src/web/public/js/ckeditor5/translations/de.js b/src/web/public/js/vendor/ckeditor5/translations/de.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/de.js rename to src/web/public/js/vendor/ckeditor5/translations/de.js diff --git a/src/web/public/js/ckeditor5/translations/de.umd.js b/src/web/public/js/vendor/ckeditor5/translations/de.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/de.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/de.umd.js diff --git a/src/web/public/js/ckeditor5/translations/el.d.ts b/src/web/public/js/vendor/ckeditor5/translations/el.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/el.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/el.d.ts diff --git a/src/web/public/js/ckeditor5/translations/el.js b/src/web/public/js/vendor/ckeditor5/translations/el.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/el.js rename to src/web/public/js/vendor/ckeditor5/translations/el.js diff --git a/src/web/public/js/ckeditor5/translations/el.umd.js b/src/web/public/js/vendor/ckeditor5/translations/el.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/el.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/el.umd.js diff --git a/src/web/public/js/ckeditor5/translations/en-au.d.ts b/src/web/public/js/vendor/ckeditor5/translations/en-au.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/en-au.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/en-au.d.ts diff --git a/src/web/public/js/ckeditor5/translations/en-au.js b/src/web/public/js/vendor/ckeditor5/translations/en-au.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/en-au.js rename to src/web/public/js/vendor/ckeditor5/translations/en-au.js diff --git a/src/web/public/js/ckeditor5/translations/en-au.umd.js b/src/web/public/js/vendor/ckeditor5/translations/en-au.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/en-au.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/en-au.umd.js diff --git a/src/web/public/js/ckeditor5/translations/en-gb.d.ts b/src/web/public/js/vendor/ckeditor5/translations/en-gb.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/en-gb.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/en-gb.d.ts diff --git a/src/web/public/js/ckeditor5/translations/en-gb.js b/src/web/public/js/vendor/ckeditor5/translations/en-gb.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/en-gb.js rename to src/web/public/js/vendor/ckeditor5/translations/en-gb.js diff --git a/src/web/public/js/ckeditor5/translations/en-gb.umd.js b/src/web/public/js/vendor/ckeditor5/translations/en-gb.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/en-gb.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/en-gb.umd.js diff --git a/src/web/public/js/ckeditor5/translations/en.d.ts b/src/web/public/js/vendor/ckeditor5/translations/en.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/en.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/en.d.ts diff --git a/src/web/public/js/ckeditor5/translations/en.js b/src/web/public/js/vendor/ckeditor5/translations/en.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/en.js rename to src/web/public/js/vendor/ckeditor5/translations/en.js diff --git a/src/web/public/js/ckeditor5/translations/en.umd.js b/src/web/public/js/vendor/ckeditor5/translations/en.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/en.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/en.umd.js diff --git a/src/web/public/js/ckeditor5/translations/eo.d.ts b/src/web/public/js/vendor/ckeditor5/translations/eo.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/eo.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/eo.d.ts diff --git a/src/web/public/js/ckeditor5/translations/eo.js b/src/web/public/js/vendor/ckeditor5/translations/eo.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/eo.js rename to src/web/public/js/vendor/ckeditor5/translations/eo.js diff --git a/src/web/public/js/ckeditor5/translations/eo.umd.js b/src/web/public/js/vendor/ckeditor5/translations/eo.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/eo.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/eo.umd.js diff --git a/src/web/public/js/ckeditor5/translations/es-co.d.ts b/src/web/public/js/vendor/ckeditor5/translations/es-co.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/es-co.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/es-co.d.ts diff --git a/src/web/public/js/ckeditor5/translations/es-co.js b/src/web/public/js/vendor/ckeditor5/translations/es-co.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/es-co.js rename to src/web/public/js/vendor/ckeditor5/translations/es-co.js diff --git a/src/web/public/js/ckeditor5/translations/es-co.umd.js b/src/web/public/js/vendor/ckeditor5/translations/es-co.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/es-co.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/es-co.umd.js diff --git a/src/web/public/js/ckeditor5/translations/es.d.ts b/src/web/public/js/vendor/ckeditor5/translations/es.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/es.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/es.d.ts diff --git a/src/web/public/js/ckeditor5/translations/es.js b/src/web/public/js/vendor/ckeditor5/translations/es.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/es.js rename to src/web/public/js/vendor/ckeditor5/translations/es.js diff --git a/src/web/public/js/ckeditor5/translations/es.umd.js b/src/web/public/js/vendor/ckeditor5/translations/es.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/es.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/es.umd.js diff --git a/src/web/public/js/ckeditor5/translations/et.d.ts b/src/web/public/js/vendor/ckeditor5/translations/et.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/et.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/et.d.ts diff --git a/src/web/public/js/ckeditor5/translations/et.js b/src/web/public/js/vendor/ckeditor5/translations/et.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/et.js rename to src/web/public/js/vendor/ckeditor5/translations/et.js diff --git a/src/web/public/js/ckeditor5/translations/et.umd.js b/src/web/public/js/vendor/ckeditor5/translations/et.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/et.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/et.umd.js diff --git a/src/web/public/js/ckeditor5/translations/eu.d.ts b/src/web/public/js/vendor/ckeditor5/translations/eu.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/eu.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/eu.d.ts diff --git a/src/web/public/js/ckeditor5/translations/eu.js b/src/web/public/js/vendor/ckeditor5/translations/eu.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/eu.js rename to src/web/public/js/vendor/ckeditor5/translations/eu.js diff --git a/src/web/public/js/ckeditor5/translations/eu.umd.js b/src/web/public/js/vendor/ckeditor5/translations/eu.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/eu.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/eu.umd.js diff --git a/src/web/public/js/ckeditor5/translations/fa.d.ts b/src/web/public/js/vendor/ckeditor5/translations/fa.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/fa.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/fa.d.ts diff --git a/src/web/public/js/ckeditor5/translations/fa.js b/src/web/public/js/vendor/ckeditor5/translations/fa.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/fa.js rename to src/web/public/js/vendor/ckeditor5/translations/fa.js diff --git a/src/web/public/js/ckeditor5/translations/fa.umd.js b/src/web/public/js/vendor/ckeditor5/translations/fa.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/fa.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/fa.umd.js diff --git a/src/web/public/js/ckeditor5/translations/fi.d.ts b/src/web/public/js/vendor/ckeditor5/translations/fi.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/fi.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/fi.d.ts diff --git a/src/web/public/js/ckeditor5/translations/fi.js b/src/web/public/js/vendor/ckeditor5/translations/fi.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/fi.js rename to src/web/public/js/vendor/ckeditor5/translations/fi.js diff --git a/src/web/public/js/ckeditor5/translations/fi.umd.js b/src/web/public/js/vendor/ckeditor5/translations/fi.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/fi.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/fi.umd.js diff --git a/src/web/public/js/ckeditor5/translations/fr.d.ts b/src/web/public/js/vendor/ckeditor5/translations/fr.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/fr.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/fr.d.ts diff --git a/src/web/public/js/ckeditor5/translations/fr.js b/src/web/public/js/vendor/ckeditor5/translations/fr.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/fr.js rename to src/web/public/js/vendor/ckeditor5/translations/fr.js diff --git a/src/web/public/js/ckeditor5/translations/fr.umd.js b/src/web/public/js/vendor/ckeditor5/translations/fr.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/fr.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/fr.umd.js diff --git a/src/web/public/js/ckeditor5/translations/gl.d.ts b/src/web/public/js/vendor/ckeditor5/translations/gl.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/gl.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/gl.d.ts diff --git a/src/web/public/js/ckeditor5/translations/gl.js b/src/web/public/js/vendor/ckeditor5/translations/gl.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/gl.js rename to src/web/public/js/vendor/ckeditor5/translations/gl.js diff --git a/src/web/public/js/ckeditor5/translations/gl.umd.js b/src/web/public/js/vendor/ckeditor5/translations/gl.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/gl.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/gl.umd.js diff --git a/src/web/public/js/ckeditor5/translations/gu.d.ts b/src/web/public/js/vendor/ckeditor5/translations/gu.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/gu.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/gu.d.ts diff --git a/src/web/public/js/ckeditor5/translations/gu.js b/src/web/public/js/vendor/ckeditor5/translations/gu.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/gu.js rename to src/web/public/js/vendor/ckeditor5/translations/gu.js diff --git a/src/web/public/js/ckeditor5/translations/gu.umd.js b/src/web/public/js/vendor/ckeditor5/translations/gu.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/gu.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/gu.umd.js diff --git a/src/web/public/js/ckeditor5/translations/he.d.ts b/src/web/public/js/vendor/ckeditor5/translations/he.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/he.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/he.d.ts diff --git a/src/web/public/js/ckeditor5/translations/he.js b/src/web/public/js/vendor/ckeditor5/translations/he.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/he.js rename to src/web/public/js/vendor/ckeditor5/translations/he.js diff --git a/src/web/public/js/ckeditor5/translations/he.umd.js b/src/web/public/js/vendor/ckeditor5/translations/he.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/he.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/he.umd.js diff --git a/src/web/public/js/ckeditor5/translations/hi.d.ts b/src/web/public/js/vendor/ckeditor5/translations/hi.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/hi.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/hi.d.ts diff --git a/src/web/public/js/ckeditor5/translations/hi.js b/src/web/public/js/vendor/ckeditor5/translations/hi.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/hi.js rename to src/web/public/js/vendor/ckeditor5/translations/hi.js diff --git a/src/web/public/js/ckeditor5/translations/hi.umd.js b/src/web/public/js/vendor/ckeditor5/translations/hi.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/hi.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/hi.umd.js diff --git a/src/web/public/js/ckeditor5/translations/hr.d.ts b/src/web/public/js/vendor/ckeditor5/translations/hr.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/hr.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/hr.d.ts diff --git a/src/web/public/js/ckeditor5/translations/hr.js b/src/web/public/js/vendor/ckeditor5/translations/hr.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/hr.js rename to src/web/public/js/vendor/ckeditor5/translations/hr.js diff --git a/src/web/public/js/ckeditor5/translations/hr.umd.js b/src/web/public/js/vendor/ckeditor5/translations/hr.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/hr.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/hr.umd.js diff --git a/src/web/public/js/ckeditor5/translations/hu.d.ts b/src/web/public/js/vendor/ckeditor5/translations/hu.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/hu.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/hu.d.ts diff --git a/src/web/public/js/ckeditor5/translations/hu.js b/src/web/public/js/vendor/ckeditor5/translations/hu.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/hu.js rename to src/web/public/js/vendor/ckeditor5/translations/hu.js diff --git a/src/web/public/js/ckeditor5/translations/hu.umd.js b/src/web/public/js/vendor/ckeditor5/translations/hu.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/hu.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/hu.umd.js diff --git a/src/web/public/js/ckeditor5/translations/hy.d.ts b/src/web/public/js/vendor/ckeditor5/translations/hy.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/hy.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/hy.d.ts diff --git a/src/web/public/js/ckeditor5/translations/hy.js b/src/web/public/js/vendor/ckeditor5/translations/hy.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/hy.js rename to src/web/public/js/vendor/ckeditor5/translations/hy.js diff --git a/src/web/public/js/ckeditor5/translations/hy.umd.js b/src/web/public/js/vendor/ckeditor5/translations/hy.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/hy.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/hy.umd.js diff --git a/src/web/public/js/ckeditor5/translations/id.d.ts b/src/web/public/js/vendor/ckeditor5/translations/id.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/id.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/id.d.ts diff --git a/src/web/public/js/ckeditor5/translations/id.js b/src/web/public/js/vendor/ckeditor5/translations/id.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/id.js rename to src/web/public/js/vendor/ckeditor5/translations/id.js diff --git a/src/web/public/js/ckeditor5/translations/id.umd.js b/src/web/public/js/vendor/ckeditor5/translations/id.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/id.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/id.umd.js diff --git a/src/web/public/js/ckeditor5/translations/it.d.ts b/src/web/public/js/vendor/ckeditor5/translations/it.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/it.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/it.d.ts diff --git a/src/web/public/js/ckeditor5/translations/it.js b/src/web/public/js/vendor/ckeditor5/translations/it.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/it.js rename to src/web/public/js/vendor/ckeditor5/translations/it.js diff --git a/src/web/public/js/ckeditor5/translations/it.umd.js b/src/web/public/js/vendor/ckeditor5/translations/it.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/it.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/it.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ja.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ja.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ja.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ja.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ja.js b/src/web/public/js/vendor/ckeditor5/translations/ja.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ja.js rename to src/web/public/js/vendor/ckeditor5/translations/ja.js diff --git a/src/web/public/js/ckeditor5/translations/ja.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ja.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ja.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ja.umd.js diff --git a/src/web/public/js/ckeditor5/translations/jv.d.ts b/src/web/public/js/vendor/ckeditor5/translations/jv.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/jv.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/jv.d.ts diff --git a/src/web/public/js/ckeditor5/translations/jv.js b/src/web/public/js/vendor/ckeditor5/translations/jv.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/jv.js rename to src/web/public/js/vendor/ckeditor5/translations/jv.js diff --git a/src/web/public/js/ckeditor5/translations/jv.umd.js b/src/web/public/js/vendor/ckeditor5/translations/jv.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/jv.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/jv.umd.js diff --git a/src/web/public/js/ckeditor5/translations/kk.d.ts b/src/web/public/js/vendor/ckeditor5/translations/kk.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/kk.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/kk.d.ts diff --git a/src/web/public/js/ckeditor5/translations/kk.js b/src/web/public/js/vendor/ckeditor5/translations/kk.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/kk.js rename to src/web/public/js/vendor/ckeditor5/translations/kk.js diff --git a/src/web/public/js/ckeditor5/translations/kk.umd.js b/src/web/public/js/vendor/ckeditor5/translations/kk.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/kk.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/kk.umd.js diff --git a/src/web/public/js/ckeditor5/translations/km.d.ts b/src/web/public/js/vendor/ckeditor5/translations/km.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/km.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/km.d.ts diff --git a/src/web/public/js/ckeditor5/translations/km.js b/src/web/public/js/vendor/ckeditor5/translations/km.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/km.js rename to src/web/public/js/vendor/ckeditor5/translations/km.js diff --git a/src/web/public/js/ckeditor5/translations/km.umd.js b/src/web/public/js/vendor/ckeditor5/translations/km.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/km.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/km.umd.js diff --git a/src/web/public/js/ckeditor5/translations/kn.d.ts b/src/web/public/js/vendor/ckeditor5/translations/kn.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/kn.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/kn.d.ts diff --git a/src/web/public/js/ckeditor5/translations/kn.js b/src/web/public/js/vendor/ckeditor5/translations/kn.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/kn.js rename to src/web/public/js/vendor/ckeditor5/translations/kn.js diff --git a/src/web/public/js/ckeditor5/translations/kn.umd.js b/src/web/public/js/vendor/ckeditor5/translations/kn.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/kn.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/kn.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ko.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ko.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ko.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ko.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ko.js b/src/web/public/js/vendor/ckeditor5/translations/ko.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ko.js rename to src/web/public/js/vendor/ckeditor5/translations/ko.js diff --git a/src/web/public/js/ckeditor5/translations/ko.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ko.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ko.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ko.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ku.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ku.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ku.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ku.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ku.js b/src/web/public/js/vendor/ckeditor5/translations/ku.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ku.js rename to src/web/public/js/vendor/ckeditor5/translations/ku.js diff --git a/src/web/public/js/ckeditor5/translations/ku.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ku.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ku.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ku.umd.js diff --git a/src/web/public/js/ckeditor5/translations/lt.d.ts b/src/web/public/js/vendor/ckeditor5/translations/lt.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/lt.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/lt.d.ts diff --git a/src/web/public/js/ckeditor5/translations/lt.js b/src/web/public/js/vendor/ckeditor5/translations/lt.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/lt.js rename to src/web/public/js/vendor/ckeditor5/translations/lt.js diff --git a/src/web/public/js/ckeditor5/translations/lt.umd.js b/src/web/public/js/vendor/ckeditor5/translations/lt.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/lt.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/lt.umd.js diff --git a/src/web/public/js/ckeditor5/translations/lv.d.ts b/src/web/public/js/vendor/ckeditor5/translations/lv.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/lv.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/lv.d.ts diff --git a/src/web/public/js/ckeditor5/translations/lv.js b/src/web/public/js/vendor/ckeditor5/translations/lv.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/lv.js rename to src/web/public/js/vendor/ckeditor5/translations/lv.js diff --git a/src/web/public/js/ckeditor5/translations/lv.umd.js b/src/web/public/js/vendor/ckeditor5/translations/lv.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/lv.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/lv.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ms.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ms.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ms.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ms.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ms.js b/src/web/public/js/vendor/ckeditor5/translations/ms.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ms.js rename to src/web/public/js/vendor/ckeditor5/translations/ms.js diff --git a/src/web/public/js/ckeditor5/translations/ms.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ms.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ms.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ms.umd.js diff --git a/src/web/public/js/ckeditor5/translations/nb.d.ts b/src/web/public/js/vendor/ckeditor5/translations/nb.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/nb.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/nb.d.ts diff --git a/src/web/public/js/ckeditor5/translations/nb.js b/src/web/public/js/vendor/ckeditor5/translations/nb.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/nb.js rename to src/web/public/js/vendor/ckeditor5/translations/nb.js diff --git a/src/web/public/js/ckeditor5/translations/nb.umd.js b/src/web/public/js/vendor/ckeditor5/translations/nb.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/nb.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/nb.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ne.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ne.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ne.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ne.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ne.js b/src/web/public/js/vendor/ckeditor5/translations/ne.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ne.js rename to src/web/public/js/vendor/ckeditor5/translations/ne.js diff --git a/src/web/public/js/ckeditor5/translations/ne.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ne.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ne.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ne.umd.js diff --git a/src/web/public/js/ckeditor5/translations/nl.d.ts b/src/web/public/js/vendor/ckeditor5/translations/nl.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/nl.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/nl.d.ts diff --git a/src/web/public/js/ckeditor5/translations/nl.js b/src/web/public/js/vendor/ckeditor5/translations/nl.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/nl.js rename to src/web/public/js/vendor/ckeditor5/translations/nl.js diff --git a/src/web/public/js/ckeditor5/translations/nl.umd.js b/src/web/public/js/vendor/ckeditor5/translations/nl.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/nl.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/nl.umd.js diff --git a/src/web/public/js/ckeditor5/translations/no.d.ts b/src/web/public/js/vendor/ckeditor5/translations/no.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/no.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/no.d.ts diff --git a/src/web/public/js/ckeditor5/translations/no.js b/src/web/public/js/vendor/ckeditor5/translations/no.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/no.js rename to src/web/public/js/vendor/ckeditor5/translations/no.js diff --git a/src/web/public/js/ckeditor5/translations/no.umd.js b/src/web/public/js/vendor/ckeditor5/translations/no.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/no.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/no.umd.js diff --git a/src/web/public/js/ckeditor5/translations/oc.d.ts b/src/web/public/js/vendor/ckeditor5/translations/oc.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/oc.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/oc.d.ts diff --git a/src/web/public/js/ckeditor5/translations/oc.js b/src/web/public/js/vendor/ckeditor5/translations/oc.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/oc.js rename to src/web/public/js/vendor/ckeditor5/translations/oc.js diff --git a/src/web/public/js/ckeditor5/translations/oc.umd.js b/src/web/public/js/vendor/ckeditor5/translations/oc.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/oc.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/oc.umd.js diff --git a/src/web/public/js/ckeditor5/translations/pl.d.ts b/src/web/public/js/vendor/ckeditor5/translations/pl.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/pl.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/pl.d.ts diff --git a/src/web/public/js/ckeditor5/translations/pl.js b/src/web/public/js/vendor/ckeditor5/translations/pl.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/pl.js rename to src/web/public/js/vendor/ckeditor5/translations/pl.js diff --git a/src/web/public/js/ckeditor5/translations/pl.umd.js b/src/web/public/js/vendor/ckeditor5/translations/pl.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/pl.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/pl.umd.js diff --git a/src/web/public/js/ckeditor5/translations/pt-br.d.ts b/src/web/public/js/vendor/ckeditor5/translations/pt-br.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/pt-br.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/pt-br.d.ts diff --git a/src/web/public/js/ckeditor5/translations/pt-br.js b/src/web/public/js/vendor/ckeditor5/translations/pt-br.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/pt-br.js rename to src/web/public/js/vendor/ckeditor5/translations/pt-br.js diff --git a/src/web/public/js/ckeditor5/translations/pt-br.umd.js b/src/web/public/js/vendor/ckeditor5/translations/pt-br.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/pt-br.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/pt-br.umd.js diff --git a/src/web/public/js/ckeditor5/translations/pt.d.ts b/src/web/public/js/vendor/ckeditor5/translations/pt.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/pt.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/pt.d.ts diff --git a/src/web/public/js/ckeditor5/translations/pt.js b/src/web/public/js/vendor/ckeditor5/translations/pt.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/pt.js rename to src/web/public/js/vendor/ckeditor5/translations/pt.js diff --git a/src/web/public/js/ckeditor5/translations/pt.umd.js b/src/web/public/js/vendor/ckeditor5/translations/pt.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/pt.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/pt.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ro.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ro.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ro.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ro.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ro.js b/src/web/public/js/vendor/ckeditor5/translations/ro.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ro.js rename to src/web/public/js/vendor/ckeditor5/translations/ro.js diff --git a/src/web/public/js/ckeditor5/translations/ro.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ro.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ro.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ro.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ru.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ru.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ru.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ru.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ru.js b/src/web/public/js/vendor/ckeditor5/translations/ru.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ru.js rename to src/web/public/js/vendor/ckeditor5/translations/ru.js diff --git a/src/web/public/js/ckeditor5/translations/ru.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ru.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ru.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ru.umd.js diff --git a/src/web/public/js/ckeditor5/translations/si.d.ts b/src/web/public/js/vendor/ckeditor5/translations/si.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/si.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/si.d.ts diff --git a/src/web/public/js/ckeditor5/translations/si.js b/src/web/public/js/vendor/ckeditor5/translations/si.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/si.js rename to src/web/public/js/vendor/ckeditor5/translations/si.js diff --git a/src/web/public/js/ckeditor5/translations/si.umd.js b/src/web/public/js/vendor/ckeditor5/translations/si.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/si.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/si.umd.js diff --git a/src/web/public/js/ckeditor5/translations/sk.d.ts b/src/web/public/js/vendor/ckeditor5/translations/sk.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/sk.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/sk.d.ts diff --git a/src/web/public/js/ckeditor5/translations/sk.js b/src/web/public/js/vendor/ckeditor5/translations/sk.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sk.js rename to src/web/public/js/vendor/ckeditor5/translations/sk.js diff --git a/src/web/public/js/ckeditor5/translations/sk.umd.js b/src/web/public/js/vendor/ckeditor5/translations/sk.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sk.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/sk.umd.js diff --git a/src/web/public/js/ckeditor5/translations/sl.d.ts b/src/web/public/js/vendor/ckeditor5/translations/sl.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/sl.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/sl.d.ts diff --git a/src/web/public/js/ckeditor5/translations/sl.js b/src/web/public/js/vendor/ckeditor5/translations/sl.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sl.js rename to src/web/public/js/vendor/ckeditor5/translations/sl.js diff --git a/src/web/public/js/ckeditor5/translations/sl.umd.js b/src/web/public/js/vendor/ckeditor5/translations/sl.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sl.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/sl.umd.js diff --git a/src/web/public/js/ckeditor5/translations/sq.d.ts b/src/web/public/js/vendor/ckeditor5/translations/sq.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/sq.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/sq.d.ts diff --git a/src/web/public/js/ckeditor5/translations/sq.js b/src/web/public/js/vendor/ckeditor5/translations/sq.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sq.js rename to src/web/public/js/vendor/ckeditor5/translations/sq.js diff --git a/src/web/public/js/ckeditor5/translations/sq.umd.js b/src/web/public/js/vendor/ckeditor5/translations/sq.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sq.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/sq.umd.js diff --git a/src/web/public/js/ckeditor5/translations/sr-latn.d.ts b/src/web/public/js/vendor/ckeditor5/translations/sr-latn.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/sr-latn.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/sr-latn.d.ts diff --git a/src/web/public/js/ckeditor5/translations/sr-latn.js b/src/web/public/js/vendor/ckeditor5/translations/sr-latn.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sr-latn.js rename to src/web/public/js/vendor/ckeditor5/translations/sr-latn.js diff --git a/src/web/public/js/ckeditor5/translations/sr-latn.umd.js b/src/web/public/js/vendor/ckeditor5/translations/sr-latn.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sr-latn.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/sr-latn.umd.js diff --git a/src/web/public/js/ckeditor5/translations/sr.d.ts b/src/web/public/js/vendor/ckeditor5/translations/sr.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/sr.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/sr.d.ts diff --git a/src/web/public/js/ckeditor5/translations/sr.js b/src/web/public/js/vendor/ckeditor5/translations/sr.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sr.js rename to src/web/public/js/vendor/ckeditor5/translations/sr.js diff --git a/src/web/public/js/ckeditor5/translations/sr.umd.js b/src/web/public/js/vendor/ckeditor5/translations/sr.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sr.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/sr.umd.js diff --git a/src/web/public/js/ckeditor5/translations/sv.d.ts b/src/web/public/js/vendor/ckeditor5/translations/sv.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/sv.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/sv.d.ts diff --git a/src/web/public/js/ckeditor5/translations/sv.js b/src/web/public/js/vendor/ckeditor5/translations/sv.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sv.js rename to src/web/public/js/vendor/ckeditor5/translations/sv.js diff --git a/src/web/public/js/ckeditor5/translations/sv.umd.js b/src/web/public/js/vendor/ckeditor5/translations/sv.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/sv.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/sv.umd.js diff --git a/src/web/public/js/ckeditor5/translations/th.d.ts b/src/web/public/js/vendor/ckeditor5/translations/th.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/th.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/th.d.ts diff --git a/src/web/public/js/ckeditor5/translations/th.js b/src/web/public/js/vendor/ckeditor5/translations/th.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/th.js rename to src/web/public/js/vendor/ckeditor5/translations/th.js diff --git a/src/web/public/js/ckeditor5/translations/th.umd.js b/src/web/public/js/vendor/ckeditor5/translations/th.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/th.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/th.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ti.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ti.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ti.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ti.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ti.js b/src/web/public/js/vendor/ckeditor5/translations/ti.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ti.js rename to src/web/public/js/vendor/ckeditor5/translations/ti.js diff --git a/src/web/public/js/ckeditor5/translations/ti.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ti.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ti.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ti.umd.js diff --git a/src/web/public/js/ckeditor5/translations/tk.d.ts b/src/web/public/js/vendor/ckeditor5/translations/tk.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/tk.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/tk.d.ts diff --git a/src/web/public/js/ckeditor5/translations/tk.js b/src/web/public/js/vendor/ckeditor5/translations/tk.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/tk.js rename to src/web/public/js/vendor/ckeditor5/translations/tk.js diff --git a/src/web/public/js/ckeditor5/translations/tk.umd.js b/src/web/public/js/vendor/ckeditor5/translations/tk.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/tk.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/tk.umd.js diff --git a/src/web/public/js/ckeditor5/translations/tr.d.ts b/src/web/public/js/vendor/ckeditor5/translations/tr.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/tr.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/tr.d.ts diff --git a/src/web/public/js/ckeditor5/translations/tr.js b/src/web/public/js/vendor/ckeditor5/translations/tr.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/tr.js rename to src/web/public/js/vendor/ckeditor5/translations/tr.js diff --git a/src/web/public/js/ckeditor5/translations/tr.umd.js b/src/web/public/js/vendor/ckeditor5/translations/tr.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/tr.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/tr.umd.js diff --git a/src/web/public/js/ckeditor5/translations/tt.d.ts b/src/web/public/js/vendor/ckeditor5/translations/tt.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/tt.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/tt.d.ts diff --git a/src/web/public/js/ckeditor5/translations/tt.js b/src/web/public/js/vendor/ckeditor5/translations/tt.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/tt.js rename to src/web/public/js/vendor/ckeditor5/translations/tt.js diff --git a/src/web/public/js/ckeditor5/translations/tt.umd.js b/src/web/public/js/vendor/ckeditor5/translations/tt.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/tt.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/tt.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ug.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ug.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ug.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ug.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ug.js b/src/web/public/js/vendor/ckeditor5/translations/ug.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ug.js rename to src/web/public/js/vendor/ckeditor5/translations/ug.js diff --git a/src/web/public/js/ckeditor5/translations/ug.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ug.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ug.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ug.umd.js diff --git a/src/web/public/js/ckeditor5/translations/uk.d.ts b/src/web/public/js/vendor/ckeditor5/translations/uk.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/uk.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/uk.d.ts diff --git a/src/web/public/js/ckeditor5/translations/uk.js b/src/web/public/js/vendor/ckeditor5/translations/uk.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/uk.js rename to src/web/public/js/vendor/ckeditor5/translations/uk.js diff --git a/src/web/public/js/ckeditor5/translations/uk.umd.js b/src/web/public/js/vendor/ckeditor5/translations/uk.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/uk.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/uk.umd.js diff --git a/src/web/public/js/ckeditor5/translations/ur.d.ts b/src/web/public/js/vendor/ckeditor5/translations/ur.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/ur.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/ur.d.ts diff --git a/src/web/public/js/ckeditor5/translations/ur.js b/src/web/public/js/vendor/ckeditor5/translations/ur.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ur.js rename to src/web/public/js/vendor/ckeditor5/translations/ur.js diff --git a/src/web/public/js/ckeditor5/translations/ur.umd.js b/src/web/public/js/vendor/ckeditor5/translations/ur.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/ur.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/ur.umd.js diff --git a/src/web/public/js/ckeditor5/translations/uz.d.ts b/src/web/public/js/vendor/ckeditor5/translations/uz.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/uz.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/uz.d.ts diff --git a/src/web/public/js/ckeditor5/translations/uz.js b/src/web/public/js/vendor/ckeditor5/translations/uz.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/uz.js rename to src/web/public/js/vendor/ckeditor5/translations/uz.js diff --git a/src/web/public/js/ckeditor5/translations/uz.umd.js b/src/web/public/js/vendor/ckeditor5/translations/uz.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/uz.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/uz.umd.js diff --git a/src/web/public/js/ckeditor5/translations/vi.d.ts b/src/web/public/js/vendor/ckeditor5/translations/vi.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/vi.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/vi.d.ts diff --git a/src/web/public/js/ckeditor5/translations/vi.js b/src/web/public/js/vendor/ckeditor5/translations/vi.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/vi.js rename to src/web/public/js/vendor/ckeditor5/translations/vi.js diff --git a/src/web/public/js/ckeditor5/translations/vi.umd.js b/src/web/public/js/vendor/ckeditor5/translations/vi.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/vi.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/vi.umd.js diff --git a/src/web/public/js/ckeditor5/translations/zh-cn.d.ts b/src/web/public/js/vendor/ckeditor5/translations/zh-cn.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/zh-cn.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/zh-cn.d.ts diff --git a/src/web/public/js/ckeditor5/translations/zh-cn.js b/src/web/public/js/vendor/ckeditor5/translations/zh-cn.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/zh-cn.js rename to src/web/public/js/vendor/ckeditor5/translations/zh-cn.js diff --git a/src/web/public/js/ckeditor5/translations/zh-cn.umd.js b/src/web/public/js/vendor/ckeditor5/translations/zh-cn.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/zh-cn.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/zh-cn.umd.js diff --git a/src/web/public/js/ckeditor5/translations/zh.d.ts b/src/web/public/js/vendor/ckeditor5/translations/zh.d.ts similarity index 100% rename from src/web/public/js/ckeditor5/translations/zh.d.ts rename to src/web/public/js/vendor/ckeditor5/translations/zh.d.ts diff --git a/src/web/public/js/ckeditor5/translations/zh.js b/src/web/public/js/vendor/ckeditor5/translations/zh.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/zh.js rename to src/web/public/js/vendor/ckeditor5/translations/zh.js diff --git a/src/web/public/js/ckeditor5/translations/zh.umd.js b/src/web/public/js/vendor/ckeditor5/translations/zh.umd.js similarity index 100% rename from src/web/public/js/ckeditor5/translations/zh.umd.js rename to src/web/public/js/vendor/ckeditor5/translations/zh.umd.js diff --git a/src/web/public/js/sortable.min.js b/src/web/public/js/vendor/sortable.min.js similarity index 100% rename from src/web/public/js/sortable.min.js rename to src/web/public/js/vendor/sortable.min.js diff --git a/src/web/routes/account/password.js b/src/web/routes/account/password.js index 0b6ec1a..8686bc5 100644 --- a/src/web/routes/account/password.js +++ b/src/web/routes/account/password.js @@ -1,12 +1,24 @@ const { renderView } = require('../../view'); -module.exports = function renderAccountPage(currentUser, message) { +function normalizeReturnUrl(value) { + const url = String(value || '').trim(); + if (!url || url === '/admin/account' || url.indexOf('/admin/account?') === 0) { + return '/admin'; + } + if (!/^\/admin(?:\/|\?|$)/.test(url)) { + return '/admin'; + } + return url; +} + +module.exports = function renderAccountPage(currentUser, message, returnUrl) { return renderView('account/password', { title: 'My account', active: 'account', currentUser: currentUser || null, message: message || '', username: currentUser ? currentUser.username : '', - name: currentUser ? String(currentUser.name || '') : '' + name: currentUser ? String(currentUser.name || '') : '', + returnUrl: normalizeReturnUrl(returnUrl) }); }; \ No newline at end of file diff --git a/src/web/routes/admin-account.js b/src/web/routes/admin-account.js new file mode 100644 index 0000000..d53e1e6 --- /dev/null +++ b/src/web/routes/admin-account.js @@ -0,0 +1,69 @@ +module.exports = function registerAdminAccountRoutes(app, deps) { + const pool = deps.pool; + const pages = deps.pages; + const formatDashboardDate = deps.formatDashboardDate; + const getAuditUserId = deps.getAuditUserId; + const verifyPassword = deps.verifyPassword; + const hashPassword = deps.hashPassword; + const createUserSession = deps.createUserSession; + const setSessionCookie = deps.setSessionCookie; + + app.get('/admin/account', function (req, res) { + res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', req.query.return_url ? String(req.query.return_url) : '')); + }); + + app.post('/admin/account/name', async function (req, res, next) { + try { + const name = String(req.body.name || '').trim(); + if (!name) { + return res.status(400).send('Name is required.'); + } + + const actorId = getAuditUserId(req); + const [result] = await pool.query('UPDATE users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]); + if (!result.affectedRows) { + return res.status(404).send('User not found.'); + } + + res.redirect('/admin/account?message=' + encodeURIComponent('Name updated.')); + } catch (error) { + next(error); + } + }); + + app.post('/admin/account/password', async function (req, res, next) { + try { + const currentPassword = String(req.body.current_password || ''); + const newPassword = String(req.body.new_password || ''); + const confirmPassword = String(req.body.confirm_password || ''); + + const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE id = ? LIMIT 1', [req.currentUser.id]); + const user = rows[0] || null; + if (!user) { + return res.status(404).send('User not found.'); + } + if (!verifyPassword(currentPassword, user)) { + return res.status(400).send('Current password is incorrect.'); + } + if (!newPassword || newPassword.length < 8) { + return res.status(400).send('New password must be at least 8 characters.'); + } + if (newPassword !== confirmPassword) { + return res.status(400).send('New passwords do not match.'); + } + + const passwordRecord = hashPassword(newPassword); + await pool.query( + 'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?', + [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id] + ); + await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [user.id]); + + const token = await createUserSession(pool, user.id); + setSessionCookie(res, token); + res.redirect('/admin/account?message=' + encodeURIComponent('Password updated.')); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/admin-content.js b/src/web/routes/admin-content.js new file mode 100644 index 0000000..1530bea --- /dev/null +++ b/src/web/routes/admin-content.js @@ -0,0 +1,392 @@ +module.exports = function registerAdminContentRoutes(app, deps) { + const pool = deps.pool; + const common = deps.common; + const pages = deps.pages; + const upload = deps.upload; + const fetchScreensBySlideId = deps.fetchScreensBySlideId; + const fetchScreensByTemplateId = deps.fetchScreensByTemplateId; + const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide; + const collectUploadReferencesFromTemplate = deps.collectUploadReferencesFromTemplate; + const collectUploadReferencesFromPayload = deps.collectUploadReferencesFromPayload; + const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange; + const getAuditUserId = deps.getAuditUserId; + const redirectAfterSave = deps.redirectAfterSave; + const notifyPlayerScreens = deps.notifyPlayerScreens; + const broadcastDashboardState = deps.broadcastDashboardState; + const getSlideDeleteBlockMessage = deps.getSlideDeleteBlockMessage; + const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage; + const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage; + + if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function') { + throw new Error('registerAdminContentRoutes requires the content route dependencies.'); + } + + async function canvasSizeExists(width, height, ignoreId) { + const params = [width, height]; + let query = 'SELECT COUNT(*) AS count FROM canvas_sizes WHERE width = ? AND height = ?'; + if (ignoreId) { + query += ' AND id <> ?'; + params.push(ignoreId); + } + const [rows] = await pool.query(query, params); + return Number(rows[0] && rows[0].count) > 0; + } + + async function fetchScreenSlideCountsBySlug(screenSlugs) { + const uniqueSlugs = Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).map(function (value) { + return String(value || '').trim(); + }).filter(Boolean); + if (!uniqueSlugs.length) { + return {}; + } + + const [rows] = await pool.query( + `SELECT s.slug, COUNT(ps.id) AS slide_count + FROM screens s + LEFT JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id + WHERE s.slug IN (?) + GROUP BY s.id, s.slug`, + [uniqueSlugs] + ); + + return rows.reduce(function (counts, row) { + counts[String(row.slug || '').trim()] = Number(row.slide_count) || 0; + return counts; + }, {}); + } + + app.get('/admin/slides', async function (req, res, next) { + try { + const data = await common.fetchAdminData(pool); + res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/admin/slides/new', async function (req, res, next) { + try { + const data = await common.fetchTemplatesData(pool); + res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/admin/slides/:id/edit', async function (req, res, next) { + try { + const slide = await common.fetchSlideById(pool, Number(req.params.id)); + if (!slide) { + return res.status(404).send('Slide not found'); + } + const data = await common.fetchTemplatesData(pool); + res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.post('/admin/slides', upload.any(), async function (req, res, next) { + try { + const payload = await common.buildSlidePayload(pool, req, null); + const actorId = getAuditUserId(req); + const [result] = await pool.query( + 'INSERT INTO slides (title, body, template_id, content_json, media_path, media_type, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, actorId] + ); + await syncPlaylistUploadsOnChange({ + key: 'slide:create:' + result.insertId, + pool: pool, + localUploadDir: deps.uploadDir, + nextUploadRefs: collectUploadReferencesFromPayload(payload) + }); + redirectAfterSave(req, res, '/admin/slides/' + result.insertId + '/edit', { + closeUrl: '/admin/slides', + newUrl: '/admin/slides/new', + message: 'Slide created.' + }); + } catch (error) { + next(error); + } + }); + + app.post('/admin/slides/:id', upload.any(), async function (req, res, next) { + try { + const slide = await common.fetchSlideById(pool, Number(req.params.id)); + if (!slide) { + return res.status(404).send('Slide not found'); + } + const affectedScreens = await fetchScreensBySlideId(pool, slide.id); + const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens); + const existingUploadRefs = collectUploadReferencesFromSlide(slide); + const payload = await common.buildSlidePayload(pool, req, slide); + const nextUploadRefs = collectUploadReferencesFromPayload(payload); + const actorId = getAuditUserId(req); + await pool.query( + 'UPDATE slides SET title = ?, body = ?, template_id = ?, content_json = ?, media_path = ?, media_type = ?, modified_by = ? WHERE id = ?', + [payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, slide.id] + ); + await syncPlaylistUploadsOnChange({ + key: 'slide:update:' + slide.id, + pool: pool, + localUploadDir: deps.uploadDir, + previousUploadRefs: existingUploadRefs, + nextUploadRefs: nextUploadRefs, + blockedSlideIds: [slide.id], + refreshScreenSlugs: affectedScreens, + screenSlideCounts: screenSlideCounts + }); + await broadcastDashboardState(); + redirectAfterSave(req, res, '/admin/slides/' + slide.id + '/edit', { + closeUrl: '/admin/slides', + newUrl: '/admin/slides/new', + message: 'Slide updated.' + }); + } catch (error) { + next(error); + } + }); + + app.post('/admin/slides/:id/delete', async function (req, res, next) { + try { + const slide = await common.fetchSlideById(pool, Number(req.params.id)); + if (!slide) { + return res.status(404).send('Slide not found'); + } + const blockMessage = await getSlideDeleteBlockMessage(pool, slide); + if (blockMessage) { + return res.redirect('/admin/slides?message=' + encodeURIComponent(blockMessage)); + } + const affectedScreens = await fetchScreensBySlideId(pool, slide.id); + const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens); + const uploadRefs = collectUploadReferencesFromSlide(slide); + await pool.query('DELETE FROM slides WHERE id = ?', [slide.id]); + await syncPlaylistUploadsOnChange({ + key: 'slide:delete:' + slide.id, + pool: pool, + localUploadDir: deps.uploadDir, + previousUploadRefs: uploadRefs, + blockedSlideIds: [slide.id], + refreshScreenSlugs: affectedScreens, + screenSlideCounts: screenSlideCounts + }); + await broadcastDashboardState(); + res.redirect('/admin/slides?message=' + encodeURIComponent('Slide deleted.')); + } catch (error) { + next(error); + } + }); + + app.get('/admin/templates', async function (req, res, next) { + try { + const data = await common.fetchTemplatesData(pool); + res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/admin/templates/new', async function (req, res, next) { + try { + const data = await common.fetchCanvasSizesData(pool); + res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.post('/admin/templates', upload.any(), async function (req, res, next) { + try { + const payload = await common.buildTemplatePayload(pool, req, null); + const actorId = getAuditUserId(req); + const [result] = await pool.query( + 'INSERT INTO slide_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', + [payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, actorId] + ); + for (let i = 0; i < payload.regions.length; i += 1) { + const region = payload.regions[i]; + await pool.query( + 'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] + ); + } + await syncPlaylistUploadsOnChange({ + key: 'template:create:' + result.insertId, + pool: pool, + localUploadDir: deps.uploadDir, + nextUploadRefs: collectUploadReferencesFromPayload(payload) + }); + redirectAfterSave(req, res, '/admin/templates/' + result.insertId + '/edit', { + closeUrl: '/admin/templates', + newUrl: '/admin/templates/new', + message: 'Template created.' + }); + } catch (error) { + next(error); + } + }); + + app.get('/admin/templates/:id/edit', async function (req, res, next) { + try { + const template = await common.fetchTemplateById(pool, Number(req.params.id)); + if (!template) { + return res.status(404).send('Template not found'); + } + const sizeData = await common.fetchCanvasSizesData(pool); + res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.post('/admin/templates/:id', upload.any(), async function (req, res, next) { + try { + const template = await common.fetchTemplateById(pool, Number(req.params.id)); + if (!template) { + return res.status(404).send('Template not found'); + } + const existingUploadRefs = collectUploadReferencesFromTemplate(template); + const payload = await common.buildTemplatePayload(pool, req, template); + const nextUploadRefs = collectUploadReferencesFromPayload(payload); + const affectedScreens = await fetchScreensByTemplateId(pool, template.id); + const actorId = getAuditUserId(req); + await pool.query( + 'UPDATE slide_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?', + [payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id] + ); + await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]); + for (let i = 0; i < payload.regions.length; i += 1) { + const region = payload.regions[i]; + await pool.query( + 'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [template.id, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] + ); + } + await syncPlaylistUploadsOnChange({ + key: 'template:update:' + template.id, + pool: pool, + localUploadDir: deps.uploadDir, + previousUploadRefs: existingUploadRefs, + nextUploadRefs: nextUploadRefs + }); + await notifyPlayerScreens(affectedScreens, 'refresh'); + redirectAfterSave(req, res, '/admin/templates/' + template.id + '/edit', { + closeUrl: '/admin/templates', + newUrl: '/admin/templates/new', + message: 'Template updated.' + }); + } catch (error) { + next(error); + } + }); + + app.post('/admin/templates/:id/delete', async function (req, res, next) { + try { + const template = await common.fetchTemplateById(pool, Number(req.params.id)); + if (!template) { + return res.status(404).send('Template not found'); + } + const blockMessage = await getTemplateDeleteBlockMessage(pool, template); + if (blockMessage) { + return res.redirect('/admin/templates?message=' + encodeURIComponent(blockMessage)); + } + const uploadRefs = collectUploadReferencesFromTemplate(template); + const affectedScreens = await fetchScreensByTemplateId(pool, template.id); + await pool.query('UPDATE slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]); + await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]); + await pool.query('DELETE FROM slide_templates WHERE id = ?', [template.id]); + await syncPlaylistUploadsOnChange({ + key: 'template:delete:' + template.id, + pool: pool, + localUploadDir: deps.uploadDir, + previousUploadRefs: uploadRefs + }); + await notifyPlayerScreens(affectedScreens, 'refresh'); + res.redirect('/admin/templates?message=' + encodeURIComponent('Template deleted.')); + } catch (error) { + next(error); + } + }); + + app.get('/admin/canvas-sizes', async function (req, res, next) { + try { + const data = await common.fetchAdminData(pool); + res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/admin/canvas-sizes/new', function (req, res) { + res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser)); + }); + + app.post('/admin/canvas-sizes', async function (req, res, next) { + try { + const payload = common.buildCanvasSizePayload(req, null); + if (await canvasSizeExists(payload.width, payload.height)) { + return res.redirect('/admin/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.')); + } + const actorId = getAuditUserId(req); + await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]); + redirectAfterSave(req, res, '/admin/canvas-sizes', { + closeUrl: '/admin/canvas-sizes', + newUrl: '/admin/canvas-sizes/new', + message: 'Canvas size created.' + }); + } catch (error) { + next(error); + } + }); + + app.get('/admin/canvas-sizes/:id/edit', async function (req, res, next) { + try { + const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); + if (!canvasSize) { + return res.status(404).send('Canvas size not found'); + } + res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.post('/admin/canvas-sizes/:id', async function (req, res, next) { + try { + const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); + if (!canvasSize) { + return res.status(404).send('Canvas size not found'); + } + const payload = common.buildCanvasSizePayload(req, canvasSize); + if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) { + return res.redirect('/admin/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.')); + } + await pool.query('UPDATE canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]); + redirectAfterSave(req, res, '/admin/canvas-sizes', { + closeUrl: '/admin/canvas-sizes', + newUrl: '/admin/canvas-sizes/new', + message: 'Canvas size updated.' + }); + } catch (error) { + next(error); + } + }); + + app.post('/admin/canvas-sizes/:id/delete', async function (req, res, next) { + try { + const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); + if (!canvasSize) { + return res.status(404).send('Canvas size not found'); + } + const blockMessage = await getCanvasSizeDeleteBlockMessage(pool, canvasSize); + if (blockMessage) { + return res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent(blockMessage)); + } + await pool.query('UPDATE slide_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]); + await pool.query('DELETE FROM canvas_sizes WHERE id = ?', [canvasSize.id]); + res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.')); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/admin-manage.js b/src/web/routes/admin-manage.js new file mode 100644 index 0000000..175e059 --- /dev/null +++ b/src/web/routes/admin-manage.js @@ -0,0 +1,542 @@ +module.exports = function registerAdminManageRoutes(app, deps) { + const pool = deps.pool; + const common = deps.common; + const pages = deps.pages; + const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides; + const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId; + const fetchPlaylistCanvasSignature = deps.fetchPlaylistCanvasSignature; + const getCanvasSignature = deps.getCanvasSignature; + const normalizeScheduleMode = deps.normalizeScheduleMode; + const parseDateTimeLocal = deps.parseDateTimeLocal; + const parseTimeLocal = deps.parseTimeLocal; + const readArrayField = deps.readArrayField; + const getAuditUserId = deps.getAuditUserId; + const redirectAfterSave = deps.redirectAfterSave; + const notifyPlayerScreens = deps.notifyPlayerScreens; + const broadcastDashboardState = deps.broadcastDashboardState; + const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage; + const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage; + const forwardPlayerCommand = deps.forwardPlayerCommand; + const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl; + + app.post('/admin/playlists', async function (req, res, next) { + try { + const name = String(req.body.name || '').trim(); + const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0; + if (!name) { + return res.status(400).send('Playlist name is required.'); + } + const actorId = getAuditUserId(req); + await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]); + redirectAfterSave(req, res, '/admin/playlists', { + closeUrl: '/admin/playlists', + newUrl: '/admin/playlists/new', + message: 'Playlist created.' + }); + } catch (error) { + next(error); + } + }); + + app.post('/admin/playlists/:id', async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const name = String(req.body.name || '').trim(); + const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0; + if (!name) { + return res.status(400).send('Playlist name is required.'); + } + const playlist = await common.fetchPlaylistById(connection, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + + const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id); + + const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']); + const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']); + const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']); + const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']); + const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']); + const scheduleStartTimes = readArrayField(req.body, ['schedule_start_time[]', 'schedule_start_time']); + const scheduleEndTimes = readArrayField(req.body, ['schedule_end_time[]', 'schedule_end_time']); + const scheduleDaysJsonValues = readArrayField(req.body, ['schedule_days_json[]', 'schedule_days_json']); + + if (durations.length && durations.length !== slideIds.length) { + return res.status(400).send('Playlist slide data is invalid.'); + } + if (scheduleModes.length && scheduleModes.length !== slideIds.length) { + return res.status(400).send('Playlist schedule data is invalid.'); + } + + const normalizedSlides = []; + const seenSlideIds = new Set(); + for (let i = 0; i < slideIds.length; i += 1) { + const slideId = Number(slideIds[i]); + if (!Number.isInteger(slideId) || slideId <= 0) { + return res.status(400).send('Invalid slide selection.'); + } + if (seenSlideIds.has(slideId)) { + return res.status(400).send('A slide can only be added to a playlist once.'); + } + seenSlideIds.add(slideId); + + const durationRaw = Number(durations[i]); + const durationSeconds = Number.isFinite(durationRaw) ? Math.max(1, Math.trunc(durationRaw)) : 10; + const scheduleMode = normalizeScheduleMode(scheduleModes[i]); + + let scheduleStartDatetime = null; + let scheduleEndDatetime = null; + let scheduleStartTime = null; + let scheduleEndTime = null; + let scheduleDaysJson = null; + + if (scheduleMode === 'dates') { + scheduleStartDatetime = parseDateTimeLocal(scheduleStartDateTimes[i]); + scheduleEndDatetime = parseDateTimeLocal(scheduleEndDateTimes[i]); + if (!scheduleStartDatetime || !scheduleEndDatetime) { + return res.status(400).send('Start and end datetimes are required for date scheduling.'); + } + if (scheduleEndDatetime < scheduleStartDatetime) { + return res.status(400).send('End datetime must be after start datetime.'); + } + } else if (scheduleMode === 'times') { + scheduleStartTime = parseTimeLocal(scheduleStartTimes[i]); + scheduleEndTime = parseTimeLocal(scheduleEndTimes[i]); + if (!scheduleStartTime || !scheduleEndTime) { + return res.status(400).send('Start and end times are required for time scheduling.'); + } + + let scheduleDays = []; + try { + const parsedDays = JSON.parse(String(scheduleDaysJsonValues[i] || '[]')); + scheduleDays = Array.isArray(parsedDays) ? parsedDays : []; + } catch (_error) { + scheduleDays = []; + } + scheduleDays = scheduleDays + .map(function (value) { return Number(value); }) + .filter(function (value) { return Number.isInteger(value) && value >= 0 && value <= 6; }); + if (!scheduleDays.length) { + return res.status(400).send('Select at least one day for time scheduling.'); + } + scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort()); + } + + normalizedSlides.push({ + slideId, + position: i, + durationSeconds, + scheduleMode, + scheduleStartDatetime, + scheduleEndDatetime, + scheduleStartTime, + scheduleEndTime, + scheduleDaysJson + }); + } + + if (normalizedSlides.length) { + const [slides] = await connection.query( + `SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height + FROM slides sl + LEFT JOIN slide_templates st ON st.id = sl.template_id + LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + WHERE sl.id IN (?)`, + [normalizedSlides.map(function (item) { return item.slideId; })] + ); + if (slides.length !== normalizedSlides.length) { + return res.status(400).send('One or more selected slides no longer exist.'); + } + const signatures = Array.from(new Set( + slides + .map(function (slide) { return getCanvasSignature(slide.canvas_width, slide.canvas_height); }) + .filter(Boolean) + )); + if (signatures.length > 1) { + return res.status(400).send('All playlist slides must share the same canvas size.'); + } + } + + const actorId = getAuditUserId(req); + + await connection.beginTransaction(); + await connection.query('UPDATE playlists SET name = ?, fade_between_slides = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, actorId, playlist.id]); + await connection.query('DELETE FROM playlist_slides WHERE playlist_id = ?', [playlist.id]); + for (let i = 0; i < normalizedSlides.length; i += 1) { + const item = normalizedSlides[i]; + await connection.query( + 'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + playlist.id, + item.slideId, + item.position, + item.durationSeconds, + item.scheduleMode, + item.scheduleStartDatetime, + item.scheduleEndDatetime, + item.scheduleStartTime, + item.scheduleEndTime, + item.scheduleDaysJson, + actorId, + actorId + ] + ); + } + await connection.commit(); + await notifyPlayerScreens(affectedScreens, 'refresh'); + await broadcastDashboardState(); + redirectAfterSave(req, res, '/admin/playlists?edit=' + playlist.id, { + closeUrl: '/admin/playlists', + newUrl: '/admin/playlists/new', + message: 'Playlist updated.' + }); + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } + next(error); + } finally { + connection.release(); + } + }); + + app.post('/admin/playlists/:id/delete', async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist); + if (blockMessage) { + return res.redirect('/admin/playlists?message=' + encodeURIComponent(blockMessage)); + } + await pool.query('DELETE FROM playlists WHERE id = ?', [playlist.id]); + res.redirect('/admin/playlists?message=' + encodeURIComponent('Playlist deleted.')); + } catch (error) { + next(error); + } + }); + + app.post('/admin/playlists/:id/slides', async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const slideId = Number(req.body.slide_id); + if (!slideId) { + return res.status(400).send('Slide is required.'); + } + const playlistCanvasSignature = await fetchPlaylistCanvasSignature(pool, playlist.id); + if (playlistCanvasSignature === 'mismatch') { + return res.status(400).send('This playlist already contains slides with different canvas sizes.'); + } + const slide = await common.fetchSlideById(pool, slideId); + if (!slide) { + return res.status(404).send('Slide not found'); + } + const slideCanvasSignature = getCanvasSignature(slide.canvas_width, slide.canvas_height); + if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) { + return res.status(400).send('The slide canvas size must match the existing playlist items.'); + } + const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10)); + const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_slides WHERE playlist_id = ?', [playlist.id]); + const nextPosition = Number(positionRows[0].max_position) + 1; + const actorId = getAuditUserId(req); + await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]); + await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); + await broadcastDashboardState(); + res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.')); + } catch (error) { + next(error); + } + }); + + app.post('/admin/playlists/:id/slides/:playlistSlideId', async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const playlistSlideId = Number(req.params.playlistSlideId); + const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10)); + const actorId = getAuditUserId(req); + const [result] = await pool.query( + 'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', + [durationSeconds, actorId, playlistSlideId, playlist.id] + ); + if (!result.affectedRows) { + return res.status(404).send('Playlist slide not found'); + } + await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); + await broadcastDashboardState(); + res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.')); + } catch (error) { + next(error); + } + }); + + app.post('/admin/playlists/:id/slides/:playlistSlideId/move', async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const playlist = await common.fetchPlaylistById(connection, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const playlistSlideId = Number(req.params.playlistSlideId); + const direction = String(req.body.direction || '').toLowerCase(); + if (direction !== 'up' && direction !== 'down') { + return res.status(400).send('Invalid move direction.'); + } + + await connection.beginTransaction(); + const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id); + const currentIndex = orderedSlides.findIndex(function (item) { + return Number(item.id) === playlistSlideId; + }); + if (currentIndex === -1) { + await connection.rollback(); + return res.status(404).send('Playlist slide not found'); + } + + const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + if (swapIndex < 0 || swapIndex >= orderedSlides.length) { + await connection.rollback(); + return res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.')); + } + + const currentSlide = orderedSlides[currentIndex]; + const swapSlide = orderedSlides[swapIndex]; + const actorId = getAuditUserId(req); + + await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]); + await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]); + await connection.commit(); + await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh'); + await broadcastDashboardState(); + res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.')); + } catch (error) { + await connection.rollback(); + next(error); + } finally { + connection.release(); + } + }); + + app.get('/admin/playlists/:id/slides/:playlistSlideId/config', async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const data = await common.fetchAdminData(pool); + let playlistSlide = (data.playlistSlides || []).find(function (item) { + return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id; + }); + if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) { + return res.status(404).send('Playlist slide not found'); + } + const scheduleMode = typeof req.query.schedule_mode === 'string' && req.query.schedule_mode ? String(req.query.schedule_mode) : String(playlistSlide && playlistSlide.schedule_mode || 'always'); + const scheduleStartDatetime = typeof req.query.schedule_start_datetime === 'string' ? String(req.query.schedule_start_datetime) : (playlistSlide && playlistSlide.schedule_start_datetime) || null; + const scheduleEndDatetime = typeof req.query.schedule_end_datetime === 'string' ? String(req.query.schedule_end_datetime) : (playlistSlide && playlistSlide.schedule_end_datetime) || null; + const scheduleStartTime = typeof req.query.schedule_start_time === 'string' ? String(req.query.schedule_start_time) : (playlistSlide && playlistSlide.schedule_start_time) || null; + const scheduleEndTime = typeof req.query.schedule_end_time === 'string' ? String(req.query.schedule_end_time) : (playlistSlide && playlistSlide.schedule_end_time) || null; + const scheduleDaysJson = typeof req.query.schedule_days_json === 'string' ? String(req.query.schedule_days_json) : (playlistSlide && playlistSlide.schedule_days_json) || '[]'; + const scheduleDays = common.parseJsonSafe(scheduleDaysJson) || []; + + playlistSlide = Object.assign({}, playlistSlide || {}, { + id: playlistSlide ? playlistSlide.id : 0, + schedule_mode: scheduleMode, + schedule_days_json: scheduleDaysJson, + schedule_days: scheduleDays, + schedule_start_datetime: scheduleStartDatetime, + schedule_end_datetime: scheduleEndDatetime, + schedule_start_time: scheduleStartTime, + schedule_end_time: scheduleEndTime + }); + return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.post('/admin/playlists/:id/slides/:playlistSlideId/config', async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const rowKey = String(req.body.row_key || '').trim(); + if (rowKey) { + return res.status(400).send('Schedule changes from the playlist editor are staged until you click Save changes.'); + } + const playlistSlideId = Number(req.params.playlistSlideId); + let scheduleMode = normalizeScheduleMode(req.body.schedule_mode); + let scheduleStartDatetime = null; + let scheduleEndDatetime = null; + let scheduleStartTime = null; + let scheduleEndTime = null; + let scheduleDaysJson = null; + + const hasDateRange = Boolean(req.body.schedule_start_datetime && req.body.schedule_end_datetime); + const hasTimeRange = Boolean(req.body.schedule_start_time && req.body.schedule_end_time); + const hasSelectedDays = Boolean(readArrayField(req.body, ['schedule_days', 'schedule_days[]']).length); + + if (scheduleMode === 'dates' && !hasDateRange) { + scheduleMode = 'always'; + } else if (scheduleMode === 'times' && (!hasTimeRange || !hasSelectedDays)) { + scheduleMode = 'always'; + } + + if (scheduleMode === 'dates') { + scheduleStartDatetime = parseDateTimeLocal(req.body.schedule_start_datetime); + scheduleEndDatetime = parseDateTimeLocal(req.body.schedule_end_datetime); + if (!scheduleStartDatetime || !scheduleEndDatetime) { + scheduleMode = 'always'; + scheduleStartDatetime = null; + scheduleEndDatetime = null; + } else if (scheduleEndDatetime < scheduleStartDatetime) { + return res.status(400).send('End datetime must be after start datetime.'); + } + } else if (scheduleMode === 'times') { + scheduleStartTime = parseTimeLocal(req.body.schedule_start_time); + scheduleEndTime = parseTimeLocal(req.body.schedule_end_time); + const scheduleDays = readArrayField(req.body, ['schedule_days', 'schedule_days[]']).map(function (value) { + return Number(value); + }).filter(function (value) { + return Number.isInteger(value) && value >= 0 && value <= 6; + }); + if (!scheduleStartTime || !scheduleEndTime) { + scheduleMode = 'always'; + scheduleStartTime = null; + scheduleEndTime = null; + scheduleDaysJson = null; + } else if (!scheduleDays.length) { + scheduleMode = 'always'; + scheduleStartTime = null; + scheduleEndTime = null; + scheduleDaysJson = null; + } else { + if (scheduleEndTime < scheduleStartTime) { + return res.status(400).send('End time must be after start time.'); + } + scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort()); + } + } + + const actorId = getAuditUserId(req); + const [result] = await pool.query( + 'UPDATE playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', + [scheduleMode, scheduleStartDatetime, scheduleEndDatetime, scheduleStartTime, scheduleEndTime, scheduleDaysJson, actorId, playlistSlideId, playlist.id] + ); + if (!result.affectedRows) { + return res.status(404).send('Playlist slide not found'); + } + await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); + await broadcastDashboardState(); + res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.')); + } catch (error) { + next(error); + } + }); + + app.post('/admin/playlists/:id/slides/:playlistSlideId/delete', async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + await pool.query('DELETE FROM playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]); + await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); + await broadcastDashboardState(); + res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.')); + } catch (error) { + next(error); + } + }); + + app.get('/admin/screens/new', async function (req, res, next) { + try { + const data = await common.fetchScreenEditData(pool); + res.send(pages.renderScreenFormPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.post('/admin/screens', async function (req, res, next) { + try { + const name = String(req.body.name || '').trim(); + if (!name) { + return res.status(400).send('Screen name is required.'); + } + const slugInput = String(req.body.slug || '').trim(); + const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null; + const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name)); + const actorId = getAuditUserId(req); + await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]); + redirectAfterSave(req, res, '/admin/screens', { + closeUrl: '/admin/screens', + newUrl: '/admin/screens/new', + message: 'Screen created.' + }); + } catch (error) { + next(error); + } + }); + + app.post('/admin/screens/:id', async function (req, res, next) { + try { + const name = String(req.body.name || '').trim(); + if (!name) { + return res.status(400).send('Screen name is required.'); + } + const screen = await common.fetchScreenById(pool, Number(req.params.id)); + if (!screen) { + return res.status(404).send('Screen not found'); + } + const slugInput = String(req.body.slug || '').trim(); + const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null; + const previousPlaylistId = screen.playlist_id; + const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id); + const previousSlug = String(screen.slug || '').trim(); + await pool.query('UPDATE screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]); + if (previousPlaylistId !== playlistId && previousSlug) { + await notifyPlayerScreens([previousSlug], 'refresh'); + } + if (previousSlug && previousSlug !== slug) { + await forwardPlayerCommand(previousSlug, { + command: 'redirect', + url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}` + }); + } + redirectAfterSave(req, res, '/admin/screens?edit=' + screen.id, { + closeUrl: '/admin/screens', + newUrl: '/admin/screens/new', + message: 'Screen updated.' + }); + } catch (error) { + next(error); + } + }); + + app.post('/admin/screens/:id/delete', async function (req, res, next) { + try { + const screen = await common.fetchScreenById(pool, Number(req.params.id)); + if (!screen) { + return res.status(404).send('Screen not found'); + } + const blockMessage = await getScreenDeleteBlockMessage(pool, screen); + if (blockMessage) { + return res.redirect('/admin/screens?message=' + encodeURIComponent(blockMessage)); + } + await pool.query('DELETE FROM screens WHERE id = ?', [screen.id]); + res.redirect('/admin/screens?message=' + encodeURIComponent('Screen deleted.')); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/admin-pages.js b/src/web/routes/admin-pages.js new file mode 100644 index 0000000..d734a86 --- /dev/null +++ b/src/web/routes/admin-pages.js @@ -0,0 +1,87 @@ +module.exports = function registerAdminPagesRoutes(app, deps) { + const pool = deps.pool; + const common = deps.common; + const pages = deps.pages; + const buildDashboardState = deps.buildDashboardState; + + app.get('/admin', async function (req, res, next) { + try { + const data = await buildDashboardState(pool); + res.send(pages.renderDashboardPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/admin/clients', async function (req, res, next) { + try { + const data = await buildDashboardState(pool); + res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/admin/screens', async function (req, res, next) { + try { + const data = await buildDashboardState(pool); + if (req.query.edit) { + const screen = await common.fetchScreenById(pool, Number(req.query.edit)); + if (!screen) { + return res.status(404).send('Screen not found'); + } + const editData = await common.fetchScreenEditData(pool); + return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser)); + } + res.send(pages.renderScreensPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/admin/screens/:id/edit', async function (req, res, next) { + try { + const screen = await common.fetchScreenById(pool, Number(req.params.id)); + if (!screen) { + return res.status(404).send('Screen not found'); + } + const editData = await common.fetchScreenEditData(pool); + return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/admin/playlists', async function (req, res, next) { + try { + const data = await common.fetchAdminData(pool); + if (req.query.edit) { + const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser)); + } + res.send(pages.renderPlaylistsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/admin/playlists/new', function (req, res) { + res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser)); + }); + + app.get('/admin/playlists/:id/edit', async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const data = await common.fetchAdminData(pool); + res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/admin-screen-commands.js b/src/web/routes/admin-screen-commands.js new file mode 100644 index 0000000..5459f2e --- /dev/null +++ b/src/web/routes/admin-screen-commands.js @@ -0,0 +1,47 @@ +module.exports = function registerAdminScreenCommandRoutes(app, deps) { + const pool = deps.pool; + const forwardPlayerCommand = deps.forwardPlayerCommand; + + app.post('/admin/screens/:slug/commands', async function (req, res, next) { + try { + const slug = String(req.params.slug || '').trim(); + const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase(); + const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim(); + const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') + ? req.body.blackout + : req.query.blackout; + + if (!slug) { + return res.status(400).json({ error: 'Screen slug is required' }); + } + if (!command) { + return res.status(400).json({ error: 'Command is required' }); + } + + const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [slug]); + if (!screenRows.length) { + return res.status(404).json({ error: 'Screen not found' }); + } + + const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) + ? Object.assign({}, req.body, { command: command }) + : { command: command }; + if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') { + commandPayload.blackout = blackoutValue; + } + + const result = connectionId + ? await forwardPlayerCommand(slug, commandPayload, connectionId) + : await forwardPlayerCommand(slug, commandPayload); + + return res.json(Object.assign({ + screen: screenRows[0], + screenSlug: slug, + command: command, + connectionId: connectionId || null + }, result && typeof result === 'object' ? result : {})); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/admin-users.js b/src/web/routes/admin-users.js new file mode 100644 index 0000000..5b74066 --- /dev/null +++ b/src/web/routes/admin-users.js @@ -0,0 +1,185 @@ +module.exports = function registerAdminUsersRoutes(app, deps) { + const pool = deps.pool; + const pages = deps.pages; + const formatDashboardDate = deps.formatDashboardDate; + const getAuditUserId = deps.getAuditUserId; + const hashPassword = deps.hashPassword; + + app.get('/admin/users', async function (req, res, next) { + try { + const [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users ORDER BY id ASC'); + const users = rows.map(function (user) { + return Object.assign({}, user, { + isCurrentUser: Number(user.id) === Number(req.currentUser.id), + createdAtLabel: formatDashboardDate(user.created_at), + modifiedAtLabel: formatDashboardDate(user.modified_at) + }); + }); + res.send(pages.renderUsersPage({ users: users }, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/admin/users/new', function (req, res) { + res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser)); + }); + + app.get('/admin/users/:id/edit', async function (req, res, next) { + try { + const userId = Number(req.params.id); + if (!Number.isInteger(userId) || userId <= 0) { + return res.status(400).send('Invalid user.'); + } + + const [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users WHERE id = ? LIMIT 1', [userId]); + const user = rows[0] || null; + if (!user) { + return res.status(404).send('User not found.'); + } + if (Number(req.currentUser.id) === userId) { + return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.')); + } + + res.send(pages.renderUsersEditPage(Object.assign({}, user, { + isCurrentUser: false, + createdAtLabel: formatDashboardDate(user.created_at), + modifiedAtLabel: formatDashboardDate(user.modified_at) + }), req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.post('/admin/users', async function (req, res, next) { + try { + const name = String(req.body.name || '').trim(); + const username = String(req.body.username || '').trim(); + const password = String(req.body.password || ''); + const confirmPassword = String(req.body.confirm_password || ''); + + if (!name) { + return res.redirect('/admin/users/new?message=' + encodeURIComponent('Name is required.')); + } + if (!username) { + return res.redirect('/admin/users/new?message=' + encodeURIComponent('Username is required.')); + } + if (!password || password.length < 8) { + return res.redirect('/admin/users/new?message=' + encodeURIComponent('Password must be at least 8 characters.')); + } + if (password !== confirmPassword) { + return res.redirect('/admin/users/new?message=' + encodeURIComponent('Passwords do not match.')); + } + + const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]); + if (existingRows.length) { + return res.redirect('/admin/users/new?message=' + encodeURIComponent('That username already exists.')); + } + + const passwordRecord = hashPassword(password); + const actorId = getAuditUserId(req); + await pool.query( + 'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', + [name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId] + ); + res.redirect('/admin/users?message=' + encodeURIComponent('User created.')); + } catch (error) { + next(error); + } + }); + + app.post('/admin/users/:id/username', async function (req, res, next) { + try { + const userId = Number(req.params.id); + const name = String(req.body.name || '').trim(); + const username = String(req.body.username || '').trim(); + + if (!Number.isInteger(userId) || userId <= 0) { + return res.status(400).send('Invalid user.'); + } + if (!name) { + return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.')); + } + if (Number(req.currentUser.id) === userId) { + return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own username or password.')); + } + if (!username) { + return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.')); + } + + const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]); + if (existingRows.length) { + return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.')); + } + + const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]); + if (!result.affectedRows) { + return res.status(404).send('User not found.'); + } + res.redirect('/admin/users?message=' + encodeURIComponent('Username updated.')); + } catch (error) { + next(error); + } + }); + + app.post('/admin/users/:id/password', async function (req, res, next) { + try { + const userId = Number(req.params.id); + const password = String(req.body.password || ''); + const confirmPassword = String(req.body.confirm_password || ''); + + if (!Number.isInteger(userId) || userId <= 0) { + return res.status(400).send('Invalid user.'); + } + if (Number(req.currentUser.id) === userId) { + return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to change your own password.')); + } + if (!password || password.length < 8) { + return res.redirect('/admin/users?message=' + encodeURIComponent('Password must be at least 8 characters.')); + } + if (password !== confirmPassword) { + return res.redirect('/admin/users?message=' + encodeURIComponent('Passwords do not match.')); + } + + const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]); + if (!rows.length) { + return res.status(404).send('User not found.'); + } + + const passwordRecord = hashPassword(password); + await pool.query( + 'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?', + [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId] + ); + await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]); + res.redirect('/admin/users?message=' + encodeURIComponent('Password updated.')); + } catch (error) { + next(error); + } + }); + + app.post('/admin/users/:id/delete', async function (req, res, next) { + try { + const userId = Number(req.params.id); + if (!Number.isInteger(userId) || userId <= 0) { + return res.status(400).send('Invalid user.'); + } + if (Number(req.currentUser.id) === userId) { + return res.redirect('/admin/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.')); + } + + const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users'); + if (!countRows.length || Number(countRows[0].user_count) <= 1) { + return res.redirect('/admin/users?message=' + encodeURIComponent('At least one user must remain.')); + } + + const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]); + if (!result.affectedRows) { + return res.status(404).send('User not found.'); + } + res.redirect('/admin/users?message=' + encodeURIComponent('User deleted.')); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/auth.js b/src/web/routes/auth.js new file mode 100644 index 0000000..9c8e907 --- /dev/null +++ b/src/web/routes/auth.js @@ -0,0 +1,58 @@ +module.exports = function registerAuthRoutes(app, deps) { + const pool = deps.pool; + const pages = deps.pages; + const createUserSession = deps.createUserSession; + const setSessionCookie = deps.setSessionCookie; + const clearSessionCookie = deps.clearSessionCookie; + const parseCookies = deps.parseCookies; + const hashSessionToken = deps.hashSessionToken; + const verifyPassword = deps.verifyPassword; + const sessionCookieName = deps.sessionCookieName; + + app.get('/', function (req, res) { + res.redirect(req.currentUser ? '/admin' : '/login'); + }); + + app.get('/login', function (req, res) { + if (req.currentUser) { + return res.redirect('/admin'); + } + res.send(pages.renderLoginPage(req.query.message ? String(req.query.message) : '')); + }); + + app.post('/login', async function (req, res, next) { + try { + const username = String(req.body.username || '').trim(); + const password = String(req.body.password || ''); + if (!username || !password) { + return res.status(400).send('Username and password are required.'); + } + + const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE username = ? LIMIT 1', [username]); + const user = rows[0] || null; + if (!user || !verifyPassword(password, user)) { + return res.redirect('/login?message=' + encodeURIComponent('Invalid username or password.')); + } + + const token = await createUserSession(pool, user.id); + setSessionCookie(res, token); + res.redirect('/admin'); + } catch (error) { + next(error); + } + }); + + app.get('/logout', async function (req, res, next) { + try { + const cookies = parseCookies(req.headers.cookie || ''); + const token = cookies[sessionCookieName]; + if (token) { + await pool.query('DELETE FROM auth_sessions WHERE session_hash = ?', [hashSessionToken(token)]); + } + clearSessionCookie(res); + res.redirect('/login?message=' + encodeURIComponent('You have been signed out.')); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/clients/list.js b/src/web/routes/clients/list.js index 2cd965a..eb2e52a 100644 --- a/src/web/routes/clients/list.js +++ b/src/web/routes/clients/list.js @@ -6,6 +6,7 @@ module.exports = function renderConnectedClientsPage(data, message, currentUser) active: 'clients', message: message, currentUser: currentUser || null, + scripts: ['js/dashboard/dashboard-page.js'], clients: data.clients || [] }); }; \ No newline at end of file diff --git a/src/web/routes/common.js b/src/web/routes/common.js index 2d1f189..76b5343 100644 --- a/src/web/routes/common.js +++ b/src/web/routes/common.js @@ -13,44 +13,7 @@ function screenPlayerUrl(slug) { return `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`; } -function renderAdminShell(active, title, body, message) { - const navItems = [ - { key: 'dashboard', label: 'Dashboard', href: '/admin' }, - { key: 'playlists', label: 'Playlists', href: '/admin/playlists' }, - { key: 'screens', label: 'Screens', href: '/admin/screens' }, - { key: 'slides', label: 'Slides', href: '/admin/slides' }, - { key: 'templates', label: 'Slide templates', href: '/admin/templates' } - ].map((item) => ` - ${escapeHtml(item.label)} - `).join(''); - - return ` - - - - - ${escapeHtml(title)} - Pulse Signage - - - -
    - -
    - - ${body} -
    -
    - - `; -} - module.exports = { escapeHtml, - screenPlayerUrl, - renderAdminShell + screenPlayerUrl }; diff --git a/src/web/routes/dashboard/index.js b/src/web/routes/dashboard/index.js index 0c67cf0..0f9fe0d 100644 --- a/src/web/routes/dashboard/index.js +++ b/src/web/routes/dashboard/index.js @@ -10,6 +10,7 @@ module.exports = function renderDashboardPage(data, message, currentUser) { screens: data.screens || [], clients: data.clients || [], slides: data.slides || [], - connectedClientsCount: Number(data.connectedClientsCount || 0) + connectedClientsCount: Number(data.connectedClientsCount || 0), + scripts: ['js/dashboard/dashboard-page.js'] }); }; diff --git a/src/web/routes/playlists/edit.js b/src/web/routes/playlists/edit.js index 815c40e..6eb3713 100644 --- a/src/web/routes/playlists/edit.js +++ b/src/web/routes/playlists/edit.js @@ -110,6 +110,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren selectableSlides: selectableSlides, playlistCanvasSignature: playlistCanvasSignature, playlistCanvasMismatch: playlistCanvasMismatch, - scripts: ['js/sortable.min.js', 'js/playlist-schedule.js'] + scripts: ['js/vendor/sortable.min.js', 'js/playlists/playlist-schedule.js'] }); }; diff --git a/src/web/routes/playlists/slide-config.js b/src/web/routes/playlists/slide-config.js index 4ce7b66..ff05bd4 100644 --- a/src/web/routes/playlists/slide-config.js +++ b/src/web/routes/playlists/slide-config.js @@ -10,6 +10,9 @@ const DAY_OPTIONS = [ { value: 6, label: 'Sat' } ]; +const DEFAULT_START_TIME = '00:00'; +const DEFAULT_END_TIME = '23:59'; + function formatDateTimeInput(value) { if (!value) { return ''; @@ -34,31 +37,43 @@ function formatTimeInput(value) { } module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, message, rowKey, currentUser) { - const scheduleMode = String(playlistSlide.schedule_mode || 'always'); - const selectedDays = new Set(Array.isArray(playlistSlide.schedule_days) ? playlistSlide.schedule_days.map(Number) : []); + const slide = playlistSlide || { + id: 0, + schedule_mode: 'always', + schedule_days: [], + schedule_start_datetime: null, + schedule_end_datetime: null, + schedule_start_time: null, + schedule_end_time: null + }; + const scheduleMode = String(slide.schedule_mode || 'always'); + const selectedDays = new Set(Array.isArray(slide.schedule_days) ? slide.schedule_days.map(Number) : []); + const startTimeValue = formatTimeInput(slide.schedule_start_time) || (scheduleMode === 'times' ? DEFAULT_START_TIME : ''); + const endTimeValue = formatTimeInput(slide.schedule_end_time) || (scheduleMode === 'times' ? DEFAULT_END_TIME : ''); return renderFragment('playlists/slide-config', { title: 'Slide schedule', + framePopupCard: true, hideFrameHeader: true, hideThemeToggle: true, frameBodyClass: 'admin-shell-body--frame-plain', message: message, currentUser: currentUser || null, playlist: playlist, - playlistSlide: playlistSlide, + playlistSlide: slide, scheduleMode: scheduleMode, isAlwaysMode: scheduleMode === 'always', isDatesMode: scheduleMode === 'dates', isTimesMode: scheduleMode === 'times', rowKey: String(rowKey || ''), - startDateTimeValue: formatDateTimeInput(playlistSlide.schedule_start_datetime), - endDateTimeValue: formatDateTimeInput(playlistSlide.schedule_end_datetime), - startTimeValue: formatTimeInput(playlistSlide.schedule_start_time), - endTimeValue: formatTimeInput(playlistSlide.schedule_end_time), + startDateTimeValue: formatDateTimeInput(slide.schedule_start_datetime), + endDateTimeValue: formatDateTimeInput(slide.schedule_end_datetime), + startTimeValue: startTimeValue, + endTimeValue: endTimeValue, dayOptions: DAY_OPTIONS.map((day) => ({ ...day, checked: selectedDays.has(day.value) })), - scripts: ['js/playlist-schedule.js'] + scripts: ['js/playlists/playlist-schedule.js'] }); }; diff --git a/src/web/routes/slides/form.js b/src/web/routes/slides/form.js index 46e129f..de8ee5f 100644 --- a/src/web/routes/slides/form.js +++ b/src/web/routes/slides/form.js @@ -13,9 +13,11 @@ module.exports = function renderSlideFormPage(data, mode, slide, message, curren active: 'slides', message: message, isEdit: mode === 'edit', + saveLabel: 'Save', action: mode === 'edit' ? `/admin/slides/${slide.id}` : '/admin/slides', slide: slide || { title: '', template_id: null, content: {} }, templates: templates, + stylesheets: ['js/vendor/ckeditor5/ckeditor5.css'], slideEditorData: { templates: templates, existingTemplateId: slide && slide.template_id ? slide.template_id : null, diff --git a/src/web/routes/templates/add.js b/src/web/routes/templates/add.js index 9b543ad..df8b87d 100644 --- a/src/web/routes/templates/add.js +++ b/src/web/routes/templates/add.js @@ -7,6 +7,7 @@ function buildDefaultTemplate() { canvas_size_id: null, canvas_size_width: 1920, canvas_size_height: 1080, + background_color: '#111111', background_image_path: '', regions: [{ region_key: 'region_1', @@ -33,7 +34,8 @@ function resolveTemplateCanvasSize(template, canvasSizes) { canvas_size_id: matched.id, canvas_size_label: matched.name, canvas_size_width: matched.width, - canvas_size_height: matched.height + canvas_size_height: matched.height, + background_color: String(template.background_color || '#111111').trim() || '#111111' }); } @@ -41,7 +43,8 @@ function resolveTemplateCanvasSize(template, canvasSizes) { canvas_size_id: '', canvas_size_label: 'Current size (' + currentWidth + 'x' + currentHeight + ')', canvas_size_width: currentWidth, - canvas_size_height: currentHeight + canvas_size_height: currentHeight, + background_color: String(template.background_color || '#111111').trim() || '#111111' }); } @@ -59,6 +62,6 @@ module.exports = function renderTemplateFormPage(template, mode, message, canvas currentUser: currentUser || null, template: current, canvasSizes: canvasSizes || [], - scripts: ['js/template-designer.js'] + scripts: ['js/templates/template-designer-utils.js', 'js/templates/template-designer.js'] }); }; diff --git a/src/web/session.js b/src/web/session.js new file mode 100644 index 0000000..631b945 --- /dev/null +++ b/src/web/session.js @@ -0,0 +1,102 @@ +function createSessionService(options) { + const sessionCookieName = String(options && options.sessionCookieName || '').trim(); + const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs); + const hashSessionToken = options && options.hashSessionToken; + const createSessionToken = options && options.createSessionToken; + + if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') { + throw new Error('createSessionService requires the session dependencies.'); + } + + function parseCookies(cookieHeader) { + return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) { + if (!pair) { + return cookies; + } + const separatorIndex = pair.indexOf('='); + if (separatorIndex === -1) { + return cookies; + } + const name = decodeURIComponent(pair.slice(0, separatorIndex).trim()); + const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim()); + if (name) { + cookies[name] = value; + } + return cookies; + }, {}); + } + + function serializeCookie(name, value, options) { + const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`]; + if (options && options.maxAge !== undefined) { + parts.push(`Max-Age=${Math.max(0, Math.trunc(Number(options.maxAge) / 1000))}`); + } + parts.push('Path=/'); + parts.push('HttpOnly'); + parts.push('SameSite=Lax'); + return parts.join('; '); + } + + function clearSessionCookie(res) { + res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, '', { maxAge: 0 })); + } + + function setSessionCookie(res, token) { + res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs })); + } + + async function loadCurrentUser(pool, req) { + const cookies = parseCookies(req.headers.cookie || ''); + const token = cookies[sessionCookieName]; + if (!token) { + return null; + } + + const tokenHash = hashSessionToken(token); + const [rows] = await pool.query( + `SELECT s.user_id, u.id, u.name, u.username + FROM auth_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.session_hash = ? + AND s.expires_at > NOW() + LIMIT 1`, + [tokenHash] + ); + if (!rows.length) { + return null; + } + + await pool.query('UPDATE auth_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]); + return rows[0]; + } + + async function createUserSession(pool, userId) { + const token = createSessionToken(); + const tokenHash = hashSessionToken(token); + const expiresAt = new Date(Date.now() + sessionMaxAgeMs); + await pool.query( + 'INSERT INTO auth_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', + [tokenHash, userId, expiresAt, userId, userId] + ); + return token; + } + + function requireAuth(req, res, next) { + if (req.currentUser) { + return next(); + } + res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.')); + } + + return { + parseCookies: parseCookies, + serializeCookie: serializeCookie, + clearSessionCookie: clearSessionCookie, + setSessionCookie: setSessionCookie, + loadCurrentUser: loadCurrentUser, + createUserSession: createUserSession, + requireAuth: requireAuth + }; +} + +module.exports = { createSessionService }; \ No newline at end of file diff --git a/src/web/upload-sync.js b/src/web/upload-sync.js new file mode 100644 index 0000000..95f5588 --- /dev/null +++ b/src/web/upload-sync.js @@ -0,0 +1,561 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const multer = require('multer'); + +function normalizeUploadRoot(uploadDir) { + return path.resolve(String(uploadDir || '').trim()); +} + +function createUploadSyncService(options) { + const common = options && options.common; + const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); + const playerSnapshotCache = options && options.playerSnapshotCache; + const notifyPlayerScreens = options && options.notifyPlayerScreens; + + let playerUploadSyncMode = null; + let playerUploadSyncModePromise = null; + const pendingPlayerUploadSyncs = new Map(); + let pendingPlayerUploadSyncFlushTimer = null; + let pendingPlayerUploadSyncFlushInFlight = null; + const pendingPlaylistUploadSyncs = new Map(); + let pendingPlaylistUploadSyncFlushTimer = null; + let pendingPlaylistUploadSyncFlushInFlight = null; + + if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') { + throw new Error('createUploadSyncService requires the upload dependencies.'); + } + + function createUploadMiddleware(uploadDir) { + const storage = multer.diskStorage({ + destination: function (_req, _file, cb) { + cb(null, uploadDir); + }, + filename: function (_req, file, cb) { + const safeExt = path.extname(file.originalname || '').toLowerCase(); + const stamp = `${Date.now()}-${crypto.randomUUID()}`; + cb(null, `${stamp}${safeExt}`); + } + }); + return multer({ storage }); + } + + function normalizeUploadReference(uploadPath) { + const value = String(uploadPath || '').trim(); + if (!value || !value.startsWith('/uploads/')) { + return null; + } + return value; + } + + function collectUploadReferencesFromValue(value, refs) { + if (!value) { + return refs; + } + const stack = [value]; + while (stack.length) { + const current = stack.pop(); + if (Array.isArray(current)) { + current.forEach(function (item) { + stack.push(item); + }); + continue; + } + if (current && typeof current === 'object') { + Object.keys(current).forEach(function (key) { + stack.push(current[key]); + }); + continue; + } + if (typeof current === 'string') { + const reference = normalizeUploadReference(current); + if (reference) { + refs.add(reference); + } + } + } + return refs; + } + + function collectUploadReferencesFromSlide(slide) { + const refs = new Set(); + if (!slide) { + return refs; + } + collectUploadReferencesFromValue(slide.media_path, refs); + collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs); + return refs; + } + + function collectUploadReferencesFromTemplate(template) { + const refs = new Set(); + if (!template) { + return refs; + } + collectUploadReferencesFromValue(template.background_image_path, refs); + return refs; + } + + function collectUploadReferencesFromPayload(payload) { + const refs = new Set(); + if (!payload) { + return refs; + } + collectUploadReferencesFromValue(payload.mediaPath, refs); + collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs); + collectUploadReferencesFromValue(payload.backgroundImagePath, refs); + return refs; + } + + async function countUploadReferences(pool, uploadPath) { + const [slideRows] = await pool.query( + `SELECT COUNT(*) AS ref_count + FROM slides + WHERE media_path = ? + OR JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`, + [uploadPath, uploadPath] + ); + const [templateRows] = await pool.query( + 'SELECT COUNT(*) AS ref_count FROM slide_templates WHERE background_image_path = ?', + [uploadPath] + ); + return Number(slideRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0); + } + + async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) { + const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean))); + for (let i = 0; i < uniquePaths.length; i += 1) { + const uploadPath = uniquePaths[i]; + const referenceCount = await countUploadReferences(pool, uploadPath); + if (referenceCount > 0) { + continue; + } + + const filePath = path.join(uploadDir, path.basename(uploadPath)); + try { + await fs.promises.unlink(filePath); + } catch (error) { + if (error && error.code !== 'ENOENT') { + console.warn('Unable to remove unused upload file:', filePath, error); + } + } + queuePlayerUploadSync({ + type: 'delete', + uploadPath: uploadPath, + uploadDir: uploadDir + }); + } + } + + async function getPlayerUploadSyncMode(localUploadDir) { + if (playerUploadSyncMode) { + return playerUploadSyncMode; + } + if (playerUploadSyncModePromise) { + return playerUploadSyncModePromise; + } + + playerUploadSyncModePromise = (async function () { + try { + const response = await fetch(`${playerInternalBaseUrl}/api/uploads/config`, { + headers: { + Accept: 'application/json' + } + }); + if (!response.ok) { + return null; + } + const data = await response.json(); + const playerUploadDir = data && data.uploadDir ? normalizeUploadRoot(data.uploadDir) : null; + if (!playerUploadDir) { + return null; + } + return playerUploadDir === normalizeUploadRoot(localUploadDir) ? 'shared' : 'different'; + } catch (_error) { + return null; + } + })().then(function (mode) { + if (mode) { + playerUploadSyncMode = mode; + } + playerUploadSyncModePromise = null; + return mode; + }, function () { + playerUploadSyncModePromise = null; + return null; + }); + + return playerUploadSyncModePromise; + } + + async function shouldMirrorUploads(localUploadDir) { + return Boolean(localUploadDir); + } + + function queuePlayerUploadSync(operation) { + if (!operation || !operation.uploadPath) { + return; + } + + pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), { + type: operation.type === 'delete' ? 'delete' : 'put', + uploadPath: normalizeUploadReference(operation.uploadPath), + uploadDir: operation.uploadDir || null + }); + + schedulePendingPlayerUploadSyncFlush(); + } + + function schedulePendingPlayerUploadSyncFlush() { + if (pendingPlayerUploadSyncFlushTimer) { + return; + } + + pendingPlayerUploadSyncFlushTimer = setTimeout(function () { + pendingPlayerUploadSyncFlushTimer = null; + flushPendingPlayerUploadSyncs().catch(function (error) { + console.warn('Unable to flush pending upload syncs:', error); + }); + }, 5000); + } + + async function pushUploadFileToPlayer(uploadPath, localUploadDir) { + if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) { + return false; + } + + const filename = path.basename(uploadPath); + const sourcePath = path.join(localUploadDir, filename); + let fileBuffer = null; + try { + fileBuffer = await fs.promises.readFile(sourcePath); + } catch (error) { + if (!error || error.code !== 'ENOENT') { + console.warn('Unable to read upload for player sync:', sourcePath, error); + } + return; + } + + try { + const response = await fetch(`${playerInternalBaseUrl}/api/uploads/${encodeURIComponent(filename)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: fileBuffer + }); + if (!response.ok) { + console.warn('Unable to sync upload to player:', filename, response.status, response.statusText); + return false; + } + return true; + } catch (error) { + console.warn('Unable to sync upload to player:', filename, error); + return false; + } + } + + async function removeUploadFileFromPlayer(uploadPath, localUploadDir) { + if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) { + return false; + } + + const filename = path.basename(uploadPath); + try { + const response = await fetch(`${playerInternalBaseUrl}/api/uploads/${encodeURIComponent(filename)}`, { + method: 'DELETE', + headers: { + Accept: 'application/json' + } + }); + if (!response.ok && response.status !== 404) { + console.warn('Unable to remove upload from player:', filename, response.status, response.statusText); + return false; + } + return true; + } catch (error) { + console.warn('Unable to remove upload from player:', filename, error); + return false; + } + } + + async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) { + if (!(await shouldMirrorUploads(localUploadDir))) { + return; + } + + const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean))); + for (let i = 0; i < uniqueRefs.length; i += 1) { + const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir); + if (!success) { + queuePlayerUploadSync({ + type: 'put', + uploadPath: uniqueRefs[i], + uploadDir: localUploadDir + }); + } + } + } + + async function syncExistingUploadsToPlayer(pool, localUploadDir) { + if (!(await shouldMirrorUploads(localUploadDir))) { + return; + } + + const data = await common.fetchAdminData(pool); + const uploadRefs = new Set(); + (data.slides || []).forEach(function (slide) { + collectUploadReferencesFromSlide(slide).forEach(function (reference) { + uploadRefs.add(reference); + }); + }); + (data.templates || []).forEach(function (template) { + collectUploadReferencesFromTemplate(template).forEach(function (reference) { + uploadRefs.add(reference); + }); + }); + Array.from(uploadRefs).forEach(function (uploadPath) { + queuePlayerUploadSync({ + type: 'put', + uploadPath: uploadPath, + uploadDir: localUploadDir + }); + }); + await flushPendingPlayerUploadSyncs(); + } + + function getVisibleCurrentSlideIds() { + const visibleSlideIds = new Set(); + playerSnapshotCache.forEach(function (snapshot) { + const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : []; + connections.forEach(function (connection) { + const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object' + ? connection.currentSlide + : null; + const slideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null + ? String(currentSlide.id).trim() + : ''; + if (slideId) { + visibleSlideIds.add(slideId); + } + }); + }); + return visibleSlideIds; + } + + function isScreenRefreshBlocked(screenSlug, blockedSlideIds, screenSlideCounts) { + const slideIds = Array.isArray(blockedSlideIds) + ? blockedSlideIds.map(function (value) { + return String(value || '').trim(); + }).filter(Boolean) + : []; + if (!slideIds.length) { + return false; + } + const normalizedScreenSlug = String(screenSlug || '').trim(); + const slideCount = screenSlideCounts && Object.prototype.hasOwnProperty.call(screenSlideCounts, normalizedScreenSlug) + ? Number(screenSlideCounts[normalizedScreenSlug]) + : null; + if (Number.isFinite(slideCount) && slideCount <= 1) { + return false; + } + const snapshot = playerSnapshotCache.get(String(screenSlug || '').trim()); + const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : []; + return connections.some(function (connection) { + const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object' + ? connection.currentSlide + : null; + const currentSlideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null + ? String(currentSlide.id).trim() + : ''; + return currentSlideId && slideIds.includes(currentSlideId); + }); + } + + function splitRefreshScreenSlugsByVisibility(screenSlugs, blockedSlideIds, screenSlideCounts) { + const ready = []; + const blocked = []; + Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).forEach(function (screenSlug) { + const normalizedScreenSlug = String(screenSlug || '').trim(); + if (!normalizedScreenSlug) { + return; + } + if (isScreenRefreshBlocked(normalizedScreenSlug, blockedSlideIds, screenSlideCounts)) { + blocked.push(normalizedScreenSlug); + } else { + ready.push(normalizedScreenSlug); + } + }); + return { ready: ready, blocked: blocked }; + } + + function normalizePlaylistUploadSyncOperation(options) { + return { + key: String(options && options.key ? options.key : '').trim(), + pool: options && options.pool ? options.pool : null, + localUploadDir: options && options.localUploadDir ? options.localUploadDir : null, + previousUploadRefs: Array.from(new Set(options && options.previousUploadRefs ? options.previousUploadRefs : [])), + nextUploadRefs: Array.from(new Set(options && options.nextUploadRefs ? options.nextUploadRefs : [])), + blockedSlideIds: Array.from(new Set(options && options.blockedSlideIds ? options.blockedSlideIds : [])).map(function (value) { + return String(value || '').trim(); + }).filter(Boolean), + refreshScreenSlugs: Array.from(new Set(options && options.refreshScreenSlugs ? options.refreshScreenSlugs : [])).map(function (value) { + return String(value || '').trim(); + }).filter(Boolean) + , + screenSlideCounts: options && options.screenSlideCounts && typeof options.screenSlideCounts === 'object' + ? options.screenSlideCounts + : {} + }; + } + + function queuePlaylistUploadSync(operation) { + if (!operation || !operation.key) { + return; + } + pendingPlaylistUploadSyncs.set(operation.key, normalizePlaylistUploadSyncOperation(operation)); + schedulePendingPlaylistUploadSyncFlush(); + } + + function schedulePendingPlaylistUploadSyncFlush() { + if (pendingPlaylistUploadSyncFlushTimer) { + return; + } + + pendingPlaylistUploadSyncFlushTimer = setTimeout(function () { + pendingPlaylistUploadSyncFlushTimer = null; + flushPendingPlaylistUploadSyncs().catch(function (error) { + console.warn('Unable to flush pending playlist upload syncs:', error); + }); + }, 5000); + } + + async function syncPlaylistUploadsOnChange(options) { + const operation = normalizePlaylistUploadSyncOperation(options); + if (!operation.key) { + return; + } + + if (operation.previousUploadRefs.length) { + const nextUploadRefSet = new Set(operation.nextUploadRefs); + await removeUnusedUploadFiles(operation.pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) { + return !nextUploadRefSet.has(reference); + })); + } + + if (operation.nextUploadRefs.length) { + await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir); + } + + if (operation.refreshScreenSlugs.length) { + const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts); + if (refreshTargets.ready.length) { + await notifyPlayerScreens(refreshTargets.ready, 'refresh'); + } + refreshTargets.blocked.forEach(function (screenSlug) { + queuePlaylistUploadSync({ + key: operation.key + ':refresh:' + screenSlug, + blockedSlideIds: operation.blockedSlideIds, + refreshScreenSlugs: [screenSlug] + }); + }); + } + } + + async function flushPendingPlaylistUploadSyncs() { + if (pendingPlaylistUploadSyncFlushInFlight) { + return pendingPlaylistUploadSyncFlushInFlight; + } + + if (!pendingPlaylistUploadSyncs.size) { + return null; + } + + pendingPlaylistUploadSyncFlushInFlight = (async function () { + const pendingEntries = Array.from(pendingPlaylistUploadSyncs.values()); + for (let i = 0; i < pendingEntries.length; i += 1) { + const operation = pendingEntries[i]; + const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts); + if (!refreshTargets.ready.length) { + continue; + } + + if (refreshTargets.ready.length) { + await notifyPlayerScreens(refreshTargets.ready, 'refresh'); + } + + pendingPlaylistUploadSyncs.delete(operation.key); + } + })().finally(function () { + pendingPlaylistUploadSyncFlushInFlight = null; + if (pendingPlaylistUploadSyncs.size) { + schedulePendingPlaylistUploadSyncFlush(); + } + }); + + return pendingPlaylistUploadSyncFlushInFlight; + } + + async function flushPendingPlayerUploadSyncs() { + if (pendingPlayerUploadSyncFlushInFlight) { + return pendingPlayerUploadSyncFlushInFlight; + } + + if (!pendingPlayerUploadSyncs.size) { + return null; + } + + pendingPlayerUploadSyncFlushInFlight = (async function () { + const pendingEntries = Array.from(pendingPlayerUploadSyncs.values()); + for (let i = 0; i < pendingEntries.length; i += 1) { + const operation = pendingEntries[i]; + let success = false; + if (operation.type === 'delete') { + success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir); + } else { + success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir); + } + if (success) { + pendingPlayerUploadSyncs.delete(operation.uploadPath); + } + } + })().finally(function () { + pendingPlayerUploadSyncFlushInFlight = null; + if (pendingPlayerUploadSyncs.size) { + schedulePendingPlayerUploadSyncFlush(); + } + }); + + return pendingPlayerUploadSyncFlushInFlight; + } + + return { + createUploadMiddleware: createUploadMiddleware, + normalizeUploadReference: normalizeUploadReference, + collectUploadReferencesFromValue: collectUploadReferencesFromValue, + collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, + collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, + collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, + countUploadReferences: countUploadReferences, + removeUnusedUploadFiles: removeUnusedUploadFiles, + getPlayerUploadSyncMode: getPlayerUploadSyncMode, + shouldMirrorUploads: shouldMirrorUploads, + queuePlayerUploadSync: queuePlayerUploadSync, + schedulePendingPlayerUploadSyncFlush: schedulePendingPlayerUploadSyncFlush, + pushUploadFileToPlayer: pushUploadFileToPlayer, + removeUploadFileFromPlayer: removeUploadFileFromPlayer, + syncUploadRefsToPlayer: syncUploadRefsToPlayer, + syncExistingUploadsToPlayer: syncExistingUploadsToPlayer, + getVisibleCurrentSlideIds: getVisibleCurrentSlideIds, + isScreenRefreshBlocked: isScreenRefreshBlocked, + splitRefreshScreenSlugsByVisibility: splitRefreshScreenSlugsByVisibility, + normalizePlaylistUploadSyncOperation: normalizePlaylistUploadSyncOperation, + queuePlaylistUploadSync: queuePlaylistUploadSync, + schedulePendingPlaylistUploadSyncFlush: schedulePendingPlaylistUploadSyncFlush, + syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, + flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs, + flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs + }; +} + +module.exports = { createUploadSyncService }; \ No newline at end of file diff --git a/src/web/view.js b/src/web/view.js index 63332bb..41f83b4 100644 --- a/src/web/view.js +++ b/src/web/view.js @@ -39,6 +39,42 @@ Handlebars.registerHelper('userInitial', function (name, username) { return value.charAt(0).toUpperCase(); }); +Handlebars.registerHelper('saveActionButtons', function (options) { + const hash = options && options.hash ? options.hash : {}; + const formId = String(hash.formId || '').trim(); + const saveLabel = String(hash.saveLabel || 'Save'); + const saveButtonClass = String(hash.saveButtonClass || 'btn btn-success'); + const saveActionName = String(hash.saveActionName || 'save_action'); + const saveActionValue = String(hash.saveActionValue || 'save'); + const closeLabel = String(hash.saveAndCloseLabel || 'Save and Close'); + const closeValue = String(hash.saveAndCloseValue || 'close'); + const newLabel = String(hash.saveAndNewLabel || 'Save and New'); + const newValue = String(hash.saveAndNewValue || 'new'); + const showSaveAndClose = hash.showSaveAndClose === undefined ? true : String(hash.showSaveAndClose).toLowerCase() !== 'false'; + const showSaveAndNew = hash.showSaveAndNew === undefined ? true : String(hash.showSaveAndNew).toLowerCase() !== 'false'; + const ariaLabel = String(hash.ariaLabel || 'Save actions'); + + if (!formId) { + return ''; + } + + const escape = Handlebars.escapeExpression; + const formAttr = ` form="${escape(formId)}"`; + + return new Handlebars.SafeString([ + '
    ', + ``, + `', + '', + '
    ', + ].join('')); +}); + function loadTemplate(relativePath) { const filePath = path.join(VIEWS_ROOT, relativePath); const stat = fs.statSync(filePath); diff --git a/src/web/views/account/password.hbs b/src/web/views/account/password.hbs index 024271e..da4fafe 100644 --- a/src/web/views/account/password.hbs +++ b/src/web/views/account/password.hbs @@ -5,35 +5,57 @@
    - -
    -
    {{playlists.length}}Playlists
    -
    {{slides.length}}Slides
    -
    {{screens.length}}Screens
    -
    {{connectedClientsCount}}Clients
    -
    +
    +
    +
    + Live overview +

    Keep the control surface focused on live state and actions.

    +

    Use the cards below for the current totals, then jump to the screens table when you need a deeper look at playback and connection state.

    +
    +
    +
    + {{playlists.length}} + playlists +
    +
    + {{screens.length}} + screens +
    +
    + {{connectedClientsCount}} + clients +
    +
    +
    +
    -
    -

    Quick actions

    -
    -
    - - -
    -
    - - - -
    +
    +
    +
    +
    +
    +

    Quick actions

    +

    Send global commands without leaving the overview.

    +
    +
    +
    +
    +
    + + + Restart every connected player page. +
    +
    + + + + Blank active players immediately. +
    +
    +
    +
    -
    -

    Current screens

    - - - - {{#if screens.length}} - {{#each screens}} - - - - - - - {{/each}} - {{else}} - - {{/if}} - -
    NamePlayer URLPlaylistConnected clients
    {{name}}{{playerUrl slug}}{{playlist_name}} - {{#if player_connection_count}} -
    {{player_connection_count}} connected
    - {{else}} - No clients connected. - {{/if}} -
    No screens yet.
    +
    +
    +
    +
    +
    +

    Current screens

    +

    Player URL, playlist assignment, and live connection count.

    +
    +
    +
    + + + + {{#if screens.length}} + {{#each screens}} + + + + + + + {{/each}} + {{else}} + + {{/if}} + +
    NamePlayer URLPlaylistConnected clients
    {{name}}{{playerUrl slug}}{{playlist_name}} + {{#if player_connection_count}} +
    {{player_connection_count}} connected
    + {{else}} + No clients connected. + {{/if}} +
    No screens yet.
    +
    +
    +
    diff --git a/src/web/views/frame-layout.hbs b/src/web/views/frame-layout.hbs index 7a471b3..23fe02b 100644 --- a/src/web/views/frame-layout.hbs +++ b/src/web/views/frame-layout.hbs @@ -6,39 +6,85 @@ {{title}} - Pulse - + + + {{#if stylesheets.length}} {{#each stylesheets}} {{/each}} {{/if}} - -
    -
    - {{#unless hideThemeToggle}} - - {{/unless}} - {{#unless hideFrameHeader}} -
    -
    - Slide workspace -

    {{title}}

    + + {{#if framePopupCard}} + {{{body}}} + {{else}} +
    + + {{#unless hideFrameHeader}} + + {{/unless}} +
    +
    +
    + {{{body}}} +
    -
    - {{/unless}} -
    -
    - {{{body}}} -
    -
    -
    -
    - + +
    + {{/if}} + + + {{#if scripts.length}} {{#each scripts}} diff --git a/src/web/views/layout.hbs b/src/web/views/layout.hbs index 66c97b0..c608a40 100644 --- a/src/web/views/layout.hbs +++ b/src/web/views/layout.hbs @@ -6,103 +6,206 @@ {{title}} - Pulse - + + + {{#if stylesheets.length}} {{#each stylesheets}} {{/each}} {{/if}} - + {{#if authShell}} -
    - {{{body}}} +
    +
    + {{else}} - {{#if message}} -
    -
    {{message}}
    - -
    - {{/if}} -
    -
    - + +
    +
    + + - - {{/if}} {{#if scripts.length}} {{#each scripts}} {{#if (isExternalUrl this)}} @@ -112,5 +215,7 @@ {{/if}} {{/each}} {{/if}} + + {{/if}} diff --git a/src/web/views/login.hbs b/src/web/views/login.hbs index b95c704..196d664 100644 --- a/src/web/views/login.hbs +++ b/src/web/views/login.hbs @@ -1,28 +1,28 @@ -
    -
    -
    -
    PS
    -
    -

    Sign in

    -

    Pulse Signage

    -
    -
    + + +
    +
    +

    Sign in

    +
    +
    \ No newline at end of file diff --git a/src/web/views/playlists/add.hbs b/src/web/views/playlists/add.hbs index faaf970..d29a707 100644 --- a/src/web/views/playlists/add.hbs +++ b/src/web/views/playlists/add.hbs @@ -3,14 +3,24 @@

    Create playlist

    Name the playlist and start adding slides.

    - Back to playlists
    -
    -
    - - +
    +
    +

    Playlist details

    +
    + +
    +
    + + +
    +
    +
    diff --git a/src/web/views/playlists/edit.hbs b/src/web/views/playlists/edit.hbs index 1c231e1..fdb99e1 100644 --- a/src/web/views/playlists/edit.hbs +++ b/src/web/views/playlists/edit.hbs @@ -1,119 +1,146 @@ -
    -
    -
    - -
    -
    - Fade: - +
    +
    +
    +
    +

    Playlist details

    +
    +
    + +
    +
    + + +
    +
    + + +
    +
    +

    Add slide to playlist

    + {{#if hasSlides}} + {{#if hasSelectableSlides}} +
    +
    +
    + +
    +
    + +
    +
    +
    + + {{else}} +
    + {{#if playlistCanvasMismatch}} + This playlist contains slides with different canvas sizes. Remove the mismatched items before adding more. + {{else if playlistCanvasSignature}} + No slides match this playlist canvas size. + {{else}} + No slides are currently available for this playlist. + {{/if}} +
    + {{/if}} + {{else}} +
    Create slides first.
    + {{/if}} +
    +
    +
    +
    - -
    -

    Playlist items

    - - - - {{#if playlistSlides.length}} - {{#each playlistSlides}} - - - - - - - - {{/each}} - {{else}} - - {{/if}} - -
    OrderSlideScheduleDurationActions
    -
    - - {{displayOrder}} -
    -
    - {{title}} - - -
    {{scheduleSummary}}
    - - - - - - -
    - - -
    - - -
    -
    No slides assigned yet.
    -
    - - -
    -

    Add slide to playlist

    - {{#if hasSlides}} - {{#if hasSelectableSlides}} -
    -
    - -
    - -
    - - {{else}} -
    - {{#if playlistCanvasMismatch}} - This playlist contains slides with different canvas sizes. Remove the mismatched items before adding more. - {{else if playlistCanvasSignature}} - No slides match this playlist canvas size. - {{else}} - No slides are currently available for this playlist. - {{/if}} +
    +
    +
    +

    Options

    - {{/if}} - {{else}} -
    Create slides first.
    - {{/if}} +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +

    Playlist items

    +
    +
    + + + + {{#if playlistSlides.length}} + {{#each playlistSlides}} + + + + + + + + {{/each}} + {{else}} + + {{/if}} + +
    OrderSlideScheduleDurationActions
    +
    + + {{displayOrder}} +
    +
    + {{title}} + + +
    {{scheduleSummary}}
    + + + + + + +
    + + +
    + + +
    +
    No slides assigned yet.
    +
    +
    +
    - - -
    -
    - Slide schedule -
    Configure availability for this playlist item.
    -
    - -
    - -
    diff --git a/src/web/views/playlists/list.hbs b/src/web/views/playlists/list.hbs index 27b180b..1aef53b 100644 --- a/src/web/views/playlists/list.hbs +++ b/src/web/views/playlists/list.hbs @@ -3,12 +3,17 @@

    Playlists

    Organize slide rotations and manage scheduled playback.

    - Create playlist
    -
    -

    Existing playlists

    - +
    +
    +

    Existing playlists

    + +
    +
    +
    {{#if playlists.length}} @@ -18,9 +23,9 @@ @@ -31,4 +36,5 @@ {{/if}}
    NameSlidesActions
    {{slideCount}} slide{{#unless slideCountIsOne}}s{{/unless}}
    - Edit + Edit
    - +
    +
    diff --git a/src/web/views/playlists/slide-config.hbs b/src/web/views/playlists/slide-config.hbs index 6d11d36..3a9932e 100644 --- a/src/web/views/playlists/slide-config.hbs +++ b/src/web/views/playlists/slide-config.hbs @@ -1,48 +1,68 @@ -
    - - - - -
    -

    Date range

    -
    - - -
    +
    +
    +

    Slide schedule

    + +
    + + -
    -

    Time range

    -
    - - -
    -
    - {{#each dayOptions}} - - {{/each}} -
    -
    +
    + + +
    -
    - -
    - +
    +
    +

    Date range

    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    +
    +

    Time range

    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + {{#each dayOptions}} + + + {{/each}} +
    +
    +
    +
    + + +
    diff --git a/src/web/views/screens/add.hbs b/src/web/views/screens/add.hbs index f673b94..1dc7034 100644 --- a/src/web/views/screens/add.hbs +++ b/src/web/views/screens/add.hbs @@ -3,25 +3,39 @@

    Add screen

    Register a player endpoint and connect it to a playlist.

    - Back to screens
    -
    -
    - - - - +
    +
    +

    Screen details

    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    diff --git a/src/web/views/screens/edit.hbs b/src/web/views/screens/edit.hbs index b5d0b30..052a346 100644 --- a/src/web/views/screens/edit.hbs +++ b/src/web/views/screens/edit.hbs @@ -3,38 +3,55 @@

    Edit screen

    Update the display name and assigned playlist.

    - Back to screens
    -
    -

    Edit screen

    -
    - - - -
    - -
    - -
    - -
    +
    +
    +
    +
    +

    Screen details

    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    -
    - -
    -

    Player URL

    -

    {{playerUrl screen.slug}}

    -
    +
    +
    +
    +

    Player URL

    +
    + +
    +
    +
    \ No newline at end of file diff --git a/src/web/views/screens/list.hbs b/src/web/views/screens/list.hbs index 1135f28..e369a84 100644 --- a/src/web/views/screens/list.hbs +++ b/src/web/views/screens/list.hbs @@ -3,12 +3,17 @@

    Screens

    Register display endpoints and keep playlist assignments in sync.

    - Add screen
    -
    -

    Existing screens

    - +
    +
    +

    Existing screens

    + +
    +
    +
    {{#if screens.length}} @@ -26,9 +31,9 @@ @@ -39,4 +44,5 @@ {{/if}}
    NamePlayer URLPlaylistConnected clientsActions
    - Edit + Edit
    - +
    +
    diff --git a/src/web/views/slides/form.hbs b/src/web/views/slides/form.hbs index 9703995..973f7a3 100644 --- a/src/web/views/slides/form.hbs +++ b/src/web/views/slides/form.hbs @@ -1,46 +1,75 @@ -
    -
    -
    -
    - - +
    + +
    +
    +

    Slide details

    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    -
    -
    -

    Preview

    -
    Select a template to preview the slide.
    -
    -
    - -
    - -
    Select a template to preview the slide.
    -
    + +
    +
    +
    +
    + {{{saveActionButtons formId="slide-form" saveLabel=saveLabel saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}} + Cancel + {{#if isEdit}} + + {{/if}} +
    + +
    - +{{#if isEdit}} +
    +{{/if}} + - + diff --git a/src/web/views/slides/list.hbs b/src/web/views/slides/list.hbs index ed4ff2f..ef4af1c 100644 --- a/src/web/views/slides/list.hbs +++ b/src/web/views/slides/list.hbs @@ -3,12 +3,17 @@

    Slides

    Manage individual slide content and template assignments.

    - Create slide
    -
    -

    Existing slides

    - +
    +
    +

    Existing slides

    + +
    +
    +
    {{#if slides.length}} @@ -18,9 +23,9 @@ @@ -31,4 +36,5 @@ {{/if}}
    TitleTemplateActions
    {{template_name}}
    - Edit + Edit
    - +
    +
    diff --git a/src/web/views/templates/add.hbs b/src/web/views/templates/add.hbs index ac7d319..0fb03ee 100644 --- a/src/web/views/templates/add.hbs +++ b/src/web/views/templates/add.hbs @@ -3,36 +3,9 @@

    Create template

    Define a canvas, upload a background, and place content regions.

    - Back to templates
    -
    -
    - - - -
    -
    - @@ -41,32 +14,141 @@
    -
    -
    -
    - -
    No background image selected
    -
    +
    +
    +

    Designer

    +
    +
    +
    + +
    No background image selected
    +
    +
    +
    - -
    -
    - - - - +
    +
    +
    + {{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" showSaveAndClose=false showSaveAndNew=false}}} + Cancel +
    +
    +
    +
    +
    +
    +

    Region information

    +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +

    Template details

    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +

    Options

    +
    +
    +
    +
    + + +
    +
    + + +
    +
    -

    Region information

    - -
    + + + + diff --git a/src/web/views/templates/edit.hbs b/src/web/views/templates/edit.hbs index 704633a..17da3a8 100644 --- a/src/web/views/templates/edit.hbs +++ b/src/web/views/templates/edit.hbs @@ -1,42 +1,11 @@ -
    -
    -
    - - - -
    -
    - + @@ -45,32 +14,148 @@
    -
    -
    -
    - -
    No background image selected
    -
    +
    +
    +

    Designer

    +
    +
    +
    + +
    No background image selected
    +
    +
    +
    - -
    -
    - - - - +
    +
    +
    + {{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}} + Cancel + +
    +
    +
    +
    +
    +
    +

    Region information

    +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +

    Template details

    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +

    Options

    +
    +
    +
    +
    + +
    + + +
    + +
    +
    + + +
    +
    -

    Region information

    - -
    +
    + + + + + diff --git a/src/web/views/templates/list.hbs b/src/web/views/templates/list.hbs index 4edff47..6fab76a 100644 --- a/src/web/views/templates/list.hbs +++ b/src/web/views/templates/list.hbs @@ -3,12 +3,17 @@

    Slide templates

    Design reusable canvas layouts and region definitions.

    - Create Template
    -
    -

    Existing templates

    - +
    +
    +

    Existing templates

    + +
    +
    +
    {{#if templates.length}} @@ -19,9 +24,9 @@ @@ -32,4 +37,5 @@ {{/if}}
    NameCanvasRegionsActions
    {{regionCount}}
    - Edit + Edit
    - +
    +
    diff --git a/src/web/views/users/add.hbs b/src/web/views/users/add.hbs index 31865f3..f43aa0b 100644 --- a/src/web/views/users/add.hbs +++ b/src/web/views/users/add.hbs @@ -5,39 +5,36 @@
    -
    -
    -
    -

    Account details

    -
    - - +
    +
    +

    User details

    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    -
    - -
    -

    Set password

    -
    - - +
    +
    - -
    - - Back to users
    \ No newline at end of file diff --git a/src/web/views/users/edit.hbs b/src/web/views/users/edit.hbs index b597126..243907d 100644 --- a/src/web/views/users/edit.hbs +++ b/src/web/views/users/edit.hbs @@ -1,49 +1,67 @@ -
    -
    -
    - -
    - {{user.username}} - {{#if user.isCurrentUser}} -
    Current signed-in user
    - {{/if}} +
    + +
    +
    +
    +
    +
    +

    Profile

    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + +
    +
    +
    +

    Reset password

    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    - Back to users
    +
    -
    -
    -

    Edit user

    - - - -
    - -
    -

    Reset password

    - - - -
    -
    -
    \ No newline at end of file +
    \ No newline at end of file diff --git a/src/web/views/users/list.hbs b/src/web/views/users/list.hbs index bbe316f..252bb04 100644 --- a/src/web/views/users/list.hbs +++ b/src/web/views/users/list.hbs @@ -3,14 +3,17 @@

    Users

    Create accounts, rename users, reset passwords, and remove unused logins.

    -
    - Add user -
    -
    -

    Existing users

    - +
    +
    +

    Existing users

    +
    + Add user +
    +
    +
    +
    @@ -24,7 +27,6 @@
    User
    -
    {{username}} {{#if isCurrentUser}} @@ -43,9 +45,9 @@
    {{#unless isCurrentUser}} - Edit + Edit
    - +
    {{else}} Protected @@ -59,4 +61,5 @@ {{/if}}
    +
    \ No newline at end of file