commit 4db62b55206946043aa174dfb960d3cec34a34f5 Author: magi200 Date: Wed Apr 16 22:30:27 2025 +0700 a diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..40a890c --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,11 @@ +module.exports = { + env: { + node: true, + commonjs: true, + es2017: true, + }, + parserOptions: { + sourceType: 'module', + ecmaVersion: 2021, + }, +}; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..c6754c0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @tabarra diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml new file mode 100644 index 0000000..dced002 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml @@ -0,0 +1,50 @@ +--- +name: Feature Request +description: Request a feature or enhancement +title: "[FEATURE]: type here a short description" +labels: [enhancement, feature] + +body: +- type: markdown + attributes: + value: | + ## Thank you for filling out this feature/enhancement request + Please fill out this short template to the best of your ability. +- type: dropdown + id: scope + attributes: + label: Scope + description: What part of txAdmin is this feature targeting? + multiple: true + options: + - Web + - In-Game Menu + - Developer API + validations: + required: true +- type: textarea + id: feat-description + attributes: + label: Feature Description + description: Please provide a short and concise description of the feature you are suggesting. + validations: + required: true +- type: textarea + id: use-case + attributes: + label: Use Case + description: | + What use-case (a scenario where you want to do "x", but are limited) do + you have for requesting this feature? + placeholder: Ex. I wanted to do `x` but was unable too... +- type: textarea + id: solution + attributes: + label: Proposed Solution + description: Do you have an idea for a proposed solution. If so, please describe it. +- type: textarea + attributes: + label: Additional Info + description: | + Is there any additional information you would like to provide? + placeholder: Screenshots, logs, etc diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..a5c3000 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] type here a short description" +labels: '' +assignees: '' + +--- + +**txAdmin/FXServer versions:** +You can see that at the bottom of the txAdmin page, or in the terminal as soon as you start fxserver. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here, like for example if it's a server issue, which OS is fxserver hosted on. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..da4f836 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: General Support + url: https://discord.gg/NsXGTszYjK + about: Please ask general support questions on our Discord! diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..d747cab --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,6 @@ +The `!NC` comments in code are tags used by a git pre-commit hook to prevent committing the lines containing it, and are generally used to mark TO-DOs that are required to be done before committing the changes. +Import `suite, it, expect` from vitest for writing tests, whith each method having one `suite()` and a list of tests using the `it()` for its definition. +Prefer implicit over explicit function return types. +Except for React components, prefer arrow functions. +Prefer using for..of instead of forEach. +Prefer single quotes over doble quotes. diff --git a/.github/workflows/locale-pull-request.yml b/.github/workflows/locale-pull-request.yml new file mode 100644 index 0000000..5c1692c --- /dev/null +++ b/.github/workflows/locale-pull-request.yml @@ -0,0 +1,45 @@ +name: Check Locale PR + +on: + pull_request: + paths: + - 'locale/**' + +jobs: + check-locale-pr: + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Enforce base branch + uses: actions/github-script@v7 + with: + script: | + // Get the pull request + const pull_request = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + + // Check if the base branch is 'main' or 'master' + if (pull_request.data.base.ref === 'main' || pull_request.data.base.ref === 'master') { + console.error('Pull request is targeting the main branch. Please target the develop branch instead.'); + process.exit(1); + } + + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm ci + + - name: Run locale:check + id: locale-check + run: npm run locale:check diff --git a/.github/workflows/publish-tagged.yml b/.github/workflows/publish-tagged.yml new file mode 100644 index 0000000..21560b0 --- /dev/null +++ b/.github/workflows/publish-tagged.yml @@ -0,0 +1,60 @@ +name: Publish Tagged Build + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+*" + +jobs: + build: + name: "Build Changelog & Release Prod" + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Download all modules + run: npm ci + + # Not truly necessary for build, but for now the vite config requires it + - name: Create .env file + run: | + echo TXDEV_FXSERVER_PATH=$(pwd)/fxserver > .env + echo TXDEV_VITE_URL='http://localhost:40122' >> .env + + - name: Build project + run: | + npm run build + cat .github/.cienv >> $GITHUB_ENV + + - name: Compress build output with zip + run: | + cd dist + zip -r ../monitor.zip . + cd .. + sha256sum monitor.zip + + - name: Attest build provenance + id: attest_build_provenance + uses: actions/attest-build-provenance@v1 + with: + subject-path: monitor.zip + + - name: Create and Upload Release + uses: "marvinpinto/action-automatic-releases@v1.2.1" + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + prerelease: ${{ env.TX_IS_PRERELEASE }} + files: monitor.zip diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..69b3fdb --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,38 @@ +name: Run Tests for Workspaces + +on: + push: + branches: + - master + pull_request: + branches: + - "**" + +jobs: + run-tests: + name: "Run Unit Testing" + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Download all modules + run: npm ci + + - name: Create .env file + run: | + echo TXDEV_FXSERVER_PATH=$(pwd)/fxserver > .env + echo TXDEV_VITE_URL='http://localhost:40122' >> .env + + - name: Run Tests + env: + CI: true + run: npm run test --workspaces diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0600f8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,82 @@ +## Custom: +db/* +cache/* +dist/* +.reports/* +license_report/* +.tsc/* +*.ignore.* +/start_*.bat +monitor-*.zip +bundle_size_report.html +.github/.cienv +scripts/**/*.local.* +.runtime/ + +# IDE Specific +.idea + +## Github's default node gitignore +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn +yarn.lock +.yarn.installed +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..fe4c17a --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx --no-install commitlint --edit "" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..52800f2 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,15 @@ +#!/bin/sh + +# Rejects commits with the !NC flag is present in the changes +# The !NC flag is used to mark code that should not be commited to the repository +# It's useful to avoid commiting debug code, test code, etc. + +# Check if the !NC flag is present in the changes +if git diff --staged --unified=0 --no-color | grep '^+' | grep -q '!NC'; then + echo -e "COMMIT REJECTED: Found the !NC flag in your changes.\nMake sure you didn't accidently staged something you shouldn't!" + echo "Flags found:" + git diff --staged --unified=0 --no-color | grep -C 2 '!NC' + exit 1 +fi + +exit 0 diff --git a/.license-reportrc b/.license-reportrc new file mode 100644 index 0000000..d018ea5 --- /dev/null +++ b/.license-reportrc @@ -0,0 +1,11 @@ +{ + "output": "html", + "fields": [ + "name", + "licenseType", + "link", + "installedVersion", + "definedVersion", + "author" + ] +} diff --git a/.npm-upgrade.json b/.npm-upgrade.json new file mode 100644 index 0000000..5ad2e42 --- /dev/null +++ b/.npm-upgrade.json @@ -0,0 +1,8 @@ +{ + "ignore": { + "@types/node": { + "versions": ">16.9.1", + "reason": "fixed to fxserver's node version" + } + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8e98ecd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2025 André Tabarra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..402cdc6 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +

+

+ +

+

+ In 2019 txAdmin was created, with the objective of making FiveM server management accessible to everyone – no matter their skill level!
+ Today, txAdmin is the full featured web panel & in-game menu to Manage & Monitor your FiveM/RedM Server, in use by over 29.000 servers worldwide at any given time! +

+

+ Join our Discord Server:   +

+

+ + zap-hosting + +

+

+ +## Main Features +- Recipe-based Server Deployer: create a server in under 60 seconds! ([docs/recipe.md](docs/recipe.md)) +- Start/Stop/Restart your server instance or resources +- Full-featured in-game admin menu: + - Player Mode: NoClip, God, SuperJump + - Teleport: waypoint, coords and back + - Vehicle: Spawn, Fix, Delete, Boost + - Heal: yourself, everyone + - Send Announcements + - Reset World Area + - Show player IDs + - Player search/sort by distance, ID, name + - Player interactions: Go To, Bring, Spectate, Freeze + - Player troll: make drunk, set fire, wild attack + - Player ban/warn/dm +- Access control: + - Login via Cfx.re or password + - Admin permission system ([docs/permissions.md](docs/permissions.md)) + - Action logging +- Discord Integration: + - Server configurable, persistent, auto-updated status embed + - Command to whitelist players + - Command to display player infos +- Monitoring: + - Auto Restart FXServer on crash or hang + - Server’s CPU/RAM consumption + - Live Console (with log file, command history and search) + - Server threads performance chart with player count + - Server Activity Log (connections/disconnections, kills, chat, explosions and [custom commands](docs/custom-server-log.md)) +- Player Manager: + - [Warning system](https://www.youtube.com/watch?v=DeE0-5vtZ4E) & Ban system + - Whitelist system (Discord member, Discord Role, Approved License, Admin-only) + - Take notes about players + - Keep track of player's play and session time + - Self-contained player database (no MySQL required!) + - Clean/Optimize the database by removing old players, or bans/warns/whitelists +- Real-time playerlist +- Scheduled restarts with warning announcements and custom events ([docs/events.md](docs/events.md)) +- Translated into over 30 languages ([docs/translation.md](docs/translation.md)) +- FiveM's Server CFG editor & validator +- Responsive web interface with Dark Mode 😎 +- And much more... + +Also, check our [Feature Graveyard](docs/feature-graveyard.md) for the features that are no longer among us (😔 RIP). + +## Running txAdmin +- Since early 2020, **txAdmin is a component of FXServer, so there is no need to downloading or installing anything**. +- To start txAdmin, simply run FXServer **without** any `+exec server.cfg` launch argument, and FXServer will automatically start txAdmin. +- On first boot, a `txData` directory will be created to store txAdmin files, and you will need to open the URL provided in the console to configure your account and server. + + +## Configuration & Integrations +- Most configuration can be done inside the txAdmin settings page, but some configs (such as TCP interface & port) are only available through Environment Variables, please see [docs/env-config.md](docs/env-config.md). +- You can listen to server events broadcasted by txAdmin to allow for custom behavior in your resources, please see [docs/events.md](docs/events.md). + + +## Contributing & Development +- All PRs should be based on the develop branch, including translation PRs. +- Before putting effort for any significant PR, make sure to join our discord and talk to us, since the change you want to do might not have been done for a reason or there might be some required context. +- If you want to build it or run from source, please check [docs/development.md](docs/development.md). + + +## License, Credits and Thanks +- This project is licensed under the [MIT License](https://github.com/tabarra/txAdmin/blob/master/LICENSE); +- ["Kick" button icons](https://www.flaticon.com/free-icon/users-avatar_8188385) made by __SeyfDesigner__ from [www.flaticon.com](https://www.flaticon.com); +- Warning Sounds ([1](https://freesound.org/people/Ultranova105/sounds/136756/)/[2](https://freesound.org/people/Ultranova105/sounds/136754/)) made by __Ultranova105__ are licensed under [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/); +- [Announcement Sound](https://freesound.org/people/IENBA/sounds/545495/) made by __IENBA__ is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); +- [Message Sound](https://freesound.org/people/Divinux/sounds/198414/) made by __Divinux__ is licensed under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/); +- Especial thanks to everyone that contributed to this project, especially the very fine Discord folks that provide support for others; diff --git a/commitlint.config.cjs b/commitlint.config.cjs new file mode 100644 index 0000000..1c4a4b9 --- /dev/null +++ b/commitlint.config.cjs @@ -0,0 +1,25 @@ +const types = [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', + + //custom + 'tweak', + 'wip', + 'locale', +]; + +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [2, 'always', types], + }, +}; diff --git a/core/.eslintrc.cjs b/core/.eslintrc.cjs new file mode 100644 index 0000000..2a38a9c --- /dev/null +++ b/core/.eslintrc.cjs @@ -0,0 +1,83 @@ +module.exports = { + env: { + node: true, + commonjs: true, + es2017: true, + }, + globals: { + GlobalData: 'writable', + ExecuteCommand: 'readonly', + GetConvar: 'readonly', + GetCurrentResourceName: 'readonly', + GetPasswordHash: 'readonly', + GetResourceMetadata: 'readonly', + GetResourcePath: 'readonly', + IsDuplicityVersion: 'readonly', + VerifyPasswordHash: 'readonly', + }, + extends: [], + ignorePatterns: [ + '*.ignore.*', + ], + rules: { + //Review these + 'no-control-regex': 'off', + 'no-empty': ['error', { allowEmptyCatch: true }], + 'no-prototype-builtins': 'off', + 'no-unused-vars': ['warn', { + varsIgnorePattern: '^_\\w*', + vars: 'all', + args: 'none', //diff + ignoreRestSiblings: true, + }], + + //From Airbnb, fixed them already + 'keyword-spacing': ['error', { + before: true, + after: true, + overrides: { + return: { after: true }, + throw: { after: true }, + case: { after: true }, + }, + }], + 'space-before-blocks': 'error', + quotes: ['error', 'single', { allowTemplateLiterals: true }], + semi: ['error', 'always'], + 'no-trailing-spaces': ['error', { + skipBlankLines: false, + ignoreComments: false, + }], + 'space-infix-ops': 'error', + 'comma-dangle': ['error', { + arrays: 'always-multiline', + objects: 'always-multiline', + imports: 'always-multiline', + exports: 'always-multiline', + functions: 'always-multiline', + }], + 'padded-blocks': ['error', { + blocks: 'never', + classes: 'never', + switches: 'never', + }, { + allowSingleLineBlocks: true, + }], + 'comma-spacing': ['error', { before: false, after: true }], + 'arrow-spacing': ['error', { before: true, after: true }], + 'arrow-parens': ['error', 'always'], + 'operator-linebreak': ['error', 'before', { overrides: { '=': 'none' } }], + + // Custom + indent: ['error', 4], + + // FIXME: re-enable it somewhen + 'linebreak-style': 'off', + 'spaced-comment': 'off', + 'object-curly-spacing': 'off', //maybe keep this disabled? + 'arrow-body-style': 'off', //maybe keep this disabled? + + // Check it: + // 'object-curly-newline': ['error', 'never'], + }, +}; diff --git a/core/.npm-upgrade.json b/core/.npm-upgrade.json new file mode 100644 index 0000000..0afa310 --- /dev/null +++ b/core/.npm-upgrade.json @@ -0,0 +1,48 @@ +{ + "ignore": { + "open": { + "versions": ">7.1.0", + "reason": "Doesn't work when powershell is not in PATH or something like that." + }, + "fs-extra": { + "versions": ">9", + "reason": "recipe errors on some OSs" + }, + "execa": { + "versions": "^5", + "reason": "following @sindresorhus/windows-release" + }, + "windows-release": { + "versions": "^5.1.1", + "reason": "inlined to transform it into async" + }, + "nanoid": { + "versions": ">4", + "reason": "dropped support for node 16" + }, + "got": { + "versions": ">13", + "reason": "dropped support for node 16" + }, + "jose": { + "versions": ">4", + "reason": "dropped support for node 16" + }, + "lowdb": { + "versions": ">6", + "reason": "dropped support for node 16" + }, + "slug": { + "versions": ">8", + "reason": "dropped support for node 16" + }, + "boxen": { + "versions": ">7", + "reason": "dropped support for node 16" + }, + "discord.js": { + "versions": ">14.11.0", + "reason": "undici sub-dependency dropped support for node 16" + } + } +} \ No newline at end of file diff --git a/core/boot/checkPreRelease.ts b/core/boot/checkPreRelease.ts new file mode 100644 index 0000000..326f08a --- /dev/null +++ b/core/boot/checkPreRelease.ts @@ -0,0 +1,48 @@ +import consoleFactory from '@lib/console'; +import fatalError from '@lib/fatalError'; +import { chalkInversePad, msToDuration } from '@lib/misc'; +const console = consoleFactory('ATTENTION'); + + +//@ts-ignore esbuild will replace TX_PRERELEASE_EXPIRATION with a string +const PRERELEASE_EXPIRATION = parseInt(TX_PRERELEASE_EXPIRATION) + +const expiredError = [ + 'This pre-release version has expired, please update your txAdmin.', + 'Bye bye 👋', +] + +const printExpirationBanner = (timeUntilExpiration: number) => { + const timeLeft = msToDuration(timeUntilExpiration) + console.error('This is a pre-release version of txAdmin!'); + console.error('This build is meant to be used by txAdmin beta testers.'); + console.error('txAdmin will automatically shut down when this pre-release expires.'); + console.error(`Time until expiration: ${chalkInversePad(timeLeft)}.`); + console.error('For more information: https://discord.gg/txAdmin.'); +} + +const cronCheckExpiration = () => { + if (isNaN(PRERELEASE_EXPIRATION) || PRERELEASE_EXPIRATION === 0) return; + + const timeUntilExpiration = PRERELEASE_EXPIRATION - Date.now(); + if (timeUntilExpiration < 0) { + fatalError.Boot(11, expiredError); + } else if (timeUntilExpiration < 24 * 60 * 60 * 1000) { + printExpirationBanner(timeUntilExpiration); + } +} + +export default () => { + if (isNaN(PRERELEASE_EXPIRATION) || PRERELEASE_EXPIRATION === 0) return; + + const timeUntilExpiration = PRERELEASE_EXPIRATION - Date.now(); + if (timeUntilExpiration < 0) { + fatalError.Boot(10, expiredError); + } + + //First warning + printExpirationBanner(timeUntilExpiration); + + //Check every 15 minutes + setInterval(cronCheckExpiration, 15 * 60 * 1000); +}; diff --git a/core/boot/getHostVars.ts b/core/boot/getHostVars.ts new file mode 100644 index 0000000..c60970c --- /dev/null +++ b/core/boot/getHostVars.ts @@ -0,0 +1,116 @@ +import path from 'node:path'; +import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; +import fatalError from '@lib/fatalError'; +import consts from '@shared/consts'; + + +/** + * Schemas for the TXHOST_ env variables + */ +export const hostEnvVarSchemas = { + //General + API_TOKEN: z.union([ + z.literal('disabled'), + z.string().regex( + /^[A-Za-z0-9_-]{16,48}$/, + 'Token must be alphanumeric, underscores or dashes only, and between 16 and 48 characters long.' + ), + ]), + DATA_PATH: z.string().min(1).refine( + (val) => path.isAbsolute(val), + 'DATA_PATH must be an absolute path' + ), + GAME_NAME: z.enum( + ['fivem', 'redm'], + { message: 'GAME_NAME must be either "gta5", "rdr3", or undefined' } + ), + MAX_SLOTS: z.coerce.number().int().positive(), + QUIET_MODE: z.preprocess((val) => val === 'true', z.boolean()), + + //Networking + TXA_URL: z.string().url(), + TXA_PORT: z.coerce.number().int().positive().refine( + (val) => val !== 30120, + 'TXA_PORT cannot be 30120' + ), + FXS_PORT: z.coerce.number().int().positive().refine( + (val) => val < 40120 || val > 40150, + 'FXS_PORT cannot be between 40120 and 40150' + ), + INTERFACE: z.string().ip({ version: "v4" }), + + //Provider + PROVIDER_NAME: z.string() + .regex( + /^[a-zA-Z0-9_.\- ]{2,16}$/, + 'Provider name must be between 2 and 16 characters long and can only contain letters, numbers, underscores, periods, hyphens and spaces.' + ) + .refine( + x => !/[_.\- ]{2,}/.test(x), + 'Provider name cannot contain consecutive special characters.' + ) + .refine( + x => /^[a-zA-Z0-9].*[a-zA-Z0-9]$/.test(x), + 'Provider name must start and end with a letter or number.' + ), + PROVIDER_LOGO: z.string().url(), + + //Defaults (no reason to coerce or check, except the cfxkey) + DEFAULT_DBHOST: z.string(), + DEFAULT_DBPORT: z.string(), + DEFAULT_DBUSER: z.string(), + DEFAULT_DBPASS: z.string(), + DEFAULT_DBNAME: z.string(), + DEFAULT_ACCOUNT: z.string().refine( + (val) => { + const parts = val.split(':').length; + return parts === 2 || parts === 3; + }, + 'The account needs to be in the username:fivemId or username:fivemId:bcrypt format', + ), + DEFAULT_CFXKEY: z.string().refine( + //apparently zap still uses the old format? + (val) => consts.regexSvLicenseNew.test(val) || consts.regexSvLicenseOld.test(val), + 'The key needs to be in the cfxk_xxxxxxxxxxxxxxxxxxxx_yyyyy format' + ), +} as const; + +export type HostEnvVars = { + [K in keyof typeof hostEnvVarSchemas]: z.infer | undefined; +} + + +/** + * Parses the TXHOST_ env variables + */ +export const getHostVars = () => { + const txHostEnv: any = {}; + for (const partialKey of Object.keys(hostEnvVarSchemas)) { + const keySchema = hostEnvVarSchemas[partialKey as keyof HostEnvVars]; + const fullKey = `TXHOST_` + partialKey; + const value = process.env[fullKey]; + if (value === undefined || value === '') continue; + if(/^['"]|['"]$/.test(value)) { + fatalError.GlobalData(20, [ + 'Invalid value for a TXHOST environment variable.', + 'The value is surrounded by quotes (" or \'), and you must remove them.', + 'This is likely a mistake in how you declared this env var.', + ['Key', fullKey], + ['Value', String(value)], + 'For more information: https://aka.cfx.re/txadmin-env-config', + ]); + } + const res = keySchema.safeParse(value); + if (!res.success) { + fatalError.GlobalData(20, [ + 'Invalid value for a TXHOST environment variable.', + ['Key', fullKey], + ['Value', String(value)], + 'For more information: https://aka.cfx.re/txadmin-env-config', + ], fromZodError(res.error, { prefix: null })); + } + txHostEnv[partialKey] = res.data; + } + return txHostEnv as HostEnvVars; +} diff --git a/core/boot/getNativeVars.ts b/core/boot/getNativeVars.ts new file mode 100644 index 0000000..6fca4c3 --- /dev/null +++ b/core/boot/getNativeVars.ts @@ -0,0 +1,85 @@ +import path from 'node:path'; + + +//Helper function to get convars WITHOUT a fallback value +const undefinedKey = 'UNDEFINED:CONVAR:' + Math.random().toString(36).substring(2, 15); +const getConvarString = (convarName: string) => { + const cvar = GetConvar(convarName, undefinedKey); + return (cvar !== undefinedKey) ? cvar.trim() : undefined; +}; + +//Helper to clean up the resource native responses which apparently might be 'null' +const cleanNativeResp = (resp: any) => { + return (typeof resp === 'string' && resp !== 'null' && resp.length) ? resp : undefined; +}; + +//Warning for convar usage +let anyWarnSent = false; +const replacedConvarWarning = (convarName: string, newName: string) => { + console.warn(`WARNING: The '${convarName}' ConVar is deprecated and will be removed in the next update.`); + console.warn(` Please use the '${newName}' environment variable instead.`); + anyWarnSent = true; +} + + +/** + * Native variables that are required for the boot process. + * This file is not supposed to validate or default any of the values. + */ +export const getNativeVars = (ignoreDeprecatedConfigs: boolean) => { + //FXServer + const fxsVersion = getConvarString('version'); + const fxsCitizenRoot = getConvarString('citizen_root'); + + //Resource + const resourceName = cleanNativeResp(GetCurrentResourceName()); + if (!resourceName) throw new Error('GetCurrentResourceName() failed'); + const txaResourceVersion = cleanNativeResp(GetResourceMetadata(resourceName, 'version', 0)); + const txaResourcePath = cleanNativeResp(GetResourcePath(resourceName)); + + //Profile Convar - with warning + const txAdminProfile = getConvarString('serverProfile'); + if (txAdminProfile) { + console.warn(`WARNING: The 'serverProfile' ConVar is deprecated and will be removed in a future update.`); + console.warn(` To create multiple servers, set up a different TXHOST_DATA_PATH instead.`); + anyWarnSent = true; + } + + //Convars replaced by TXHOST_* env vars + let txDataPath, txAdminPort, txAdminInterface; + if (!ignoreDeprecatedConfigs) { + txDataPath = getConvarString('txDataPath'); + if (txDataPath) { + replacedConvarWarning('txDataPath', 'TXHOST_DATA_PATH'); + //As it used to support relative paths, we need to resolve it + if (!path.isAbsolute(txDataPath)) { + txDataPath = path.resolve(txDataPath); + console.error(`WARNING: The 'txDataPath' ConVar is not an absolute path, please update it to:`); + console.error(` TXHOST_DATA_PATH=${txDataPath}`); + } + } + txAdminPort = getConvarString('txAdminPort'); + if (txAdminPort) replacedConvarWarning('txAdminPort', 'TXHOST_TXA_PORT'); + txAdminInterface = getConvarString('txAdminInterface'); + if (txAdminInterface) replacedConvarWarning('txAdminInterface', 'TXHOST_INTERFACE'); + } + + if (anyWarnSent) { + console.warn(`WARNING: For more information: https://aka.cfx.re/txadmin-env-config`); + } + + //Final object + return { + fxsVersion, + fxsCitizenRoot, + resourceName, + txaResourceVersion, + txaResourcePath, + + //custom vars + txAdminProfile, + txDataPath, + txAdminPort, + txAdminInterface, + }; +} diff --git a/core/boot/getZapVars.ts b/core/boot/getZapVars.ts new file mode 100644 index 0000000..31ba6e8 --- /dev/null +++ b/core/boot/getZapVars.ts @@ -0,0 +1,73 @@ +import fs from 'node:fs'; + + +//Keeping the typo mostly so I can remember the old usage types +type ZapConfigVars = { + providerName: string; + forceInterface: string | undefined; + forceFXServerPort: number | undefined; + txAdminPort: number | undefined; + loginPageLogo: string | undefined; + defaultMasterAccount?: { + name: string, + password_hash: string + }; + deployerDefaults: { + license?: string, + maxClients?: number, + mysqlHost?: string, + mysqlPort?: string, + mysqlUser?: string, + mysqlPassword?: string, + mysqlDatabase?: string, + }; +} + +const allowType = (type: 'string' | 'number', value: any) => typeof value === type ? value : undefined; + + +/** + * Gets & parses the txAdminZapConfig.json variables + */ +export const getZapVars = (zapCfgFilePath: string): ZapConfigVars | undefined => { + if (!fs.existsSync(zapCfgFilePath)) return; + console.warn(`WARNING: The 'txAdminZapConfig.json' file has been deprecated and this feature will be removed in the next update.`); + console.warn(` Please use the 'TXHOST_' environment variables instead.`); + console.warn(` For more information: https://aka.cfx.re/txadmin-env-config.`); + const cfgFileData = JSON.parse(fs.readFileSync(zapCfgFilePath, 'utf8')); + + const zapVars: ZapConfigVars = { + providerName: 'ZAP-Hosting', + + forceInterface: allowType('string', cfgFileData.interface), + forceFXServerPort: allowType('number', cfgFileData.fxServerPort), + txAdminPort: allowType('number', cfgFileData.txAdminPort), + loginPageLogo: allowType('string', cfgFileData.loginPageLogo), + + deployerDefaults: { + license: allowType('string', cfgFileData.defaults.license), + maxClients: allowType('number', cfgFileData.defaults.maxClients), + mysqlHost: allowType('string', cfgFileData.defaults.mysqlHost), + mysqlUser: allowType('string', cfgFileData.defaults.mysqlUser), + mysqlPassword: allowType('string', cfgFileData.defaults.mysqlPassword), + mysqlDatabase: allowType('string', cfgFileData.defaults.mysqlDatabase), + }, + } + + //Port is a special case because the cfg is likely int, but we want string + if(typeof cfgFileData.defaults.mysqlPort === 'string') { + zapVars.deployerDefaults.mysqlPort = cfgFileData.defaults.mysqlPort; + } else if (typeof cfgFileData.defaults.mysqlPort === 'number') { + zapVars.deployerDefaults.mysqlPort = String(cfgFileData.defaults.mysqlPort); + } + + //Validation is done in the globalData file + if (cfgFileData.customer) { + zapVars.defaultMasterAccount = { + name: allowType('string', cfgFileData.customer.name), + password_hash: allowType('string', cfgFileData.customer.password_hash), + }; + } + + return zapVars; +} diff --git a/core/boot/globalPlaceholder.ts b/core/boot/globalPlaceholder.ts new file mode 100644 index 0000000..6ba70b8 --- /dev/null +++ b/core/boot/globalPlaceholder.ts @@ -0,0 +1,52 @@ +import { txDevEnv } from "@core/globalData"; +import consoleFactory from "@lib/console"; +import fatalError from "@lib/fatalError"; +const console = consoleFactory('GlobalPlaceholder'); + +//Messages +const MSG_VIOLATION = 'Global Proxy Access Violation!'; +const MSG_BOOT_FAIL = 'Failed to boot due to Module Race Condition.'; +const MSG_CONTACT_DEV = 'This error should never happen, please report it to the developers.'; +const MSG_ERR_PARTIAL = 'Attempted to access txCore before it was initialized!'; + + +/** + * Returns a Proxy that will throw a fatalError when accessing an uninitialized property + */ +export const getCoreProxy = (refSrc: any) => { + return new Proxy(refSrc, { + get: function (target, prop) { + // if (!txDevEnv.ENABLED && Reflect.has(target, prop)) { + // if (console.isVerbose) { + // console.majorMultilineError([ + // MSG_VIOLATION, + // MSG_CONTACT_DEV, + // `Getter for ${String(prop)}`, + // ]); + // } + // return Reflect.get(target, prop).deref(); + // } + fatalError.Boot( + 22, + [ + MSG_BOOT_FAIL, + MSG_CONTACT_DEV, + ['Getter for', String(prop)], + ], + new Error(MSG_ERR_PARTIAL) + ); + }, + set: function (target, prop, value) { + fatalError.Boot( + 23, + [ + MSG_BOOT_FAIL, + MSG_CONTACT_DEV, + ['Setter for', String(prop)], + ], + new Error(MSG_ERR_PARTIAL) + ); + return true; + } + }); +} diff --git a/core/boot/setup.ts b/core/boot/setup.ts new file mode 100644 index 0000000..bff1951 --- /dev/null +++ b/core/boot/setup.ts @@ -0,0 +1,67 @@ +import path from 'node:path'; +import fs from 'node:fs'; + +import fatalError from '@lib/fatalError'; +import { txEnv } from '@core/globalData'; +import ConfigStore from '@modules/ConfigStore'; +import { chalkInversePad } from '@lib/misc'; + + +/** + * Ensure the profile subfolders exist + */ +export const ensureProfileStructure = () => { + const dataPath = path.join(txEnv.profilePath, 'data'); + if (!fs.existsSync(dataPath)) { + fs.mkdirSync(dataPath); + } + + const logsPath = path.join(txEnv.profilePath, 'logs'); + if (!fs.existsSync(logsPath)) { + fs.mkdirSync(logsPath); + } +} + + +/** + * Setup the profile folder structure + */ +export const setupProfile = () => { + //Create new profile folder + try { + fs.mkdirSync(txEnv.profilePath); + const configStructure = ConfigStore.getEmptyConfigFile(); + fs.writeFileSync( + path.join(txEnv.profilePath, 'config.json'), + JSON.stringify(configStructure, null, 2) + ); + ensureProfileStructure(); + } catch (error) { + fatalError.Boot(4, [ + 'Failed to set up data folder structure.', + ['Path', txEnv.profilePath], + ], error); + } + console.log(`Server data will be saved in ${chalkInversePad(txEnv.profilePath)}`); + + //Saving start.bat (yes, I also wish this didn't exist) + if (txEnv.isWindows && txEnv.profileName !== 'default') { + const batFilename = `start_${txEnv.fxsVersion}_${txEnv.profileName}.bat`; + try { + const fxsPath = path.join(txEnv.fxsPath, 'FXServer.exe'); + const batLines = [ + //TODO: add note to not add any server convars in here + `@echo off`, + `"${fxsPath}" +set serverProfile "${txEnv.profileName}"`, + `pause` + ]; + const batFolder = path.resolve(txEnv.fxsPath, '..'); + const batPath = path.join(batFolder, batFilename); + fs.writeFileSync(batPath, batLines.join('\r\n')); + console.ok(`You can use ${chalkInversePad(batPath)} to start this profile.`); + } catch (error) { + console.warn(`Failed to create '${batFilename}' with error:`); + console.dir(error); + } + } +}; diff --git a/core/boot/setupProcessHandlers.ts b/core/boot/setupProcessHandlers.ts new file mode 100644 index 0000000..6f79ade --- /dev/null +++ b/core/boot/setupProcessHandlers.ts @@ -0,0 +1,37 @@ +import { txDevEnv } from "@core/globalData"; +import consoleFactory from "@lib/console"; +const console = consoleFactory('ProcessHandlers'); + + +export default function setupProcessHandlers() { + //Handle any stdio error + process.stdin.on('error', (data) => { }); + process.stdout.on('error', (data) => { }); + process.stderr.on('error', (data) => { }); + + //Handling warnings (ignoring some) + Error.stackTraceLimit = 25; + process.removeAllListeners('warning'); //FIXME: this causes errors in Bun + process.on('warning', (warning) => { + //totally ignoring the warning, we know this is bad and shouldn't happen + if (warning.name === 'UnhandledPromiseRejectionWarning') return; + + if (warning.name !== 'ExperimentalWarning' || txDevEnv.ENABLED) { + console.verbose.dir(warning, { multilineError: true }); + } + }); + + //Handle "the unexpected" + process.on('unhandledRejection', (err: Error) => { + //We are handling this inside the DiscordBot component + if (err.message === 'Used disallowed intents') return; + + console.error('Ohh nooooo - unhandledRejection'); + console.dir(err); + }); + process.on('uncaughtException', function (err: Error) { + console.error('Ohh nooooo - uncaughtException'); + console.error(err.message); + console.dir(err.stack); + }); +} diff --git a/core/boot/startReadyWatcher.ts b/core/boot/startReadyWatcher.ts new file mode 100644 index 0000000..ad93f0b --- /dev/null +++ b/core/boot/startReadyWatcher.ts @@ -0,0 +1,193 @@ +import boxen, { type Options as BoxenOptions } from 'boxen'; +import chalk from 'chalk'; +import open from 'open'; +import { shuffle } from 'd3-array'; +import { z } from 'zod'; + +import got from '@lib/got'; +import getOsDistro from '@lib/host/getOsDistro.js'; +import { txDevEnv, txEnv, txHostConfig } from '@core/globalData'; +import consoleFactory from '@lib/console'; +import { addLocalIpAddress } from '@lib/host/isIpAddressLocal'; +import { chalkInversePad } from '@lib/misc'; +const console = consoleFactory(); + + +const getPublicIp = async () => { + const zIpValidator = z.string().ip(); + const reqOptions = { + timeout: { request: 2000 }, + }; + const httpGetter = async (url: string, jsonPath: string) => { + const res = await got(url, reqOptions).json(); + return zIpValidator.parse((res as any)[jsonPath]); + }; + + const allApis = shuffle([ + ['https://api.ipify.org?format=json', 'ip'], + ['https://api.myip.com', 'ip'], + ['https://ipv4.jsonip.com/', 'ip'], + ['https://api.my-ip.io/v2/ip.json', 'ip'], + ['https://www.l2.io/ip.json', 'ip'], + ]); + for await (const [url, jsonPath] of allApis) { + try { + return await httpGetter(url, jsonPath); + } catch (error) { } + } + return false; +}; + +const getOSMessage = async () => { + const serverMessage = [ + `To be able to access txAdmin from the internet open port ${txHostConfig.txaPort}`, + 'on your OS Firewall as well as in the hosting company.', + ]; + const winWorkstationMessage = [ + '[!] Home-hosting fxserver is not recommended [!]', + 'You need to open the fxserver port (usually 30120) on Windows Firewall', + 'and set up port forwarding on your router so other players can access it.', + ]; + if (txEnv.displayAds) { + winWorkstationMessage.push('We recommend renting a server from ' + chalk.inverse(' https://zap-hosting.com/txAdmin ') + '.'); + } + + //FIXME: use si.osInfo() instead + const distro = await getOsDistro(); + return (distro && distro.includes('Linux') || distro.includes('Server')) + ? serverMessage + : winWorkstationMessage; +}; + +const awaitHttp = new Promise((resolve, reject) => { + const tickLimit = 100; //if over 15 seconds + let counter = 0; + let interval: NodeJS.Timeout; + const check = () => { + counter++; + if (txCore.webServer && txCore.webServer.isListening && txCore.webServer.isServing) { + clearInterval(interval); + resolve(true); + } else if (counter == tickLimit) { + clearInterval(interval); + interval = setInterval(check, 2500); + } else if (counter > tickLimit) { + console.warn('The WebServer is taking too long to start:', { + module: !!txCore.webServer, + listening: txCore?.webServer?.isListening, + serving: txCore?.webServer?.isServing, + }); + } + }; + interval = setInterval(check, 150); +}); + +const awaitMasterPin = new Promise((resolve, reject) => { + const tickLimit = 100; //if over 15 seconds + let counter = 0; + let interval: NodeJS.Timeout; + const check = () => { + counter++; + if (txCore.adminStore && txCore.adminStore.admins !== null) { + clearInterval(interval); + const pin = (txCore.adminStore.admins === false) ? txCore.adminStore.addMasterPin : false; + resolve(pin); + } else if (counter == tickLimit) { + clearInterval(interval); + interval = setInterval(check, 2500); + } else if (counter > tickLimit) { + console.warn('The AdminStore is taking too long to start:', { + module: !!txCore.adminStore, + admins: txCore?.adminStore?.admins === null ? 'null' : 'not null', + }); + } + }; + interval = setInterval(check, 150); +}); + +const awaitDatabase = new Promise((resolve, reject) => { + const tickLimit = 100; //if over 15 seconds + let counter = 0; + let interval: NodeJS.Timeout; + const check = () => { + counter++; + if (txCore.database && txCore.database.isReady) { + clearInterval(interval); + resolve(true); + } else if (counter == tickLimit) { + clearInterval(interval); + interval = setInterval(check, 2500); + } else if (counter > tickLimit) { + console.warn('The Database is taking too long to start:', { + module: !!txCore.database, + ready: !!txCore?.database?.isReady, + }); + } + }; + interval = setInterval(check, 150); +}); + + +export const startReadyWatcher = async (cb: () => void) => { + const [publicIpResp, msgRes, adminPinRes] = await Promise.allSettled([ + getPublicIp(), + getOSMessage(), + awaitMasterPin as Promise, + awaitHttp, + awaitDatabase, + ]); + + //Addresses + let detectedUrls; + if (txHostConfig.netInterface && txHostConfig.netInterface !== '0.0.0.0') { + detectedUrls = [txHostConfig.netInterface]; + } else { + detectedUrls = [ + (txEnv.isWindows) ? 'localhost' : 'your-public-ip', + ]; + if ('value' in publicIpResp && publicIpResp.value) { + detectedUrls.push(publicIpResp.value); + addLocalIpAddress(publicIpResp.value); + } + } + const bannerUrls = txHostConfig.txaUrl + ? [txHostConfig.txaUrl] + : detectedUrls.map((addr) => `http://${addr}:${txHostConfig.txaPort}/`); + + //Admin PIN + const adminMasterPin = 'value' in adminPinRes && adminPinRes.value ? adminPinRes.value : false; + const adminPinLines = !adminMasterPin ? [] : [ + '', + 'Use the PIN below to register:', + chalk.inverse(` ${adminMasterPin} `), + ]; + + //Printing stuff + const boxOptions = { + padding: 1, + margin: 1, + align: 'center', + borderStyle: 'bold', + borderColor: 'cyan', + } satisfies BoxenOptions; + const boxLines = [ + 'All ready! Please access:', + ...bannerUrls.map(chalkInversePad), + ...adminPinLines, + ]; + console.multiline(boxen(boxLines.join('\n'), boxOptions), chalk.bgGreen); + if (!txDevEnv.ENABLED && !txHostConfig.netInterface && 'value' in msgRes && msgRes.value) { + console.multiline(msgRes.value, chalk.bgBlue); + } + + //Opening page + if (txEnv.isWindows && adminMasterPin && bannerUrls[0]) { + const linkUrl = new URL(bannerUrls[0]); + linkUrl.pathname = '/addMaster/pin'; + linkUrl.hash = adminMasterPin; + open(linkUrl.href); + } + + //Callback + cb(); +}; diff --git a/core/deployer/index.js b/core/deployer/index.js new file mode 100644 index 0000000..9c5aacb --- /dev/null +++ b/core/deployer/index.js @@ -0,0 +1,216 @@ +const modulename = 'Deployer'; +import path from 'node:path'; +import { cloneDeep } from 'lodash-es'; +import fse from 'fs-extra'; +import open from 'open'; +import getOsDistro from '@lib/host/getOsDistro.js'; +import { txEnv } from '@core/globalData'; +import recipeEngine from './recipeEngine.js'; +import consoleFactory from '@lib/console.js'; +import recipeParser from './recipeParser.js'; +import { getTimeHms } from '@lib/misc.js'; +import { makeTemplateRecipe } from './utils.js'; +const console = consoleFactory(modulename); + + +//Constants +export const RECIPE_DEPLOYER_VERSION = 3; + + +/** + * The deployer class is responsible for running the recipe and handling status and errors + */ +export class Deployer { + /** + * @param {string|false} originalRecipe + * @param {string} deployPath + * @param {boolean} isTrustedSource + * @param {object} customMetaData + */ + constructor(originalRecipe, deploymentID, deployPath, isTrustedSource, customMetaData = {}) { + console.log('Deployer instance ready.'); + + //Setup variables + this.step = 'review'; //FIXME: transform into an enum + this.deployFailed = false; + this.deployPath = deployPath; + this.isTrustedSource = isTrustedSource; + this.originalRecipe = originalRecipe; + this.deploymentID = deploymentID; + this.progress = 0; + this.serverName = customMetaData.serverName || txConfig.general.serverName || ''; + this.logLines = []; + + //Load recipe + const impRecipe = (originalRecipe !== false) + ? originalRecipe + : makeTemplateRecipe(customMetaData.serverName, customMetaData.author); + try { + this.recipe = recipeParser(impRecipe); + } catch (error) { + console.verbose.dir(error); + throw new Error(`Recipe Error: ${error.message}`); + } + } + + //Dumb helpers - don't care enough to make this less bad + customLog(str) { + this.logLines.push(`[${getTimeHms()}] ${str}`); + console.log(str); + } + customLogError(str) { + this.logLines.push(`[${getTimeHms()}] ${str}`); + console.error(str); + } + getDeployerLog() { + return this.logLines.join('\n'); + } + + /** + * Confirms the recipe and goes to the input stage + * @param {string} userRecipe + */ + async confirmRecipe(userRecipe) { + if (this.step !== 'review') throw new Error('expected review step'); + + //Parse/set recipe + try { + this.recipe = recipeParser(userRecipe); + } catch (error) { + throw new Error(`Cannot start() deployer due to a Recipe Error: ${error.message}`); + } + + //Ensure deployment path + try { + await fse.ensureDir(this.deployPath); + } catch (error) { + console.verbose.dir(error); + throw new Error(`Failed to create ${this.deployPath} with error: ${error.message}`); + } + + this.step = 'input'; + } + + /** + * Returns the recipe variables for the deployer run step + */ + getRecipeVars() { + if (this.step !== 'input') throw new Error('expected input step'); + return cloneDeep(this.recipe.variables); + //TODO: ?? Object.keys pra montar varname: {type: 'string'}? + } + + /** + * Starts the deployment process + * @param {string} userInputs + */ + start(userInputs) { + if (this.step !== 'input') throw new Error('expected input step'); + Object.assign(this.recipe.variables, userInputs); + this.logLines = []; + this.customLog(`Starting deployment of ${this.recipe.name}.`); + this.deployFailed = false; + this.progress = 0; + this.step = 'run'; + this.runTasks(); + } + + /** + * Marks the deploy as failed + */ + async markFailedDeploy() { + this.deployFailed = true; + try { + const filePath = path.join(this.deployPath, '_DEPLOY_FAILED_DO_NOT_USE'); + await fse.outputFile(filePath, 'This deploy has failed, please do not use these files.'); + } catch (error) { } + } + + /** + * (Private) Run the tasks in a sequential way. + */ + async runTasks() { + if (this.step !== 'run') throw new Error('expected run step'); + const contextVariables = cloneDeep(this.recipe.variables); + contextVariables.deploymentID = this.deploymentID; + contextVariables.serverName = this.serverName; + contextVariables.recipeName = this.recipe.name; + contextVariables.recipeAuthor = this.recipe.author; + contextVariables.recipeDescription = this.recipe.description; + + //Run all the tasks + for (let index = 0; index < this.recipe.tasks.length; index++) { + this.progress = Math.round((index / this.recipe.tasks.length) * 100); + const task = this.recipe.tasks[index]; + const taskID = `[task${index + 1}:${task.action}]`; + this.customLog(`Running ${taskID}...`); + const taskTimeoutSeconds = task.timeoutSeconds ?? recipeEngine[task.action].timeoutSeconds; + + try { + contextVariables.$step = `loading task ${task.action}`; + await Promise.race([ + recipeEngine[task.action].run(task, this.deployPath, contextVariables), + new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error(`timed out after ${taskTimeoutSeconds}s.`)); + }, taskTimeoutSeconds * 1000); + }), + ]); + this.logLines[this.logLines.length - 1] += ' ✔️'; + } catch (error) { + this.logLines[this.logLines.length - 1] += ' ❌'; + let msg = `Task Failed: ${error.message}\n` + + 'Options: \n' + + JSON.stringify(task, null, 2); + if (contextVariables.$step) { + msg += '\nDebug/Status: ' + + JSON.stringify([ + txEnv.txaVersion, + await getOsDistro(), + contextVariables.$step + ]); + } + this.customLogError(msg); + return await this.markFailedDeploy(); + } + } + + //Set progress + this.progress = 100; + this.customLog('All tasks completed.'); + + //Check deploy folder validity (resources + server.cfg) + try { + if (!fse.existsSync(path.join(this.deployPath, 'resources'))) { + throw new Error('this recipe didn\'t create a \'resources\' folder.'); + } else if (!fse.existsSync(path.join(this.deployPath, 'server.cfg'))) { + throw new Error('this recipe didn\'t create a \'server.cfg\' file.'); + } + } catch (error) { + this.customLogError(`Deploy validation error: ${error.message}`); + return await this.markFailedDeploy(); + } + + //Replace all vars in the server.cfg + try { + const task = { + mode: 'all_vars', + file: './server.cfg', + }; + await recipeEngine['replace_string'].run(task, this.deployPath, contextVariables); + this.customLog('Replacing all vars in server.cfg... ✔️'); + } catch (error) { + this.customLogError(`Failed to replace all vars in server.cfg: ${error.message}`); + return await this.markFailedDeploy(); + } + + //Else: success :) + this.customLog('Deploy finished and folder validated. All done!'); + this.step = 'configure'; + if (txEnv.isWindows) { + try { + await open(path.normalize(this.deployPath), { app: 'explorer' }); + } catch (error) { } + } + } +} diff --git a/core/deployer/recipeEngine.js b/core/deployer/recipeEngine.js new file mode 100644 index 0000000..2a27550 --- /dev/null +++ b/core/deployer/recipeEngine.js @@ -0,0 +1,565 @@ +const modulename = 'RecipeEngine'; +import { promisify } from 'node:util'; +import fse from 'fs-extra'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import stream from 'node:stream'; +import StreamZip from 'node-stream-zip'; +import { cloneDeep, escapeRegExp } from 'lodash-es'; +import mysql from 'mysql2/promise'; +import got from '@lib/got'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + + +//Helper functions +const safePath = (base, suffix) => { + const safeSuffix = path.normalize(suffix).replace(/^(\.\.(\/|\\|$))+/, ''); + return path.join(base, safeSuffix); +}; +const isPathLinear = (pathInput) => { + return pathInput.match(/(\.\.(\/|\\|$))+/g) === null; +}; +const isPathRoot = (pathInput) => { + return /^\.[/\\]*$/.test(pathInput); +}; +const pathCleanTrail = (pathInput) => { + return pathInput.replace(/[/\\]+$/, ''); +}; +const isPathValid = (pathInput, acceptRoot = true) => { + return ( + typeof pathInput == 'string' + && pathInput.length + && isPathLinear(pathInput) + && (acceptRoot || !isPathRoot(pathInput)) + ); +}; +const replaceVars = (inputString, deployerCtx) => { + const allVars = Object.keys(deployerCtx); + for (const varName of allVars) { + const varNameReplacer = new RegExp(escapeRegExp(`{{${varName}}}`), 'g'); + inputString = inputString.replace(varNameReplacer, deployerCtx[varName].toString()); + } + return inputString; +}; + + +/** + * Downloads a file to a target path using streams + */ +const validatorDownloadFile = (options) => { + return ( + typeof options.url == 'string' + && isPathValid(options.path) + ); +}; +const taskDownloadFile = async (options, basePath, deployerCtx) => { + if (!validatorDownloadFile(options)) throw new Error('invalid options'); + if (options.path.endsWith('/')) throw new Error('target filename not specified'); //FIXME: this should be on the validator + + //Process and create target file/path + const destPath = safePath(basePath, options.path); + await fse.outputFile(destPath, 'file save attempt, please ignore or remove'); + + //Start file download and create write stream + deployerCtx.$step = 'before stream'; + const gotOptions = { + timeout: { request: 150e3 }, + retry: { limit: 5 }, + }; + const gotStream = got.stream(options.url, gotOptions); + gotStream.on('downloadProgress', (progress) => { + deployerCtx.$step = `downloading ${Math.round(progress.percent * 100)}%`; + }); + const pipeline = promisify(stream.pipeline); + await pipeline( + gotStream, + fse.createWriteStream(destPath), + ); + deployerCtx.$step = 'after stream'; +}; + + +/** + * Downloads a github repository with an optional reference (branch, tag, commit hash) or subpath. + * If the directory structure does not exist, it is created. + */ +const githubRepoSourceRegex = /^((https?:\/\/github\.com\/)?|@)?([\w.\-_]+)\/([\w.\-_]+).*$/; +const validatorDownloadGithub = (options) => { + return ( + typeof options.src == 'string' + && isPathValid(options.dest, false) + && (typeof options.ref == 'string' || typeof options.ref == 'undefined') + && (typeof options.subpath == 'string' || typeof options.subpath == 'undefined') + ); +}; +const taskDownloadGithub = async (options, basePath, deployerCtx) => { + if (!validatorDownloadGithub(options)) throw new Error('invalid options'); + //FIXME: caso seja eperm, tentar criar um arquivo na pasta e checar se funciona + + //Parsing source + deployerCtx.$step = 'task start'; + const srcMatch = options.src.match(githubRepoSourceRegex); + if (!srcMatch || !srcMatch[3] || !srcMatch[4]) throw new Error('invalid repository'); + const repoOwner = srcMatch[3]; + const repoName = srcMatch[4]; + + //Setting git ref + let reference; + if (options.ref) { + reference = options.ref; + } else { + const data = await got.get( + `https://api.github.com/repos/${repoOwner}/${repoName}`, + { + timeout: { request: 15e3 } + } + ).json(); + if (typeof data !== 'object' || !data.default_branch) { + throw new Error('reference not set, and wasn ot able to detect using github\'s api'); + } + reference = data.default_branch; + } + deployerCtx.$step = 'ref set'; + + //Preparing vars + const downURL = `https://api.github.com/repos/${repoOwner}/${repoName}/zipball/${reference}`; + const tmpFilePath = path.join(basePath, `.${(Date.now() % 100000000).toString(36)}.download`); + const destPath = safePath(basePath, options.dest); + + //Downloading file + deployerCtx.$step = 'before stream'; + const gotOptions = { + timeout: { request: 150e3 }, + retry: { limit: 5 }, + }; + const gotStream = got.stream(downURL, gotOptions); + gotStream.on('downloadProgress', (progress) => { + deployerCtx.$step = `downloading ${Math.round(progress.percent * 100)}%`; + }); + const pipeline = promisify(stream.pipeline); + await pipeline( + gotStream, + fse.createWriteStream(tmpFilePath), + ); + deployerCtx.$step = 'after stream'; + + //Extracting files + const zip = new StreamZip.async({ file: tmpFilePath }); + const entries = Object.values(await zip.entries()); + if (!entries.length || !entries[0].isDirectory) throw new Error('unexpected zip structure'); + const zipSubPath = path.posix.join(entries[0].name, options.subpath || ''); + deployerCtx.$step = 'zip parsed'; + await fsp.mkdir(destPath, { recursive: true }); + deployerCtx.$step = 'dest path created'; + await zip.extract(zipSubPath, destPath); + deployerCtx.$step = 'zip extracted'; + await zip.close(); + deployerCtx.$step = 'zip closed'; + + //Removing temp path + await fse.remove(tmpFilePath); + deployerCtx.$step = 'task finished'; +}; + + +/** + * Removes a file or directory. The directory can have contents. If the path does not exist, silently does nothing. + */ +const validatorRemovePath = (options) => { + return ( + isPathValid(options.path, false) + ); +}; +const taskRemovePath = async (options, basePath, deployerCtx) => { + if (!validatorRemovePath(options)) throw new Error('invalid options'); + + //Process and create target file/path + const targetPath = safePath(basePath, options.path); + + //NOTE: being extra safe about not deleting itself + const cleanBasePath = pathCleanTrail(path.normalize(basePath)); + if (cleanBasePath == targetPath) throw new Error('cannot remove base folder'); + await fse.remove(targetPath); +}; + + +/** + * Ensures that the directory exists. If the directory structure does not exist, it is created. + */ +const validatorEnsureDir = (options) => { + return ( + isPathValid(options.path, false) + ); +}; +const taskEnsureDir = async (options, basePath, deployerCtx) => { + if (!validatorEnsureDir(options)) throw new Error('invalid options'); + + //Process and create target file/path + const destPath = safePath(basePath, options.path); + await fse.ensureDir(destPath); +}; + + +/** + * Extracts a ZIP file to a targt folder. + * NOTE: wow that was not easy to pick a library! + * - tar: no zip files + * - minizlib: terrible docs, probably too low level + * - yauzl: deprecation warning, slow + * - extract-zip: deprecation warning, slow due to yauzl + * - jszip: it's more a browser thing than node, doesn't appear to have an extract option + * - archiver: no extract + * - zip-stream: no extract + * - adm-zip: 50ms the old one, shitty + * - node-stream-zip: 180ms, acceptable + * - unzip: last update 7 years ago + * - unzipper: haven't tested + * - fflate: haven't tested + * - decompress-zip: haven't tested + */ +const validatorUnzip = (options) => { + return ( + isPathValid(options.src, false) + && isPathValid(options.dest) + ); +}; +const taskUnzip = async (options, basePath, deployerCtx) => { + if (!validatorUnzip(options)) throw new Error('invalid options'); + + const srcPath = safePath(basePath, options.src); + const destPath = safePath(basePath, options.dest); + await fsp.mkdir(destPath, { recursive: true }); + + const zip = new StreamZip.async({ file: srcPath }); + const count = await zip.extract(null, destPath); + console.log(`Extracted ${count} entries`); + await zip.close(); +}; + + +/** + * Moves a file or directory. The directory can have contents. + */ +const validatorMovePath = (options) => { + return ( + isPathValid(options.src, false) + && isPathValid(options.dest, false) + ); +}; +const taskMovePath = async (options, basePath, deployerCtx) => { + if (!validatorMovePath(options)) throw new Error('invalid options'); + + const srcPath = safePath(basePath, options.src); + const destPath = safePath(basePath, options.dest); + await fse.move(srcPath, destPath, { + overwrite: (options.overwrite === 'true' || options.overwrite === true), + }); +}; + + +/** + * Copy a file or directory. The directory can have contents. + * TODO: add a filter property and use a glob lib in the fse.copy filter function + */ +const validatorCopyPath = (options) => { + return ( + isPathValid(options.src) + && isPathValid(options.dest) + ); +}; +const taskCopyPath = async (options, basePath, deployerCtx) => { + if (!validatorCopyPath(options)) throw new Error('invalid options'); + + const srcPath = safePath(basePath, options.src); + const destPath = safePath(basePath, options.dest); + await fse.copy(srcPath, destPath, { + overwrite: (typeof options.overwrite !== 'undefined' && (options.overwrite === 'true' || options.overwrite === true)), + }); +}; + + +/** + * Writes or appends data to a file. If not in the append mode, the file will be overwritten and the directory structure will be created if it doesn't exists. + */ +const validatorWriteFile = (options) => { + return ( + typeof options.data == 'string' + && options.data.length + && isPathValid(options.file, false) + ); +}; +const taskWriteFile = async (options, basePath, deployerCtx) => { + if (!validatorWriteFile(options)) throw new Error('invalid options'); + + const filePath = safePath(basePath, options.file); + if (options.append === 'true' || options.append === true) { + await fse.appendFile(filePath, options.data); + } else { + await fse.outputFile(filePath, options.data); + } +}; + + +/** + * Replaces a string in the target file or files array based on a search string. + * Modes: + * - template: (default) target string will be processed for vars + * - literal: normal string search/replace without any vars + * - all_vars: all vars.toString() will be replaced. The search option will be ignored + */ +const validatorReplaceString = (options) => { + //Validate file + const fileList = (Array.isArray(options.file)) ? options.file : [options.file]; + if (fileList.some((s) => !isPathValid(s, false))) { + return false; + } + + //Validate mode + if ( + typeof options.mode == 'undefined' + || options.mode == 'template' + || options.mode == 'literal' + ) { + return ( + typeof options.search == 'string' + && options.search.length + && typeof options.replace == 'string' + ); + } else if (options.mode == 'all_vars') { + return true; + } else { + return false; + } +}; +const taskReplaceString = async (options, basePath, deployerCtx) => { + if (!validatorReplaceString(options)) throw new Error('invalid options'); + + const fileList = (Array.isArray(options.file)) ? options.file : [options.file]; + for (let i = 0; i < fileList.length; i++) { + const filePath = safePath(basePath, fileList[i]); + const original = await fse.readFile(filePath, 'utf8'); + let changed; + if (typeof options.mode == 'undefined' || options.mode == 'template') { + changed = original.replace(new RegExp(options.search, 'g'), replaceVars(options.replace, deployerCtx)); + } else if (options.mode == 'all_vars') { + changed = replaceVars(original, deployerCtx); + } else if (options.mode == 'literal') { + changed = original.replace(new RegExp(options.search, 'g'), options.replace); + } + await fse.writeFile(filePath, changed); + } +}; + + +/** + * Connects to a MySQL/MariaDB server and creates a database if the dbName variable is null. + */ +const validatorConnectDatabase = (options) => { + return true; +}; +const taskConnectDatabase = async (options, basePath, deployerCtx) => { + if (!validatorConnectDatabase(options)) throw new Error('invalid options'); + if (typeof deployerCtx.dbHost !== 'string') throw new Error('invalid dbHost'); + if (typeof deployerCtx.dbPort !== 'number') throw new Error('invalid dbPort, should be number'); + if (typeof deployerCtx.dbUsername !== 'string') throw new Error('invalid dbUsername'); + if (typeof deployerCtx.dbPassword !== 'string') throw new Error('dbPassword should be a string'); + if (typeof deployerCtx.dbName !== 'string') throw new Error('dbName should be a string'); + if (typeof deployerCtx.dbDelete !== 'boolean') throw new Error('dbDelete should be a boolean'); + //Connect to the database + const mysqlOptions = { + host: deployerCtx.dbHost, + port: deployerCtx.dbPort, + user: deployerCtx.dbUsername, + password: deployerCtx.dbPassword, + multipleStatements: true, + }; + deployerCtx.dbConnection = await mysql.createConnection(mysqlOptions); + const escapedDBName = mysql.escapeId(deployerCtx.dbName); + if (deployerCtx.dbDelete) { + await deployerCtx.dbConnection.query(`DROP DATABASE IF EXISTS ${escapedDBName}`); + } + await deployerCtx.dbConnection.query(`CREATE DATABASE IF NOT EXISTS ${escapedDBName} CHARACTER SET utf8 COLLATE utf8_general_ci`); + await deployerCtx.dbConnection.query(`USE ${escapedDBName}`); +}; + + +/** + * Runs a SQL query in the previously connected database. This query can be a file path or a string. + */ +const validatorQueryDatabase = (options) => { + if (typeof options.file !== 'undefined' && typeof options.query !== 'undefined') return false; + if (typeof options.file == 'string') return isPathValid(options.file, false); + if (typeof options.query == 'string') return options.query.length; + return false; +}; +const taskQueryDatabase = async (options, basePath, deployerCtx) => { + if (!validatorQueryDatabase(options)) throw new Error('invalid options'); + if (!deployerCtx.dbConnection) throw new Error('Database connection not found. Run connect_database before query_database'); + + let sql; + if (options.file) { + const filePath = safePath(basePath, options.file); + sql = await fse.readFile(filePath, 'utf8'); + } else { + sql = options.query; + } + await deployerCtx.dbConnection.query(sql); +}; + + +/** + * Loads variables from a json file to the context. + */ +const validatorLoadVars = (options) => { + return isPathValid(options.src, false); +}; +const taskLoadVars = async (options, basePath, deployerCtx) => { + if (!validatorLoadVars(options)) throw new Error('invalid options'); + + const srcPath = safePath(basePath, options.src); + const rawData = await fse.readFile(srcPath, 'utf8'); + const inData = JSON.parse(rawData); + inData.dbConnection = undefined; + Object.assign(deployerCtx, inData); +}; + + +/** + * DEBUG Just wastes time /shrug + */ +const validatorWasteTime = (options) => { + return (typeof options.seconds == 'number'); +}; +const taskWasteTime = (options, basePath, deployerCtx) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve(true); + }, options.seconds * 1000); + }); +}; + + +/** + * DEBUG Fail fail fail :o + */ +const taskFailTest = async (options, basePath, deployerCtx) => { + throw new Error('test error :p'); +}; + + +/** + * DEBUG logs all ctx vars + */ +const taskDumpVars = async (options, basePath, deployerCtx) => { + const toDump = cloneDeep(deployerCtx); + toDump.dbConnection = toDump?.dbConnection?.constructor?.name; + console.dir(toDump); +}; + + +/* +DONE: + - download_file + - remove_path (file or folder) + - ensure_dir + - unzip + - move_path (file or folder) + - copy_path (file or folder) + - write_file (with option to append only) + - replace_string (single or array) + - connect_database (connects to mysql, creates db if not set) + - query_database (file or string) + - download_github (with ref and subpath) + - load_vars + +DEBUG: + - waste_time + - fail_test + - dump_vars + +TODO: + - ?????? +*/ + + +//Exports +export default { + download_file: { + validate: validatorDownloadFile, + run: taskDownloadFile, + timeoutSeconds: 180, + }, + download_github: { + validate: validatorDownloadGithub, + run: taskDownloadGithub, + timeoutSeconds: 180, + }, + remove_path: { + validate: validatorRemovePath, + run: taskRemovePath, + timeoutSeconds: 15, + }, + ensure_dir: { + validate: validatorEnsureDir, + run: taskEnsureDir, + timeoutSeconds: 15, + }, + unzip: { + validate: validatorUnzip, + run: taskUnzip, + timeoutSeconds: 180, + }, + move_path: { + validate: validatorMovePath, + run: taskMovePath, + timeoutSeconds: 180, + }, + copy_path: { + validate: validatorCopyPath, + run: taskCopyPath, + timeoutSeconds: 180, + }, + write_file: { + validate: validatorWriteFile, + run: taskWriteFile, + timeoutSeconds: 15, + }, + replace_string: { + validate: validatorReplaceString, + run: taskReplaceString, + timeoutSeconds: 15, + }, + connect_database: { + validate: validatorConnectDatabase, + run: taskConnectDatabase, + timeoutSeconds: 30, + }, + query_database: { + validate: validatorQueryDatabase, + run: taskQueryDatabase, + timeoutSeconds: 90, + }, + load_vars: { + validate: validatorLoadVars, + run: taskLoadVars, + timeoutSeconds: 5, + }, + + //DEBUG only + waste_time: { + validate: validatorWasteTime, + run: taskWasteTime, + timeoutSeconds: 300, + }, + fail_test: { + validate: (() => true), + run: taskFailTest, + timeoutSeconds: 300, + }, + dump_vars: { + validate: (() => true), + run: taskDumpVars, + timeoutSeconds: 5, + }, +}; diff --git a/core/deployer/recipeParser.ts b/core/deployer/recipeParser.ts new file mode 100644 index 0000000..60a64f1 --- /dev/null +++ b/core/deployer/recipeParser.ts @@ -0,0 +1,126 @@ +const modulename = 'Deployer'; +import YAML from 'js-yaml'; +import { txEnv } from '@core/globalData'; +import { default as untypedRecipeEngine } from './recipeEngine.js'; +import consoleFactory from '@lib/console.js'; +import { RECIPE_DEPLOYER_VERSION } from './index.js'; //FIXME: circular_dependency +const console = consoleFactory(modulename); + + +//Types +type YamlRecipeTaskType = { + action: string; + [key: string]: any; +} +type YamlRecipeType = Partial<{ + $engine: number; + $minFxVersion: number; + $onesync: string; + $steamRequired: boolean; + + name: string; + author: string; + description: string; + + variables: Record; + tasks: YamlRecipeTaskType[]; +}>; +type ParsedRecipeType = { + raw: string; + name: string; + author: string; + description: string; + variables: Record; //TODO: define this + tasks: YamlRecipeTaskType[]; + onesync?: string; + fxserverMinVersion?: number; + recipeEngineVersion?: number; + steamRequired?: boolean; + requireDBConfig: boolean; +} + + +//FIXME: move to the recipeEngine.js file after typescript migration +type RecipeEngineTask = { + validate: (task: YamlRecipeTaskType) => boolean; + run: (options: YamlRecipeTaskType, basePath: string, deployerCtx: unknown) => Promise; + timeoutSeconds: number; +}; +type RecipeEngine = Record; +const recipeEngine = untypedRecipeEngine as RecipeEngine; + + +/** + * Validates a Recipe file + * FIXME: use Zod for schema validaiton + */ +const recipeParser = (rawRecipe: string) => { + if (typeof rawRecipe !== 'string') throw new Error('not a string'); + + //Loads YAML + let recipe: YamlRecipeType; + try { + recipe = YAML.load(rawRecipe, { schema: YAML.JSON_SCHEMA }) as YamlRecipeType; + } catch (error) { + console.verbose.dir(error); + throw new Error('invalid yaml'); + } + + //Basic validation + if (typeof recipe !== 'object') throw new Error('invalid YAML, couldn\'t resolve to object'); + if (!Array.isArray(recipe.tasks)) throw new Error('no tasks array found'); + + //Preparing output + const outRecipe: ParsedRecipeType = { + raw: rawRecipe.trim(), + name: (recipe.name ?? 'unnamed').trim(), + author: (recipe.author ?? 'unknown').trim(), + description: (recipe.description ?? '').trim(), + variables: {}, + tasks: [], + requireDBConfig: false, + }; + + //Checking/parsing meta tag requirements + if (typeof recipe.$onesync == 'string') { + const onesync = recipe.$onesync.trim(); + if (!['off', 'legacy', 'on'].includes(onesync)) throw new Error(`the onesync option selected required for this recipe ("${onesync}") is not supported by this FXServer version.`); + outRecipe.onesync = onesync; + } + if (typeof recipe.$minFxVersion == 'number') { + if (recipe.$minFxVersion > txEnv.fxsVersion) throw new Error(`this recipe requires FXServer v${recipe.$minFxVersion} or above`); + outRecipe.fxserverMinVersion = recipe.$minFxVersion; //NOTE: currently no downstream use + } + if (typeof recipe.$engine == 'number') { + if (recipe.$engine < RECIPE_DEPLOYER_VERSION) throw new Error(`unsupported '$engine' version ${recipe.$engine}`); + outRecipe.recipeEngineVersion = recipe.$engine; //NOTE: currently no downstream use + } + if (recipe.$steamRequired === true) { + outRecipe.steamRequired = true; + } + + //Validate tasks + if (!Array.isArray(recipe.tasks)) throw new Error('no tasks array found'); + recipe.tasks.forEach((task, index) => { + if (typeof task.action !== 'string') throw new Error(`[task${index + 1}] no action specified`); + if (typeof recipeEngine[task.action] === 'undefined') throw new Error(`[task${index + 1}] unknown action '${task.action}'`); + if (!recipeEngine[task.action].validate(task)) throw new Error(`[task${index + 1}:${task.action}] invalid parameters`); + outRecipe.tasks.push(task); + }); + + //Process inputs + outRecipe.requireDBConfig = recipe.tasks.some((t) => t.action.includes('database')); + const protectedVarNames = ['licenseKey', 'dbHost', 'dbUsername', 'dbPassword', 'dbName', 'dbConnection', 'dbPort']; + if (typeof recipe.variables == 'object' && recipe.variables !== null) { + const varNames = Object.keys(recipe.variables); + if (varNames.some((n) => protectedVarNames.includes(n))) { + throw new Error('One or more of the variables declared in the recipe are not allowed.'); + } + Object.assign(outRecipe.variables, recipe.variables); + } + + //Output + return outRecipe; +}; + +export default recipeParser; diff --git a/core/deployer/utils.ts b/core/deployer/utils.ts new file mode 100644 index 0000000..88140e9 --- /dev/null +++ b/core/deployer/utils.ts @@ -0,0 +1,40 @@ +import { canWriteToPath, getPathFiles } from '@lib/fs'; + +//File created up to v7.3.2 +const EMPTY_FILE_NAME = '.empty'; + + +/** + * Perform deployer local target path permission/emptiness checking. + */ +export const validateTargetPath = async (deployPath: string) => { + const canCreateFolder = await canWriteToPath(deployPath); + if(!canCreateFolder) { + throw new Error('Path is not writable due to missing permissions or invalid path.'); + } + try { + const pathFiles = await getPathFiles(deployPath); + if (pathFiles.some((x) => x.name !== EMPTY_FILE_NAME)) { + throw new Error('This folder already exists and is not empty!'); + } + } catch (error) { + if ((error as any).code !== 'ENOENT') throw error; + } + return true as const; +}; + + +/** + * Create a template recipe file + */ +export const makeTemplateRecipe = (serverName: string, author: string) => [ + `name: ${serverName}`, + `author: ${author}`, + '', + '# This is just a placeholder, please don\'t use it!', + 'tasks: ', + ' - action: waste_time', + ' seconds: 5', + ' - action: waste_time', + ' seconds: 5', +].join('\n'); diff --git a/core/global.d.ts b/core/global.d.ts new file mode 100644 index 0000000..1a9145a --- /dev/null +++ b/core/global.d.ts @@ -0,0 +1,66 @@ +//NOTE: don't import anything at the root of this file or it breaks the type definitions + +/** + * MARK: txAdmin stuff + */ +type RefreshConfigFunc = import('@modules/ConfigStore/').RefreshConfigFunc; +interface GenericTxModuleInstance { + public handleConfigUpdate?: RefreshConfigFunc; + public handleShutdown?: () => void; + public timers?: NodeJS.Timer[]; + // public measureMemory?: () => { [key: string]: number }; +} +declare interface GenericTxModule { + new(): InstanceType & GenericTxModuleInstance; + static readonly configKeysWatched?: string[]; +} + +declare type TxConfigs = import('@modules/ConfigStore/schema').TxConfigs +declare const txConfig: TxConfigs; + +declare type TxCoreType = import('./txAdmin').TxCoreType; +declare const txCore: TxCoreType; + +declare type TxManagerType = import('./txManager').TxManagerType; +declare const txManager: TxManagerType; + +declare type TxConsole = import('./lib/console').TxConsole; +declare namespace globalThis { + interface Console extends TxConsole { } +} + + +/** + * MARK: Natives + * Natives extracted from https://www.npmjs.com/package/@citizenfx/server + * I prefer extracting than importing the whole package because it's + * easier to keep track of what natives are being used. + * + * To use the package, add the following line to the top of the file: + * /// + */ +declare function ExecuteCommand(commandString: string): void; +declare function GetConvar(varName: string, default_: string): string; +declare function GetCurrentResourceName(): string; +declare function GetPasswordHash(password: string): string; +declare function GetResourceMetadata(resourceName: string, metadataKey: string, index: number): string; +declare function GetResourcePath(resourceName: string): string; +declare function IsDuplicityVersion(): boolean; +declare function PrintStructuredTrace(payload: string): void; +declare function RegisterCommand(commandName: string, handler: Function, restricted: boolean): void; +declare function ScanResourceRoot(rootPath: string, callback: (data: object) => void): boolean; +declare function VerifyPasswordHash(password: string, hash: string): boolean; + + +/** + * MARK: Fixes + */ +declare module 'unicode-emoji-json/data-ordered-emoji' { + const emojis: string[]; + export = emojis; +} + +//FIXME: checar se eu preciso disso +// interface ProcessEnv { +// [x: string]: string | undefined; +// } diff --git a/core/globalData.ts b/core/globalData.ts new file mode 100644 index 0000000..e4ae077 --- /dev/null +++ b/core/globalData.ts @@ -0,0 +1,578 @@ +import os from 'node:os'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import slash from 'slash'; + +import consoleFactory, { setConsoleEnvData } from '@lib/console'; +import { addLocalIpAddress } from '@lib/host/isIpAddressLocal'; +import { parseFxserverVersion } from '@lib/fxserver/fxsVersionParser'; +import { parseTxDevEnv, TxDevEnvType } from '@shared/txDevEnv'; +import { Overwrite } from 'utility-types'; +import fatalError from '@lib/fatalError'; +import { getNativeVars } from './boot/getNativeVars'; +import { getHostVars, hostEnvVarSchemas } from './boot/getHostVars'; +import { getZapVars } from './boot/getZapVars'; +import { z, ZodSchema } from 'zod'; +import { fromZodError } from 'zod-validation-error'; +import defaultAds from '../dynamicAds2.json'; +import consts from '@shared/consts'; +const console = consoleFactory(); + + +/** + * MARK: GETTING VARIABLES + */ +//Get OSType +const osTypeVar = os.type(); +let isWindows; +if (osTypeVar === 'Windows_NT') { + isWindows = true; +} else if (osTypeVar === 'Linux') { + isWindows = false; +} else { + fatalError.GlobalData(0, `OS type not supported: ${osTypeVar}`); +} + +//Simple env vars +const ignoreDeprecatedConfigs = process.env?.TXHOST_IGNORE_DEPRECATED_CONFIGS === 'true'; + + +/** + * MARK: HELPERS + */ +const cleanPath = (x: string) => slash(path.normalize(x)); +const handleMultiVar = ( + name: string, + schema: T, + procenv: z.infer | undefined, + zapcfg: string | number | undefined, + convar: any, +): z.infer | undefined => { + const alt = zapcfg ?? convar; + if (alt === undefined) { + return procenv; + } + const whichAlt = zapcfg !== undefined ? 'txAdminZapConfig.json' : 'ConVar'; + if (procenv !== undefined) { + console.warn(`WARNING: Both the environment variable 'TXHOST_${name}' and the ${whichAlt} equivalent are set. The environment variable will be prioritized.`); + return procenv; + } + const parsed = schema.safeParse(alt); + if (!parsed.success) { + fatalError.GlobalData(20, [ + `Invalid value for the TXHOST_${name}-equivalent config in ${whichAlt}.`, + ['Value', alt], + 'For more information: https://aka.cfx.re/txadmin-env-config', + ], fromZodError(parsed.error, { prefix: null })); + } + return parsed.data; +} + + +/** + * MARK: DEV ENV + */ +type TxDevEnvEnabledType = Overwrite; +type TxDevEnvDisabledType = Overwrite; +let _txDevEnv: TxDevEnvEnabledType | TxDevEnvDisabledType; +const devVars = parseTxDevEnv(); +if (devVars.ENABLED) { + console.debug('Starting txAdmin in DEV mode.'); + if (!devVars.SRC_PATH || !devVars.VITE_URL) { + fatalError.GlobalData(8, 'Missing TXDEV_VITE_URL and/or TXDEV_SRC_PATH env variables.'); + } + _txDevEnv = devVars as TxDevEnvEnabledType; +} else { + _txDevEnv = { + ...devVars, + SRC_PATH: undefined, + VITE_URL: undefined, + } as TxDevEnvDisabledType; +} + + +/** + * MARK: CHECK HOST VARS + */ +const nativeVars = getNativeVars(ignoreDeprecatedConfigs); + +//Getting fxserver version +//4380 = GetVehicleType was exposed server-side +//4548 = more or less when node v16 was added +//4574 = add missing PRINT_STRUCTURED_TRACE declaration +//4574 = add resource field to PRINT_STRUCTURED_TRACE +//5894 = CREATE_VEHICLE_SERVER_SETTER +//6185 = added ScanResourceRoot (not yet in use) +//6508 = unhandledRejection is now handlable, we need this due to discord.js's bug +//8495 = changed prometheus::Histogram::BucketBoundaries +//9423 = feat(server): add more infos to playerDropped event +//9655 = Fixed ScanResourceRoot + latent events +const minFxsVersion = 5894; +const fxsVerParsed = parseFxserverVersion(nativeVars.fxsVersion); +const fxsVersion = fxsVerParsed.valid ? fxsVerParsed.build : 99999; +if (!fxsVerParsed.valid) { + console.error('It looks like you are running a custom build of fxserver.'); + console.error('And because of that, there is no guarantee that txAdmin will work properly.'); + console.error(`Convar: ${nativeVars.fxsVersion}`); + console.error(`Parsed Build: ${fxsVerParsed.build}`); + console.error(`Parsed Branch: ${fxsVerParsed.branch}`); + console.error(`Parsed Platform: ${fxsVerParsed.platform}`); +} else if (fxsVerParsed.build < minFxsVersion) { + fatalError.GlobalData(2, [ + 'This version of FXServer is too outdated and NOT compatible with txAdmin', + ['Current FXServer version', fxsVerParsed.build.toString()], + ['Minimum required version', minFxsVersion.toString()], + 'Please update your FXServer to a newer version.', + ]); +} else if (fxsVerParsed.branch !== 'master') { + console.warn(`You are running a custom branch of FXServer: ${fxsVerParsed.branch}`); +} + +//Getting txAdmin version +if (!nativeVars.txaResourceVersion) { + fatalError.GlobalData(3, [ + 'txAdmin version not set or in the wrong format.', + ['Detected version', nativeVars.txaResourceVersion], + ]); +} +const txaVersion = nativeVars.txaResourceVersion; + +//Get txAdmin Resource Path +if (!nativeVars.txaResourcePath) { + fatalError.GlobalData(4, [ + 'Could not resolve txAdmin resource path.', + ['Convar', nativeVars.txaResourcePath], + ]); +} +const txaPath = cleanPath(nativeVars.txaResourcePath); + +//Get citizen Root +if (!nativeVars.fxsCitizenRoot) { + fatalError.GlobalData(5, [ + 'citizen_root convar not set', + ['Convar', nativeVars.fxsCitizenRoot], + ]); +} +const fxsPath = cleanPath(nativeVars.fxsCitizenRoot as string); + +//Check if server is inside WinRar's temp folder +if (isWindows && /Temp[\\/]+Rar\$/i.test(fxsPath)) { + fatalError.GlobalData(12, [ + 'It looks like you ran FXServer inside WinRAR without extracting it first.', + 'Please extract the server files to a proper folder before running it.', + ['Server path', fxsPath.replace(/\\/g, '/').replace(/\/$/, '')], + ]); +} + + +//Setting the variables in console without it having to importing from here (circular dependency) +setConsoleEnvData( + txaVersion, + txaPath, + _txDevEnv.ENABLED, + _txDevEnv.VERBOSE +); + + +/** + * MARK: TXDATA & PROFILE + */ +const hostVars = getHostVars(); +//Setting data path +let hasCustomDataPath = false; +let dataPath = cleanPath(path.join( + fxsPath, + isWindows ? '..' : '../../../', + 'txData' +)); +const dataPathVar = handleMultiVar( + 'DATA_PATH', + hostEnvVarSchemas.DATA_PATH, + hostVars.DATA_PATH, + undefined, + nativeVars.txDataPath, +); +if (dataPathVar) { + hasCustomDataPath = true; + dataPath = cleanPath(dataPathVar); +} + +//Check paths for non-ASCII characters +//NOTE: Non-ASCII in one of those paths (don't know which) will make NodeJS crash due to a bug in v8 (or something) +// when running localization methods like Date.toLocaleString(). +// There was also an issue with the slash() lib and with the +exec on FXServer +const nonASCIIRegex = /[^\x00-\x80]+/; +if (nonASCIIRegex.test(fxsPath) || nonASCIIRegex.test(dataPath)) { + fatalError.GlobalData(7, [ + 'Due to environmental restrictions, your paths CANNOT contain non-ASCII characters.', + 'Example of non-ASCII characters: çâýå, ρέθ, ñäé, ēļæ, глж, เซิร์, 警告.', + 'Please make sure FXServer is not in a path contaning those characters.', + `If on windows, we suggest you moving the artifact to "C:/fivemserver/${fxsVersion}/".`, + ['FXServer path', fxsPath], + ['txData path', dataPath], + ]); +} + +//Profile - not available as env var +let profileVar = nativeVars.txAdminProfile; +if (profileVar) { + profileVar = profileVar.replace(/[^a-z0-9._-]/gi, ''); + if (profileVar.endsWith('.base')) { + fatalError.GlobalData(13, [ + ['Invalid server profile name', profileVar], + 'Profile names cannot end with ".base".', + 'It looks like you are trying to point to a server folder instead of a profile.', + ]); + } + if (!profileVar.length) { + fatalError.GlobalData(14, [ + 'Invalid server profile name.', + 'If you are using Google Translator on the instructions page,', + 'make sure there are no additional spaces in your command.', + ]); + } +} +const profileName = profileVar ?? 'default'; +const profilePath = cleanPath(path.join(dataPath, profileName)); + + +/** + * MARK: ZAP & NETWORKING + */ +let zapVars: ReturnType | undefined; +if (!ignoreDeprecatedConfigs) { + //FIXME: ZAP doesn't need this anymore, remove ASAP + const zapCfgFilePath = path.join(dataPath, 'txAdminZapConfig.json'); + try { + zapVars = getZapVars(zapCfgFilePath); + if (!_txDevEnv.ENABLED) fsp.unlink(zapCfgFilePath).catch(() => { }); + } catch (error) { + fatalError.GlobalData(9, 'Failed to load with ZAP-Hosting configuration.', error); + } +} + +//No default, no convar/zap cfg +const txaUrl = hostVars.TXA_URL; + +//txAdmin port +const txaPort = handleMultiVar( + 'TXA_PORT', + hostEnvVarSchemas.TXA_PORT, + hostVars.TXA_PORT, + zapVars?.txAdminPort, + nativeVars.txAdminPort, +) ?? 40120; + +//fxserver port +const fxsPort = handleMultiVar( + 'FXS_PORT', + hostEnvVarSchemas.FXS_PORT, + hostVars.FXS_PORT, + zapVars?.forceFXServerPort, + undefined, +); + +//Forced interface +const netInterface = handleMultiVar( + 'INTERFACE', + hostEnvVarSchemas.INTERFACE, + hostVars.INTERFACE, + zapVars?.forceInterface, + nativeVars.txAdminInterface, +); +if (netInterface) { + addLocalIpAddress(netInterface); +} + + +/** + * MARK: GENERAL + */ +const forceGameName = hostVars.GAME_NAME; +const hostApiToken = hostVars.API_TOKEN; + +const forceMaxClients = handleMultiVar( + 'MAX_SLOTS', + hostEnvVarSchemas.MAX_SLOTS, + hostVars.MAX_SLOTS, + zapVars?.deployerDefaults?.maxClients, + undefined, +); + +const forceQuietMode = handleMultiVar( + 'QUIET_MODE', + hostEnvVarSchemas.QUIET_MODE, + hostVars.QUIET_MODE, + zapVars?.deployerDefaults?.maxClients, + undefined, +) ?? false; + + +/** + * MARK: PROVIDER + */ +const providerName = handleMultiVar( + 'PROVIDER_NAME', + hostEnvVarSchemas.PROVIDER_NAME, + hostVars.PROVIDER_NAME, + zapVars?.providerName, + undefined, +); +const providerLogo = handleMultiVar( + 'PROVIDER_LOGO', + hostEnvVarSchemas.PROVIDER_LOGO, + hostVars.PROVIDER_LOGO, + zapVars?.loginPageLogo, + undefined, +); + + +/** + * MARK: DEFAULTS + */ +const defaultDbHost = handleMultiVar( + 'DEFAULT_DBHOST', + hostEnvVarSchemas.DEFAULT_DBHOST, + hostVars.DEFAULT_DBHOST, + zapVars?.deployerDefaults?.mysqlHost, + undefined, +); +const defaultDbPort = handleMultiVar( + 'DEFAULT_DBPORT', + hostEnvVarSchemas.DEFAULT_DBPORT, + hostVars.DEFAULT_DBPORT, + zapVars?.deployerDefaults?.mysqlPort, + undefined, +); +const defaultDbUser = handleMultiVar( + 'DEFAULT_DBUSER', + hostEnvVarSchemas.DEFAULT_DBUSER, + hostVars.DEFAULT_DBUSER, + zapVars?.deployerDefaults?.mysqlUser, + undefined, +); +const defaultDbPass = handleMultiVar( + 'DEFAULT_DBPASS', + hostEnvVarSchemas.DEFAULT_DBPASS, + hostVars.DEFAULT_DBPASS, + zapVars?.deployerDefaults?.mysqlPassword, + undefined, +); +const defaultDbName = handleMultiVar( + 'DEFAULT_DBNAME', + hostEnvVarSchemas.DEFAULT_DBNAME, + hostVars.DEFAULT_DBNAME, + zapVars?.deployerDefaults?.mysqlDatabase, + undefined, +); + +//Default Master Account +type DefaultMasterAccount = { + username: string; + fivemId?: string; + password?: string; +} | { + username: string; + password: string; +} | undefined; +let defaultMasterAccount: DefaultMasterAccount; +const bcryptRegex = /^\$2[aby]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/; +if (hostVars.DEFAULT_ACCOUNT) { + let [username, fivemId, password] = hostVars.DEFAULT_ACCOUNT.split(':') as (string | undefined)[]; + if (username === '') username = undefined; + if (fivemId === '') fivemId = undefined; + if (password === '') password = undefined; + + const errArr: [string, any][] = [ + ['Username', username], + ['FiveM ID', fivemId], + ['Password', password], + ]; + if (!username || !consts.regexValidFivemUsername.test(username)) { + fatalError.GlobalData(21, [ + 'Invalid default account username.', + 'It should be a valid FiveM username.', + ...errArr, + ]); + } + if (fivemId && !consts.validIdentifierParts.fivem.test(fivemId)) { + fatalError.GlobalData(22, [ + 'Invalid default account FiveM ID.', + 'It should match the number in the fivem:0000000 game identifier.', + ...errArr, + ]); + } + if (password && !bcryptRegex.test(password)) { + fatalError.GlobalData(23, [ + 'Invalid default account password.', + 'Expected bcrypt hash.', + ...errArr, + ]); + } + if (!fivemId && !password) { + fatalError.GlobalData(24, [ + 'Invalid default account.', + 'Expected at least the FiveM ID or password to be present.', + ...errArr, + ]); + } + defaultMasterAccount = { + username, + fivemId, + password, + }; +} else if (zapVars?.defaultMasterAccount) { + const username = zapVars.defaultMasterAccount?.name; + const password = zapVars.defaultMasterAccount?.password_hash; + if (!consts.regexValidFivemUsername.test(username)) { + fatalError.GlobalData(25, [ + 'Invalid default account username.', + 'It should be a valid FiveM username.', + ['Username', username], + ]); + } + if (!bcryptRegex.test(password)) { + fatalError.GlobalData(26, [ + 'Invalid default account password.', + 'Expected bcrypt hash.', + ['Hash', password], + ]); + } + defaultMasterAccount = { + username: username, + password: password, + }; +} + +//Default cfx key +const defaultCfxKey = handleMultiVar( + 'DEFAULT_CFXKEY', + hostEnvVarSchemas.DEFAULT_CFXKEY, + hostVars.DEFAULT_CFXKEY, + zapVars?.deployerDefaults?.license, + undefined, +); + + +/** + * MARK: FINAL SETUP + */ +if (ignoreDeprecatedConfigs) { + console.verbose.debug('TXHOST_IGNORE_DEPRECATED_CONFIGS is set to true. Ignoring deprecated configs.'); +} + +const isPterodactyl = !isWindows && process.env?.TXADMIN_ENABLE === '1'; +const isZapHosting = providerName === 'ZAP-Hosting'; + +//Quick config to disable ads +const displayAds = process.env?.TXHOST_TMP_HIDE_ADS !== 'true' || isPterodactyl || isZapHosting; +const adSchema = z.object({ + img: z.string(), + url: z.string(), +}).nullable(); +const adsDataSchema = z.object({ + login: adSchema, + main: adSchema, +}); +let adsData: z.infer = { + login: null, + main: null, +}; +if (displayAds) { + try { + adsData = adsDataSchema.parse(defaultAds); + } catch (error) { + console.error('Failed to load ads data.', error); + } +} + +//FXServer Display Version +let fxsVersionTag = fxsVersion.toString(); +if (fxsVerParsed.branch && fxsVerParsed.branch !== 'master') { + fxsVersionTag += '-ft'; +} +if (isZapHosting) { + fxsVersionTag += '/ZAP'; +} else if (isPterodactyl) { + fxsVersionTag += '/Ptero'; +} else if (isWindows && fxsVerParsed.platform === 'windows') { + fxsVersionTag += '/Win'; +} else if (!isWindows && fxsVerParsed.platform === 'linux') { + fxsVersionTag += '/Lin'; +} else { + fxsVersionTag += '/Unk'; +} + + +/** + * MARK: Exports + */ +export const txDevEnv = Object.freeze(_txDevEnv); + +export const txEnv = Object.freeze({ + //Calculated + isWindows, + isPterodactyl, //TODO: remove, used only in HB Data + isZapHosting, //TODO: remove, used only in HB Data and authLogic to disable src check + displayAds, + adsData, + + //Natives + fxsVersionTag, + fxsVersion, + txaVersion, + txaPath, + fxsPath, + + //ConVar + profileName, + profilePath, //FIXME: replace by profileSubPath in most places + profileSubPath: (...parts: string[]) => path.join(profilePath, ...parts), +}); + +export const txHostConfig = Object.freeze({ + //General + dataPath, + dataSubPath: (...parts: string[]) => path.join(dataPath, ...parts), + hasCustomDataPath, + forceGameName, + forceMaxClients, + forceQuietMode, + hostApiToken, + + //Networking + txaUrl, + txaPort, + fxsPort, + netInterface, + + //Provider + providerName, + providerLogo, + sourceName: providerName ?? 'Host Config', + + //Defaults + defaults: { + account: defaultMasterAccount, + cfxKey: defaultCfxKey, + dbHost: defaultDbHost, + dbPort: defaultDbPort, + dbUser: defaultDbUser, + dbPass: defaultDbPass, + dbName: defaultDbName, + }, +}); + + +//DEBUG +// console.dir(txEnv, { compact: true }); +// console.dir(txDevEnv, { compact: true }); +// console.dir(txHostConfig, { compact: true }); diff --git a/core/index.ts b/core/index.ts new file mode 100644 index 0000000..21589e7 --- /dev/null +++ b/core/index.ts @@ -0,0 +1,83 @@ +//NOTE: must be imported first to setup the environment +import { txEnv, txHostConfig } from './globalData'; +import consoleFactory, { setTTYTitle } from '@lib/console'; + +//Can be imported after +import fs from 'node:fs'; +import checkPreRelease from './boot/checkPreRelease'; +import fatalError from '@lib/fatalError'; +import { ensureProfileStructure, setupProfile } from './boot/setup'; +import setupProcessHandlers from './boot/setupProcessHandlers'; +import bootTxAdmin from './txAdmin'; +const console = consoleFactory(); + + +//Early process stuff +try { + process.title = 'txAdmin'; //doesn't work for now + setupProcessHandlers(); + setTTYTitle(); + checkPreRelease(); +} catch (error) { + fatalError.Boot(0, 'Failed early process setup.', error); +} +console.log(`Starting txAdmin v${txEnv.txaVersion}/b${txEnv.fxsVersionTag}...`); + + +//Setting up txData & Profile +try { + if (!fs.existsSync(txHostConfig.dataPath)) { + fs.mkdirSync(txHostConfig.dataPath); + } +} catch (error) { + fatalError.Boot(1, [ + `Failed to check or create the data folder.`, + ['Path', txHostConfig.dataPath], + ], error); +} +let isNewProfile = false; +try { + if (fs.existsSync(txEnv.profilePath)) { + ensureProfileStructure(); + } else { + setupProfile(); + isNewProfile = true; + } +} catch (error) { + fatalError.Boot(2, [ + `Failed to check or create the txAdmin profile folder.`, + ['Data Path', txHostConfig.dataPath], + ['Profile Name', txEnv.profileName], + ['Profile Path', txEnv.profilePath], + ], error); +} +if (isNewProfile && txEnv.profileName !== 'default') { + console.log(`Profile path: ${txEnv.profilePath}`); +} + + +//Start txAdmin (have fun 😀) +try { + bootTxAdmin(); +} catch (error) { + fatalError.Boot(3, 'Failed to start txAdmin.', error); +} + + +//Freeze detector - starts after 10 seconds due to the initial bootup lag +const bootGracePeriod = 15_000; +const loopInterval = 500; +const loopElapsedLimit = 2_000; +setTimeout(() => { + let hdTimer = Date.now(); + setInterval(() => { + const now = Date.now(); + if (now - hdTimer > loopElapsedLimit) { + console.majorMultilineError([ + 'Major VPS freeze/lag detected!', + 'THIS IS NOT AN ERROR CAUSED BY TXADMIN!', + ]); + } + hdTimer = now; + }, loopInterval); +}, bootGracePeriod); diff --git a/core/lib/MemCache.ts b/core/lib/MemCache.ts new file mode 100644 index 0000000..40dd93a --- /dev/null +++ b/core/lib/MemCache.ts @@ -0,0 +1,47 @@ +import { cloneDeep } from 'lodash-es'; + +export default class MemCache { + public readonly ttl: number; + public dataTimestamp: number | undefined; + private data: T | undefined; + + constructor(ttlSeconds = 60) { + this.ttl = ttlSeconds * 1000; //converting to ms + } + + /** + * Checks if the data is still valid or wipes it + */ + isValid() { + if (this.dataTimestamp === undefined) return false; + if (this.dataTimestamp < Date.now() - this.ttl) { + this.dataTimestamp = undefined; + this.data = undefined; + return false; + } + return true; + } + + /** + * Sets the cache + */ + set(data: T) { + this.dataTimestamp = Date.now(); + this.data = data; + } + + /** + * Returns the cache if valid, or undefined + */ + get() { + if (this.dataTimestamp === undefined || this.data === undefined) { + return undefined; + } + + if (this.isValid()) { + return cloneDeep(this.data); + } else { + return undefined; + } + } +}; diff --git a/core/lib/console.test.ts b/core/lib/console.test.ts new file mode 100644 index 0000000..b4d191a --- /dev/null +++ b/core/lib/console.test.ts @@ -0,0 +1,48 @@ +import { suite, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { processStdioWriteRaw, processStdioEnsureEol } from './console'; + + +suite('processStdioWriteRaw & processStdioEnsureEol', () => { + let writeSpy: ReturnType; + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout as any, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('should write a non-newline string and then add a newline', () => { + processStdioWriteRaw("Hello"); + expect(writeSpy).toHaveBeenCalledWith("Hello"); + processStdioEnsureEol(); + expect(writeSpy).toHaveBeenCalledWith('\n'); + }); + + it('should write a string ending in newline without adding an extra one', () => { + processStdioWriteRaw("Hello\n"); + expect(writeSpy).toHaveBeenCalledWith("Hello\n"); + writeSpy.mockClear(); + processStdioEnsureEol(); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('should write Uint8Array without trailing newline and then add one', () => { + const buffer = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + processStdioWriteRaw(buffer); + expect(writeSpy).toHaveBeenCalledWith(buffer); + processStdioEnsureEol(); + expect(writeSpy).toHaveBeenCalledWith('\n'); + }); + + it('should write Uint8Array with trailing newline and not add an extra one', () => { + const newline = 10; + const buffer = new Uint8Array([72, 101, 108, 108, 111, newline]); // "Hello\n" + processStdioWriteRaw(buffer); + expect(writeSpy).toHaveBeenCalledWith(buffer); + writeSpy.mockClear(); + processStdioEnsureEol(); + expect(writeSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/core/lib/console.ts b/core/lib/console.ts new file mode 100644 index 0000000..9e16911 --- /dev/null +++ b/core/lib/console.ts @@ -0,0 +1,388 @@ +//NOTE: due to the monkey patching of the console, this should be imported before anything else +// which means in this file you cannot import anything from inside txAdmin to prevent cyclical dependencies +import { Console } from 'node:console'; +import { InspectOptions } from 'node:util'; +import { Writable } from 'node:stream'; +import path from 'node:path'; +import chalk, { ChalkInstance } from 'chalk'; +import slash from 'slash'; +import ErrorStackParser from 'error-stack-parser'; +import sourceMapSupport from 'source-map-support'; + + +//Buffer handler +//NOTE: the buffer will take between 64~72kb +const headBufferLimit = 8 * 1024; //4kb +const bodyBufferLimit = 64 * 1024; //64kb +const bodyTrimSliceSize = 8 * 1024; +const BUFFER_CUT_WARNING = chalk.bgRgb(255, 69, 0)('[!] The log body was sliced to prevent memory exhaustion. [!]'); +const DEBUG_COLOR = chalk.bgHex('#FF45FF'); +let headBuffer = ''; +let bodyBuffer = ''; + +const writeToBuffer = (chunk: string) => { + //if head not full yet + if (headBuffer.length + chunk.length < headBufferLimit) { + headBuffer += chunk; + return; + } + + //write to body and trim if needed + bodyBuffer += chunk; + if (bodyBuffer.length > bodyBufferLimit) { + let trimmedBody = bodyBuffer.slice(bodyTrimSliceSize - bodyBufferLimit); + trimmedBody = trimmedBody.substring(trimmedBody.indexOf('\n')); + bodyBuffer = `\n${BUFFER_CUT_WARNING}\n${trimmedBody}`; + } +} + +export const getLogBuffer = () => headBuffer + bodyBuffer; + + +//Variables +const header = 'tx'; +let stackPathAlias: { path: string, alias: string } | undefined; +let _txAdminVersion: string | undefined; +let _verboseFlag = false; + +export const setConsoleEnvData = ( + txAdminVersion: string, + txAdminResourcePath: string, + isDevMode: boolean, + isVerbose: boolean, +) => { + _txAdminVersion = txAdminVersion; + _verboseFlag = isVerbose; + if (isDevMode) { + sourceMapSupport.install(); + //for some reason when using sourcemap it ends up with core/core/ + stackPathAlias = { + path: txAdminResourcePath + '/core', + alias: '@monitor', + } + } else { + stackPathAlias = { + path: txAdminResourcePath, + alias: '@monitor', + } + } +} + + +/** + * STDOUT EOL helper + */ +let stdioEolPending = false; +export const processStdioWriteRaw = (buffer: Uint8Array | string) => { + if (!buffer.length) return; + const comparator = typeof buffer === 'string' ? '\n' : 10; + stdioEolPending = buffer[buffer.length - 1] !== comparator; + process.stdout.write(buffer); +} +export const processStdioEnsureEol = () => { + if (stdioEolPending) { + process.stdout.write('\n'); + stdioEolPending = false; + } +} + + +/** + * New console and streams + */ +const defaultStream = new Writable({ + decodeStrings: true, + defaultEncoding: 'utf8', + highWaterMark: 64 * 1024, + write(chunk, encoding, callback) { + writeToBuffer(chunk) + process.stdout.write(chunk); + callback(); + }, +}); +const verboseStream = new Writable({ + decodeStrings: true, + defaultEncoding: 'utf8', + highWaterMark: 64 * 1024, + write(chunk, encoding, callback) { + writeToBuffer(chunk) + if (_verboseFlag) process.stdout.write(chunk); + callback(); + }, +}); +const defaultConsole = new Console({ + //@ts-ignore some weird change from node v16 to v22, check after update + stdout: defaultStream, + stderr: defaultStream, + colorMode: true, +}); +const verboseConsole = new Console({ + //@ts-ignore some weird change from node v16 to v22, check after update + stdout: verboseStream, + stderr: verboseStream, + colorMode: true, +}); + + +/** + * Returns current ts in h23 format + * FIXME: same thing as utils/misc.ts getTimeHms + */ +export const getTimestamp = () => (new Date).toLocaleString( + undefined, + { timeStyle: 'medium', hourCycle: 'h23' } +); + + +/** + * Generated the colored log prefix (ts+tags) + */ +export const genLogPrefix = (currContext: string, color: ChalkInstance) => { + return color.black(`[${getTimestamp()}][${currContext}]`); +} + + +//Dir helpers +const cleanPath = (x: string) => slash(path.normalize(x)); +const ERR_STACK_PREFIX = chalk.redBright(' => '); +const DIVIDER_SIZE = 60; +const DIVIDER_CHAR = '='; +const DIVIDER = DIVIDER_CHAR.repeat(DIVIDER_SIZE); +const DIR_DIVIDER = chalk.cyan(DIVIDER); +const specialsColor = chalk.rgb(255, 228, 181).italic; +const lawngreenColor = chalk.rgb(124, 252, 0); +const orangeredColor = chalk.rgb(255, 69, 0); + + +/** + * Parses an error and returns string with prettified error and stack + * The stack filters out node modules and aliases monitor folder + */ +const getPrettyError = (error: Error, multilineError?: boolean) => { + const out: string[] = []; + const prefixStr = `[${getTimestamp()}][tx]`; + let prefixColor = chalk.redBright; + let nameColor = chalk.redBright; + if (error.name === 'ExperimentalWarning') { + prefixColor = chalk.bgYellow.black; + nameColor = chalk.yellowBright; + } else if (multilineError) { + prefixColor = chalk.bgRed.black; + } + const prefix = prefixColor(prefixStr) + ' '; + + //banner + out.push(prefix + nameColor(`${error.name}: `) + error.message); + if ('type' in error) out.push(prefix + nameColor('Type:') + ` ${error.type}`); + if ('code' in error) out.push(prefix + nameColor('Code:') + ` ${error.code}`); + + //stack + if (typeof error.stack === 'string') { + const stackPrefix = multilineError ? prefix : ERR_STACK_PREFIX; + try { + for (const line of ErrorStackParser.parse(error)) { + if (line.fileName && line.fileName.startsWith('node:')) continue; + let outPath = cleanPath(line.fileName ?? 'unknown'); + if(stackPathAlias){ + outPath = outPath.replace(stackPathAlias.path, stackPathAlias.alias); + } + const outPos = chalk.blueBright(`${line.lineNumber}:${line.columnNumber}`); + const outName = chalk.yellowBright(line.functionName || ''); + if (!outPath.startsWith('@monitor/core')) { + out.push(chalk.dim(`${stackPrefix}${outPath} > ${outPos} > ${outName}`)); + } else { + out.push(`${stackPrefix}${outPath} > ${outPos} > ${outName}`); + } + } + } catch (error) { + out.push(`${prefix} Unnable to parse error stack.`); + } + } else { + out.push(`${prefix} Error stack not available.`); + } + return out.join('\n'); +} + + +/** + * Drop-in replacement for console.dir + */ +const dirHandler = (data: any, options?: TxInspectOptions, consoleInstance?: Console) => { + if (!consoleInstance) consoleInstance = defaultConsole; + + if (data instanceof Error) { + consoleInstance.log(getPrettyError(data, options?.multilineError)); + if (!options?.multilineError) consoleInstance.log(); + } else { + consoleInstance.log(DIR_DIVIDER); + if (data === undefined) { + consoleInstance.log(specialsColor('> undefined')); + } else if (data === null) { + consoleInstance.log(specialsColor('> null')); + } else if (data instanceof Promise) { + consoleInstance.log(specialsColor('> Promise')); + } else if (typeof data === 'boolean') { + consoleInstance.log(data ? lawngreenColor('true') : orangeredColor('false')); + } else { + consoleInstance.dir(data, options); + } + consoleInstance.log(DIR_DIVIDER); + } +} + +type TxInspectOptions = InspectOptions & { + multilineError?: boolean; +} + + +/** + * Cleans the terminal + */ +export const cleanTerminal = () => { + process.stdout.write('.\n'.repeat(80) + '\x1B[2J\x1B[H'); +} + +/** + * Sets terminal title + */ +export const setTTYTitle = (title?: string) => { + const txVers = _txAdminVersion ? `txAdmin v${_txAdminVersion}` : 'txAdmin'; + const out = title ? `${title} - txAdmin` : txVers; + process.stdout.write(`\x1B]0;${out}\x07`); +} + + +/** + * Generates a custom log function with custom context and specific Console + */ +const getLogFunc = ( + currContext: string, + color: ChalkInstance, + consoleInstance?: Console, +): LogFunction => { + return (message?: any, ...optParams: any) => { + if (!consoleInstance) consoleInstance = defaultConsole; + const prefix = genLogPrefix(currContext, color); + if (typeof message === 'string') { + return consoleInstance.log.call(null, `${prefix} ${message}`, ...optParams); + } else { + return consoleInstance.log.call(null, prefix, message, ...optParams); + } + } +} + +//Reused types +type LogFunction = typeof Console.prototype.log; +type DirFunction = (data: any, options?: TxInspectOptions) => void; +interface TxBaseLogTypes { + debug: LogFunction; + log: LogFunction; + ok: LogFunction; + warn: LogFunction; + error: LogFunction; + dir: DirFunction; +} + + +/** + * Factory for console.log drop-ins + */ +const consoleFactory = (ctx?: string, subCtx?: string): CombinedConsole => { + const currContext = [header, ctx, subCtx].filter(x => x).join(':'); + const baseLogs: TxBaseLogTypes = { + debug: getLogFunc(currContext, DEBUG_COLOR), + log: getLogFunc(currContext, chalk.bgBlue), + ok: getLogFunc(currContext, chalk.bgGreen), + warn: getLogFunc(currContext, chalk.bgYellow), + error: getLogFunc(currContext, chalk.bgRed), + dir: (data: any, options?: TxInspectOptions & {}) => dirHandler.call(null, data, options), + }; + + return { + ...defaultConsole, + ...baseLogs, + tag: (subCtx: string) => consoleFactory(ctx, subCtx), + multiline: (text: string | string[], color: ChalkInstance) => { + if (!Array.isArray(text)) text = text.split('\n'); + const prefix = genLogPrefix(currContext, color); + for (const line of text) { + defaultConsole.log(prefix, line); + } + }, + + /** + * Prints a multiline error message with a red background + * @param text + */ + majorMultilineError: (text: string | (string | null)[]) => { + if (!Array.isArray(text)) text = text.split('\n'); + const prefix = genLogPrefix(currContext, chalk.bgRed); + defaultConsole.log(prefix, DIVIDER); + for (const line of text) { + if (line) { + defaultConsole.log(prefix, line); + } else { + defaultConsole.log(prefix, DIVIDER); + } + } + defaultConsole.log(prefix, DIVIDER); + }, + + //Returns a set of log functions that will be executed after a delay + defer: (ms = 250) => ({ + debug: (...args) => setTimeout(() => baseLogs.debug(...args), ms), + log: (...args) => setTimeout(() => baseLogs.log(...args), ms), + ok: (...args) => setTimeout(() => baseLogs.ok(...args), ms), + warn: (...args) => setTimeout(() => baseLogs.warn(...args), ms), + error: (...args) => setTimeout(() => baseLogs.error(...args), ms), + dir: (...args) => setTimeout(() => baseLogs.dir(...args), ms), + }), + + //Log functions that will output tothe verbose stream + verbose: { + debug: getLogFunc(currContext, DEBUG_COLOR, verboseConsole), + log: getLogFunc(currContext, chalk.bgBlue, verboseConsole), + ok: getLogFunc(currContext, chalk.bgGreen, verboseConsole), + warn: getLogFunc(currContext, chalk.bgYellow, verboseConsole), + error: getLogFunc(currContext, chalk.bgRed, verboseConsole), + dir: (data, options) => dirHandler.call(null, data, options, verboseConsole) + }, + + //Verbosity getter and explicit setter + get isVerbose() { + return _verboseFlag + }, + setVerbose: (state: boolean) => { + _verboseFlag = !!state; + }, + + //Consts used by the fatalError util + DIVIDER, + DIVIDER_CHAR, + DIVIDER_SIZE, + }; +}; +export default consoleFactory; + +interface CombinedConsole extends TxConsole, Console { + dir: DirFunction; +} + +export interface TxConsole extends TxBaseLogTypes { + tag: (subCtx: string) => TxConsole; + multiline: (text: string | string[], color: ChalkInstance) => void; + majorMultilineError: (text: string | (string | null)[]) => void; + defer: (ms?: number) => TxBaseLogTypes; + verbose: TxBaseLogTypes; + readonly isVerbose: boolean; + setVerbose: (state: boolean) => void; + DIVIDER: string; + DIVIDER_CHAR: string; + DIVIDER_SIZE: number; +} + + +/** + * Replaces the global console with the new one + */ +global.console = consoleFactory(); diff --git a/core/lib/diagnostics.ts b/core/lib/diagnostics.ts new file mode 100644 index 0000000..e7b9a1b --- /dev/null +++ b/core/lib/diagnostics.ts @@ -0,0 +1,331 @@ +const modulename = 'WebServer:DiagnosticsFuncs'; +import os from 'node:os'; +import humanizeDuration, { HumanizerOptions } from 'humanize-duration'; +import got from '@lib/got'; +import getOsDistro from '@lib/host/getOsDistro.js'; +import getHostUsage from '@lib/host/getHostUsage'; +import pidUsageTree from '@lib/host/pidUsageTree.js'; +import { txEnv, txHostConfig } from '@core/globalData'; +import si from 'systeminformation'; +import consoleFactory from '@lib/console'; +import { parseFxserverVersion } from '@lib/fxserver/fxsVersionParser'; +import { getHeapStatistics } from 'node:v8'; +import bytes from 'bytes'; +import { msToDuration } from './misc'; +const console = consoleFactory(modulename); + + +//Helpers +const MEGABYTE = 1024 * 1024; +type HostStaticDataType = { + nodeVersion: string, + username: string, + osDistro: string, + cpu: { + manufacturer: string; + brand: string; + speedMin: number; + speedMax: number; + physicalCores: number; + cores: number; + clockWarning: string; + }, +}; +type HostDynamicDataType = { + cpuUsage: number; + memory: { + usage: number; + used: number; + total: number; + }, +}; +type HostDataReturnType = { + static: HostStaticDataType, + dynamic?: HostDynamicDataType +} | { error: string }; +let _hostStaticDataCache: HostStaticDataType; + + +/** + * Gets the Processes Data. + * FIXME: migrate to use gwmi on windows by default + */ +export const getProcessesData = async () => { + type ProcDataType = { + pid: number; + ppid: number | string; + name: string; + cpu: number; + memory: number; + order: number; + } + const procList: ProcDataType[] = []; + try { + const txProcessId = process.pid; + const processes = await pidUsageTree(txProcessId); + + //NOTE: Cleaning invalid proccesses that might show up in Linux + Object.keys(processes).forEach((pid) => { + if (processes[pid] === null) delete processes[pid]; + }); + + //Foreach PID + Object.keys(processes).forEach((pid) => { + const curr = processes[pid]; + const currPidInt = parseInt(pid); + + //Define name and order + let procName; + let order = curr.timestamp || 1; + if (currPidInt === txProcessId) { + procName = 'txAdmin (inside FXserver)'; + order = 0; //forcing order because all process can start at the same second + } else if (curr.memory <= 10 * MEGABYTE) { + procName = 'FXServer MiniDump'; + } else { + procName = 'FXServer'; + } + + procList.push({ + pid: currPidInt, + ppid: (curr.ppid === txProcessId) ? `${txProcessId} (txAdmin)` : curr.ppid, + name: procName, + cpu: curr.cpu, + memory: curr.memory / MEGABYTE, + order: order, + }); + }); + } catch (error) { + if ((error as any).code = 'ENOENT') { + console.error('Failed to get processes tree usage data.'); + if (txEnv.isWindows) { + console.error('This is probably because the `wmic` command is not available in your system.'); + console.error('If you are on Windows 11 or Windows Server 2025, you can enable it in the "Windows Features" settings.'); + } else { + console.error('This is probably because the `ps` command is not available in your system.'); + console.error('This command is part of the `procps` package in most Linux distributions.'); + } + } else { + console.error('Error getting processes tree usage data.'); + console.verbose.dir(error); + } + } + + //Sort procList array + procList.sort((a, b) => a.order - b.order); + + return procList; +} + + +/** + * Gets the FXServer Data. + */ +export const getFXServerData = async () => { + //Check runner child state + const childState = txCore.fxRunner.child; + if (!childState?.isAlive) { + return { error: 'Server Offline' }; + } + if (!childState?.netEndpoint) { + return { error: 'Server is has no network endpoint' }; + } + + //Preparing request + const requestOptions = { + url: `http://${childState.netEndpoint}/info.json`, + maxRedirects: 0, + timeout: { request: 1500 }, + retry: { limit: 0 }, + }; + + //Making HTTP Request + let infoData: Record; + try { + infoData = await got.get(requestOptions).json(); + } catch (error) { + console.warn('Failed to get FXServer information.'); + console.verbose.dir(error); + return { error: 'Failed to retrieve FXServer data.
The server must be online for this operation.
Check the terminal for more information (if verbosity is enabled)' }; + } + + //Processing result + try { + const ver = parseFxserverVersion(infoData.server); + return { + error: false, + statusColor: 'success', + status: ' ONLINE ', + version: ver.valid ? `${ver.platform}:${ver.branch}:${ver.build}` : `${ver.platform ?? 'unknown'}:INVALID`, + versionMismatch: (ver.build !== txEnv.fxsVersion), + resources: infoData.resources.length, + onesync: (infoData.vars && infoData.vars.onesync_enabled === 'true') ? 'enabled' : 'disabled', + maxClients: (infoData.vars && infoData.vars.sv_maxClients) ? infoData.vars.sv_maxClients : '--', + txAdminVersion: (infoData.vars && infoData.vars['txAdmin-version']) ? infoData.vars['txAdmin-version'] : '--', + }; + } catch (error) { + console.warn('Failed to process FXServer information.'); + console.verbose.dir(error); + return { error: 'Failed to process FXServer data.
Check the terminal for more information (if verbosity is enabled)' }; + } +} + + + +/** + * Gets the Host Data. + */ +export const getHostData = async (): Promise => { + //Get and cache static information + if (!_hostStaticDataCache) { + //This errors out on pterodactyl egg + let osUsername = 'unknown'; + try { + const userInfo = os.userInfo(); + osUsername = userInfo.username; + } catch (error) { } + + try { + const cpuStats = await si.cpu(); + const cpuSpeed = cpuStats.speedMin ?? cpuStats.speed; + + //TODO: move this to frontend + let clockWarning = ''; + if (cpuStats.cores < 8) { + if (cpuSpeed <= 2.4) { + clockWarning = ' VERY SLOW! '; + } else if (cpuSpeed < 3.0) { + clockWarning = ' SLOW '; + } + } + + _hostStaticDataCache = { + nodeVersion: process.version, + username: osUsername, + osDistro: await getOsDistro(), + cpu: { + manufacturer: cpuStats.manufacturer, + brand: cpuStats.brand, + speedMin: cpuSpeed, + speedMax: cpuStats.speedMax, + physicalCores: cpuStats.physicalCores, + cores: cpuStats.cores, + clockWarning, + } + } + } catch (error) { + console.error('Error getting Host static data.'); + console.verbose.dir(error); + return { error: 'Failed to retrieve host static data.
Check the terminal for more information (if verbosity is enabled)' }; + } + } + + //Get dynamic info (mem/cpu usage) and prepare output + try { + const stats = await Promise.race([ + getHostUsage(), + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2500)) + ]); + if (stats) { + return { + static: _hostStaticDataCache, + dynamic: { + cpuUsage: stats.cpu.usage, + memory: { + usage: stats.memory.usage, + used: stats.memory.used, + total: stats.memory.total, + } + } + }; + } else { + return { + static: _hostStaticDataCache, + }; + } + } catch (error) { + console.error('Error getting Host dynamic data.'); + console.verbose.dir(error); + return { error: 'Failed to retrieve host dynamic data.
Check the terminal for more information (if verbosity is enabled)' }; + } +} + + +/** + * Gets the Host Static Data from cache. + */ +export const getHostStaticData = (): HostStaticDataType => { + if (!_hostStaticDataCache) { + throw new Error(`hostStaticDataCache not yet ready`); + } + return _hostStaticDataCache; +} + + +/** + * Gets txAdmin Data + */ +export const getTxAdminData = async () => { + const stats = txCore.metrics.txRuntime; //shortcut + const memoryUsage = getHeapStatistics(); + + let hostApiTokenState = 'not configured'; + if (txHostConfig.hostApiToken === 'disabled') { + hostApiTokenState = 'disabled'; + } else if (txHostConfig.hostApiToken) { + hostApiTokenState = 'configured'; + } + + const defaultFlags = Object.entries(txHostConfig.defaults).filter(([k, v]) => Boolean(v)).map(([k, v]) => k); + return { + //Stats + uptime: msToDuration(process.uptime() * 1000), + databaseFileSize: bytes(txCore.database.fileSize), + txHostConfig: { + ...txHostConfig, + dataSubPath: undefined, + hostApiToken: hostApiTokenState, + defaults: defaultFlags, + }, + txEnv: { + ...txEnv, + adsData: undefined, + }, + monitor: { + hbFails: { + http: stats.monitorStats.healthIssues.http, + fd3: stats.monitorStats.healthIssues.fd3, + }, + restarts: { + bootTimeout: stats.monitorStats.restartReasons.bootTimeout, + close: stats.monitorStats.restartReasons.close, + heartBeat: stats.monitorStats.restartReasons.heartBeat, + healthCheck: stats.monitorStats.restartReasons.healthCheck, + both: stats.monitorStats.restartReasons.both, + } + }, + performance: { + banCheck: stats.banCheckTime.resultSummary('ms').summary, + whitelistCheck: stats.whitelistCheckTime.resultSummary('ms').summary, + playersTableSearch: stats.playersTableSearchTime.resultSummary('ms').summary, + historyTableSearch: stats.historyTableSearchTime.resultSummary('ms').summary, + databaseSave: stats.databaseSaveTime.resultSummary('ms').summary, + perfCollection: stats.perfCollectionTime.resultSummary('ms').summary, + }, + logger: { + storageSize: (await txCore.logger.getStorageSize()).total, + statusAdmin: txCore.logger.admin.getUsageStats(), + statusFXServer: txCore.logger.fxserver.getUsageStats(), + statusServer: txCore.logger.server.getUsageStats(), + }, + memoryUsage: { + heap_used: bytes(memoryUsage.used_heap_size), + heap_limit: bytes(memoryUsage.heap_size_limit), + heap_pct: (memoryUsage.heap_size_limit > 0) + ? (memoryUsage.used_heap_size / memoryUsage.heap_size_limit * 100).toFixed(2) + : 0, + physical: bytes(memoryUsage.total_physical_size), + peak_malloced: bytes(memoryUsage.peak_malloced_memory), + }, + }; +} diff --git a/core/lib/fatalError.ts b/core/lib/fatalError.ts new file mode 100644 index 0000000..036ee75 --- /dev/null +++ b/core/lib/fatalError.ts @@ -0,0 +1,90 @@ + +import chalk from "chalk"; +import consoleFactory from "./console"; +import quitProcess from "./quitProcess"; +const console = consoleFactory(); + +type ErrorLineSkipType = null | undefined | false; +type ErrorLineType = string | [desc: string, value: any] | ErrorLineSkipType; +type ErrorMsgType = ErrorLineType | ErrorLineType[]; + +const padStartEnd = (str: string): string => { + str = ` ${str} `; + const padStart = Math.ceil((console.DIVIDER_SIZE + str.length) / 2); + return str.padStart(padStart, '-').padEnd(console.DIVIDER_SIZE, '-'); +} + +const printSingleLine = (line: ErrorLineType): void => { + if (Array.isArray(line)) { + if (line.length === 2 && typeof line[0] === 'string') { + let value = typeof line[1] === 'string' ? line[1] : String(line[1]); + console.error(`${line[0]}: ${chalk.dim(value)}`); + } else { + console.error(JSON.stringify(line)); + } + } else if (typeof line === 'string') { + console.error(line); + } +} + +function fatalError(code: number, msg: ErrorMsgType, err?: any): never { + console.error(console.DIVIDER); + console.error(chalk.inverse( + padStartEnd(`FATAL ERROR: E${code}`) + )); + console.error(console.DIVIDER); + if (Array.isArray(msg)) { + for (const line of msg) { + printSingleLine(line); + } + } else { + printSingleLine(msg); + } + if (err) { + console.error('-'.repeat(console.DIVIDER_SIZE)); + console.dir(err, { multilineError: true }); + } + console.error(console.DIVIDER); + console.error(chalk.inverse( + padStartEnd('For support: https://discord.gg/txAdmin') + )); + console.error(console.DIVIDER); + quitProcess(code); +} + + +/* +NOTE: Going above 1000 to avoid collision with default nodejs error codes +ref: https://nodejs.org/docs/latest-v22.x/api/process.html#exit-codes + +1000 - global data +2000 - boot + 2001 - txdata + 2002 - setup profile throw + 2003 - boot throw + 2010 - expired + 2011 - expired cron + 2022 - txCore placeholder getter error + 2023 - txCore placeholder setter error + +5100 - config store +5300 - admin store +5400 - fxrunner +5600 - database +5700 - stats txruntime +5800 - webserver +*/ + + +fatalError.GlobalData = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(1000 + code, msg, err); +fatalError.Boot = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(2000 + code, msg, err); + +fatalError.ConfigStore = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5100 + code, msg, err); +fatalError.Translator = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5200 + code, msg, err); +fatalError.AdminStore = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5300 + code, msg, err); +// fatalError.FxRunner = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5400 + code, msg, err); +fatalError.Database = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5600 + code, msg, err); +fatalError.StatsTxRuntime = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5700 + code, msg, err); +fatalError.WebServer = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5800 + code, msg, err); + +export default fatalError; diff --git a/core/lib/fs.ts b/core/lib/fs.ts new file mode 100644 index 0000000..e20c160 --- /dev/null +++ b/core/lib/fs.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; +import { txEnv } from '@core/globalData'; +import path from 'node:path'; + + +/** + * Check if its possible to create a file in a folder + */ +export const canWriteToPath = async (targetPath: string) => { + try { + await fsp.access(path.dirname(targetPath), fs.constants.W_OK); + return true; + } catch (error) { + return false; + } +} + + +/** + * Returns an array of directory entries (files and directories) from the specified root path. + */ +export const getPathContent = async (root: string, filter?: (entry: Dirent) => boolean) => { + const stats = await fsp.stat(root); + if (!stats.isDirectory()) { + throw new Error(`Path '${root}' is not a directory`); + } + const allEntries = await fsp.readdir(root, { withFileTypes: true }); + return allEntries.filter((entry) => ( + (entry.isFile() || entry.isDirectory()) + && filter ? filter(entry) : true + )); +} + + +/** + * Returns an array of file entries from the specified root path. + */ +export const getPathFiles = async (root: string, filter?: (entry: Dirent) => boolean) => { + const entries = await getPathContent(root, filter); + return entries.filter((entry) => entry.isFile()); +} + + +/** + * Returns an array of subdirectory entries from the specified root path. + */ +export const getPathSubdirs = async (root: string, filter?: (entry: Dirent) => boolean) => { + const entries = await getPathContent(root, filter); + return entries.filter((entry) => entry.isDirectory()); +} + + +/** + * Generates a user-friendly markdown error message for filesystem operations. + * Handles common cases like Windows paths on Linux and permission issues. + */ +export const getFsErrorMdMessage = (error: any, targetPath: string) => { + if(typeof error.message !== 'string') return 'unknown error'; + + if (!txEnv.isWindows && /^[a-zA-Z]:[\\/]/.test(targetPath)) { + return `Looks like you're using a Windows path on a Linux server.\nThis likely means you are attempting to use a path from your computer on a remote server.\nIf you want to use your local files, you will first need to upload them to the server.`; + } else if (error.message?.includes('ENOENT')) { + return `The path provided does not exist:\n\`${targetPath}\``; + } else if (error.message?.includes('EACCES') || error.message?.includes('EPERM')) { + return `The path provided is not accessible:\n\`${targetPath}\``; + } + + return error.message as string; +} diff --git a/core/lib/fxserver/fxsConfigHelper.ts b/core/lib/fxserver/fxsConfigHelper.ts new file mode 100644 index 0000000..fee029d --- /dev/null +++ b/core/lib/fxserver/fxsConfigHelper.ts @@ -0,0 +1,776 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import isLocalhost from 'is-localhost-ip'; +import { txHostConfig } from '@core/globalData'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(); + + +/** + * Detect the dominant newline character of a string. + * Extracted from https://www.npmjs.com/package/detect-newline + */ +const detectNewline = (str: string) => { + if (typeof str !== 'string') { + throw new TypeError('Expected a string'); + } + + const newlines = str.match(/(?:\r?\n)/g) || []; + + if (newlines.length === 0) { + return; + } + + const crlf = newlines.filter((newline) => newline === '\r\n').length; + const lf = newlines.length - crlf; + + return crlf > lf ? '\r\n' : '\n'; +}; + + +/** + * Helper function to store commands + */ +class Command { + readonly command: string; + readonly args: string[]; + readonly file: string; + readonly line: number; + + constructor(tokens: string[], filePath: string, fileLine: number) { + if (!Array.isArray(tokens) || tokens.length < 1) { + throw new Error('Invalid command format'); + } + if (typeof tokens[0] === 'string' && tokens[0].length) { + this.command = tokens[0].toLocaleLowerCase(); + } else { + this.command = 'invalid_empty_command'; + } + this.args = tokens.slice(1); + this.file = filePath; + this.line = fileLine; + } + + //Kinda confusing name, but it returns the value of a set if it's for that ne var + isConvarSetterFor(varname: string) { + if ( + ['set', 'sets', 'setr'].includes(this.command) + && this.args.length === 2 + && this.args[0].toLowerCase() === varname.toLowerCase() + ) { + return this.args[1]; + } + + if ( + this.command === varname.toLowerCase() + && this.args.length === 1 + ) { + return this.args[0]; + } + + return false; + } +} + + +/** + * Helper function to store exec errors + */ +class ExecRecursionError { + constructor(readonly file: string, readonly message: string, readonly line: number) { } +} + +/** + * Helper class to store file TODOs, errors and warnings + */ +class FilesInfoList { + readonly store: Record = {}; + + add(file: string, line: number | false, msg: string) { + if (Array.isArray(this.store[file])) { + this.store[file].push([line, msg]); + } else { + this.store[file] = [[line, msg]]; + } + } + count() { + return Object.keys(this.store).length; + } + toJSON() { + return this.store; + } + toMarkdown(hasHostConfig = false) { + const files = Object.keys(this.store); + if (!files) return null; + + const msgLines = []; + for (const file of files) { + const fileInfos = this.store[file]; + msgLines.push(`\`${file}\`:`); + for (const [line, msg] of fileInfos) { + const linePrefix = line ? `Line ${line}: ` : ''; + const indentedMsg = msg.replaceAll(/\n\t/gm, '\n\t- '); + msgLines.push(`- ${linePrefix}${indentedMsg}`); + } + } + if (hasHostConfig) { + msgLines.push(''); //blank line so the warning doesn't join the list + msgLines.push(`**Some of the configuration above is controlled by ${txHostConfig.sourceName}.**`); + } + return msgLines.join('\n'); + } +} + + +/** + * Returns the first likely server.cfg given a server data path, or false + */ +export const findLikelyCFGPath = (serverDataPath: string) => { + const commonCfgFileNames = [ + 'server.cfg', + 'server.cfg.txt', + 'server.cfg.cfg', + 'server.txt', + 'server', + ]; + + for (const cfgFileName of commonCfgFileNames) { + const absoluteCfgPath = path.join(serverDataPath, cfgFileName); + try { + if (fs.lstatSync(absoluteCfgPath).isFile()) { + return cfgFileName; + } + } catch (error) { } + } + return false; +} + + +/** + * Returns the absolute path of the given CFG Path + */ +export const resolveCFGFilePath = (cfgPath: string, dataPath: string) => { + return (path.isAbsolute(cfgPath)) ? path.normalize(cfgPath) : path.resolve(dataPath, cfgPath); +}; + + +/** + * Reads CFG Path and return the file contents, or throw error if: + * - the path is not valid (must be absolute) + * - cannot read the file data + */ +export const readRawCFGFile = async (cfgPath: string) => { + //Validating if the path is absolute + if (!path.isAbsolute(cfgPath)) { + throw new Error('File path must be absolute.'); + } + + //Validating file existence + if (!fs.existsSync(cfgPath)) { + throw new Error("File doesn't exist or its unreadable."); + } + + //Validating if its actually a file + if (!fs.lstatSync(cfgPath).isFile()) { + throw new Error("File doesn't exist or its unreadable. Make sure to include the CFG file in the path, and not just the directory that contains it."); + } + + //Reading file + try { + return await fsp.readFile(cfgPath, 'utf8'); + } catch (error) { + throw new Error('Cannot read CFG file.'); + } +}; + + +/** + * Parse a cfg/console line and return an array of commands with tokens. + * Notable difference: we don't handle inline block comment + * Original Line Parser: + * fivem/code/client/citicore/console/Console.cpp > ProgramArguments Tokenize + */ +export const readLineCommands = (input: string) => { + let inQuote = false; + let inEscape = false; + const prevCommands = []; + let currCommand = []; + let currToken = ''; + for (let i = 0; i < input.length; i++) { + if (inEscape) { + if (input[i] === '"' || input[i] === '\\') { + currToken += input[i]; + } + inEscape = false; + continue; + } + + if (!currToken.length) { + if ( + input.slice(i, i + 2) === '//' + || input[i] === '#' + ) { + break; + } + } + + if (!inQuote && input.charCodeAt(i) <= 32) { + if (currToken.length) { + currCommand.push(currToken); + currToken = ''; + } + continue; + } + + if (input[i] === '"') { + if (inQuote) { + currCommand.push(currToken); + currToken = ''; + inQuote = false; + } else { + inQuote = true; + } + continue; + } + + if (input[i] === '\\') { + inEscape = true; + continue; + } + + if (!inQuote && input[i] === ';') { + if (currToken.length) { + currCommand.push(currToken); + currToken = ''; + } + prevCommands.push(currCommand); + currCommand = []; + continue; + }; + + currToken += input[i]; + } + if (currToken.length) { + currCommand.push(currToken); + } + prevCommands.push(currCommand); + + return prevCommands; +}; +//NOTE: tests for the parser above +// import chalk from 'chalk'; +// const testCommands = [ +// ' \x1B ONE_ARG_WITH_SPACE "part1 part2"', +// 'TWO_ARGS arg1 arg2', +// 'ONE_ARG_WITH_SPACE_SEMICOLON "arg mid;cut"', +// 'ESCAPED_QUOTE "aa\\"bb"', +// 'ESCAPED_ESCAPE "aa\\\\bb"', +// 'ESCAPED_X "aa\\xbb"', +// // 'NO_CLOSING_QUOTE "aa', +// // 'SHOW_AB_C aaa#bbb ccc', +// // 'COMMENT //anything noshow', +// 'COMMENT #anything noshow', +// 'noshow2', +// ]; +// const parsed = readLineCommands(testCommands.join(';')); +// for (const commandTokens of parsed) { +// console.log(`${commandTokens[0]}:`); +// commandTokens.slice(1).forEach((token) => { +// console.log(chalk.inverse(token)); +// }); +// console.log('\n'); +// } + + +/** + * Recursively parse server.cfg files losely based on the FXServer original parser. + * Notable differences: we have recursivity depth limit, and no json parsing + * Original CFG (console) parser: + * fivem/code/client/citicore/console/Console.cpp > Context::ExecuteBuffer + * + * FIXME: support `@resource/whatever.cfg` syntax + */ +export const parseRecursiveConfig = async ( + cfgInputString: string | null, //cfg string, or null to load from file + cfgAbsolutePath: string, + serverDataPath: string, + stack?: string[] +) => { + if (typeof cfgInputString !== 'string' && cfgInputString !== null) { + throw new Error('cfgInputString expected to be string or null'); + } + + // Ensure safe stack + const MAX_DEPTH = 5; + if (!Array.isArray(stack)) { + stack = []; + } else if (stack.length >= MAX_DEPTH) { + throw new Error(`cfg 'exec' command depth above ${MAX_DEPTH}`); + } else if (stack.includes(cfgAbsolutePath)) { + throw new Error(`cfg cyclical 'exec' command detected to file ${cfgAbsolutePath}`); //should block + } + stack.push(cfgAbsolutePath); + + // Read raw config and split lines + const cfgData = cfgInputString ?? await readRawCFGFile(cfgAbsolutePath); + const cfgLines = cfgData.split('\n'); + + // Parse CFG lines + const parsedCommands: (Command | ExecRecursionError)[] = []; + for (let i = 0; i < cfgLines.length; i++) { + const lineString = cfgLines[i].trim(); + const lineNumber = i + 1; + const lineCommands = readLineCommands(lineString); + + // For each command in that line + for (const cmdTokens of lineCommands) { + if (!cmdTokens.length) continue; + const cmdObject = new Command(cmdTokens, cfgAbsolutePath, lineNumber); + parsedCommands.push(cmdObject); + + // If exec command, process recursively then flatten the output + if (cmdObject.command === 'exec' && typeof cmdObject.args[0] === 'string') { + //FIXME: temporarily disable resoure references + if (!cmdObject.args[0].startsWith('@')) { + const recursiveCfgAbsolutePath = resolveCFGFilePath(cmdObject.args[0], serverDataPath); + try { + const extractedCommands = await parseRecursiveConfig(null, recursiveCfgAbsolutePath, serverDataPath, stack); + parsedCommands.push(...extractedCommands); + } catch (error) { + parsedCommands.push(new ExecRecursionError(cfgAbsolutePath, (error as Error).message, lineNumber)); + } + } + } + } + } + + stack.pop(); + return parsedCommands; +}; + +type EndpointsObjectType = Record + +/** + * Validates a list of parsed commands to return endpoints, errors, warnings and lines to comment out + */ +const validateCommands = async (parsedCommands: (ExecRecursionError | Command)[]) => { + const checkedInterfaces = new Map(); + let detectedGameName: string | undefined; + const requiredGameName = txHostConfig.forceGameName + ? txHostConfig.forceGameName === 'fivem' ? 'gta5' : 'rdr3' + : undefined; + + //To return + let hasHostConfigMessage = false; + let hasEndpointCommand = false; + const endpoints: EndpointsObjectType = {}; + const errors = new FilesInfoList(); + const warnings = new FilesInfoList(); + const toCommentOut = new FilesInfoList(); + + for (const cmd of parsedCommands) { + //In case of error + if (cmd instanceof ExecRecursionError) { + warnings.add(cmd.file, cmd.line, cmd.message); + continue; + } + + //Check for +set + if (['+set', '+setr', '+setr'].includes(cmd.command)) { + const msg = `Line ${cmd.line}: remove the '+' from '${cmd.command}', as this is not an launch parameter.`; + warnings.add(cmd.file, cmd.line, msg); + continue; + } + + //Check for start/stop/ensure txAdmin/txAdminClient/monitor + if ( + ['start', 'stop', 'ensure'].includes(cmd.command) + && cmd.args.length >= 1 + && ['txadmin', 'txadminclient', 'monitor'].includes(cmd.args[0].toLowerCase()) + ) { + toCommentOut.add( + cmd.file, + cmd.line, + 'you MUST NOT start/stop/ensure txadmin resources.', + ); + continue; + } + + //Check sv_maxClients against TXHOST config + const isMaxClientsString = cmd.isConvarSetterFor('sv_maxclients'); + if ( + txHostConfig.forceMaxClients + && isMaxClientsString + ) { + const maxClients = parseInt(isMaxClientsString); + if (maxClients > txHostConfig.forceMaxClients) { + hasHostConfigMessage = true; + errors.add( + cmd.file, + cmd.line, + `your 'sv_maxclients' MUST be <= ${txHostConfig.forceMaxClients}.` + ); + continue; + } + } + + //Check gamename against TXHOST config + const isGameNameString = cmd.isConvarSetterFor('gamename'); + if (isGameNameString && detectedGameName) { + errors.add( + cmd.file, + cmd.line, + `you already set the 'gamename' to '${detectedGameName}', please remove this line.` + ); + continue; + } + if ( + txHostConfig.forceGameName + && isGameNameString + ) { + detectedGameName = isGameNameString; + if (isGameNameString !== requiredGameName) { + hasHostConfigMessage = true; + errors.add( + cmd.file, + cmd.line, + `your 'gamename' MUST be '${requiredGameName}'.` + ); + continue; + } + } + + //Comment out any onesync sets + if (cmd.isConvarSetterFor('onesync')) { + toCommentOut.add( + cmd.file, + cmd.line, + 'onesync MUST only be set in the txAdmin settings page.', + ); + continue; + } + + //FIXME: add isConvarSetterFor for all "Settings page only" convars + + //Extract & process endpoint validity + if (cmd.command === 'endpoint_add_tcp' || cmd.command === 'endpoint_add_udp') { + hasEndpointCommand = true; + + //Validating args length + if (cmd.args.length !== 1) { + warnings.add( + cmd.file, + cmd.line, + `the \`endpoint_add_*\` commands MUST have exactly 1 argument (received ${cmd.args.length})` + ); + continue; + } + + //Extracting parts & validating format + const endpointsRegex = /^\[?(([0-9.]{7,15})|([a-z0-9:]{2,29}))\]?:(\d{1,5})$/gi; + const matches = [...cmd.args[0].matchAll(endpointsRegex)]; + if (!Array.isArray(matches) || !matches.length) { + errors.add( + cmd.file, + cmd.line, + `the \`${cmd.args[0]}\` is not in a valid \`ip:port\` format.` + ); + continue; + } + const [_matchedString, iface, ipv4, ipv6, portString] = matches[0]; + + //Checking if that interface is available to binding + let canBind = checkedInterfaces.get(iface); + if (typeof canBind === 'undefined') { + canBind = await isLocalhost(iface, true); + checkedInterfaces.set(iface, canBind); + } + if (canBind === false) { + errors.add( + cmd.file, + cmd.line, + `the \`${cmd.command}\` interface \`${iface}\` is not available for this host.` + ); + continue; + } + if (txHostConfig.netInterface && iface !== txHostConfig.netInterface) { + hasHostConfigMessage = true; + errors.add( + cmd.file, + cmd.line, + `the \`${cmd.command}\` interface MUST be \`${txHostConfig.netInterface}\`.` + ); + continue; + } + + //Validating port + const port = parseInt(portString); + if (port >= 40120 && port <= 40150) { + errors.add( + cmd.file, + cmd.line, + `the \`${cmd.command}\` port \`${port}\` is dedicated for txAdmin and CAN NOT be used for FXServer.` + ); + continue; + } + if (port === txHostConfig.txaPort) { + errors.add( + cmd.file, + cmd.line, + `the \`${cmd.command}\` port \`${port}\` is being used by txAdmin and CAN NOT be used for FXServer at the same time.` + ); + continue; + } + if (txHostConfig.fxsPort && port !== txHostConfig.fxsPort) { + hasHostConfigMessage = true; + errors.add( + cmd.file, + cmd.line, + `the \`${cmd.command}\` port MUST be \`${txHostConfig.fxsPort}\`.` + ); + continue; + } + + //Add to the endpoint list and check duplicity + const endpoint = (ipv4) ? `${ipv4}:${port}` : `[${ipv6}]:${port}`; + const protocol = (cmd.command === 'endpoint_add_tcp') ? 'tcp' : 'udp'; + if (typeof endpoints[endpoint] === 'undefined') { + endpoints[endpoint] = {}; + } + if (endpoints[endpoint][protocol]) { + errors.add( + cmd.file, + cmd.line, + `you CANNOT execute \`${cmd.command}\` twice for the interface \`${endpoint}\`.` + ); + continue; + } else { + endpoints[endpoint][protocol] = true; + } + } + } + + //Since gta5 is the default, we need to check TXHOST for redm + if (txHostConfig.forceGameName === 'redm' && detectedGameName !== 'rdr3') { + const initFile = parsedCommands[0]?.file ?? 'unknown'; + hasHostConfigMessage = true; + errors.add( + initFile, + false, + `your config MUST have a 'gamename' set to '${requiredGameName}'.` + ); + } + + return { + endpoints, + hasEndpointCommand, + hasHostConfigMessage, + errors, + warnings, + toCommentOut, + }; +}; + + +/** + * Process endpoints object, checks validity, and then returns a connection string + */ +const getConnectEndpoint = (endpoints: EndpointsObjectType, hasEndpointCommand: boolean) => { + if (!Object.keys(endpoints).length) { + const instruction = hasEndpointCommand + ? 'Please delete all \`endpoint_add_*\` lines and' + : 'Please' + const suggestedPort = txHostConfig.fxsPort ?? 30120; + const suggestedInterface = txHostConfig.netInterface ?? '0.0.0.0'; + const desidredEndpoint = `${suggestedInterface}:${suggestedPort}`; + const msg = [ + `Your config file does not specify a valid endpoints for FXServer to use. ${instruction} add the following to the start of the file:`, + `\t\`endpoint_add_tcp "${desidredEndpoint}"\``, + `\t\`endpoint_add_udp "${desidredEndpoint}"\``, + ].join('\n'); + throw new Error(msg); + } + const tcpudpEndpoint = Object.keys(endpoints).find((ep) => { + return endpoints[ep].tcp && endpoints[ep].udp; + }); + if (!tcpudpEndpoint) { + throw new Error('Your config file does not not contain a ip:port used in both `endpoint_add_tcp` and `endpoint_add_udp` commands. Players would not be able to connect.'); + } + + return tcpudpEndpoint.replace(/(0\.0\.0\.0|\[::\])/, '127.0.0.1'); +}; + + +/** + * Validates & ensures correctness in FXServer config file recursively. + * Used when trying to start server, or validate the server.cfg. + * Returns errors, warnings and connectEndpoint + */ +export const validateFixServerConfig = async (cfgPath: string, serverDataPath: string) => { + //Parsing FXServer config & going through each command + const cfgAbsolutePath = resolveCFGFilePath(cfgPath, serverDataPath); + const parsedCommands = await parseRecursiveConfig(null, cfgAbsolutePath, serverDataPath); + const { + endpoints, + hasEndpointCommand, + hasHostConfigMessage, + errors, + warnings, + toCommentOut + } = await validateCommands(parsedCommands); + + //Validating if a valid endpoint was detected + let connectEndpoint: string | null = null; + try { + connectEndpoint = getConnectEndpoint(endpoints, hasEndpointCommand); + } catch (error) { + errors.add(cfgAbsolutePath, false, (error as Error).message); + } + + //Commenting out lines or registering them as warnings + for (const targetCfgPath in toCommentOut.store) { + const actions = toCommentOut.store[targetCfgPath]; + try { + const cfgRaw = await fsp.readFile(targetCfgPath, 'utf8'); + + //modify the cfg lines + const fileEOL = detectNewline(cfgRaw); + const cfgLines = cfgRaw.split(/\r?\n/); + for (const [ln, reason] of actions) { + if (ln === false) continue; + if (typeof cfgLines[ln - 1] !== 'string') { + throw new Error(`Line ${ln} not found.`); + } + cfgLines[ln - 1] = `## [txAdmin CFG validator]: ${reason}${fileEOL}# ${cfgLines[ln - 1]}`; + warnings.add(targetCfgPath, ln, `Commented out: ${reason}`); + } + + //Saving modified lines + const newCfg = cfgLines.join(fileEOL); + console.warn(`Saving modified file '${targetCfgPath}'`); + await fsp.writeFile(targetCfgPath, newCfg, 'utf8'); + } catch (error) { + console.verbose.error(error); + for (const [ln, reason] of actions) { + errors.add(targetCfgPath, ln, `Please comment out this line: ${reason}`); + } + } + } + + //Prepare response + return { + connectEndpoint, + errors: errors.toMarkdown(hasHostConfigMessage), + warnings: warnings.toMarkdown(hasHostConfigMessage), + // errors: errors.store, + // warnings: warnings.store, + // endpoints, //Not being used + }; +}; + + +/** + * Validating config contents + saving file and backup. + * In case of any errors, it does not save the contents. + * Does not comment out (fix) bad lines. + * Used whenever a user wants to modify server.cfg. + * Returns if saved, and warnings + */ +export const validateModifyServerConfig = async ( + cfgInputString: string, + cfgPath: string, + serverDataPath: string +) => { + if (typeof cfgInputString !== 'string') { + throw new Error('cfgInputString expected to be string.'); + } + + //Parsing FXServer config & going through each command + const cfgAbsolutePath = resolveCFGFilePath(cfgPath, serverDataPath); + const parsedCommands = await parseRecursiveConfig(cfgInputString, cfgAbsolutePath, serverDataPath); + const { + endpoints, + hasEndpointCommand, + hasHostConfigMessage, + errors, + warnings, + toCommentOut + } = await validateCommands(parsedCommands); + + //Validating if a valid endpoint was detected + try { + const _connectEndpoint = getConnectEndpoint(endpoints, hasEndpointCommand); + } catch (error) { + errors.add(cfgAbsolutePath, false, (error as Error).message); + } + + //If there are any errors + if (errors.count()) { + return { + success: false, + errors: errors.toMarkdown(hasHostConfigMessage), + warnings: warnings.toMarkdown(hasHostConfigMessage), + }; + } + + //Save file + backup + try { + console.warn(`Saving modified file '${cfgAbsolutePath}'`); + await fsp.copyFile(cfgAbsolutePath, `${cfgAbsolutePath}.bkp`); + await fsp.writeFile(cfgAbsolutePath, cfgInputString, 'utf8'); + } catch (error) { + throw new Error(`Failed to edit 'server.cfg' with error: ${(error as Error).message}`); + } + + return { + success: true, + warnings: warnings.toMarkdown(), + }; +}; +/* + fxrunner spawnServer: recursive validate file, get endpoint + settings handleFXServer: recursive validate file + setup handleValidateCFGFile: recursive validate file + setup handleSaveLocal: recursive validate file + + cfgEditor CFGEditorSave: validate string, save + deployer handleSaveConfig: validate string, save * +*/ + +/* + +# Endpoints test cases: +/"\[?(([0-9.]{7,15})|([a-z0-9:]{2,29}))\]?:(\d{1,5})"/gmi + +# default +"0.0.0.0:30120" +"[0.0.0.0]:30120" +"0.0.0.0" +"[0.0.0.0]" + +# ipv6/ipv4 +"[::]:30120" +":::30120" +"[::]" + +# ipv6 only +"fe80::4cec:1264:187e:ce2b:30120" +"[fe80::4cec:1264:187e:ce2b]:30120" +"::1:30120" +"[::1]:30120" +"[fe80::4cec:1264:187e:ce2b]" +"::1" +"[::1]" + +# FXServer doesn't accept +"::1.30120" +"::1 port 30120" +"::1p30120" +"::1#30120" +"::" + +# FXServer misreads last part as a port +"fe80::4cec:1264:187e:ce2b" + +*/ diff --git a/core/lib/fxserver/fxsVersionParser.test.ts b/core/lib/fxserver/fxsVersionParser.test.ts new file mode 100644 index 0000000..59253f7 --- /dev/null +++ b/core/lib/fxserver/fxsVersionParser.test.ts @@ -0,0 +1,70 @@ +//@ts-nocheck +import { test, expect } from 'vitest'; +import { parseFxserverVersion } from './fxsVersionParser'; +const p = parseFxserverVersion; + + +test('normal versions', () => { + expect(p('FXServer-master SERVER v1.0.0.7290 win32')).toEqual({ + build: 7290, + platform: 'windows', + branch: 'master', + valid: true, + }); + expect(p('FXServer-master SERVER v1.0.0.10048 win32')).toEqual({ + build: 10048, + platform: 'windows', + branch: 'master', + valid: true, + }); + expect(p('FXServer-master v1.0.0.9956 linux')).toEqual({ + build: 9956, + platform: 'linux', + branch: 'master', + valid: true, + }); +}); + +test('feat branch versions', () => { + expect(p('FXServer-feature/improve_player_dropped_event SERVER v1.0.0.20240707 win32')).toEqual({ + build: 20240707, + platform: 'windows', + branch: 'feature/improve_player_dropped_event', + valid: true, + }); + expect(p('FXServer-abcdef SERVER v1.0.0.20240707 win32')).toEqual({ + build: 20240707, + platform: 'windows', + branch: 'abcdef', + valid: true, + }); +}); + +test('invalids', () => { + expect(() => p(1111 as any)).toThrow('expected'); + expect(p('FXServer-no-version (didn\'t run build tools?)')).toEqual({ + valid: false, + build: null, + branch: null, + platform: null, + }); + expect(p('Invalid server (internal validation failed)')).toEqual({ + valid: false, + build: null, + branch: null, + platform: null, + }); + //attempt to salvage platform + expect(p('xxxxxxxx win32')).toEqual({ + valid: false, + build: null, + branch: null, + platform: 'windows', + }); + expect(p('xxxxxxxx linux')).toEqual({ + valid: false, + build: null, + branch: null, + platform: 'linux', + }); +}); diff --git a/core/lib/fxserver/fxsVersionParser.ts b/core/lib/fxserver/fxsVersionParser.ts new file mode 100644 index 0000000..971453b --- /dev/null +++ b/core/lib/fxserver/fxsVersionParser.ts @@ -0,0 +1,25 @@ +/** + * Parses a fxserver version convar into a number. +*/ +export const parseFxserverVersion = (version: any): ParseFxserverVersionResult => { + if (typeof version !== 'string') throw new Error(`expected string`); + + return { + valid: true, + branch: "master", + build: 9999, + platform: "windows", + } +}; + +type ParseFxserverVersionResult = { + valid: true; + branch: string; + build: number; + platform: string; +} | { + valid: false; + branch: null; + build: null; + platform: 'windows' | 'linux' | null; +}; diff --git a/core/lib/fxserver/runtimeFiles.ts b/core/lib/fxserver/runtimeFiles.ts new file mode 100644 index 0000000..564dd61 --- /dev/null +++ b/core/lib/fxserver/runtimeFiles.ts @@ -0,0 +1,43 @@ +import path from 'node:path'; +import fsp from 'node:fs/promises'; +import { txEnv } from "@core/globalData"; + + +/** + * Creates or removes a monitor/.runtime/ file + */ +export const setRuntimeFile = async (fileName: string, fileData: string | Buffer | null) => { + const destRuntimePath = path.resolve(txEnv.txaPath, '.runtime'); + const destFilePath = path.resolve(destRuntimePath, fileName); + + //Ensure the /.runtime/ folder exists + try { + await fsp.mkdir(destRuntimePath, { recursive: true }); + } catch (error) { + console.error(`Failed to create .runtime folder: ${(error as any).message}`); + return false; + } + + //If deleting the file, just unlink it + if (fileData === null) { + try { + await fsp.unlink(destFilePath); + } catch (error) { + const msg = (error as Error).message ?? 'Unknown error'; + if (!msg.includes('ENOENT')) { + console.error(`Failed to delete runtime file: ${msg}`); + return false; + } + } + return true; + } + + //Write the file + try { + await fsp.writeFile(destFilePath, fileData); + return true; + } catch (error) { + console.error(`Failed to write runtime file: ${(error as any).message}`); + } + return false; +} diff --git a/core/lib/fxserver/scanMonitorFiles.ts b/core/lib/fxserver/scanMonitorFiles.ts new file mode 100644 index 0000000..f304942 --- /dev/null +++ b/core/lib/fxserver/scanMonitorFiles.ts @@ -0,0 +1,107 @@ +//FIXME: after refactor, move to the correct path +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { txEnv } from '../../globalData'; + +//Hash test +const hashFile = async (filePath: string) => { + const rawFile = await fs.readFile(filePath, 'utf8') + const normalized = rawFile.normalize('NFKC') + .replace(/\r\n/g, '\n') + .replace(/^\uFEFF/, ''); + return createHash('sha1').update(normalized).digest('hex'); +} + +// Limits +const MAX_FILES = 300; +const MAX_TOTAL_SIZE = 52_428_800; // 50MB +const MAX_FILE_SIZE = 20_971_520; // 20MB +const MAX_DEPTH = 10; +const MAX_EXECUTION_TIME = 30 * 1000; +const IGNORED_FOLDERS = [ + 'db', + 'cache', + 'dist', + '.reports', + 'license_report', + 'tmp_core_tsc', + 'node_modules', + 'txData', +]; + + +type ContentFileType = { + path: string; + size: number; + hash: string; +} + +export default async function scanMonitorFiles() { + const rootPath = txEnv.txaPath; + const allFiles: ContentFileType[] = []; + let totalFiles = 0; + let totalSize = 0; + + try { + const tsStart = Date.now(); + const scanDir = async (dir: string, depth: number = 0) => { + if (depth > MAX_DEPTH) { + throw new Error('MAX_DEPTH'); + } + + let filesFound = 0; + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (totalFiles >= MAX_FILES) { + throw new Error('MAX_FILES'); + } else if (totalSize >= MAX_TOTAL_SIZE) { + throw new Error('MAX_TOTAL_SIZE'); + } else if (Date.now() - tsStart > MAX_EXECUTION_TIME) { + throw new Error('MAX_EXECUTION_TIME'); + } + + const entryPath = path.join(dir, entry.name); + let relativeEntryPath = path.relative(rootPath, entryPath); + relativeEntryPath = './' + relativeEntryPath.split(path.sep).join(path.posix.sep); + + if (entry.isDirectory()) { + if (IGNORED_FOLDERS.includes(entry.name)) { + continue; + } + await scanDir(entryPath, depth + 1); + } else if (entry.isFile()) { + const stats = await fs.stat(entryPath); + if (stats.size > MAX_FILE_SIZE) { + throw new Error('MAX_SIZE'); + } + + allFiles.push({ + path: relativeEntryPath, + size: stats.size, + hash: await hashFile(entryPath), + }); + filesFound++; + totalFiles++; + totalSize += stats.size; + } + } + return filesFound; + }; + await scanDir(rootPath); + allFiles.sort((a, b) => a.path.localeCompare(b.path)); + return { + totalFiles, + totalSize, + allFiles, + }; + } catch (error) { + //At least saving the progress + return { + error: (error as any).message, + totalFiles, + totalSize, + allFiles, + }; + } +} diff --git a/core/lib/fxserver/serverData.ts b/core/lib/fxserver/serverData.ts new file mode 100644 index 0000000..5b3b52b --- /dev/null +++ b/core/lib/fxserver/serverData.ts @@ -0,0 +1,236 @@ +import { txEnv } from '@core/globalData'; +import { getFsErrorMdMessage, getPathSubdirs } from '@lib/fs'; +import * as fsp from 'node:fs/promises'; +import * as path from 'node:path'; + +const IGNORED_DIRS = ['cache', 'db', 'node_modules', '.git', '.idea', '.vscode']; +const MANIFEST_FILES = ['fxmanifest.lua', '__resource.lua']; +const RES_CATEGORIES_LIMIT = 250; //Some servers go over 100 +const CFG_SIZE_LIMIT = 32 * 1024; //32kb + + +//Types +export type ServerDataContentType = [string, number | boolean][]; +export type ServerDataConfigsType = [string, string][]; + + +/** + * Scans a server data folder and lists all files, up to the first level of each resource. + * Behavior reference: fivem/code/components/citizen-server-impl/src/ServerResources.cpp + * + * NOTE: this would probably be better + * + * TODO: the current sorting is not right, changing it back to recursive (depth-first) would + * probably solve it, but right now it's not critical. + * A better behavior would be to set "MAX_DEPTH" and do that for all folders, ignoring "resources"/[categories] + */ +export const getServerDataContent = async (serverDataPath: string): Promise => { + //Runtime vars + let resourcesInRoot = false; + const content: ServerDataContentType = []; //relative paths + let resourceCategories = 0; + + //Scan root path + const rootEntries = await fsp.readdir(serverDataPath, { withFileTypes: true }); + for (const entry of rootEntries) { + + if (entry.isDirectory()) { + content.push([entry.name, false]); + if (entry.name === 'resources') { + resourcesInRoot = true; + } + } else if (entry.isFile()) { + const stat = await fsp.stat(path.join(serverDataPath, entry.name)); + content.push([entry.name, stat.size]); + } + } + //no resources, early return + if (!resourcesInRoot) return content; + + + //Scan categories + const categoriesToScan = [path.join(serverDataPath, 'resources')]; + while (categoriesToScan.length) { + if (resourceCategories >= RES_CATEGORIES_LIMIT) { + throw new Error(`Scanning above the limit of ${RES_CATEGORIES_LIMIT} resource categories.`); + } + resourceCategories++; + const currCategory = categoriesToScan.shift()!; + const currCatDirEntries = await fsp.readdir(currCategory, { withFileTypes: true }); + + for (const catDirEntry of currCatDirEntries) { + const catDirEntryFullPath = path.join(currCategory, catDirEntry.name); + const catDirEntryRelativePath = path.relative(serverDataPath, catDirEntryFullPath); + + if (catDirEntry.isDirectory()) { + content.push([path.relative(serverDataPath, catDirEntryFullPath), false]); + if (!catDirEntry.name.length || IGNORED_DIRS.includes(catDirEntry.name)) continue; + + if (catDirEntry.name[0] === '[' && catDirEntry.name[catDirEntry.name.length - 1] === ']') { + //It's a category + categoriesToScan.push(catDirEntryFullPath); + + } else { + //It's a resource + const resourceFullPath = catDirEntryFullPath; + const resourceRelativePath = catDirEntryRelativePath; + let resourceHasManifest = false; + const resDirEntries = await fsp.readdir(resourceFullPath, { withFileTypes: true }); + + //for every file/folder in resources folder + for (const resDirEntry of resDirEntries) { + const resEntryFullPath = path.join(resourceFullPath, resDirEntry.name); + const resEntryRelativePath = path.join(resourceRelativePath, resDirEntry.name); + if (resDirEntry.isDirectory()) { + content.push([resEntryRelativePath, false]); + + } else if (resDirEntry.isFile()) { + const stat = await fsp.stat(resEntryFullPath); + content.push([resEntryRelativePath, stat.size]); + if (!resourceHasManifest && MANIFEST_FILES.includes(resDirEntry.name)) { + resourceHasManifest = true; + } + } + }; + } + + } else if (catDirEntry.isFile()) { + const stat = await fsp.stat(catDirEntryFullPath); + content.push([catDirEntryRelativePath, stat.size]); + } + } + }//while categories + + // Sorting content (folders first, then utf8) + content.sort(([aName, aSize], [bName, bSize]) => { + const aDir = path.parse(aName).dir; + const bDir = path.parse(bName).dir; + if (aDir !== bDir) { + return aName.localeCompare(bName); + } else if (aSize === false && bSize !== false) { + return -1; + } else if (aSize !== false && bSize === false) { + return 1; + } else { + return aName.localeCompare(bName); + } + }); + + return content; +} + + +/** + * Returns the content of all .cfg files based on a server data content scan. + */ +export const getServerDataConfigs = async (serverDataPath: string, serverDataContent: ServerDataContentType): Promise => { + const configs: ServerDataConfigsType = []; + for (const [entryPath, entrySize] of serverDataContent) { + if (typeof entrySize !== 'number' || !entryPath.endsWith('.cfg')) continue; + if (entrySize > CFG_SIZE_LIMIT) { + configs.push([entryPath, 'file is too big']); + } + + try { + const rawData = await fsp.readFile(path.join(serverDataPath, entryPath), 'utf8'); + configs.push([entryPath, rawData]); + } catch (error) { + configs.push([entryPath, (error as Error).message]); + } + } + + return configs; +} + + +/** + * Validate server data path for the + */ +export const isValidServerDataPath = async (dataPath: string) => { + //Check if root folder is valid + try { + const rootEntries = await getPathSubdirs(dataPath); + if (!rootEntries.some(e => e.name === 'resources')) { + throw new Error('The provided directory does not contain a \`resources\` subdirectory.'); + } + } catch (err) { + const error = err as Error; + let msg = getFsErrorMdMessage(error, dataPath); + if (dataPath.includes('resources')) { + msg = `Looks like this path is the \`resources\` folder, but the server data path must be the folder that contains the resources folder instead of the resources folder itself.\n**Try removing the \`resources\` part at the end of the path.**`; + } + throw new Error(msg); + } + + //Check if resources folder is valid + try { + const resourceEntries = await getPathSubdirs(path.join(dataPath, 'resources')); + if (!resourceEntries.length) { + throw new Error('The \`resources\` directory is empty.'); + } + } catch (err) { + const error = err as Error; + let msg = error.message; + if (error.message?.includes('ENOENT')) { + msg = `The \`resources\` directory does not exist inside the provided Server Data Folder:\n\`${dataPath}\``; + } else if (error.message?.includes('EACCES') || error.message?.includes('EPERM')) { + msg = `The \`resources\` directory is not accessible inside the provided Server Data Folder:\n\`${dataPath}\``; + } + throw new Error(msg); + } + return true; +}; + + +/** + * Look for a potential server data folder in/around the provided path. + * Forgiving behavior: + * - Ignore trailing slashes, as well as fix backslashes + * - Check if its the parent folder + * - Check if its a sibling folder + * - Check if its a child folder + * - Check if current path is a resource folder deep inside a server data folder + */ +export const findPotentialServerDataPaths = async (initialPath: string) => { + const checkTarget = async (target: string) => { + try { + return await isValidServerDataPath(target); + } catch (error) { + return false; + } + }; + + //Recovery if parent folder + const parentPath = path.join(initialPath, '..'); + const isParentPath = await checkTarget(parentPath); + if (isParentPath) return parentPath; + + //Recovery if sibling folder + try { + const siblingPaths = await getPathSubdirs(parentPath); + for (const sibling of siblingPaths) { + const siblingPath = path.join(parentPath, sibling.name); + if (siblingPath === initialPath) continue; + if (await checkTarget(siblingPath)) return siblingPath; + } + } catch (error) { } + + //Recovery if children folder + try { + const childPaths = await getPathSubdirs(initialPath); + for (const child of childPaths) { + const childPath = path.join(initialPath, child.name); + if (await checkTarget(childPath)) return childPath; + } + } catch (error) { } + + //Recovery if current path is a resources folder + const resourceSplitAttempt = initialPath.split(/[/\\]resources(?:[/\\]?|$)/, 2); + if (resourceSplitAttempt.length === 2) { + const potentialServerDataPath = resourceSplitAttempt[0]; + if (await checkTarget(potentialServerDataPath)) return potentialServerDataPath; + } + + //Really couldn't find anything + return false; +}; diff --git a/core/lib/got.ts b/core/lib/got.ts new file mode 100644 index 0000000..c901eb3 --- /dev/null +++ b/core/lib/got.ts @@ -0,0 +1,12 @@ +import { txEnv, txHostConfig } from '@core/globalData'; +import got from 'got'; + +export default got.extend({ + timeout: { + request: 5000 + }, + headers: { + 'User-Agent': `txAdmin ${txEnv.txaVersion}`, + }, + localAddress: txHostConfig.netInterface, +}); diff --git a/core/lib/host/getHostUsage.ts b/core/lib/host/getHostUsage.ts new file mode 100644 index 0000000..6e3d6ef --- /dev/null +++ b/core/lib/host/getHostUsage.ts @@ -0,0 +1,60 @@ +const modulename = 'GetHostUsage'; +import os from 'node:os'; +import si from 'systeminformation'; +import { txEnv } from '@core/globalData'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + +//Const -hopefully +const giga = 1024 * 1024 * 1024; +const cpus = os.cpus(); + + +/** + * Get the host's current memory and CPU usage. + * NOTE: It was used by the hw stats on the sidebar + * Currently only in use by diagnostics page + */ +export default async () => { + const out = { + memory: { usage: 0, used: 0, total: 0 }, + cpu: { + count: cpus.length, + usage: 0, + }, + }; + + //Getting memory usage + try { + let free, total, used; + if (txEnv.isWindows) { + free = os.freemem() / giga; + total = os.totalmem() / giga; + used = total - free; + } else { + const memoryData = await si.mem(); + free = memoryData.available / giga; + total = memoryData.total / giga; + used = memoryData.active / giga; + } + out.memory = { + used, + total, + usage: Math.round((used / total) * 100), + }; + } catch (error) { + console.verbose.error('Failed to get memory usage.'); + console.verbose.dir(error); + } + + //Getting CPU usage + try { + const loads = await si.currentLoad(); + out.cpu.usage = Math.round(loads.currentLoad); + } catch (error) { + console.verbose.error('Failed to get CPU usage.'); + console.verbose.dir(error); + } + + return out; +}; diff --git a/core/lib/host/getOsDistro.js b/core/lib/host/getOsDistro.js new file mode 100644 index 0000000..6841f74 --- /dev/null +++ b/core/lib/host/getOsDistro.js @@ -0,0 +1,97 @@ +const modulename = 'getOsDistro'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + +/* + NOTE: this is straight from @sindresorhus/windows-release, but with async functions. + I have windows-release dependency mostly just so I know when there are updates to it. +*/ +import os from 'node:os'; +import execa from 'execa'; + +// Reference: https://www.gaijin.at/en/lstwinver.php +// Windows 11 reference: https://docs.microsoft.com/en-us/windows/release-health/windows11-release-information +const names = new Map([ + ['10.0.2', '11'], // It's unclear whether future Windows 11 versions will use this version scheme: https://github.com/sindresorhus/windows-release/pull/26/files#r744945281 + ['10.0', '10'], + ['6.3', '8.1'], + ['6.2', '8'], + ['6.1', '7'], + ['6.0', 'Vista'], + ['5.2', 'Server 2003'], + ['5.1', 'XP'], + ['5.0', '2000'], + ['4.90', 'ME'], + ['4.10', '98'], + ['4.03', '95'], + ['4.00', '95'], +]); + +async function windowsRelease(release) { + const version = /(\d+\.\d+)(?:\.(\d+))?/.exec(release || os.release()); + + if (release && !version) { + throw new Error('`release` argument doesn\'t match `n.n`'); + } + + let ver = version[1] || ''; + const build = version[2] || ''; + + // Server 2008, 2012, 2016, and 2019 versions are ambiguous with desktop versions and must be detected at runtime. + // If `release` is omitted or we're on a Windows system, and the version number is an ambiguous version + // then use `wmic` to get the OS caption: https://msdn.microsoft.com/en-us/library/aa394531(v=vs.85).aspx + // If `wmic` is obsolete (later versions of Windows 10), use PowerShell instead. + // If the resulting caption contains the year 2008, 2012, 2016, 2019 or 2022, it is a server version, so return a server OS name. + if ((!release || release === os.release()) && ['6.1', '6.2', '6.3', '10.0'].includes(ver)) { + let stdout; + try { + const out = await execa('wmic', ['os', 'get', 'Caption']); + stdout = out.stdout || ''; + } catch { + //NOTE: custom code to select the powershell path + //if systemroot/windir is not defined, just try "powershell" and hope for the best + const systemRoot = process.env?.SYSTEMROOT ?? process.env?.WINDIR ?? false; + const psBinary = systemRoot + ? `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell` + : 'powershell'; + const out = await execa(psBinary, ['(Get-CimInstance -ClassName Win32_OperatingSystem).caption']); + stdout = out.stdout || ''; + } + + const year = (stdout.match(/2008|2012|2016|2019|2022/) || [])[0]; + + if (year) { + return `Server ${year}`; + } + } + + // Windows 11 + if (ver === '10.0' && build.startsWith('2')) { + ver = '10.0.2'; + } + + return names.get(ver); +}; + + +/** + * Cache calculated os distro + */ +let _osDistro; +export default async () => { + if (_osDistro) return _osDistro; + + const osType = os.type(); + if (osType == 'Linux') { + _osDistro = `${osType} ${os.release()}`; + } else { + try { + const distro = await windowsRelease(); + _osDistro = `Windows ${distro}`; + } catch (error) { + console.warn(`Failed to detect windows version with error: ${error.message}`); + _osDistro = `Windows Unknown`; + } + } + return _osDistro; +}; diff --git a/core/lib/host/isIpAddressLocal.ts b/core/lib/host/isIpAddressLocal.ts new file mode 100644 index 0000000..580f491 --- /dev/null +++ b/core/lib/host/isIpAddressLocal.ts @@ -0,0 +1,29 @@ +const modulename = 'IpChecker'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + +const extendedAllowedLanIps: string[] = []; + + +/** + * Return if the IP Address is a loopback interface, LAN, detected WAN or any other + * IP that is registered by the user via the forceInterface convar or config file. + * + * This is used to secure the webpipe auth and the rate limiter. + */ +export const isIpAddressLocal = (ipAddress: string): boolean => { + return ( + /^(127\.|192\.168\.|10\.|::1|fd00::)/.test(ipAddress) + || extendedAllowedLanIps.includes(ipAddress) + ) +} + + +/** + * Used to register a new LAN interface. + * Added automatically from TXHOST_INTERFACE and banner.js after detecting the WAN address. + */ +export const addLocalIpAddress = (ipAddress: string): void => { + // console.verbose.debug(`Adding local IP address: ${ipAddress}`); + extendedAllowedLanIps.push(ipAddress); +} diff --git a/core/lib/host/pidUsageTree.js b/core/lib/host/pidUsageTree.js new file mode 100644 index 0000000..da0a3c5 --- /dev/null +++ b/core/lib/host/pidUsageTree.js @@ -0,0 +1,7 @@ +import pidtree from 'pidtree'; +import pidusage from 'pidusage'; + +export default async (pid) => { + const pids = await pidtree(pid); + return await pidusage([pid, ...pids]); +}; diff --git a/core/lib/misc.test.ts b/core/lib/misc.test.ts new file mode 100644 index 0000000..2c8bca7 --- /dev/null +++ b/core/lib/misc.test.ts @@ -0,0 +1,238 @@ +import { test, expect, suite, it } from 'vitest'; +import * as misc from './misc'; + + +suite('parseSchedule', () => { + it('should parse a valid schedule', () => { + const result = misc.parseSchedule(['00:00', '00:15', '1:30', '12:30']); + expect(result.valid).toEqual([ + { string: '00:00', hours: 0, minutes: 0 }, + { string: '00:15', hours: 0, minutes: 15 }, + { string: '01:30', hours: 1, minutes: 30 }, + { string: '12:30', hours: 12, minutes: 30 }, + ]); + expect(result.invalid).toEqual([]); + }); + + it('should let the average american type 24:00', () => { + const result = misc.parseSchedule(['24:00']); + expect(result.valid).toEqual([ + { string: '00:00', hours: 0, minutes: 0 }, + ]); + expect(result.invalid).toEqual([]); + }); + + it('should handle invalid stuff', () => { + const result = misc.parseSchedule(['12:34', 'invalid', '1030', '25:00', '1', '01', '']); + expect(result).toBeTruthy(); + expect(result.valid).toEqual([ + { string: '12:34', hours: 12, minutes: 34 }, + ]); + expect(result.invalid).toEqual(['invalid', '1030', '25:00', '1', '01']); + }); + + it('should remove duplicates', () => { + const result = misc.parseSchedule(['02:00', '02:00', '05:55', '13:55']); + expect(result.valid).toEqual([ + { string: '02:00', hours: 2, minutes: 0 }, + { string: '05:55', hours: 5, minutes: 55 }, + { string: '13:55', hours: 13, minutes: 55 }, + ]); + expect(result.invalid).toEqual([]); + }); + + it('should sort the times', () => { + const result = misc.parseSchedule(['00:00', '00:01', '23:59', '01:01', '01:00']); + expect(result.valid).toEqual([ + { string: '00:00', hours: 0, minutes: 0 }, + { string: '00:01', hours: 0, minutes: 1 }, + { string: '01:00', hours: 1, minutes: 0 }, + { string: '01:01', hours: 1, minutes: 1 }, + { string: '23:59', hours: 23, minutes: 59 }, + ]); + expect(result.invalid).toEqual([]); + }); +}); + +test('redactApiKeys', () => { + expect(misc.redactApiKeys('')).toBe('') + expect(misc.redactApiKeys('abc')).toBe('abc') + + const example = ` + sv_licenseKey cfxk_NYWn5555555500000000_2TLnnn + sv_licenseKey "cfxk_NYWn5555555500000000_2TLnnn" + sv_licenseKey 'cfxk_NYWn5555555500000000_2TLnnn' + + steam_webApiKey A2FAF8CF83B87E795555555500000000 + sv_tebexSecret 238a98bec4c0353fee20ac865555555500000000 + rcon_password a5555555500000000 + rcon_password "a5555555500000000" + rcon_password 'a5555555500000000' + mysql_connection_string "mysql://root:root@localhost:3306/txAdmin" + https://discord.com/api/webhooks/33335555555500000000/xxxxxxxxxxxxxxxxxxxx5555555500000000`; + + const result = misc.redactApiKeys(example) + expect(result).toContain('[REDACTED]'); + expect(result).toContain('2TLnnn'); + expect(result).not.toContain('5555555500000000'); + expect(result).not.toContain('mysql://'); +}) + + +suite('redactStartupSecrets', () => { + const redactedString = '[REDACTED]'; + it('should return an empty array when given an empty array', () => { + expect(misc.redactStartupSecrets([])).toEqual([]); + }); + + it('should return the same array if no redaction keys are present', () => { + const args = ['node', 'script.js', '--help']; + expect(misc.redactStartupSecrets(args)).toEqual(args); + }); + + it('should redact a sv_licenseKey secret correctly', () => { + const args = ['sv_licenseKey', 'cfxk_12345_secret']; + // The regex captures "secret" and returns "[REDACTED cfxk...secret]" + const expected = ['sv_licenseKey', '[REDACTED cfxk...secret]']; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); + + it('should not redact sv_licenseKey secret if the secret does not match the regex', () => { + const args = ['sv_licenseKey', 'invalidsecret']; + const expected = ['sv_licenseKey', 'invalidsecret']; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); + + it('should redact steam_webApiKey secret correctly', () => { + const validKey = 'a'.repeat(32); + const args = ['steam_webApiKey', validKey]; + const expected = ['steam_webApiKey', redactedString]; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); + + it('should redact sv_tebexSecret secret correctly', () => { + const validSecret = 'b'.repeat(40); + const args = ['sv_tebexSecret', validSecret]; + const expected = ['sv_tebexSecret', redactedString]; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); + + it('should redact rcon_password secret correctly', () => { + const args = ['rcon_password', 'mysecretpassword']; + const expected = ['rcon_password', redactedString]; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); + + it('should redact mysql_connection_string secret correctly', () => { + const args = [ + 'mysql_connection_string', + 'Server=myServerAddress;Database=myDataBase;User Id=myUsername;Password=myPassword;', + ]; + const expected = ['mysql_connection_string', redactedString]; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); + + it('should handle multiple redactions in a single array', () => { + const validSteamKey = 'c'.repeat(32); + const args = [ + 'sv_licenseKey', 'cfxk_12345_abcdef', + 'someOtherArg', 'value', + 'steam_webApiKey', validSteamKey, + ]; + const expected = [ + 'sv_licenseKey', '[REDACTED cfxk...abcdef]', + 'someOtherArg', 'value', + 'steam_webApiKey', redactedString, + ]; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); + + it('should handle case-insensitive key matching', () => { + const args = ['SV_LICENSEKEY', 'cfxk_12345_SECRET']; + const expected = ['SV_LICENSEKEY', '[REDACTED cfxk...SECRET]']; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); + + it('should leave a key unchanged if it is the last element', () => { + const args = ['sv_licenseKey']; + const expected = ['sv_licenseKey']; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); + + it('should handle rules without regex', () => { + const args = ['rcon_password', 'whatever']; + const expected = ['rcon_password', redactedString]; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); + + it('should handle a real example', () => { + const args = [ + "+setr", "txAdmin-debugMode", "true", + "+set", "tx2faSecret", "whatever", + "+set", "sv_licenseKey", "cfxk_xxxxxxxxxxxxxxxxxxxx_yyyyy", + "+set", "onesync", "enabled", + "+set", "sv_enforceGameBuild", "2545", + ]; + const expected = [ + "+setr", "txAdmin-debugMode", "true", + "+set", "tx2faSecret", redactedString, + "+set", "sv_licenseKey", "[REDACTED cfxk...yyyyy]", + "+set", "onesync", "enabled", + "+set", "sv_enforceGameBuild", "2545", + ]; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); + + it('should redact discord webhooks', () => { + const args = [ + "aaa", + "https://discord.com/api/webhooks/33335555555500000000/xxxxxxxxxxxxxxxxxxxx5555555500000000", + "bbb", + ]; + const expected = [ + "aaa", + "https://discord.com/api/webhooks/[REDACTED]/[REDACTED]", + "bbb", + ]; + expect(misc.redactStartupSecrets(args)).toEqual(expected); + }); +}); + + +test('now', () => { + const result = misc.now(); + expect(typeof result).toBe('number'); + expect(result.toString().length).toBe(10); + expect(result.toString()).not.toContain('.'); + expect(result.toString()).not.toContain('-'); +}); + +test('anyUndefined', () => { + expect(misc.anyUndefined(undefined, 'test')).toBe(true); + expect(misc.anyUndefined('test', 'xxxx')).toBe(false); + expect(misc.anyUndefined(undefined, undefined)).toBe(true); +}); + +test('calcExpirationFromDuration', () => { + const currTs = misc.now(); + let result = misc.calcExpirationFromDuration('1 hour'); + expect(result?.duration).toBe(3600); + expect(result?.expiration).toBe(currTs + 3600); + + result = misc.calcExpirationFromDuration('1 hours'); + expect(result?.duration).toBe(3600); + + result = misc.calcExpirationFromDuration('permanent'); + expect(result?.expiration).toBe(false); + + expect(() => misc.calcExpirationFromDuration('x day')).toThrowError('duration number'); + expect(() => misc.calcExpirationFromDuration('')).toThrowError('duration number'); + expect(() => misc.calcExpirationFromDuration('-1 day')).toThrowError('duration number'); +}); + +test('parseLimitedFloat', () => { + expect(misc.parseLimitedFloat('123.4567899999')).toBe(123.45679); + expect(misc.parseLimitedFloat(123.4567899999)).toBe(123.45679); + expect(misc.parseLimitedFloat(123.4567899999, 2)).toBe(123.46); + expect(misc.parseLimitedFloat(0.1 + 0.2)).toBe(0.3); +}); diff --git a/core/lib/misc.ts b/core/lib/misc.ts new file mode 100644 index 0000000..d9f1867 --- /dev/null +++ b/core/lib/misc.ts @@ -0,0 +1,301 @@ +import chalk from 'chalk'; +import dateFormat from 'dateformat'; +import humanizeDuration, { HumanizerOptions } from 'humanize-duration'; +import { DeepReadonly } from 'utility-types'; + +export const regexHoursMinutes = /^(?[01]?[0-9]|2[0-4]):(?[0-5][0-9])$/; + + +/** + * Extracts hours and minutes from an string containing times + */ +export const parseSchedule = (scheduleTimes: string[]) => { + const valid: { + string: string; + hours: number; + minutes: number; + }[] = []; + const invalid = []; + for (const timeInput of scheduleTimes) { + if (typeof timeInput !== 'string') continue; + const timeTrim = timeInput.trim(); + if (!timeTrim.length) continue; + + const m = timeTrim.match(regexHoursMinutes); + if (m && m.groups?.hours && m.groups?.minutes) { + if (m.groups.hours === '24') m.groups.hours = '00'; //Americans, amirite?!?! + const timeStr = m.groups.hours.padStart(2, '0') + ':' + m.groups.minutes.padStart(2, '0'); + if (valid.some(item => item.string === timeStr)) continue; + valid.push({ + string: timeStr, + hours: parseInt(m.groups.hours), + minutes: parseInt(m.groups.minutes), + }); + } else { + invalid.push(timeTrim); + } + } + valid.sort((a, b) => { + return a.hours - b.hours || a.minutes - b.minutes; + }); + return { valid, invalid }; +}; + + +/** + * Redacts known keys and tokens from a string + * @deprecated Use redactApiKeysArr instead + */ +export const redactApiKeys = (src: string) => { + if (typeof src !== 'string' || !src.length) return src; + return src + .replace(/licenseKey\s+["']?cfxk_\w{1,60}_(\w+)["']?.?$/gim, 'licenseKey [REDACTED cfxk...$1]') + .replace(/steam_webApiKey\s+["']?\w{32}["']?.?$/gim, 'steam_webApiKey [REDACTED]') + .replace(/sv_tebexSecret\s+["']?\w{40}["']?.?$/gim, 'sv_tebexSecret [REDACTED]') + .replace(/rcon_password\s+["']?[^"']+["']?.?$/gim, 'rcon_password [REDACTED]') + .replace(/mysql_connection_string\s+["']?[^"']+["']?.?$/gim, 'mysql_connection_string [REDACTED]') + .replace(/discord\.com\/api\/webhooks\/\d{17,20}\/[\w\-_./=]{10,}(.*)/gim, 'discord.com/api/webhooks/[REDACTED]/[REDACTED]'); +}; + + +/** + * Redacts known keys and tokens from an array of startup arguments. + */ +export const redactStartupSecrets = (args: string[]): string[] => { + if (!Array.isArray(args) || args.length === 0) return args; + + const redactionRules: ApiRedactionRuleset = { + sv_licenseKey: { + regex: /^cfxk_\w{1,60}_(\w+)$/i, + replacement: (_match, p1) => `[REDACTED cfxk...${p1}]`, + }, + steam_webApiKey: { + regex: /^\w{32}$/i, + replacement: '[REDACTED]', + }, + sv_tebexSecret: { + regex: /^\w{40}$/i, + replacement: '[REDACTED]', + }, + rcon_password: { + replacement: '[REDACTED]', + }, + mysql_connection_string: { + replacement: '[REDACTED]', + }, + tx2faSecret: { + replacement: '[REDACTED]', + }, + 'txAdmin-luaComToken': { + replacement: '[REDACTED]', + }, + }; + + + let outArgs: string[] = []; + for (let i = 0; i < args.length; i++) { + const currElem = args[i]; + const currElemLower = currElem.toLocaleLowerCase(); + const ruleMatchingPrefix = Object.keys(redactionRules).find((key) => + currElemLower.includes(key.toLocaleLowerCase()) + ); + // If no rule matches or there is no subsequent element, just push the current element. + if (!ruleMatchingPrefix || i + 1 >= args.length) { + outArgs.push(currElem); + continue; + } + const rule = redactionRules[ruleMatchingPrefix]; + const nextElem = args[i + 1]; + // If the secret doesn't match the expected regex, treat it as a normal argument. + if (rule.regex && !nextElem.match(rule.regex)) { + outArgs.push(currElem); + continue; + } + // Push the key and then the redacted secret. + outArgs.push(currElem); + if (typeof rule.replacement === 'string') { + outArgs.push(rule.replacement); + } else if (rule.regex) { + outArgs.push(nextElem.replace(rule.regex, rule.replacement)); + } + // Skip the secret value we just processed. + i++; + } + + //Apply standalone redaction rules + outArgs = outArgs.map((arg) => arg + .replace(/discord\.com\/api\/webhooks\/\d{17,20}\/[\w\-_./=]{10,}(.*)/gim, 'discord.com/api/webhooks/[REDACTED]/[REDACTED]') + ); + + if (args.length !== outArgs.length) { + throw new Error('Input and output lengths are different after redaction.'); + } + return outArgs; +}; + +type ApiRedactionRule = { + regex?: RegExp; + replacement: string | ((...args: any[]) => string); +}; + +type ApiRedactionRuleset = Record; + + +/** + * Returns the unix timestamp in seconds. + */ +export const now = () => Math.round(Date.now() / 1000); + + +/** + * Returns the current time in HH:MM:ss format + */ +export const getTimeHms = (time?: string | number | Date) => dateFormat(time ?? new Date(), 'HH:MM:ss'); + + +/** + * Returns the current time in filename-friendly format + */ +export const getTimeFilename = (time?: string | number | Date) => dateFormat(time ?? new Date(), 'yyyy-mm-dd_HH-MM-ss'); + + +/** + * Converts a number of milliseconds to english words + * Accepts a humanizeDuration config object + * eg: msToDuration(ms, { units: ['h', 'm'] }); + */ +export const msToDuration = humanizeDuration.humanizer({ + round: true, + units: ['d', 'h', 'm'], + fallbacks: ['en'], +} satisfies HumanizerOptions); + + +/** + * Converts a number of milliseconds to short-ish english words + */ +export const msToShortishDuration = humanizeDuration.humanizer({ + round: true, + units: ['d', 'h', 'm'], + largest: 2, + language: 'shortishEn', + languages: { + shortishEn: { + y: (c) => 'year' + (c === 1 ? '' : 's'), + mo: (c) => 'month' + (c === 1 ? '' : 's'), + w: (c) => 'week' + (c === 1 ? '' : 's'), + d: (c) => 'day' + (c === 1 ? '' : 's'), + h: (c) => 'hr' + (c === 1 ? '' : 's'), + m: (c) => 'min' + (c === 1 ? '' : 's'), + s: (c) => 'sec' + (c === 1 ? '' : 's'), + ms: (c) => 'ms', + }, + }, +}); + + +/** + * Converts a number of milliseconds to shortest english representation possible + */ +export const msToShortestDuration = humanizeDuration.humanizer({ + round: true, + units: ['d', 'h', 'm', 's'], + delimiter: '', + spacer: '', + largest: 2, + language: 'shortestEn', + languages: { + shortestEn: { + y: () => 'y', + mo: () => 'mo', + w: () => 'w', + d: () => 'd', + h: () => 'h', + m: () => 'm', + s: () => 's', + ms: () => 'ms', + }, + }, +}); + + +/** + * Shorthand to convert seconds to the shortest english representation possible + */ +export const secsToShortestDuration = (ms: number, options?: humanizeDuration.Options) => { + return msToShortestDuration(ms * 1000, options); +}; + + +/** + * Returns false if any argument is undefined + */ +export const anyUndefined = (...args: any) => [...args].some((x) => (typeof x === 'undefined')); + + +/** + * Calculates expiration and duration from a ban duration string like "1 day" + */ +export const calcExpirationFromDuration = (inputDuration: string) => { + let expiration; + let duration; + if (inputDuration === 'permanent') { + expiration = false as const; + } else { + const [multiplierInput, unit] = inputDuration.split(/\s+/); + const multiplier = parseInt(multiplierInput); + if (isNaN(multiplier) || multiplier < 1) { + throw new Error(`The duration number must be at least 1.`); + } + + if (unit.startsWith('hour')) { + duration = multiplier * 3600; + } else if (unit.startsWith('day')) { + duration = multiplier * 86400; + } else if (unit.startsWith('week')) { + duration = multiplier * 604800; + } else if (unit.startsWith('month')) { + duration = multiplier * 2592000; //30 days + } else { + throw new Error(`Invalid ban duration. Supported units: hours, days, weeks, months`); + } + expiration = now() + duration; + } + + return { expiration, duration }; +}; + + +/** + * Parses a number or string to a float with a limited precision. + */ +export const parseLimitedFloat = (src: number | string, precision = 6) => { + const srcAsNum = typeof src === 'string' ? parseFloat(src) : src; + return parseFloat(srcAsNum.toFixed(precision)); +} + + +/** + * Deeply freezes an object and all its nested properties + */ +export const deepFreeze = >(obj: T) => { + Object.freeze(obj); + Object.getOwnPropertyNames(obj).forEach((prop) => { + if (Object.prototype.hasOwnProperty.call(obj, prop) + && obj[prop] !== null + && (typeof obj[prop] === 'object' || typeof obj[prop] === 'function') + && !Object.isFrozen(obj[prop]) + ) { + deepFreeze(obj[prop] as object); + } + }); + return obj; + //FIXME: using DeepReadonly will cause ts errors in ConfigStore + // return obj as DeepReadonly; +}; + + +/** + * Returns a chalk.inverse of a string with a 1ch padding + */ +export const chalkInversePad = (str: string) => chalk.inverse(` ${str} `); diff --git a/core/lib/player/idUtils.test.ts b/core/lib/player/idUtils.test.ts new file mode 100644 index 0000000..0ddd2b7 --- /dev/null +++ b/core/lib/player/idUtils.test.ts @@ -0,0 +1,57 @@ +import { test, expect, suite, it } from 'vitest'; +import * as idUtils from './idUtils'; + + +test('parsePlayerId', () => { + let result = idUtils.parsePlayerId('FIVEM:555555'); + expect(result.isIdValid).toBe(true); + expect(result.idType).toBe('fivem'); + expect(result.idValue).toBe('555555'); + expect(result.idlowerCased).toBe('fivem:555555'); + + result = idUtils.parsePlayerId('fivem:xxxxx'); + expect(result.isIdValid).toBe(false); +}); + +test('parsePlayerIds', () => { + const result = idUtils.parsePlayerIds(['fivem:555555', 'fivem:xxxxx']); + expect(result.validIdsArray).toEqual(['fivem:555555']); + expect(result.invalidIdsArray).toEqual(['fivem:xxxxx']); + expect(result.validIdsObject?.fivem).toBe('555555'); +}); + +test('filterPlayerHwids', () => { + const result = idUtils.filterPlayerHwids([ + '5:55555555000000002d267c6638c8873d55555555000000005555555500000000', + 'invalidHwid' + ]); + expect(result.validHwidsArray).toEqual(['5:55555555000000002d267c6638c8873d55555555000000005555555500000000']); + expect(result.invalidHwidsArray).toEqual(['invalidHwid']); +}); + +test('parseLaxIdsArrayInput', () => { + const result = idUtils.parseLaxIdsArrayInput('55555555000000009999, steam:1100001ffffffff, invalid'); + expect(result.validIds).toEqual(['discord:55555555000000009999', 'steam:1100001ffffffff']); + expect(result.invalids).toEqual(['invalid']); +}); + +test('getIdFromOauthNameid', () => { + expect(idUtils.getIdFromOauthNameid('https://forum.cfx.re/internal/user/555555')).toBe('fivem:555555'); + expect(idUtils.getIdFromOauthNameid('xxxxx')).toBe(false); +}); + +test('shortenId', () => { + // Invalid ids + expect(() => idUtils.shortenId(123 as any)).toThrow('id is not a string'); + expect(idUtils.shortenId('invalidFormat')).toBe('invalidFormat'); + expect(idUtils.shortenId(':1234567890123456')).toBe(':1234567890123456'); + expect(idUtils.shortenId('discord:')).toBe('discord:'); + + // Valid ID with length greater than >= 10 + expect(idUtils.shortenId('discord:383919883341266945')).toBe('discord:3839…6945'); + expect(idUtils.shortenId('xbl:12345678901')).toBe('xbl:1234…8901'); + + // Valid ID with length <= 10 (should not be shortened) + expect(idUtils.shortenId('fivem:1234567890')).toBe('fivem:1234567890'); + expect(idUtils.shortenId('steam:1234')).toBe('steam:1234'); +}); diff --git a/core/lib/player/idUtils.ts b/core/lib/player/idUtils.ts new file mode 100644 index 0000000..13712f3 --- /dev/null +++ b/core/lib/player/idUtils.ts @@ -0,0 +1,159 @@ +import type { PlayerIdsObjectType } from "@shared/otherTypes"; +import consts from "@shared/consts"; + + +/** + * Validates a single identifier and return its parts lowercased + */ +export const parsePlayerId = (idString: string) => { + if (typeof idString !== 'string') { + return { isIdValid: false, idType: null, idValue: null, idlowerCased: null }; + } + + const idlowerCased = idString.toLocaleLowerCase(); + const [idType, idValue] = idlowerCased.split(':', 2); + if (idType === "ip") { + return { isIdValid: false, idType, idValue, idlowerCased }; + } + return { isIdValid: true, idType, idValue, idlowerCased }; +} + + +/** + * Get valid, invalid and license identifier from array of ids + */ +export const parsePlayerIds = (ids: string[]) => { + let invalidIdsArray: string[] = []; + let validIdsArray: string[] = []; + const validIdsObject: PlayerIdsObjectType = {} + + for (const idString of ids) { + if (typeof idString !== 'string') continue; + const { isIdValid, idType, idValue } = parsePlayerId(idString); + if (isIdValid) { + validIdsArray.push(idString); + validIdsObject[idType as keyof PlayerIdsObjectType] = idValue; + } else { + invalidIdsArray.push(idString); + } + } + + return { invalidIdsArray, validIdsArray, validIdsObject }; +} + + +/** + * Get valid and invalid player HWIDs + */ +export const filterPlayerHwids = (hwids: string[]) => { + let invalidHwidsArray: string[] = []; + let validHwidsArray: string[] = []; + + for (const hwidString of hwids) { + if (typeof hwidString !== 'string') continue; + if (consts.regexValidHwidToken.test(hwidString)) { + validHwidsArray.push(hwidString); + } else { + invalidHwidsArray.push(hwidString); + } + } + + return { invalidHwidsArray, validHwidsArray }; +} + + +/** + * Attempts to parse a user-provided string into an array of valid identifiers. + * This function is lenient and will attempt to parse any string into an array of valid identifiers. + * For non-prefixed ids, it will attempt to parse it as discord, fivem, steam, or license. + * Returns an array of valid ids/hwids, and array of invalid identifiers. + * + * Stricter version of this function is parsePlayerIds + */ +export const parseLaxIdsArrayInput = (fullInput: string) => { + const validIds: string[] = []; + const validHwids: string[] = []; + const invalids: string[] = []; + + if (typeof fullInput !== 'string') { + return { validIds, validHwids, invalids }; + } + const inputs = fullInput.toLowerCase().split(/[,;\s]+/g).filter(Boolean); + + for (const input of inputs) { + if (input.includes(':')) { + if (consts.regexValidHwidToken.test(input)) { + validHwids.push(input); + } else if (Object.values(consts.validIdentifiers).some((regex) => regex.test(input))) { + validIds.push(input); + } else { + const [type, value] = input.split(':', 1); + if (consts.validIdentifierParts[type as keyof typeof consts.validIdentifierParts]?.test(value)) { + validIds.push(input); + } else { + invalids.push(input); + } + } + } else if (consts.validIdentifierParts.discord.test(input)) { + validIds.push(`discord:${input}`); + } else if (consts.validIdentifierParts.fivem.test(input)) { + validIds.push(`fivem:${input}`); + } else if (consts.validIdentifierParts.license.test(input)) { + validIds.push(`license:${input}`); + } else if (consts.validIdentifierParts.steam.test(input)) { + validIds.push(`steam:${input}`); + } else { + invalids.push(input); + } + } + + return { validIds, validHwids, invalids }; +} + + +/** + * Extracts the fivem:xxxxxx identifier from the nameid field from the userInfo oauth response. + * Example: https://forum.cfx.re/internal/user/271816 -> fivem:271816 + */ +export const getIdFromOauthNameid = (nameid: string) => { + try { + const res = /\/user\/(\d{1,8})/.exec(nameid); + //@ts-expect-error + return `fivem:${res[1]}`; + } catch (error) { + return false; + } +} + + +/** + * Shortens an ID/HWID string to just leading and trailing 4 characters. + * Unicode symbol alternatives: ‥,…,~,≈,-,•,◇ + */ +export const shortenId = (id: string) => { + if (typeof id !== 'string') throw new Error(`id is not a string`); + + const [idType, idValue] = id.split(':', 2); + if (!idType || !idValue) { + return id; // Invalid format, return as is + } + + if (idValue.length <= 10) { + return id; // Do not shorten if ID value is 10 characters or fewer + } + + const start = idValue.slice(0, 4); + const end = idValue.slice(-4); + return `${idType}:${start}…${end}`; +} + + +/** + * Returns a string of shortened IDs/HWIDs + */ +export const summarizeIdsArray = (ids: string[]) => { + if (!Array.isArray(ids)) return ''; + if (ids.length === 0) return ''; + const shortList = ids.map(shortenId).join(', '); + return `[${shortList}]`; +} diff --git a/core/lib/player/playerClasses.ts b/core/lib/player/playerClasses.ts new file mode 100644 index 0000000..bcbd248 --- /dev/null +++ b/core/lib/player/playerClasses.ts @@ -0,0 +1,365 @@ +const modulename = 'Player'; +import cleanPlayerName from '@shared/cleanPlayerName'; +import { DatabaseActionWarnType, DatabasePlayerType, DatabaseWhitelistApprovalsType } from '@modules/Database/databaseTypes'; +import { cloneDeep, union } from 'lodash-es'; +import { now } from '@lib/misc'; +import { parsePlayerIds } from '@lib/player/idUtils'; +import consoleFactory from '@lib/console'; +import consts from '@shared/consts'; +import type FxPlayerlist from '@modules/FxPlayerlist'; +const console = consoleFactory(modulename); + + +/** + * Base class for ServerPlayer and DatabasePlayer. + * NOTE: player classes are responsible to every and only business logic regarding the player object in the database. + * In the future, when actions become part of the player object, also add them to these classes. + */ +export class BasePlayer { + displayName: string = 'unknown'; + pureName: string = 'unknown'; + ids: string[] = []; + hwids: string[] = []; + license: null | string = null; //extracted for convenience + dbData: false | DatabasePlayerType = false; + isConnected: boolean = false; + + constructor(readonly uniqueId: Symbol) { } + + /** + * Mutates the database data based on a source object to be applied + * FIXME: if this is called for a disconnected ServerPlayer, it will not clean after 120s + */ + protected mutateDbData(srcData: object) { + if (!this.license) throw new Error(`cannot mutate database for a player that has no license`); + this.dbData = txCore.database.players.update(this.license, srcData, this.uniqueId); + } + + /** + * Returns all available identifiers (current+db) + */ + getAllIdentifiers() { + if (this.dbData && this.dbData.ids) { + return union(this.ids, this.dbData.ids); + } else { + return [...this.ids]; + } + } + + /** + * Returns all available hardware identifiers (current+db) + */ + getAllHardwareIdentifiers() { + if (this.dbData && this.dbData.hwids) { + return union(this.hwids, this.dbData.hwids); + } else { + return [...this.hwids]; + } + } + + /** + * Returns all actions related to all available ids + * NOTE: theoretically ServerPlayer.setupDatabaseData() guarantees that DatabasePlayer.dbData.ids array + * will contain the license but may be better to also explicitly add it to the array here? + */ + getHistory() { + if (!this.ids.length) return []; + return txCore.database.actions.findMany( + this.getAllIdentifiers(), + this.getAllHardwareIdentifiers() + ); + } + + /** + * Saves notes for this player. + * NOTE: Techinically, we should be checking this.isRegistered, but not available in BasePlayer + */ + setNote(text: string, author: string) { + if (!this.license) throw new Error(`cannot save notes for a player that has no license`); + this.mutateDbData({ + notes: { + text, + lastAdmin: author, + tsLastEdit: now(), + } + }); + } + + /** + * Saves the whitelist status for this player + * NOTE: Techinically, we should be checking this.isRegistered, but not available in BasePlayer + */ + setWhitelist(enabled: boolean) { + if (!this.license) throw new Error(`cannot set whitelist status for a player that has no license`); + this.mutateDbData({ + tsWhitelisted: enabled ? now() : undefined, + }); + + //Remove entries from whitelistApprovals & whitelistRequests + const allIdsFilter = (x: DatabaseWhitelistApprovalsType) => { + return this.ids.includes(x.identifier); + } + txCore.database.whitelist.removeManyApprovals(allIdsFilter); + txCore.database.whitelist.removeManyRequests({ license: this.license }); + } +} + + +type PlayerDataType = { + name: string, + ids: string[], + hwids: string[], +} + +/** + * Class to represent a player that is or was connected to the currently running server process. + */ +export class ServerPlayer extends BasePlayer { + readonly #fxPlayerlist: FxPlayerlist; + // readonly psid: string; //TODO: calculate player session id (sv mutex, netid, rollover id) here + readonly netid: number; + readonly tsConnected = now(); + readonly isRegistered: boolean; + readonly #minuteCronInterval?: ReturnType; + // #offlineDbDataCacheTimeout?: ReturnType; + + constructor( + netid: number, + playerData: PlayerDataType, + fxPlayerlist: FxPlayerlist + ) { + super(Symbol(`netid${netid}`)); + this.#fxPlayerlist = fxPlayerlist; + this.netid = netid; + this.isConnected = true; + if ( + playerData === null + || typeof playerData !== 'object' + || typeof playerData.name !== 'string' + || !Array.isArray(playerData.ids) + || !Array.isArray(playerData.hwids) + ) { + throw new Error(`invalid player data`); + } + + //Processing identifiers + //NOTE: ignoring IP completely + const { validIdsArray, validIdsObject } = parsePlayerIds(playerData.ids); + this.license = validIdsObject.license; + this.ids = validIdsArray; + this.hwids = playerData.hwids.filter(x => { + return typeof x === 'string' && consts.regexValidHwidToken.test(x); + }); + + //Processing player name + const { displayName, pureName } = cleanPlayerName(playerData.name); + this.displayName = displayName; + this.pureName = pureName; + + //If this player is eligible to be on the database + if (this.license) { + this.#setupDatabaseData(); + this.isRegistered = !!this.dbData; + this.#minuteCronInterval = setInterval(this.#minuteCron.bind(this), 60_000); + } else { + this.isRegistered = false; + } + console.log(167, this.isRegistered) + } + + + /** + * Registers or retrieves the player data from the database. + * NOTE: if player has license, we are guaranteeing license will be added to the database ids array + */ + #setupDatabaseData() { + if (!this.license || !this.isConnected) return; + + //Make sure the database is ready - this should be impossible + if (!txCore.database.isReady) { + console.error(`Players database not yet ready, cannot read db status for player id ${this.displayName}.`); + return; + } + + //Check if player is already on the database + try { + const dbPlayer = txCore.database.players.findOne(this.license); + if (dbPlayer) { + //Updates database data + this.dbData = dbPlayer; + this.mutateDbData({ + displayName: this.displayName, + pureName: this.pureName, + tsLastConnection: this.tsConnected, + ids: union(dbPlayer.ids, this.ids), + hwids: union(dbPlayer.hwids, this.hwids), + }); + } else { + //Register player to the database + console.log(`Registering '${this.displayName}' to players database.`); + const toRegister = { + license: this.license, + ids: this.ids, + hwids: this.hwids, + displayName: this.displayName, + pureName: this.pureName, + playTime: 0, + tsLastConnection: this.tsConnected, + tsJoined: this.tsConnected, + }; + txCore.database.players.register(toRegister); + this.dbData = toRegister; + console.verbose.ok(`Adding '${this.displayName}' to players database.`); + + } + setImmediate(this.#sendInitialData.bind(this)); + } catch (error) { + console.error(`Failed to load/register player ${this.displayName} from/to the database with error:`); + console.dir(error); + } + } + + /** + * Prepares the initial player data and reports to FxPlayerlist, which will dispatch to the server via command. + * TODO: adapt to be used for admin auth and player tags. + */ + #sendInitialData() { + if (!this.isRegistered) return; + if (!this.dbData) throw new Error(`cannot send initial data for a player that has no dbData`); + + let oldestPendingWarn: undefined | DatabaseActionWarnType; + const actionHistory = this.getHistory(); + for (const action of actionHistory) { + if (action.type !== 'warn' || action.revocation.timestamp !== null) continue; + if (!action.acked) { + oldestPendingWarn = action; + break; + } + } + + if (oldestPendingWarn) { + this.#fxPlayerlist.dispatchInitialPlayerData(this.netid, oldestPendingWarn); + } + } + + /** + * Sets the dbData. + * Used when some other player instance mutates the database and we need to sync all players + * with the same license. + */ + syncUpstreamDbData(srcData: DatabasePlayerType) { + this.dbData = cloneDeep(srcData) + } + + /** + * Returns a clone of this.dbData. + * If the data is not available, it means the player was disconnected and dbData wiped to save memory, + * so start an 120s interval to wipe it from memory again. This period can be considered a "cache" + * FIXME: review dbData optimization, 50k players would be up to 50mb + */ + getDbData() { + if (this.dbData) { + return cloneDeep(this.dbData); + } else if (this.license && this.isRegistered) { + const dbPlayer = txCore.database.players.findOne(this.license); + if (!dbPlayer) return false; + + this.dbData = dbPlayer; + // clearTimeout(this.#offlineDbDataCacheTimeout); //maybe not needed? + // this.#offlineDbDataCacheTimeout = setTimeout(() => { + // this.dbData = false; + // }, 120_000); + return cloneDeep(this.dbData); + } else { + return false; + } + } + + + /** + * Updates dbData play time every minute + */ + #minuteCron() { + //FIXME: maybe use UIntXarray or mnemonist.Uint16Vector circular buffers to save memory + //TODO: rough draft of a playtime tracking system written before note above + // let list: [day: string, mins: number][] = []; + // const today = new Date; + // const currDay = today.toISOString().split('T')[0]; + // if(!list.length){ + // list.push([currDay, 1]); + // return; + // } + // if(list.at(-1)![0] === currDay){ + // list.at(-1)![1]++; + // } else { + // //FIXME: move this cutoff to a const in the database or playerlist manager + // const cutoffTs = today.setUTCHours(0, 0, 0, 0) - 1000 * 60 * 60 * 24 * 28; + // const cutoffIndex = list.findIndex(x => new Date(x[0]).getTime() < cutoffTs); + // list = list.slice(cutoffIndex); + // list.push([currDay, 1]); + // } + + + if (!this.dbData || !this.isConnected) return; + try { + this.mutateDbData({ playTime: this.dbData.playTime + 1 }); + } catch (error) { + console.warn(`Failed to update playtime for player ${this.displayName}:`); + console.dir(error); + } + } + + + /** + * Marks this player as disconnected, clears dbData (mem optimization) and clears minute cron + */ + disconnect() { + this.isConnected = false; + // this.dbData = false; + clearInterval(this.#minuteCronInterval); + } +} + + +/** + * Class to represent players stored in the database. + */ +export class DatabasePlayer extends BasePlayer { + readonly isRegistered = true; //no need to check because otherwise constructor throws + + constructor(license: string, srcPlayerData?: DatabasePlayerType) { + super(Symbol(`db${license}`)); + if (typeof license !== 'string') { + throw new Error(`invalid player license`); + } + + //Set dbData either from constructor params, or from querying the database + if (srcPlayerData) { + this.dbData = srcPlayerData; + } else { + const foundData = txCore.database.players.findOne(license); + if (!foundData) { + throw new Error(`player not found in database`); + } else { + this.dbData = foundData; + } + } + + //fill in data + this.license = license; + this.ids = this.dbData.ids; + this.hwids = this.dbData.hwids; + this.displayName = this.dbData.displayName; + this.pureName = this.dbData.pureName; + } + + /** + * Returns a clone of this.dbData + */ + getDbData() { + return cloneDeep(this.dbData); + } +} + + +export type PlayerClass = ServerPlayer | DatabasePlayer; diff --git a/core/lib/player/playerFinder.ts b/core/lib/player/playerFinder.ts new file mode 100644 index 0000000..9a73ab6 --- /dev/null +++ b/core/lib/player/playerFinder.ts @@ -0,0 +1,15 @@ +import { DatabasePlayerType } from "@modules/Database/databaseTypes.js"; +import { DatabasePlayer } from "./playerClasses.js" + + +/** + * Finds all players in the database with a particular matching identifier + */ +export const findPlayersByIdentifier = (identifier: string): DatabasePlayer[] => { + if(typeof identifier !== 'string' || !identifier.length) throw new Error(`invalid identifier`); + + const filter = (player: DatabasePlayerType) => player.ids.includes(identifier); + const playersData = txCore.database.players.findMany(filter); + + return playersData.map((dbData) => new DatabasePlayer(dbData.license, dbData)) +} diff --git a/core/lib/player/playerResolver.ts b/core/lib/player/playerResolver.ts new file mode 100644 index 0000000..b768add --- /dev/null +++ b/core/lib/player/playerResolver.ts @@ -0,0 +1,62 @@ +import { SYM_CURRENT_MUTEX } from "@lib/symbols.js"; +import { DatabasePlayer, ServerPlayer } from "./playerClasses.js" + + +/** + * Resolves a ServerPlayer or DatabasePlayer based on mutex, netid and license. + * When mutex#netid is present, it takes precedence over license. + * If the mutex is not from the current server, search for the license in FxPlayerlist.licenseCache[] + * and then search for the license in the database. + */ +export default (mutex: any, netid: any, license: any) => { + const parsedNetid = parseInt(netid); + let searchLicense = license; + + //For error clarification only + let hasMutex = false; + + //Attempt to resolve current mutex, if needed + if(mutex === SYM_CURRENT_MUTEX){ + mutex = txCore.fxRunner.child?.mutex; + if (!mutex) { + throw new Error(`current mutex not available`); + } + } + + //If mutex+netid provided + if (typeof mutex === 'string' && typeof netid === 'number' && !isNaN(parsedNetid)) { + hasMutex = true; + if (mutex && mutex === txCore.fxRunner.child?.mutex) { + //If the mutex is from the server currently online + const player = txCore.fxPlayerlist.getPlayerById(netid); + if (player instanceof ServerPlayer) { + return player; + } else { + throw new Error(`player not found in current server playerlist`); + } + } else { + // If mutex is from previous server, overwrite any given license + const searchRef = `${mutex}#${netid}`; + const found = txCore.fxPlayerlist.licenseCache.find(c => c[0] === searchRef); + if (found) searchLicense = found[1]; + } + } + + //If license provided or resolved through licenseCache, search in the database + if (typeof searchLicense === 'string' && searchLicense.length) { + const onlineMatches = txCore.fxPlayerlist.getOnlinePlayersByLicense(searchLicense); + if(onlineMatches.length){ + return onlineMatches.at(-1) as ServerPlayer; + }else{ + return new DatabasePlayer(searchLicense); + } + } + + //Player not found + //If not found in the db, the search above already threw error + if(hasMutex){ + throw new Error(`could not resolve player by its net id which likely means it has disconnected long ago`); + }else{ + throw new Error(`could not resolve this player`); + } +} diff --git a/core/lib/quitProcess.ts b/core/lib/quitProcess.ts new file mode 100644 index 0000000..9ffbd01 --- /dev/null +++ b/core/lib/quitProcess.ts @@ -0,0 +1,18 @@ +/** + * Force Quits the process with a small delay and padding for the console. + */ +export default function quitProcess(code = 0): never { + //Process.exit will not quit if there are listeners on exit + process.removeAllListeners('SIGHUP'); + process.removeAllListeners('SIGINT'); + process.removeAllListeners('SIGTERM'); + + //Hacky solution to guarantee the error is flushed + //before fxserver double prints the exit code + process.stdout.write('\n'); + process.stdout.write('\n'); + + //This will make the process hang for 100ms before exiting + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); + process.exit(code); +} diff --git a/core/lib/symbols.ts b/core/lib/symbols.ts new file mode 100644 index 0000000..fc02bb0 --- /dev/null +++ b/core/lib/symbols.ts @@ -0,0 +1,16 @@ +/** + * ConfigStore Schemas + */ +//Symbols used to mark the validation fail behavior +export const SYM_FIXER_FATAL = Symbol('ConfigSchema:FixerFatalError'); +export const SYM_FIXER_DEFAULT = Symbol('ConfigSchema:FixerFallbackDefault'); + +//Other symbols +export const SYM_RESET_CONFIG = Symbol('ConfigSchema:SaverResetConfig'); + + +/** + * Other symbols + */ +export const SYM_SYSTEM_AUTHOR = Symbol('Definition:AuthorIsSystem'); +export const SYM_CURRENT_MUTEX = Symbol('Definition:CurrentServerMutex'); diff --git a/core/lib/xss.js b/core/lib/xss.js new file mode 100644 index 0000000..26aabc9 --- /dev/null +++ b/core/lib/xss.js @@ -0,0 +1,13 @@ +import xssClass from 'xss'; + + +/** + * Returns a function with the passed whitelist parameter. + * https://github.com/leizongmin/js-xss#whitelist + */ +export default (customWL = []) => { + const xss = new xssClass.FilterXSS({ + whiteList: customWL, + }); + return (x) => xss.process(x); +}; diff --git a/core/modules/AdminStore/index.js b/core/modules/AdminStore/index.js new file mode 100644 index 0000000..422fffd --- /dev/null +++ b/core/modules/AdminStore/index.js @@ -0,0 +1,682 @@ +const modulename = 'AdminStore'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import { cloneDeep } from 'lodash-es'; +import { nanoid } from 'nanoid'; +import { txHostConfig } from '@core/globalData'; +import CfxProvider from './providers/CitizenFX.js'; +import { createHash } from 'node:crypto'; +import consoleFactory from '@lib/console.js'; +import fatalError from '@lib/fatalError.js'; +import { chalkInversePad } from '@lib/misc.js'; +const console = consoleFactory(modulename); + +//NOTE: The way I'm doing versioning right now is horrible but for now it's the best I can do +//NOTE: I do not need to version every admin, just the file itself +const ADMIN_SCHEMA_VERSION = 1; + + +//Helpers +const migrateProviderIdentifiers = (providerName, providerData) => { + if (providerName === 'citizenfx') { + // data may be empty, or nameid may be invalid + try { + const res = /\/user\/(\d{1,8})/.exec(providerData.data.nameid); + providerData.identifier = `fivem:${res[1]}`; + } catch (error) { + providerData.identifier = 'fivem:00000000'; + } + } else if (providerName === 'discord') { + providerData.identifier = `discord:${providerData.id}`; + } +}; + + +/** + * Module responsible for storing, retrieving and validating admins data. + */ +export default class AdminStore { + constructor() { + this.adminsFile = txHostConfig.dataSubPath('admins.json'); + this.adminsFileHash = null; + this.admins = null; + this.refreshRoutine = null; + + //Not alphabetical order, but that's fine + //FIXME: move to a separate file + //TODO: maybe put in @shared so the frontend's UnauthorizedPage can use it + //TODO: when migrating the admins page to react, definitely put this in @shared so the front rendering doesn't depend on the backend response - lessons learned from the settings page. + //FIXME: if not using enums, definitely use so other type of type safety + //FIXME: maybe rename all_permissions to `administrator` (just like discord) or `super_admin` and rename the `Admins` page to `Users`. This fits better with how people use txAdmin as "mods" are not really admins + this.registeredPermissions = { + 'all_permissions': 'All Permissions', + 'manage.admins': 'Manage Admins', //will enable the "set admin" button in the player modal + 'settings.view': 'Settings: View (no tokens)', + 'settings.write': 'Settings: Change', + 'console.view': 'Console: View', + 'console.write': 'Console: Write', + 'control.server': 'Start/Stop Server + Scheduler', //FIXME: horrible name + 'announcement': 'Send Announcements', + 'commands.resources': 'Start/Stop Resources', + 'server.cfg.editor': 'Read/Write server.cfg', //FIXME: rename to server.cfg_editor + 'txadmin.log.view': 'View System Logs', //FIXME: rename to system.log.view + 'server.log.view': 'View Server Logs', + + 'menu.vehicle': 'Spawn / Fix Vehicles', + 'menu.clear_area': 'Reset world area', + 'menu.viewids': 'View Player IDs in-game', //be able to see the ID of the players + 'players.direct_message': 'Direct Message', + 'players.whitelist': 'Whitelist', + 'players.warn': 'Warn', + 'players.kick': 'Kick', + 'players.ban': 'Ban', + 'players.freeze': 'Freeze Players', + 'players.heal': 'Heal', //self, everyone, and the "heal" button in player modal + 'players.playermode': 'NoClip / God Mode', //self playermode, and also the player spectate option + 'players.spectate': 'Spectate', //self playermode, and also the player spectate option + 'players.teleport': 'Teleport', //self teleport, and the bring/go to on player modal + 'players.troll': 'Troll Actions', //all the troll options in the player modal + }; + //FIXME: pode remover, hardcode na cron function + this.hardConfigs = { + refreshInterval: 15e3, + }; + + + //Load providers + //FIXME: pode virar um top-level singleton , não precisa estar na classe + try { + this.providers = { + discord: false, + citizenfx: new CfxProvider(), + }; + } catch (error) { + throw new Error(`Failed to load providers with error: ${error.message}`); + } + + //Check if admins file exists + let adminFileExists; + try { + fs.statSync(this.adminsFile, fs.constants.F_OK); + adminFileExists = true; + } catch (error) { + if (error.code === 'ENOENT') { + adminFileExists = false; + } else { + throw new Error(`Failed to check presence of admin file with error: ${error.message}`); + } + } + + //Printing PIN or starting loop + if (!adminFileExists) { + if (!txHostConfig.defaults.account) { + this.addMasterPin = (Math.random() * 10000).toFixed().padStart(4, '0'); + this.admins = false; + } else { + const { username, fivemId, password } = txHostConfig.defaults.account; + this.createAdminsFile( + username, + fivemId ? `fivem:${fivemId}` : undefined, + undefined, + password, + password ? false : undefined, + ); + console.ok(`Created master account ${chalkInversePad(username)} with credentials provided by ${txHostConfig.sourceName}.`); + } + } else { + this.loadAdminsFile(); + this.setupRefreshRoutine(); + } + } + + + /** + * sets the admins file refresh routine + */ + setupRefreshRoutine() { + this.refreshRoutine = setInterval(() => { + this.checkAdminsFile(); + }, this.hardConfigs.refreshInterval); + } + + + /** + * Creates a admins.json file based on the first account + * @param {string} username + * @param {string|undefined} fivemId with the fivem: prefix + * @param {string|undefined} discordId with the discord: prefix + * @param {string|undefined} password backup password + * @param {boolean|undefined} isPlainTextPassword + * @returns {(boolean)} true or throws an error + */ + createAdminsFile(username, fivemId, discordId, password, isPlainTextPassword) { + //Sanity check + if (this.admins !== false && this.admins !== null) throw new Error('Admins file already exists.'); + if (typeof username !== 'string' || username.length < 3) throw new Error('Invalid username parameter.'); + + //Handling password + let password_hash, password_temporary; + if(password){ + password_hash = isPlainTextPassword ? GetPasswordHash(password) : password; + // password_temporary = false; //undefined will do the same + } else { + const veryRandomString = `${username}-password-not-meant-to-be-used-${nanoid()}`; + password_hash = GetPasswordHash(veryRandomString); + password_temporary = true; + } + + //Handling third party providers + const providers = {}; + if (fivemId) { + providers.citizenfx = { + id: username, + identifier: fivemId, + data: {}, + }; + } + if (discordId) { + providers.discord = { + id: discordId, + identifier: `discord:${discordId}`, + data: {}, + }; + } + + //Creating new admin + const newAdmin = { + $schema: ADMIN_SCHEMA_VERSION, + name: username, + master: true, + password_hash, + password_temporary, + providers, + permissions: [], + }; + this.admins = [newAdmin]; + this.addMasterPin = undefined; + + //Saving admin file + try { + const jsonData = JSON.stringify(this.admins); + this.adminsFileHash = createHash('sha1').update(jsonData).digest('hex'); + fs.writeFileSync(this.adminsFile, jsonData, { encoding: 'utf8', flag: 'wx' }); + this.setupRefreshRoutine(); + return newAdmin; + } catch (error) { + let message = `Failed to create '${this.adminsFile}' with error: ${error.message}`; + console.verbose.error(message); + throw new Error(message); + } + } + + + /** + * Returns a list of admins and permissions + */ + getAdminsList() { + if (this.admins == false) return []; + return this.admins.map((user) => { + return { + name: user.name, + master: user.master, + providers: Object.keys(user.providers), + permissions: user.permissions, + }; + }); + } + + + /** + * Returns the raw array of admins, except for the hash + */ + getRawAdminsList() { + if (this.admins === false) return []; + return cloneDeep(this.admins); + } + + + /** + * Returns all data from an admin by provider user id (ex discord id), or false + * @param {string} uid + */ + getAdminByProviderUID(uid) { + if (this.admins == false) return false; + let id = uid.trim().toLowerCase(); + if (!id.length) return false; + let admin = this.admins.find((user) => { + return Object.keys(user.providers).find((provider) => { + return (id === user.providers[provider].id.toLowerCase()); + }); + }); + return (admin) ? cloneDeep(admin) : false; + } + + + /** + * Returns an array with all identifiers of the admins (fivem/discord) + */ + getAdminsIdentifiers() { + if (this.admins === false) return []; + const ids = []; + for (const admin of this.admins) { + admin.providers.citizenfx && ids.push(admin.providers.citizenfx.identifier); + admin.providers.discord && ids.push(admin.providers.discord.identifier); + } + return ids; + } + + + /** + * Returns all data from an admin by their name, or false + * @param {string} uname + */ + getAdminByName(uname) { + if (!this.admins) return false; + const username = uname.trim().toLowerCase(); + if (!username.length) return false; + const admin = this.admins.find((user) => { + return (username === user.name.toLowerCase()); + }); + return (admin) ? cloneDeep(admin) : false; + } + + + /** + * Returns all data from an admin by game identifier, or false + * @param {string[]} identifiers + */ + getAdminByIdentifiers(identifiers) { + if (!this.admins) return false; + identifiers = identifiers + .map((i) => i.trim().toLowerCase()) + .filter((i) => i.length); + if (!identifiers.length) return false; + const admin = this.admins.find((user) => + identifiers.find((identifier) => + Object.keys(user.providers).find((provider) => + (identifier === user.providers[provider].identifier.toLowerCase())))); + return (admin) ? cloneDeep(admin) : false; + } + + + /** + * Returns a list with all registered permissions + */ + getPermissionsList() { + return cloneDeep(this.registeredPermissions); + } + + + /** + * Writes to storage the admins file + */ + async writeAdminsFile() { + const jsonData = JSON.stringify(this.admins, null, 2); + this.adminsFileHash = createHash('sha1').update(jsonData).digest('hex'); + await fsp.writeFile(this.adminsFile, jsonData, 'utf8'); + return true; + } + + + /** + * Writes to storage the admins file + */ + async checkAdminsFile() { + const restore = async () => { + try { + await this.writeAdminsFile(); + console.ok('Restored admins.json file.'); + } catch (error) { + console.error(`Failed to restore admins.json file: ${error.message}`); + console.verbose.dir(error); + } + }; + try { + const jsonData = await fsp.readFile(this.adminsFile, 'utf8'); + const inboundHash = createHash('sha1').update(jsonData).digest('hex'); + if (this.adminsFileHash !== inboundHash) { + console.warn('The admins.json file was modified or deleted by an external source, txAdmin will try to restore it.'); + restore(); + } + } catch (error) { + console.error(`Cannot check admins file integrity: ${error.message}`); + } + } + + + /** + * Add a new admin to the admins file + * NOTE: I'm fully aware this coud be optimized. Leaving this way to improve readability and error verbosity + * @param {string} name + * @param {object|undefined} citizenfxData or false + * @param {object|undefined} discordData or false + * @param {string} password + * @param {array} permissions + */ + async addAdmin(name, citizenfxData, discordData, password, permissions) { + if (this.admins == false) throw new Error('Admins not set'); + + //Check if username is already taken + if (this.getAdminByName(name)) throw new Error('Username already taken'); + + //Preparing admin + const admin = { + $schema: ADMIN_SCHEMA_VERSION, + name, + master: false, + password_hash: GetPasswordHash(password), + password_temporary: true, + providers: {}, + permissions, + }; + + //Check if provider uid already taken and inserting into admin object + if (citizenfxData) { + const existingCitizenFX = this.getAdminByProviderUID(citizenfxData.id); + if (existingCitizenFX) throw new Error('CitizenFX ID already taken'); + admin.providers.citizenfx = { + id: citizenfxData.id, + identifier: citizenfxData.identifier, + data: {}, + }; + } + if (discordData) { + const existingDiscord = this.getAdminByProviderUID(discordData.id); + if (existingDiscord) throw new Error('Discord ID already taken'); + admin.providers.discord = { + id: discordData.id, + identifier: discordData.identifier, + data: {}, + }; + } + + //Saving admin file + this.admins.push(admin); + this.refreshOnlineAdmins().catch((e) => { }); + try { + return await this.writeAdminsFile(); + } catch (error) { + throw new Error(`Failed to save admins.json with error: ${error.message}`); + } + } + + + /** + * Edit admin and save to the admins file + * @param {string} name + * @param {string|null} password + * @param {object|false} [citizenfxData] or false + * @param {object|false} [discordData] or false + * @param {string[]} [permissions] + */ + async editAdmin(name, password, citizenfxData, discordData, permissions) { + if (this.admins == false) throw new Error('Admins not set'); + + //Find admin index + let username = name.toLowerCase(); + let adminIndex = this.admins.findIndex((user) => { + return (username === user.name.toLowerCase()); + }); + if (adminIndex == -1) throw new Error('Admin not found'); + + //Editing admin + if (password !== null) { + this.admins[adminIndex].password_hash = GetPasswordHash(password); + delete this.admins[adminIndex].password_temporary; + } + if (typeof citizenfxData !== 'undefined') { + if (!citizenfxData) { + delete this.admins[adminIndex].providers.citizenfx; + } else { + this.admins[adminIndex].providers.citizenfx = { + id: citizenfxData.id, + identifier: citizenfxData.identifier, + data: {}, + }; + } + } + if (typeof discordData !== 'undefined') { + if (!discordData) { + delete this.admins[adminIndex].providers.discord; + } else { + this.admins[adminIndex].providers.discord = { + id: discordData.id, + identifier: discordData.identifier, + data: {}, + }; + } + } + if (typeof permissions !== 'undefined') this.admins[adminIndex].permissions = permissions; + + //Prevent race condition, will allow the session to be updated before refreshing socket.io + //sessions which will cause reauth and closing of the temp password modal on first access + setTimeout(() => { + this.refreshOnlineAdmins().catch((e) => { }); + }, 250); + + //Saving admin file + try { + await this.writeAdminsFile(); + return (password !== null) ? this.admins[adminIndex].password_hash : true; + } catch (error) { + throw new Error(`Failed to save admins.json with error: ${error.message}`); + } + } + + + /** + * Delete admin and save to the admins file + * @param {string} name + */ + async deleteAdmin(name) { + if (this.admins == false) throw new Error('Admins not set'); + + //Delete admin + let username = name.toLowerCase(); + let found = false; + this.admins = this.admins.filter((user) => { + if (username !== user.name.toLowerCase()) { + return true; + } else { + found = true; + return false; + } + }); + if (!found) throw new Error('Admin not found'); + + //Saving admin file + this.refreshOnlineAdmins().catch((e) => { }); + try { + return await this.writeAdminsFile(); + } catch (error) { + throw new Error(`Failed to save admins.json with error: ${error.message}`); + } + } + + /** + * Loads the admins.json file into the admins list + * NOTE: The verbosity here is driving me insane. + * But still seems not to be enough for people that don't read the README. + */ + async loadAdminsFile() { + let raw = null; + let jsonData = null; + let hasMigration = false; + + const callError = (reason) => { + let details; + if (reason === 'cannot read file') { + details = ['This means the file doesn\'t exist or txAdmin doesn\'t have permission to read it.']; + } else { + details = [ + 'This likely means the file got somehow corrupted.', + 'You can try restoring it or you can delete it and let txAdmin create a new one.', + ]; + } + fatalError.AdminStore(0, [ + ['Unable to load admins.json', reason], + ...details, + ['Admin File Path', this.adminsFile], + ]); + }; + + try { + raw = await fsp.readFile(this.adminsFile, 'utf8'); + this.adminsFileHash = createHash('sha1').update(raw).digest('hex'); + } catch (error) { + return callError('cannot read file'); + } + + if (!raw.length) { + return callError('empty file'); + } + + try { + jsonData = JSON.parse(raw); + } catch (error) { + return callError('json parse error'); + } + + if (!Array.isArray(jsonData)) { + return callError('not an array'); + } + + if (!jsonData.length) { + return callError('no admins'); + } + + const structureIntegrityTest = jsonData.some((x) => { + if (typeof x.name !== 'string' || x.name.length < 3) return true; + if (typeof x.master !== 'boolean') return true; + if (typeof x.password_hash !== 'string' || !x.password_hash.startsWith('$2')) return true; + if (typeof x.providers !== 'object') return true; + const providersTest = Object.keys(x.providers).some((y) => { + if (!Object.keys(this.providers).includes(y)) return true; + if (typeof x.providers[y].id !== 'string' || x.providers[y].id.length < 3) return true; + if (typeof x.providers[y].data !== 'object') return true; + if (typeof x.providers[y].identifier === 'string') { + if (x.providers[y].identifier.length < 3) return true; + } else { + migrateProviderIdentifiers(y, x.providers[y]); + hasMigration = true; + } + }); + if (providersTest) return true; + if (!Array.isArray(x.permissions)) return true; + return false; + }); + if (structureIntegrityTest) { + return callError('invalid data in the admins file'); + } + + const masters = jsonData.filter((x) => x.master); + if (masters.length !== 1) { + return callError('must have exactly 1 master account'); + } + + //Migrate admin stuff + jsonData.forEach((admin) => { + //Migration (tx v7.3.0) + if (admin.$schema === undefined) { + //adding schema version + admin.$schema = ADMIN_SCHEMA_VERSION; + hasMigration = true; + + //separate DM and Announcement permissions + if (admin.permissions.includes('players.message')) { + hasMigration = true; + admin.permissions = admin.permissions.filter((perm) => perm !== 'players.message'); + admin.permissions.push('players.direct_message'); + admin.permissions.push('announcement'); + } + + //Adding the new permission, except if they have no permissions or all of them + if (admin.permissions.length && !admin.permissions.includes('all_permissions')) { + admin.permissions.push('server.log.view'); + } + } + }); + + this.admins = jsonData; + if (hasMigration) { + try { + await this.writeAdminsFile(); + console.ok('The admins.json file was migrated to a new version.'); + } catch (error) { + console.error(`Failed to migrate admins.json with error: ${error.message}`); + } + } + + return true; + } + + + /** + * Notify game server about admin changes + */ + async refreshOnlineAdmins() { + //Refresh auth of all admins connected to socket.io + txCore.webServer.webSocket.reCheckAdminAuths().catch((e) => { }); + + try { + //Getting all admin identifiers + const adminIDs = this.admins.reduce((ids, adm) => { + const adminIDs = Object.keys(adm.providers).map((pName) => adm.providers[pName].identifier); + return ids.concat(adminIDs); + }, []); + + //Finding online admins + const playerList = txCore.fxPlayerlist.getPlayerList(); + const onlineIDs = playerList.filter((p) => { + return p.ids.some((i) => adminIDs.includes(i)); + }).map((p) => p.netid); + + txCore.fxRunner.sendEvent('adminsUpdated', onlineIDs); + } catch (error) { + console.verbose.error('Failed to refreshOnlineAdmins() with error:'); + console.verbose.dir(error); + } + } + + + /** + * Returns a random token to be used as CSRF Token. + */ + genCsrfToken() { + return nanoid(); + } + + + /** + * Checks if there are admins configured or not. + * Optionally, prints the master PIN on the console. + */ + hasAdmins(printPin = false) { + if (Array.isArray(this.admins) && this.admins.length) { + return true; + } else { + if (printPin) { + console.warn('Use this PIN to add a new master account: ' + chalkInversePad(this.addMasterPin)); + } + return false; + } + } + + + /** + * Returns the public name to display for that particular purpose + * TODO: maybe use enums for the purpose + */ + getAdminPublicName(name, purpose) { + if (!name || !purpose) throw new Error('Invalid parameters'); + const replacer = txConfig.general.serverName ?? 'txAdmin'; + + if (purpose === 'punishment') { + return txConfig.gameFeatures.hideAdminInPunishments ? replacer : name; + } else if (purpose === 'message') { + return txConfig.gameFeatures.hideAdminInMessages ? replacer : name; + } else { + throw new Error(`Invalid purpose: ${purpose}`); + } + } +}; diff --git a/core/modules/AdminStore/providers/CitizenFX.ts b/core/modules/AdminStore/providers/CitizenFX.ts new file mode 100644 index 0000000..8a50c3d --- /dev/null +++ b/core/modules/AdminStore/providers/CitizenFX.ts @@ -0,0 +1,107 @@ +const modulename = 'AdminStore:CfxProvider'; +import crypto from 'node:crypto'; +import { BaseClient, Issuer, custom } from 'openid-client'; +import { URL } from 'node:url'; +import consoleFactory from '@lib/console'; +import { z } from 'zod'; +const console = consoleFactory(modulename); + +const userInfoSchema = z.object({ + name: z.string().min(1), + profile: z.string().min(1), + nameid: z.string().min(1), +}); +export type UserInfoType = z.infer & { picture: string | undefined }; + +const getOauthState = (stateKern: string) => { + const stateSeed = `tx:cfxre:${stateKern}`; + return crypto.createHash('SHA1').update(stateSeed).digest('hex'); +}; + + +export default class CfxProvider { + private client?: BaseClient; + + constructor() { + //NOTE: using static config due to performance concerns + // const fivemIssuer = await Issuer.discover('https://idms.fivem.net/.well-known/openid-configuration'); + const fivemIssuer = new Issuer({ 'issuer': 'https://idms.fivem.net', 'jwks_uri': 'https://idms.fivem.net/.well-known/openid-configuration/jwks', 'authorization_endpoint': 'https://idms.fivem.net/connect/authorize', 'token_endpoint': 'https://idms.fivem.net/connect/token', 'userinfo_endpoint': 'https://idms.fivem.net/connect/userinfo', 'end_session_endpoint': 'https://idms.fivem.net/connect/endsession', 'check_session_iframe': 'https://idms.fivem.net/connect/checksession', 'revocation_endpoint': 'https://idms.fivem.net/connect/revocation', 'introspection_endpoint': 'https://idms.fivem.net/connect/introspect', 'device_authorization_endpoint': 'https://idms.fivem.net/connect/deviceauthorization', 'frontchannel_logout_supported': true, 'frontchannel_logout_session_supported': true, 'backchannel_logout_supported': true, 'backchannel_logout_session_supported': true, 'scopes_supported': ['openid', 'email', 'identify', 'offline_access'], 'claims_supported': ['sub', 'email', 'email_verified', 'nameid', 'name', 'picture', 'profile'], 'grant_types_supported': ['authorization_code', 'client_credentials', 'refresh_token', 'implicit', 'urn:ietf:params:oauth:grant-type:device_code'], 'response_types_supported': ['code', 'token', 'id_token', 'id_token token', 'code id_token', 'code token', 'code id_token token'], 'response_modes_supported': ['form_post', 'query', 'fragment'], 'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'], 'subject_types_supported': ['public'], 'id_token_signing_alg_values_supported': ['RS256'], 'code_challenge_methods_supported': ['plain', 'S256'], 'request_parameter_supported': true }); + + this.client = new fivemIssuer.Client({ + client_id: 'txadmin_test', + client_secret: 'txadmin_test', + response_types: ['openid'], + }); + this.client[custom.clock_tolerance] = 2 * 60 * 60; //Two hours due to the DST change. + custom.setHttpOptionsDefaults({ + timeout: 10000, + }); + } + + + /** + * Returns the Provider Auth URL + */ + getAuthURL(redirectUri: string, stateKern: string) { + if (!this.client) throw new Error(`${modulename} is not ready`); + + const url = this.client.authorizationUrl({ + redirect_uri: redirectUri, + state: getOauthState(stateKern), + response_type: 'code', + scope: 'openid identify', + }); + if (typeof url !== 'string') throw new Error('url is not string'); + return url; + } + + + /** + * Processes the callback and returns the tokenSet + */ + async processCallback(sessionCallbackUri: string, sessionStateKern: string, callbackUri: string) { + if (!this.client) throw new Error(`${modulename} is not ready`); + + //Process the request + const parsedUri = new URL(callbackUri); + const callback = parsedUri.searchParams; + const callbackCode = callback.get('code'); + const callbackState = callback.get('state'); + if (typeof callbackCode !== 'string') throw new Error('code not present'); + if (typeof callbackState !== 'string') throw new Error('state not present'); + + //Exchange code for token + const tokenSet = await this.client.callback( + sessionCallbackUri, + { + code: callbackCode, + state: callbackState, + }, + { + state: getOauthState(sessionStateKern) + } + ); + if (typeof tokenSet !== 'object') throw new Error('tokenSet is not an object'); + if (typeof tokenSet.access_token == 'undefined') throw new Error('access_token not present'); + if (typeof tokenSet.expires_at == 'undefined') throw new Error('expires_at not present'); + return tokenSet; + } + + + /** + * Gets user info via access token + */ + async getUserInfo(accessToken: string): Promise { + if (!this.client) throw new Error(`${modulename} is not ready`); + + //Perform introspection + const userInfo = await this.client.userinfo(accessToken); + const parsed = userInfoSchema.parse(userInfo); + let picture: string | undefined; + if (typeof userInfo.picture == 'string' && userInfo.picture.startsWith('https://')) { + picture = userInfo.picture; + } + + return { ...parsed, picture }; + } +}; diff --git a/core/modules/CacheStore.ts b/core/modules/CacheStore.ts new file mode 100644 index 0000000..34c60a8 --- /dev/null +++ b/core/modules/CacheStore.ts @@ -0,0 +1,143 @@ +const modulename = 'CacheStore'; +import fsp from 'node:fs/promises'; +import throttle from 'lodash-es/throttle.js'; +import consoleFactory from '@lib/console'; +import { txDevEnv, txEnv } from '@core/globalData'; +import type { z, ZodSchema } from 'zod'; +import type { UpdateConfigKeySet } from './ConfigStore/utils'; +const console = consoleFactory(modulename); + + +//NOTE: due to limitations on how we compare value changes we can only accept these types +//This is to prevent saving the same value repeatedly (eg sv_maxClients every 3 seconds) +export const isBoolean = (val: any): val is boolean => typeof val === 'boolean'; +export const isNull = (val: any): val is null => val === null; +export const isNumber = (val: any): val is number => typeof val === 'number'; +export const isString = (val: any): val is string => typeof val === 'string'; +type IsTypeFunctions = typeof isBoolean | typeof isNull | typeof isNumber | typeof isString; +type InferValType = T extends (val: any) => val is infer R ? R : never; +type AcceptedCachedTypes = boolean | null | number | string; +type CacheMap = Map; +const isAcceptedType = (val: any): val is AcceptedCachedTypes => { + const valType = typeof val; + return (val === null || valType === 'string' || valType === 'boolean' || valType === 'number'); +} + +const CACHE_FILE_NAME = 'cachedData.json'; + + +/** + * Dead-simple Map-based persistent cache, saved in txData//cachedData.json. + * This is not meant to store anything super important, the async save does not throw in case of failure, + * and it will reset the cache in case it fails to load. + */ +export default class CacheStore { + static readonly configKeysWatched = [ + 'server.dataPath', + 'server.cfgPath', + ]; + + private cache: CacheMap = new Map(); + readonly cacheFilePath = `${txEnv.profilePath}/data/${CACHE_FILE_NAME}`; + readonly throttledSaveCache = throttle( + this.saveCache.bind(this), + 5000, + { leading: false, trailing: true } + ); + + constructor() { + this.loadCachedData(); + + //TODO: handle shutdown? copied from Metrics.svRuntime + // this.throttledSaveCache.cancel({ upcomingOnly: true }); + // this.saveCache(); + } + + //Resets the fxsRuntime cache on server reset + public handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) { + this.delete('fxsRuntime:gameName'); //from logger + this.delete('fxsRuntime:cfxId'); //from fd3 + this.delete('fxsRuntime:maxClients'); //from /dynamic.json + + //from /info.json + this.delete('fxsRuntime:bannerConnecting'); + this.delete('fxsRuntime:bannerDetail'); + this.delete('fxsRuntime:iconFilename'); + this.delete('fxsRuntime:locale'); + this.delete('fxsRuntime:projectDesc'); + this.delete('fxsRuntime:projectName'); + this.delete('fxsRuntime:tags'); + } + + public has(key: string) { + return this.cache.has(key); + } + + public get(key: string) { + return this.cache.get(key); + } + + public getTyped(key: string, typeChecker: T) { + const value = this.cache.get(key); + if (!value) return undefined; + if (typeChecker(value)) return value as InferValType; + return undefined; + } + + public set(key: string, value: AcceptedCachedTypes) { + if (!isAcceptedType(value)) throw new Error(`Value of type ${typeof value} is not acceptable.`); + const currValue = this.cache.get(key); + if (currValue !== value) { + this.cache.set(key, value); + this.throttledSaveCache(); + } + } + + public upsert(key: string, value: AcceptedCachedTypes | undefined) { + if (value === undefined) { + this.delete(key); + } else { + this.set(key, value); + } + } + + public delete(key: string) { + const deleteResult = this.cache.delete(key); + this.throttledSaveCache(); + return deleteResult; + } + + private async saveCache() { + try { + const serializer = (txDevEnv.ENABLED) + ? (obj: any) => JSON.stringify(obj, null, 4) + : JSON.stringify + const toSave = serializer([...this.cache.entries()]); + await fsp.writeFile(this.cacheFilePath, toSave); + // console.verbose.debug(`Saved ${CACHE_FILE_NAME} with ${this.cache.size} entries.`); + } catch (error) { + console.error(`Unable to save ${CACHE_FILE_NAME} with error: ${(error as Error).message}`); + } + } + + private async loadCachedData() { + try { + const rawFileData = await fsp.readFile(this.cacheFilePath, 'utf8'); + const fileData = JSON.parse(rawFileData); + if (!Array.isArray(fileData)) throw new Error('data_is_not_an_array'); + this.cache = new Map(fileData); + console.verbose.ok(`Loaded ${CACHE_FILE_NAME} with ${this.cache.size} entries.`); + } catch (error) { + this.cache = new Map(); + if ((error as any)?.code === 'ENOENT') { + console.verbose.debug(`${CACHE_FILE_NAME} not found, making a new one.`); + } else if ((error as any)?.message === 'data_is_not_an_array') { + console.warn(`Failed to load ${CACHE_FILE_NAME} due to invalid data.`); + console.warn('Since this is not a critical file, it will be reset.'); + } else { + console.warn(`Failed to load ${CACHE_FILE_NAME} with message: ${(error as any).message}`); + console.warn('Since this is not a critical file, it will be reset.'); + } + } + } +}; diff --git a/core/modules/ConfigStore/changelog.ts b/core/modules/ConfigStore/changelog.ts new file mode 100644 index 0000000..5604224 --- /dev/null +++ b/core/modules/ConfigStore/changelog.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +// Configs +const daysMs = 24 * 60 * 60 * 1000; +export const CCLOG_SIZE_LIMIT = 32; +export const CCLOG_RETENTION = 120 * daysMs; +export const CCLOG_VERSION = 1; + +//Schemas +const ConfigChangelogEntrySchema = z.object({ + author: z.string().min(1), + ts: z.number().int().nonnegative(), + keys: z.string().array(), +}); +export const ConfigChangelogFileSchema = z.object({ + version: z.literal(1), + log: z.array(ConfigChangelogEntrySchema), +}); +export type ConfigChangelogEntry = z.infer; +export type ConfigChangelogFile = z.infer; + +//Optimizer +export const truncateConfigChangelog = (log: ConfigChangelogEntry[]): ConfigChangelogEntry[] => { + if (!log.length) return []; + + const now = Date.now(); + return log + .filter(entry => (now - entry.ts) <= CCLOG_RETENTION) + .slice(-CCLOG_SIZE_LIMIT); +} diff --git a/core/modules/ConfigStore/configMigrations.ts b/core/modules/ConfigStore/configMigrations.ts new file mode 100644 index 0000000..a67fd9c --- /dev/null +++ b/core/modules/ConfigStore/configMigrations.ts @@ -0,0 +1,84 @@ +const modulename = 'ConfigStore:Migration'; +import fs from 'node:fs'; +import { ConfigFileData, PartialTxConfigs } from './schema/index'; +import { txEnv } from '@core/globalData'; +import { cloneDeep } from 'lodash-es'; +import fatalError from '@lib/fatalError'; +import { CONFIG_VERSION } from './index'; //FIXME: circular_dependency +import { migrateOldConfig } from './schema/oldConfig'; +import consoleFactory from '@lib/console'; +import { chalkInversePad } from '@lib/misc'; +const console = consoleFactory(modulename); + + +/** + * Saves a backup of the current config file + */ +const saveBackupFile = (version: number) => { + const bkpFileName = `config.backup.v${version}.json`; + fs.copyFileSync( + `${txEnv.profilePath}/config.json`, + `${txEnv.profilePath}/${bkpFileName}`, + ); + console.log(`A backup of your config file was saved as: ${chalkInversePad(bkpFileName)}`); +} + + +/** + * Migrates the old config file to the new schema + */ +export const migrateConfigFile = (fileData: any): ConfigFileData => { + const oldConfig = cloneDeep(fileData); + let newConfig: ConfigFileData | undefined; + let oldVersion: number | undefined; + + //Sanity check + if ('version' in fileData && typeof fileData.version !== 'number') { + fatalError.ConfigStore(20, 'Your txAdmin config.json version is not a number!'); + } + if (typeof fileData.version === 'number' && fileData.version > CONFIG_VERSION) { + fatalError.ConfigStore(21, [ + `Your config.json file is on v${fileData.version}, and this txAdmin supports up to v${CONFIG_VERSION}.`, + 'This means you likely downgraded your txAdmin or FXServer.', + 'Please make sure your txAdmin is updated!', + '', + 'If you want to downgrade FXServer (the "artifact") but keep txAdmin updated,', + 'you can move the updated "citizen/system_resources/monitor" folder', + 'to older FXserver artifact, replacing the old files.', + `Alternatively, you can restore the v${fileData.version} backup on the folder below.`, + ['File Path', `${txEnv.profilePath}/config.json`], + ]); + } + //The v1 is implicit, if explicit then it's a problem + if (fileData.version === 1) { + throw new Error(`File with explicit version '1' should not exist.`); + } + + + //Migrate from v1 (no version) to v2 + //- remapping the old config to the new structure + //- applying some default changes and migrations + //- extracting just the non-default values + //- truncating the serverName to 18 chars + //- generating new banlist template IDs + if (!('version' in fileData) && 'global' in fileData && 'fxRunner' in fileData) { + console.warn('Updating your txAdmin config.json from v1 to v2.'); + oldVersion ??= 1; + + //Final object + const justNonDefaults = migrateOldConfig(oldConfig) as PartialTxConfigs; + newConfig = { + version: 2, + ...justNonDefaults, + } + } + + + //Final check + if (oldVersion && newConfig && newConfig.version === CONFIG_VERSION) { + saveBackupFile(oldVersion); + return newConfig; + } else { + throw new Error(`Unknown file version: ${fileData.version}`); + } +} diff --git a/core/modules/ConfigStore/configParser.test.ts b/core/modules/ConfigStore/configParser.test.ts new file mode 100644 index 0000000..9ea0139 --- /dev/null +++ b/core/modules/ConfigStore/configParser.test.ts @@ -0,0 +1,274 @@ +import { suite, it, expect } from 'vitest'; +import { parseConfigFileData, bootstrapConfigProcessor, getConfigDefaults, runtimeConfigProcessor } from './configParser'; +import { z } from 'zod'; +import { typeDefinedConfig, typeNullableConfig } from './schema/utils'; +import ConfigStore from '.'; +import { SYM_FIXER_DEFAULT, SYM_FIXER_FATAL, SYM_RESET_CONFIG } from '@lib/symbols'; + + +suite('parseConfigFileData', () => { + it('should correctly parse a valid config file', () => { + const configFileData = { + version: 1, + example: { + serverName: 'MyServer', + enabled: true, + }, + server: { + dataPath: '/path/to/data', + }, + }; + const result = parseConfigFileData(configFileData); + expect(result).toEqual([ + { scope: 'example', key: 'serverName', value: 'MyServer' }, + { scope: 'example', key: 'enabled', value: true }, + { scope: 'server', key: 'dataPath', value: '/path/to/data' }, + ]); + }); + + it('should ignore the version key', () => { + const configFileData = { + version: 1, + example: { + serverName: 'MyServer', + }, + }; + const result = parseConfigFileData(configFileData); + expect(result).toEqual([ + { scope: 'example', key: 'serverName', value: 'MyServer' }, + ]); + }); + + it('should handle empty config file', () => { + const configFileData = {}; + const result = parseConfigFileData(configFileData); + expect(result).toEqual([]); + }); + + it('should handle undefined items', () => { + const configFileData = { + example: { + aaa: 'whatever', + bbb: undefined, + }, + }; + const result = parseConfigFileData(configFileData); + expect(result).toEqual([ + { scope: 'example', key: 'aaa', value: 'whatever' }, + ]); + }); + + it('should handle nested scopes', () => { + const configFileData = { + version: 1, + example: { + serverName: 'MyServer', + enabled: true, + }, + server: { + dataPath: { path: '/path/to/data' }, + }, + }; + const result = parseConfigFileData(configFileData as any); + expect(result).toEqual([ + { scope: 'example', key: 'serverName', value: 'MyServer' }, + { scope: 'example', key: 'enabled', value: true }, + { scope: 'server', key: 'dataPath', value: { path: '/path/to/data' } }, + ]); + }); +}); + + +suite('bootstrapConfigProcessor', () => { + const allConfigScopes = { + example: { + serverName: typeDefinedConfig({ + name: 'Server Name', + default: 'change-me', + validator: z.string().min(1).max(18), + fixer: SYM_FIXER_DEFAULT, + }), + enabled: typeDefinedConfig({ + name: 'Enabled', + default: true, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, + }), + }, + server: { + dataPath: typeNullableConfig({ + name: 'Data Path', + default: null, + validator: z.string().min(1).nullable(), + fixer: SYM_FIXER_FATAL, + }), + } + }; + const defaultConfigs = getConfigDefaults(allConfigScopes); + + it('should process valid config items', () => { + const parsedInput = [ + { scope: 'example', key: 'serverName', value: 'MyServer' }, + { scope: 'server', key: 'dataPath', value: '/path/to/data' }, + ]; + const result = bootstrapConfigProcessor(parsedInput, allConfigScopes, defaultConfigs); + expect(result.stored.example.serverName).toBe('MyServer'); + expect(result.stored.server.dataPath).toBe('/path/to/data'); + expect(result.active.example.serverName).toBe('MyServer'); + expect(result.active.server.dataPath).toBe('/path/to/data'); + }); + + it('should handle unknown config items', () => { + const parsedInput = [ + { scope: 'unknownScope', key: 'key1', value: 'value1' }, + ]; + const result = bootstrapConfigProcessor(parsedInput, allConfigScopes, defaultConfigs); + expect(result.unknown.unknownScope.key1).toBe('value1'); + }); + + it('should apply default for active but not stored', () => { + const parsedInput = [ + { scope: 'unknownScope', key: 'key1', value: 'value1' }, + ]; + const result = bootstrapConfigProcessor(parsedInput, allConfigScopes, defaultConfigs); + expect(result.stored?.example?.serverName).toBeUndefined(); + expect(result.active.example.serverName).toBe(defaultConfigs.example.serverName); + }); + + it('should apply default values for invalid config items', () => { + const parsedInput = [ + { scope: 'example', key: 'serverName', value: '' }, + ]; + const result = bootstrapConfigProcessor(parsedInput, allConfigScopes, defaultConfigs); + expect(result.stored.example.serverName).toBe(defaultConfigs.example.serverName); + expect(result.active.example.serverName).toBe(defaultConfigs.example.serverName); + }); + + it('should throw error for unfixable invalid config items', () => { + const parsedInput = [ + { scope: 'server', key: 'dataPath', value: '' }, + ]; + expect(() => bootstrapConfigProcessor(parsedInput, allConfigScopes, defaultConfigs)).toThrow(); + }); +}); + + +suite('runtimeConfigProcessor', () => { + const allConfigScopes = { + example: { + serverName: typeDefinedConfig({ + name: 'Server Name', + default: 'change-me', + validator: z.string().min(1).max(18), + fixer: SYM_FIXER_DEFAULT, + }), + enabled: typeDefinedConfig({ + name: 'Enabled', + default: true, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, + }), + }, + server: { + dataPath: typeNullableConfig({ + name: 'Data Path', + default: null, + validator: z.string().min(1).nullable(), + fixer: SYM_FIXER_FATAL, + }), + scheduledRestarts: typeDefinedConfig({ + name: 'Scheduled Restarts', + default: [], + validator: z.array(z.number().int()).default([]), + fixer: SYM_FIXER_DEFAULT, + }), + }, + }; + const storedConfigs = { + example: { + serverName: 'StoredServer', + enabled: false, + }, + server: { + dataPath: '/stored/path', + } + }; + const activeConfigs = { + example: { + serverName: 'ActiveServer', + enabled: true, + }, + server: { + dataPath: '/active/path', + } + }; + + it('should process valid config changes', () => { + const parsedInput = [ + { scope: 'example', key: 'serverName', value: 'NewServer' }, + ]; + const result = runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs); + expect(result.stored.example.serverName).toBe('NewServer'); + expect(result.active.example.serverName).toBe('NewServer'); + expect(result.active.server.dataPath).toBe('/active/path'); + expect(result.storedKeysChanges.list).toEqual(['example.serverName']); + expect(result.activeKeysChanges.list).toEqual(['example.serverName']); + }); + + it('should reset config to default', () => { + const parsedInput = [ + { scope: 'example', key: 'serverName', value: SYM_RESET_CONFIG }, + ]; + const result = runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs); + expect(result.stored.example.serverName).toBeUndefined(); + expect(result.active.example.serverName).toBe('change-me'); + expect(result.storedKeysChanges.list).toEqual(['example.serverName']); + expect(result.activeKeysChanges.list).toEqual(['example.serverName']); + }); + + it('should list the correct changes', () => { + const parsedInput = [ + { scope: 'example', key: 'serverName', value: 'StoredServer' }, + { scope: 'server', key: 'dataPath', value: '/active/path' }, + ]; + const result = runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs); + expect(result.storedKeysChanges.list).toEqual(['server.dataPath']); + expect(result.activeKeysChanges.list).toEqual(['example.serverName']); + }); + + it('should throw error for invalid config changes', () => { + const parsedInput = [ + { scope: 'example', key: 'serverName', value: false }, + ]; + expect(() => runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs)).toThrow(); + }); + + it('should handle unknown config items', () => { + const parsedInput = [ + { scope: 'unknownScope', key: 'key1', value: 'value1' }, + ]; + expect(() => runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs)).toThrow(); + }); + + it('should handle default equality checking', () => { + const parsedInput = [ + { scope: 'server', key: 'scheduledRestarts', value: [] }, + ]; + const result = runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs); + expect(result.stored.server.scheduledRestarts).toBeUndefined(); + expect(result.active.server.scheduledRestarts).toEqual([]); + }); +}); + + +suite('schema sanity check', () => { + it('should have the same keys in all schemas', () => { + for (const [scopeName, scopeConfigs] of Object.entries(ConfigStore.Schema)) { + for (const [configKey, configData] of Object.entries(scopeConfigs)) { + expect(configData.default).toBeDefined(); + expect(configData.validator).toBeDefined(); + expect(configData.fixer).toBeDefined(); + } + } + }); +}); diff --git a/core/modules/ConfigStore/configParser.ts b/core/modules/ConfigStore/configParser.ts new file mode 100644 index 0000000..ba65a75 --- /dev/null +++ b/core/modules/ConfigStore/configParser.ts @@ -0,0 +1,218 @@ +const modulename = 'ConfigStore:Parser'; +import consoleFactory from "@lib/console"; +import { ConfigFileData, ConfigScaffold } from "./schema"; +import { ConfigScope, ListOf, ScopeConfigItem } from "./schema/utils"; +import { confx, UpdateConfigKeySet } from "./utils"; +import { cloneDeep } from "lodash"; +import { dequal } from 'dequal/lite'; +import { fromZodError } from "zod-validation-error"; +import { SYM_FIXER_DEFAULT, SYM_RESET_CONFIG } from "@lib/symbols"; +const console = consoleFactory(modulename); + + +// Returns object with all the scopes empty +// export const getConfigScaffold = (allConfigScopes: ListOf) => { +// const scaffold: ConfigScaffold = Object.fromEntries( +// Object.entries(allConfigScopes).map(([k, s]) => [k, {} as any]) +// ); +// return scaffold; +// }; + + +// Returns object scope containing all the valid config values +export const getScopeDefaults = (scope: ConfigScope): T => { + return Object.fromEntries( + Object.entries(scope) + .map(([key, schema]) => [key, schema.default]) + ) as T; +}; + + +// Returns object with all the scopes and their default values +export const getConfigDefaults = (allConfigScopes: ListOf) => { + const defaults: ConfigScaffold = Object.fromEntries( + Object.entries(allConfigScopes).map(([k, s]) => [k, getScopeDefaults(s)]) + ); + return defaults; +} + + +/** + * Convert a config structure into a list of parsed config items + */ +export const parseConfigFileData = (configFileData: ConfigScaffold | ConfigFileData) => { + const parsedConfigItems: ParsedConfigItem[] = []; + for (const [scope, values] of Object.entries(configFileData)) { + if (scope === 'version') continue; + for (const [key, value] of Object.entries(values)) { + if (value === undefined) continue; + parsedConfigItems.push({ scope, key, value }); + } + } + return parsedConfigItems; +} +type ParsedConfigItem = { + scope: string; + key: string; + value: any; +} + + +/** + * Attempt to fix the value - USED DURING BOOTSTRAP ONLY + */ +const attemptConfigFix = (scope: string, key: string, value: any, configSchema: ScopeConfigItem) => { + const shouldBeArray = Array.isArray(configSchema.default); + if (configSchema.fixer === SYM_FIXER_DEFAULT) { + if (shouldBeArray) { + console.error(`Invalid value for '${scope}.${key}', applying default value.`); + } else { + console.error(`Invalid value for '${scope}.${key}', applying default value:`, configSchema.default); + } + return { + success: true, + value: configSchema.default, + }; + } else if (typeof configSchema.fixer === 'function') { + try { + const fixed = configSchema.fixer(value); + if (shouldBeArray) { + console.error(`Invalid value for '${scope}.${key}' has been automatically fixed.`); + } else { + console.error(`Invalid value for '${scope}.${key}', the value has been fixed to:`, fixed); + } + return { + success: true, + value: fixed, + }; + } catch (error) { + console.error(`Invalid value for '${scope}.${key}', fixer failed with reason: ${(error as any).message}`); + return { + success: false, + error, + }; + } + } + return { + success: false, + }; +} + + +/** + * Processes a parsed config based on a schema to get the stored and active values + */ +export const bootstrapConfigProcessor = ( + parsedInput: ParsedConfigItem[], + allConfigScopes: ListOf, + defaultConfigs: ConfigScaffold, +) => { + //Scaffold the objects + const unknown: ListOf = {}; + const stored: ListOf = {}; + const active = cloneDeep(defaultConfigs); + + //Process each item + for (const { scope, key, value } of parsedInput) { + //Check if the scope is known + const configSchema = allConfigScopes?.[scope]?.[key]; + if (!configSchema) { + console.warn(`Unknown config: ${scope}.${key}`); + unknown[scope] ??= {}; + unknown[scope][key] = value; + continue; + } + stored[scope] ??= {}; + + //Validate the value + const zResult = configSchema.validator.safeParse(value); + if (zResult.success) { + stored[scope][key] = zResult.data; + active[scope][key] = zResult.data; + continue; + } + + //Attempt to fix the value + const fResult = attemptConfigFix(scope, key, value, configSchema); + if (fResult.success && fResult.value !== undefined) { + stored[scope][key] = fResult.value; + active[scope][key] = fResult.value; + } else { + console.warn(`Invalid value for '${scope}.${key}': ${(zResult.error as any).message}`); + throw fResult?.error ?? fromZodError(zResult.error, { prefix: `${scope}.${key}` }); + } + } + + return { unknown, stored, active }; +} + + +/** + * Diff the parsed input against the stored and active configs, and validate the changes + */ +export const runtimeConfigProcessor = ( + parsedInput: ParsedConfigItem[], + allConfigScopes: ListOf, + storedConfigs: ConfigScaffold, + activeConfigs: ConfigScaffold, +) => { + //Scaffold the objects + const storedKeysChanges = new UpdateConfigKeySet(); + const activeKeysChanges = new UpdateConfigKeySet(); + const thisStoredCopy = cloneDeep(storedConfigs); + const thisActiveCopy = cloneDeep(activeConfigs); + + //Process each item + for (const { scope, key, value } of parsedInput) { + //Check if the scope is known + const configSchema = confx(allConfigScopes).get(scope, key) as ScopeConfigItem; + if (!configSchema) throw new Error(`Unknown config: ${scope}.${key}`); + + //Restore or Validate the value + let newValue: any; + if (value === SYM_RESET_CONFIG) { + newValue = configSchema.default; + } else { + const zResult = configSchema.validator.safeParse(value); + if (!zResult.success) { + throw fromZodError(zResult.error, { prefix: configSchema.name }); + } + newValue = zResult.data; + } + + //Check if the value is different from the stored value + const defaultValue = configSchema.default; + const storedValue = confx(thisStoredCopy).get(scope, key); + const isNewValueDefault = dequal(newValue, defaultValue); + if (storedValue === undefined) { + if (!isNewValueDefault) { + storedKeysChanges.add(scope, key); + confx(thisStoredCopy).set(scope, key, newValue); + } + } else if (!dequal(newValue, storedValue)) { + storedKeysChanges.add(scope, key); + if (!isNewValueDefault) { + //NOTE: if default, it's being removed below already + confx(thisStoredCopy).set(scope, key, newValue); + } + } + + //If the value is the default, remove + if (isNewValueDefault) { + confx(thisStoredCopy).unset(scope, key); + } + + //Check if the value is different from the active value + if (!dequal(newValue, confx(thisActiveCopy).get(scope, key))) { + activeKeysChanges.add(scope, key); + confx(thisActiveCopy).set(scope, key, newValue); + } + } + + return { + storedKeysChanges, + activeKeysChanges, + stored: thisStoredCopy, + active: thisActiveCopy, + } +} diff --git a/core/modules/ConfigStore/index.ts b/core/modules/ConfigStore/index.ts new file mode 100644 index 0000000..91dfe8d --- /dev/null +++ b/core/modules/ConfigStore/index.ts @@ -0,0 +1,273 @@ +const modulename = 'ConfigStore'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import { cloneDeep } from 'lodash-es'; +import consoleFactory from '@lib/console'; +import fatalError from '@lib/fatalError'; +import { txEnv } from '@core/globalData'; +import { ConfigFileData, ConfigSchemas_v2, PartialTxConfigs, PartialTxConfigsToSave, TxConfigs } from './schema'; +import { migrateConfigFile } from './configMigrations'; +import { deepFreeze } from '@lib/misc'; +import { parseConfigFileData, bootstrapConfigProcessor, runtimeConfigProcessor, getConfigDefaults } from './configParser'; +import { ListOf } from './schema/utils'; +import { CCLOG_VERSION, ConfigChangelogEntry, ConfigChangelogFileSchema, truncateConfigChangelog } from './changelog'; +import { UpdateConfigKeySet } from './utils'; +const console = consoleFactory(modulename); + + +//Types +export type RefreshConfigKey = { full: string, scope: string, key: string }; +export type RefreshConfigFunc = (updatedConfigs: UpdateConfigKeySet) => void; +type RefreshConfigRegistry = { + moduleName: string, + callback: RefreshConfigFunc, + rules: string[], +}[]; + +//Consts +export const CONFIG_VERSION = 2; + + +/** + * Module to handle the configuration file, validation, defaults and retrieval. + * The setup is fully sync, as nothing else can start without the config. + */ +export default class ConfigStore /*does not extend TxModuleBase*/ { + //Statics + public static readonly Schema = ConfigSchemas_v2; + public static readonly SchemaDefaults = getConfigDefaults(ConfigSchemas_v2) as TxConfigs; + public static getEmptyConfigFile() { + return { version: CONFIG_VERSION }; + } + + //Instance + private readonly changelogFilePath = `${txEnv.profilePath}/data/configChangelog.json`; + private readonly configFilePath = `${txEnv.profilePath}/config.json`; + private readonly moduleRefreshCallbacks: RefreshConfigRegistry = []; //Modules are in boot order + private unknownConfigs: ListOf; //keeping so we can save it back + private storedConfigs: PartialTxConfigs; + private activeConfigs: TxConfigs; + private changelog: ConfigChangelogEntry[] = []; + + constructor() { + //Load raw file + //TODO: create a lock file to prevent starting twice the same config file? + let fileRaw; + try { + fileRaw = fs.readFileSync(this.configFilePath, 'utf8'); + } catch (error) { + fatalError.ConfigStore(10, [ + 'Unable to read configuration file (filesystem error).', + ['Path', this.configFilePath], + ['Error', (error as Error).message], + ]); + } + + //Json parse + let fileData: ConfigFileData; + try { + fileData = JSON.parse(fileRaw); + } catch (error) { + fatalError.ConfigStore(11, [ + 'Unable to parse configuration file (invalid JSON).', + 'This means the file somehow got corrupted and is not a valid anymore.', + ['Path', this.configFilePath], + ['Error', (error as Error).message], + ]); + } + + //Check version & migrate if needed + let fileMigrated = false; + if (fileData?.version !== CONFIG_VERSION) { + try { + fileData = migrateConfigFile(fileData); + fileMigrated = true; + } catch (error) { + fatalError.ConfigStore(25, [ + 'Unable to migrate configuration file.', + ['Path', this.configFilePath], + ['File version', String(fileData?.version)], + ['Supported version', String(CONFIG_VERSION)], + ], error); + } + } + + //Parse & validate + try { + const configItems = parseConfigFileData(fileData); + if (!configItems.length) console.verbose.debug('Empty configuration file.'); + const config = bootstrapConfigProcessor(configItems, ConfigSchemas_v2, ConfigStore.SchemaDefaults); + this.unknownConfigs = config.unknown; + this.storedConfigs = config.stored as PartialTxConfigs; + this.activeConfigs = config.active as TxConfigs; + } catch (error) { + fatalError.ConfigStore(14, [ + 'Unable to process configuration file.', + ], error); + } + + //If migrated, write the new file + if (fileMigrated) { + try { + this.saveFile(this.storedConfigs); + } catch (error) { + fatalError.ConfigStore(26, [ + 'Unable to save the updated config.json file.', + ['Path', this.configFilePath], + ], error); + } + } + + //Reflect to global + this.updatePublicConfig(); + + //Load changelog + setImmediate(() => { + this.loadChangelog(); + }); + } + + + /** + * Mirrors the #config object to the public deep frozen config object + */ + private updatePublicConfig() { + (globalThis as any).txConfig = deepFreeze(cloneDeep(this.activeConfigs)); + } + + + /** + * Returns the stored config object, with only the known keys + */ + public getStoredConfig() { + return cloneDeep(this.storedConfigs); + } + + + /** + * Returns the changelog + * TODO: add filters to be used in pages like ban templates + * TODO: increase CCLOG_SIZE_LIMIT to a few hundred + * TODO: increase CCLOG_RETENTION to a year, or deprecate it in favor of a full log + */ + public getChangelog() { + return cloneDeep(this.changelog); + } + + + /** + * Applies an input config object to the stored and active configs, then saves it to the file + */ + public saveConfigs(inputConfig: PartialTxConfigsToSave, author: string | null) { + //Process each item + const parsedInput = parseConfigFileData(inputConfig); + const processed = runtimeConfigProcessor( + parsedInput, + ConfigSchemas_v2, + this.storedConfigs, + this.activeConfigs, + ); + + //If nothing thrown, update the state, file, and + this.saveFile(processed.stored); + this.storedConfigs = processed.stored as PartialTxConfigs; + this.activeConfigs = processed.active as TxConfigs; + this.logChanges(author ?? 'txAdmin', processed.storedKeysChanges.list); + this.updatePublicConfig(); //before callbacks + this.processCallbacks(processed.activeKeysChanges); + return processed.storedKeysChanges; + } + + + /** + * Saves the config.json file, maintaining the unknown configs + */ + private saveFile(toStore: PartialTxConfigs) { + const outFile = { + version: CONFIG_VERSION, + ...this.unknownConfigs, + ...toStore, + }; + fs.writeFileSync(this.configFilePath, JSON.stringify(outFile, null, 2)); + } + + + /** + * Logs changes to logger and changelog file + * FIXME: ignore banlist.templates? or join consequent changes? + */ + private logChanges(author: string, keysUpdated: string[]) { + txCore.logger.admin.write(author, `Config changes: ${keysUpdated.join(', ')}`); + this.changelog.push({ + author, + ts: Date.now(), + keys: keysUpdated, + }); + this.changelog = truncateConfigChangelog(this.changelog); + setImmediate(async () => { + try { + const json = JSON.stringify({ + version: CCLOG_VERSION, + log: this.changelog, + }); + await fsp.writeFile(this.changelogFilePath, json); + } catch (error) { + console.warn(`Failed to save ${this.changelogFilePath} with message: ${(error as any).message}`); + } + }); + } + + /** + * Loads the changelog file + */ + private async loadChangelog() { + try { + const rawFileData = await fsp.readFile(this.changelogFilePath, 'utf8'); + const fileData = JSON.parse(rawFileData); + if (fileData?.version !== CCLOG_VERSION) throw new Error(`invalid_version`); + const changelogData = ConfigChangelogFileSchema.parse(fileData); + this.changelog = truncateConfigChangelog(changelogData.log); + } catch (error) { + if ((error as any)?.code === 'ENOENT') { + console.verbose.debug(`${this.changelogFilePath} not found, making a new one.`); + } else if ((error as any)?.message === 'invalid_version') { + console.warn(`Failed to load ${this.changelogFilePath} due to invalid version.`); + console.warn('Since this is not a critical file, it will be reset.'); + } else { + console.warn(`Failed to load ${this.changelogFilePath} with message: ${(error as any).message}`); + console.warn('Since this is not a critical file, it will be reset.'); + } + } + } + + + /** + * Process the callbacks for the modules that registered for config changes + */ + private processCallbacks(updatedConfigs: UpdateConfigKeySet) { + for (const txModule of this.moduleRefreshCallbacks) { + if (!updatedConfigs.hasMatch(txModule.rules)) continue; + setImmediate(() => { + try { + console.verbose.debug(`Triggering update callback for module ${txModule.moduleName}`); + txModule.callback(updatedConfigs); + } catch (error) { + console.error(`Error in config update callback for module ${txModule.moduleName}: ${(error as any).message}`); + console.verbose.dir(error); + } + }); + } + } + + + /** + * Register a callback to be called when the config is updated + */ + public registerUpdateCallback(moduleName: string, rules: string[], callback: RefreshConfigFunc) { + this.moduleRefreshCallbacks.push({ + moduleName, + callback, + rules, + }); + } +} diff --git a/core/modules/ConfigStore/schema/banlist.ts b/core/modules/ConfigStore/schema/banlist.ts new file mode 100644 index 0000000..9f6ad07 --- /dev/null +++ b/core/modules/ConfigStore/schema/banlist.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; +import { typeDefinedConfig } from "./utils"; + +import { alphanumeric } from 'nanoid-dictionary'; +import { customAlphabet } from "nanoid"; +import { SYM_FIXER_DEFAULT, SYM_FIXER_FATAL } from "@lib/symbols"; + + + + +/** + * MARK: Ban templates + */ +export const BAN_TEMPLATE_ID_LENGTH = 21; + +export const genBanTemplateId = customAlphabet(alphanumeric, BAN_TEMPLATE_ID_LENGTH); + +export const BanDurationTypeSchema = z.union([ + z.literal('permanent'), + z.object({ + value: z.number().positive(), + unit: z.enum(['hours', 'days', 'weeks', 'months']), + }), +]); +export type BanDurationType = z.infer; + +export const BanTemplatesDataSchema = z.object({ + id: z.string().length(BAN_TEMPLATE_ID_LENGTH), //nanoid fixed at 21 chars + reason: z.string().min(3).max(2048), //should be way less, but just in case + duration: BanDurationTypeSchema, +}); +export type BanTemplatesDataType = z.infer; + +//Ensure all templates have unique ids +export const polishBanTemplatesArray = (input: BanTemplatesDataType[]) => { + const ids = new Set(); + const unique: BanTemplatesDataType[] = []; + for (const template of input) { + if (ids.has(template.id)) { + unique.push({ + ...template, + id: genBanTemplateId(), + }); + } else { + unique.push(template); + } + ids.add(template.id); + } + return unique; +} + + + +/** + * MARK: Default + */ +const enabled = typeDefinedConfig({ + name: 'Ban Checking Enabled', + default: true, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const rejectionMessage = typeDefinedConfig({ + name: 'Ban Rejection Message', + default: 'You can join http://discord.gg/example to appeal this ban.', + validator: z.string(), + fixer: SYM_FIXER_DEFAULT, +}); + +const requiredHwidMatches = typeDefinedConfig({ + name: 'Required Ban HWID Matches', + default: 1, + validator: z.number().int().min(0), + fixer: SYM_FIXER_DEFAULT, +}); + +const templates = typeDefinedConfig({ + name: 'Ban Templates', + default: [], + validator: BanTemplatesDataSchema.array().transform(polishBanTemplatesArray), + //NOTE: if someone messed with their templates and broke it, we don't want to wipe it all out + fixer: SYM_FIXER_FATAL, +}); + + +export default { + enabled, + rejectionMessage, + requiredHwidMatches, + templates, +} as const; diff --git a/core/modules/ConfigStore/schema/discordBot.ts b/core/modules/ConfigStore/schema/discordBot.ts new file mode 100644 index 0000000..144f8ff --- /dev/null +++ b/core/modules/ConfigStore/schema/discordBot.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { discordSnowflakeSchema, typeDefinedConfig, typeNullableConfig } from "./utils"; +import { defaultEmbedConfigJson, defaultEmbedJson } from "@modules/DiscordBot/defaultJsons"; +import { SYM_FIXER_DEFAULT } from "@lib/symbols"; + + +const enabled = typeDefinedConfig({ + name: 'Bot Enabled', + default: false, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const token = typeNullableConfig({ + name: 'Bot Token', + default: null, + validator: z.string().min(1).nullable(), + fixer: SYM_FIXER_DEFAULT, +}); + +const guild = typeNullableConfig({ + name: 'Server ID', + default: null, + validator: discordSnowflakeSchema.nullable(), + fixer: SYM_FIXER_DEFAULT, +}); + +const warningsChannel = typeNullableConfig({ + name: 'Warnings Channel ID', + default: null, + validator: discordSnowflakeSchema.nullable(), + fixer: SYM_FIXER_DEFAULT, +}); + + +//We are not validating the JSON, only that it is a string +export const attemptMinifyJsonString = (input: string) => { + try { + return JSON.stringify(JSON.parse(input)); + } catch (error) { + return input; + } +}; + +const embedJson = typeDefinedConfig({ + name: 'Status Embed JSON', + default: defaultEmbedJson, + validator: z.string().min(1).transform(attemptMinifyJsonString), + //NOTE: no true valiation in here, done in the module only + fixer: SYM_FIXER_DEFAULT, +}); + +const embedConfigJson = typeDefinedConfig({ + name: 'Status Config JSON', + default: defaultEmbedConfigJson, + validator: z.string().min(1).transform(attemptMinifyJsonString), + //NOTE: no true valiation in here, done in the module only + fixer: SYM_FIXER_DEFAULT, +}); + + +export default { + enabled, + token, + guild, + warningsChannel, + embedJson, + embedConfigJson, +} as const; diff --git a/core/modules/ConfigStore/schema/gameFeatures.ts b/core/modules/ConfigStore/schema/gameFeatures.ts new file mode 100644 index 0000000..83ee551 --- /dev/null +++ b/core/modules/ConfigStore/schema/gameFeatures.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import { typeDefinedConfig } from "./utils"; +import { SYM_FIXER_DEFAULT } from "@lib/symbols"; + + +const menuEnabled = typeDefinedConfig({ + name: 'Menu Enabled', + default: true, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const menuAlignRight = typeDefinedConfig({ + name: 'Align Menu Right', + default: false, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const menuPageKey = typeDefinedConfig({ + name: 'Menu Page Switch Key', + default: 'Tab', + validator: z.string().min(1), + fixer: SYM_FIXER_DEFAULT, +}); + +const playerModePtfx = typeDefinedConfig({ + name: 'Player Mode Change Effect', + default: true, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const hideAdminInPunishments = typeDefinedConfig({ + name: 'Hide Admin Name In Punishments', + default: true, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const hideAdminInMessages = typeDefinedConfig({ + name: 'Hide Admin Name In Messages', + default: false, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const hideDefaultAnnouncement = typeDefinedConfig({ + name: 'Hide Announcement Notifications', + default: false, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const hideDefaultDirectMessage = typeDefinedConfig({ + name: 'Hide Direct Message Notification', + default: false, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const hideDefaultWarning = typeDefinedConfig({ + name: 'Hide Warning Notification', + default: false, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const hideDefaultScheduledRestartWarning = typeDefinedConfig({ + name: 'Hide Scheduled Restart Warnings', + default: false, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + + +export default { + menuEnabled, + menuAlignRight, + menuPageKey, + playerModePtfx, + hideAdminInPunishments, + hideAdminInMessages, + hideDefaultAnnouncement, + hideDefaultDirectMessage, + hideDefaultWarning, + hideDefaultScheduledRestartWarning, +} as const; diff --git a/core/modules/ConfigStore/schema/general.ts b/core/modules/ConfigStore/schema/general.ts new file mode 100644 index 0000000..89fbe68 --- /dev/null +++ b/core/modules/ConfigStore/schema/general.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { typeDefinedConfig } from "./utils"; +import { SYM_FIXER_DEFAULT } from "@lib/symbols"; +import localeMap from "@shared/localeMap"; + + +const serverName = typeDefinedConfig({ + name: 'Server Name', + default: 'change-me', + validator: z.string().min(1).max(18), + fixer: SYM_FIXER_DEFAULT, +}); + +const language = typeDefinedConfig({ + name: 'Language', + default: 'en', + validator: z.string().min(2).refine( + (value) => (value === 'custom' || localeMap[value] !== undefined), + (value) => ({ message: `Invalid language code \`${value ?? '??'}\`.` }), + ), + fixer: SYM_FIXER_DEFAULT, +}); + + +export default { + serverName, + language, +} as const; diff --git a/core/modules/ConfigStore/schema/index.ts b/core/modules/ConfigStore/schema/index.ts new file mode 100644 index 0000000..96b78cd --- /dev/null +++ b/core/modules/ConfigStore/schema/index.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { ConfigScope, ListOf } from "./utils"; +import general from "./general"; +import server from "./server"; +import restarter from "./restarter"; +import banlist from "./banlist"; +import whitelist from "./whitelist"; +import discordBot from "./discordBot"; +import gameFeatures from "./gameFeatures"; +import webServer from "./webServer"; +import logger from "./logger"; +import { SYM_RESET_CONFIG } from "@lib/symbols"; + + +//Type inference utils +type InferConfigScopes = IferConfigValues; +type IferConfigValues = { + [K in keyof S]: S[K]['default'] | z.infer; +} +type WritableValues = { + -readonly [P in keyof T]: T[P] +}; +type InferConfigScopesToSave = InferConfigValuesToSave>; +type InferConfigValuesToSave = WritableValues<{ + [K in keyof S]: S[K]['default'] | z.infer | typeof SYM_RESET_CONFIG; +}>; + +//Exporting the schemas +export const ConfigSchemas_v2 = { + general, + server, + restarter, + banlist, + whitelist, + discordBot, + gameFeatures, + webServer, + logger, +} satisfies ListOf; + +//Exporting the types +export type TxConfigScopes = keyof typeof ConfigSchemas_v2; +export type TxConfigs = { + [K in TxConfigScopes]: InferConfigScopes +}; +export type PartialTxConfigs = Partial<{ + [K in TxConfigScopes]: Partial> +}>; +export type PartialTxConfigsToSave = Partial<{ + [K in TxConfigScopes]: Partial> +}>; +export type ConfigFileData = PartialTxConfigs & { version: number }; + +//Allow unknown scopes/keys +export type ConfigScaffold = ListOf>; diff --git a/core/modules/ConfigStore/schema/logger.ts b/core/modules/ConfigStore/schema/logger.ts new file mode 100644 index 0000000..996998d --- /dev/null +++ b/core/modules/ConfigStore/schema/logger.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { typeDefinedConfig } from "./utils"; +import { SYM_FIXER_FATAL } from "@lib/symbols"; + + +/* + The logger module passes the options to the library which is responsible for evaluating them. + There has never been strict definitions about those settings in txAdmin. + The only exception is setting it to false for disabling the specific logger. + Ref: https://github.com/iccicci/rotating-file-stream#options +*/ +const rfsOptionValidator = z.union([ + z.literal(false), + z.object({}).passthrough(), +]); + + +//NOTE: don't fallback to default because storage issues might crash the server +export default { + //admin & some system logs + admin: typeDefinedConfig({ + name: 'Admin Logs', + default: {}, + validator: rfsOptionValidator, + fixer: SYM_FIXER_FATAL, + }), + //fxserver output + fxserver: typeDefinedConfig({ + name: 'FXServer Logs', + default: {}, + validator: rfsOptionValidator, + fixer: SYM_FIXER_FATAL, + }), + //in-game logs + server: typeDefinedConfig({ + name: 'Server Logs', + default: {}, + validator: rfsOptionValidator, + fixer: SYM_FIXER_FATAL, + }), +} as const; diff --git a/core/modules/ConfigStore/schema/oldConfig.ts b/core/modules/ConfigStore/schema/oldConfig.ts new file mode 100644 index 0000000..c9e70c0 --- /dev/null +++ b/core/modules/ConfigStore/schema/oldConfig.ts @@ -0,0 +1,168 @@ +import { dequal } from 'dequal/lite'; +import parseArgsStringToArgv from "string-argv"; +import { ConfigSchemas_v2 } from "./index"; +import { ListOf } from "./utils"; +import { genBanTemplateId } from "./banlist"; +import { getConfigDefaults } from "../configParser"; +import { confx } from "../utils"; + +const restructureOldConfig = (old: any) => { + //Apply the legacy migrations (mutation) + old.playerDatabase ??= old.playerDatabase ?? old.playerController ?? {}; + if (old.global.language === 'pt_PT' || old.global.language === 'pt_BR') { + old.global.language = 'pt'; + } + if (typeof old.monitor.resourceStartingTolerance === 'string') { + old.monitor.resourceStartingTolerance = parseInt(old.monitor.resourceStartingTolerance); + if (isNaN(old.monitor.resourceStartingTolerance)) { + old.monitor.resourceStartingTolerance = 120; + } + } + + //Remap the old config to the new structure + const remapped: TxConfigs = { + general: { //NOTE:renamed + serverName: old?.global?.serverName, + language: old?.global?.language, + }, + webServer: { + disableNuiSourceCheck: old?.webServer?.disableNuiSourceCheck, + limiterMinutes: old?.webServer?.limiterMinutes, + limiterAttempts: old?.webServer?.limiterAttempts, + }, + discordBot: { + enabled: old?.discordBot?.enabled, + token: old?.discordBot?.token, + guild: old?.discordBot?.guild, + warningsChannel: old?.discordBot?.announceChannel, //NOTE:renamed + embedJson: old?.discordBot?.embedJson, + embedConfigJson: old?.discordBot?.embedConfigJson, + }, + server: {//NOTE:renamed + dataPath: old?.fxRunner?.serverDataPath, //NOTE:renamed + cfgPath: old?.fxRunner?.cfgPath, + startupArgs: old?.fxRunner?.commandLine, //NOTE:renamed + onesync: old?.fxRunner?.onesync, + autoStart: old?.fxRunner?.autostart, //NOTE:renamed + quiet: old?.fxRunner?.quiet, + shutdownNoticeDelayMs: old?.fxRunner?.shutdownNoticeDelay, //NOTE:renamed + restartSpawnDelayMs: old?.fxRunner?.restartDelay, //NOTE:renamed + }, + restarter: { + schedule: old?.monitor?.restarterSchedule, //NOTE:renamed + bootGracePeriod: old?.monitor?.cooldown, //NOTE:renamed + resourceStartingTolerance: old?.monitor?.resourceStartingTolerance, + }, + banlist: { //NOTE: All Renamed + enabled: old?.playerDatabase?.onJoinCheckBan, + rejectionMessage: old?.playerDatabase?.banRejectionMessage, + requiredHwidMatches: old?.playerDatabase?.requiredBanHwidMatches, + templates: old?.banTemplates, + }, + whitelist: { //NOTE: All Renamed + mode: old?.playerDatabase?.whitelistMode, + rejectionMessage: old?.playerDatabase?.whitelistRejectionMessage, + discordRoles: old?.playerDatabase?.whitelistedDiscordRoles, + }, + gameFeatures: { + menuEnabled: old?.global?.menuEnabled, + menuAlignRight: old?.global?.menuAlignRight, + menuPageKey: old?.global?.menuPageKey, + playerModePtfx: true, //NOTE: new config + hideAdminInPunishments: old?.global?.hideAdminInPunishments, + hideAdminInMessages: old?.global?.hideAdminInMessages, + hideDefaultAnnouncement: old?.global?.hideDefaultAnnouncement, + hideDefaultDirectMessage: old?.global?.hideDefaultDirectMessage, + hideDefaultWarning: old?.global?.hideDefaultWarning, + hideDefaultScheduledRestartWarning: old?.global?.hideDefaultScheduledRestartWarning, + }, + logger: { + admin: old?.logger?.admin, + fxserver: old?.logger?.fxserver, + server: old?.logger?.server, + }, + } + + return remapped; +}; + + +export const migrateOldConfig = (old: any) => { + //Get the old configs in the new structure + const remapped = restructureOldConfig(old) as any; + + //Some migrations before comparing because defaults changed + if (typeof remapped.restarter?.bootGracePeriod === 'number') { + remapped.restarter.bootGracePeriod = Math.round(remapped.restarter.bootGracePeriod); + if (remapped.restarter.bootGracePeriod === 60) { + remapped.restarter.bootGracePeriod = 45; + } + } + if (typeof remapped.server?.shutdownNoticeDelayMs === 'number') { + remapped.server.shutdownNoticeDelayMs *= 1000; + } + if (remapped.server?.restartSpawnDelayMs === 750) { + remapped.server.restartSpawnDelayMs = 500; + } + if (remapped.whitelist?.mode === 'guildMember') { + remapped.whitelist.mode = 'discordMember'; + } + if (remapped.whitelist?.mode === 'guildRoles') { + remapped.whitelist.mode = 'discordRoles'; + } + + //Migrating the menu ptfx convar (can't do anything about it being set in server.cfg tho) + if (typeof remapped.server?.startupArgs === 'string') { + try { + const str = remapped.server.startupArgs.trim(); + const convarSetRegex = /\+setr?\s+['"]?txAdmin-menuPtfxDisable['"]?\s+['"]?(?\w+)['"]?\s?/g; + const matches = [...str.matchAll(convarSetRegex)]; + if (matches.length) { + const valueSet = matches[matches.length - 1].groups?.value; + remapped.gameFeatures.playerModePtfx = valueSet !== 'true'; + remapped.server.startupArgs = str.replaceAll(convarSetRegex, ''); + } + } catch (error) { + console.warn('Failed to migrate the menuPtfxDisable convar. Assuming it\'s unset.'); + console.verbose.dir(error); + } + remapped.server.startupArgs = remapped.server.startupArgs.length + ? parseArgsStringToArgv(remapped.server.startupArgs) + : []; + } + + //Removing stuff from unconfigured profile + if (remapped.general?.serverName === null) { + delete remapped.general.serverName; + } + if (remapped.server?.cfgPath === null) { + delete remapped.server.cfgPath; + } + + //Extract just the non-default values + const baseConfigs = getConfigDefaults(ConfigSchemas_v2) as TxConfigs; + const justNonDefaults: ListOf = {}; + for (const [scopeName, scopeConfigs] of Object.entries(baseConfigs)) { + for (const [configKey, configDefault] of Object.entries(scopeConfigs)) { + const configValue = confx(remapped).get(scopeName, configKey); + if (configValue === undefined) continue; + if (!dequal(configValue, configDefault)) { + confx(justNonDefaults).set(scopeName, configKey, configValue); + } + } + } + + //Last migrations + if (typeof justNonDefaults.general?.serverName === 'string') { + justNonDefaults.general.serverName = justNonDefaults.general.serverName.slice(0, 18); + } + if (Array.isArray(justNonDefaults.banlist?.templates)) { + for (const tpl of justNonDefaults.banlist.templates) { + if (typeof tpl.id !== 'string') continue; + tpl.id = genBanTemplateId(); + } + } + + //Final object + return justNonDefaults; +} diff --git a/core/modules/ConfigStore/schema/restarter.ts b/core/modules/ConfigStore/schema/restarter.ts new file mode 100644 index 0000000..26a8ccb --- /dev/null +++ b/core/modules/ConfigStore/schema/restarter.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { typeDefinedConfig } from "./utils"; +import { SYM_FIXER_DEFAULT } from "@lib/symbols"; +import { parseSchedule, regexHoursMinutes } from "@lib/misc"; + +export const polishScheduleTimesArray = (input: string[]) => { + return parseSchedule(input).valid.map((v) => v.string); +}; + +const schedule = typeDefinedConfig({ + name: 'Restart Schedule', + default: [], + validator: z.string().regex(regexHoursMinutes).array().transform(polishScheduleTimesArray), + fixer: (input: any) => { + if(!Array.isArray(input)) return []; + return polishScheduleTimesArray(input); + }, +}); + +const bootGracePeriod = typeDefinedConfig({ + name: 'Boot Grace Period', + default: 45, + validator: z.number().int().min(15), + fixer: SYM_FIXER_DEFAULT, +}); + +const resourceStartingTolerance = typeDefinedConfig({ + name: 'Resource Starting Tolerance', + default: 90, + validator: z.number().int().min(30), + fixer: SYM_FIXER_DEFAULT, +}); + + +export default { + schedule, + bootGracePeriod, + resourceStartingTolerance, +} as const; diff --git a/core/modules/ConfigStore/schema/server.ts b/core/modules/ConfigStore/schema/server.ts new file mode 100644 index 0000000..374d0a2 --- /dev/null +++ b/core/modules/ConfigStore/schema/server.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { typeDefinedConfig, typeNullableConfig } from "./utils"; +import { SYM_FIXER_DEFAULT, SYM_FIXER_FATAL } from "@lib/symbols"; + + +const dataPath = typeNullableConfig({ + name: 'Server Data Path', + default: null, + validator: z.string().min(1).nullable(), + fixer: SYM_FIXER_FATAL, +}); + +const cfgPath = typeDefinedConfig({ + name: 'CFG File Path', + default: 'server.cfg', + validator: z.string().min(1), + fixer: SYM_FIXER_FATAL, +}); + +const startupArgs = typeDefinedConfig({ + name: 'Startup Arguments', + default: [], + validator: z.string().array(), + fixer: SYM_FIXER_DEFAULT, +}); + +const onesync = typeDefinedConfig({ + name: 'OneSync', + default: 'on', + validator: z.enum(['on', 'legacy', 'off']), + fixer: SYM_FIXER_FATAL, +}); + +const autoStart = typeDefinedConfig({ + name: 'Autostart', + default: true, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const quiet = typeDefinedConfig({ + name: 'Quiet Mode', + default: false, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const shutdownNoticeDelayMs = typeDefinedConfig({ + name: 'Shutdown Notice Delay', + default: 5000, + validator: z.number().int().min(0).max(60_000), + fixer: SYM_FIXER_DEFAULT, +}); + +const restartSpawnDelayMs = typeDefinedConfig({ + name: 'Restart Spawn Delay', + default: 500, + validator: z.number().int().min(0).max(15_000), + fixer: SYM_FIXER_DEFAULT, +}); + + +export default { + dataPath, + cfgPath, + startupArgs, + onesync, + autoStart, + quiet, + shutdownNoticeDelayMs, + restartSpawnDelayMs, +} as const; diff --git a/core/modules/ConfigStore/schema/utils.ts b/core/modules/ConfigStore/schema/utils.ts new file mode 100644 index 0000000..58e7678 --- /dev/null +++ b/core/modules/ConfigStore/schema/utils.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; +import { SYM_FIXER_DEFAULT, SYM_FIXER_FATAL } from '@lib/symbols'; +import consts from '@shared/consts'; +import { fromError } from 'zod-validation-error'; + + +/** + * MARK: Types + */ +//Definitions +export type ConfigScope = ListOf; +export type ConfigItemFixer = (value: any) => T; +interface BaseConfigItem { + name: string; + validator: z.Schema; + fixer: typeof SYM_FIXER_FATAL | typeof SYM_FIXER_DEFAULT | ConfigItemFixer; +} + +//Utilities +export type ListOf = { [key: string]: T }; +export interface DefinedConfigItem extends BaseConfigItem { + default: T extends null ? never : T; +} +export interface NulledConfigItem extends BaseConfigItem { + default: null; +} +export type ScopeConfigItem = DefinedConfigItem | NulledConfigItem; + +//NOTE: Split into two just because I couldn't figure out how to make the default value be null +export const typeDefinedConfig = (config: DefinedConfigItem): DefinedConfigItem => config; +export const typeNullableConfig = (config: NulledConfigItem): NulledConfigItem => config; + + +/** + * MARK: Common Schemas + */ +export const discordSnowflakeSchema = z.string().regex( + consts.regexDiscordSnowflake, + 'The ID should be a 17-20 digit number.' +); + + +/** + * MARK: Utilities + */ +export const getSchemaChainError = (chain: [schema: ScopeConfigItem, val: any][]) => { + for (const [schema, val] of chain) { + const res = schema.validator.safeParse(val); + if (!res.success) { + return fromError(res.error, { prefix: schema.name }).message; + } + } +} diff --git a/core/modules/ConfigStore/schema/webServer.ts b/core/modules/ConfigStore/schema/webServer.ts new file mode 100644 index 0000000..5b79307 --- /dev/null +++ b/core/modules/ConfigStore/schema/webServer.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { typeDefinedConfig } from "./utils"; +import { SYM_FIXER_DEFAULT } from "@lib/symbols"; + + +const disableNuiSourceCheck = typeDefinedConfig({ + name: 'Disable NUI Source Check', + default: false, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const limiterMinutes = typeDefinedConfig({ + name: 'Rate Limiter Minutes', + default: 15, + validator: z.number().int().min(1), + fixer: SYM_FIXER_DEFAULT, +}); + +const limiterAttempts = typeDefinedConfig({ + name: 'Rate Limiter Attempts', + default: 10, + validator: z.number().int().min(5), + fixer: SYM_FIXER_DEFAULT, +}); + + +export default { + disableNuiSourceCheck, + limiterMinutes, + limiterAttempts, +} as const; diff --git a/core/modules/ConfigStore/schema/whitelist.ts b/core/modules/ConfigStore/schema/whitelist.ts new file mode 100644 index 0000000..c2f466c --- /dev/null +++ b/core/modules/ConfigStore/schema/whitelist.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { discordSnowflakeSchema, typeDefinedConfig } from "./utils"; +import { SYM_FIXER_DEFAULT } from "@lib/symbols"; +import consts from "@shared/consts"; + + +const mode = typeDefinedConfig({ + name: 'Whitelist Mode', + default: 'disabled', + validator: z.enum(['disabled', 'adminOnly', 'approvedLicense', 'discordMember', 'discordRoles']), + fixer: SYM_FIXER_DEFAULT, +}); + +const rejectionMessage = typeDefinedConfig({ + name: 'Whitelist Rejection Message', + default: 'Please join http://discord.gg/example and request to be whitelisted.', + validator: z.string(), + fixer: SYM_FIXER_DEFAULT, +}); + +export const polishDiscordRolesArray = (input: string[]) => { + const unique = [...new Set(input)]; + unique.sort((a, b) => Number(a) - Number(b)); + return unique; +} + +const discordRoles = typeDefinedConfig({ + name: 'Whitelisted Discord Roles', + default: [], + validator: discordSnowflakeSchema.array().transform(polishDiscordRolesArray), + fixer: (input: any) => { + if (!Array.isArray(input)) return []; + const valid = input.filter(item => consts.regexDiscordSnowflake.test(item)); + return polishDiscordRolesArray(valid); + }, +}); + + +export default { + mode, + rejectionMessage, + discordRoles, +} as const; diff --git a/core/modules/ConfigStore/utils.test.ts b/core/modules/ConfigStore/utils.test.ts new file mode 100644 index 0000000..fe3444d --- /dev/null +++ b/core/modules/ConfigStore/utils.test.ts @@ -0,0 +1,157 @@ +import { suite, it, expect } from 'vitest'; +import { ConfigScaffold } from './schema'; +import { confx, UpdateConfigKeySet } from './utils'; + + +suite('confx utility', () => { + it('should check if a value exists (has)', () => { + const config: ConfigScaffold = { + scope1: { + key1: 'value1', + }, + }; + const conf = confx(config); + + expect(conf.has('scope1', 'key1')).toBe(true); + expect(conf.has('scope1', 'key2')).toBe(false); + expect(conf.has('scope2', 'key1')).toBe(false); + }); + + it('should retrieve a value (get)', () => { + const config: ConfigScaffold = { + scope1: { + key1: 'value1', + }, + }; + const conf = confx(config); + + expect(conf.get('scope1', 'key1')).toBe('value1'); + expect(conf.get('scope1', 'key2')).toBeUndefined(); + expect(conf.get('scope2', 'key1')).toBeUndefined(); + }); + + it('should set a value (set)', () => { + const config: ConfigScaffold = {}; + const conf = confx(config); + + conf.set('scope1', 'key1', 'value1'); + expect(config.scope1?.key1).toBe('value1'); + + conf.set('scope1', 'key2', 'value2'); + expect(config.scope1?.key2).toBe('value2'); + }); + + it('should unset a value (unset)', () => { + const config: ConfigScaffold = { + scope1: { + key1: 'value1', + key2: 'value2', + }, + }; + const conf = confx(config); + + conf.unset('scope1', 'key1'); + expect(config.scope1?.key1).toBeUndefined(); + expect(config.scope1?.key2).toBe('value2'); + + conf.unset('scope1', 'key2'); + expect(config.scope1).toBeUndefined(); + }); + + it('should handle nested configurations properly', () => { + const config: ConfigScaffold = { + scope1: { + key1: { nested: 'value' }, + }, + }; + const conf = confx(config); + + expect(conf.get('scope1', 'key1')).toEqual({ nested: 'value' }); + conf.set('scope1', 'key2', { another: 'value' }); + expect(config.scope1?.key2).toEqual({ another: 'value' }); + + conf.unset('scope1', 'key1'); + expect(config.scope1?.key1).toBeUndefined(); + }); +}); + + +suite('UpdateConfigKeySet', () => { + it('should add keys with scope and key separately', () => { + const set = new UpdateConfigKeySet(); + set.add('example', 'serverName'); + expect(set.raw).toEqual([{ + full: 'example.serverName', + scope: 'example', + key: 'serverName' + }]); + }); + + it('should add keys with dot notation', () => { + const set = new UpdateConfigKeySet(); + set.add('example.serverName'); + expect(set.raw).toEqual([{ + full: 'example.serverName', + scope: 'example', + key: 'serverName' + }]); + }); + + it('should match exact keys', () => { + const set = new UpdateConfigKeySet(); + set.add('example', 'serverName'); + expect(set.hasMatch('example.serverName')).toBe(true); + expect(set.hasMatch('example.enabled')).toBe(false); + }); + + it('should match wildcard patterns when checking', () => { + const set = new UpdateConfigKeySet(); + set.add('example', 'serverName'); + set.add('example', 'enabled'); + + expect(set.hasMatch('example.*')).toBe(true); + expect(set.hasMatch('server.*')).toBe(false); + expect(set.hasMatch('example.whatever')).toBe(false); + expect(set.hasMatch('*.serverName')).toBe(true); + expect(set.hasMatch('*.*')).toBe(true); + }); + + it('should match when providing an array of patterns', () => { + const set = new UpdateConfigKeySet(); + set.add('example', 'serverName'); + set.add('monitor', 'enabled'); + + expect(set.hasMatch(['example.serverName', 'monitor.status'])).toBe(true); + expect(set.hasMatch(['server.*', 'example.*'])).toBe(true); + expect(set.hasMatch(['other.thing', 'another.config'])).toBe(false); + expect(set.hasMatch(['*.enabled', '*.disabled'])).toBe(true); + expect(set.hasMatch([])).toBe(false); + }); + + it('should not allow adding wildcard', () => { + const set = new UpdateConfigKeySet(); + expect(() => set.add('example.*')).toThrow(); + expect(() => set.add('example', '*')).toThrow(); + expect(() => set.add('*.example')).toThrow(); + expect(() => set.add('*', 'example')).toThrow(); + expect(() => set.add('*.*')).toThrow(); + expect(() => set.add('*', '*')).toThrow(); + }); + + it('should track size correctly', () => { + const set = new UpdateConfigKeySet(); + expect(set.size).toBe(0); + set.add('example', 'serverName'); + expect(set.size).toBe(1); + set.add('example.enabled'); + expect(set.size).toBe(2); + }); + + it('should list all added items', () => { + const set = new UpdateConfigKeySet(); + set.add('example', 'serverName'); + expect(set.list).toEqual(['example.serverName']); + set.add('example', 'enabled'); + expect(set.list).toEqual(['example.serverName','example.enabled']); + }); +}); diff --git a/core/modules/ConfigStore/utils.ts b/core/modules/ConfigStore/utils.ts new file mode 100644 index 0000000..5ac513d --- /dev/null +++ b/core/modules/ConfigStore/utils.ts @@ -0,0 +1,120 @@ +import type { RefreshConfigKey } from "./index"; +import { ConfigScaffold } from "./schema"; + + +/** + * A utility for manipulating a configuration scaffold with nested key-value structures. + * Provides convenient methods to check, retrieve, set, and remove values in the configuration object. + */ +export const confx = (cfg: any) => { + return { + //Check if the config has a value defined + has: (scope: string, key: string) => { + return scope in cfg && key in cfg[scope] && cfg[scope][key] !== undefined; + }, + //Get a value from the config + get: (scope: string, key: string) => { + return cfg[scope]?.[key] as any | undefined; + }, + //Set a value in the config + set: (scope: string, key: string, value: any) => { + cfg[scope] ??= {}; + cfg[scope][key] = value; + }, + //Remove a value from the config + unset: (scope: string, key: string) => { + let deleted = false; + if (scope in cfg && key in cfg[scope]) { + delete cfg[scope][key]; + deleted = true; + if (Object.keys(cfg[scope]).length === 0) { + delete cfg[scope]; + } + } + return deleted; + }, + }; +}; + + +/** + * Not really intended for use, but it's a more type-safe version if you need it + */ +export const confxTyped = (cfg: T) => { + return { + //Check if the config has a value defined + has: (scope: keyof T, key: keyof T[typeof scope]) => { + return scope in cfg && key in cfg[scope] && cfg[scope][key] !== undefined; + }, + //Get a value from the config + get: (scope: keyof T, key: keyof T[typeof scope]) => { + if (scope in cfg && key in cfg[scope]) { + return cfg[scope][key] as T[typeof scope][typeof key]; + } else { + return undefined; + } + }, + //Set a value in the config + set: (scope: keyof T, key: keyof T[typeof scope], value: any) => { + cfg[scope] ??= {} as T[typeof scope]; + cfg[scope][key] = value; + }, + //Remove a value from the config + unset: (scope: keyof T, key: keyof T[typeof scope]) => { + if (scope in cfg && key in cfg[scope]) { + delete cfg[scope][key]; + if (Object.keys(cfg[scope]).length === 0) { + delete cfg[scope]; + } + } + }, + }; +}; + + +/** + * Helper class to deal with config keys + */ +export class UpdateConfigKeySet { + public readonly raw: RefreshConfigKey[] = []; + + public add(input1: string, input2?: string) { + let full, scope, key; + if (input2) { + full = `${input1}.${input2}`; + scope = input1; + key = input2; + } else { + full = input1; + [scope, key] = input1.split('.'); + } + if (full.includes('*')) { + throw new Error('Wildcards are not allowed when adding config keys'); + } + this.raw.push({ full, scope, key }); + } + + private _hasMatch(rule: string) { + const [inputScope, inputKey] = rule.split('.'); + return this.raw.some(rawCfg => + (inputScope === '*' || rawCfg.scope === inputScope) && + (inputKey === '*' || rawCfg.key === inputKey) + ); + } + + public hasMatch(rule: string | string[]) { + if (Array.isArray(rule)) { + return rule.some(f => this._hasMatch(f)); + } else { + return this._hasMatch(rule); + } + } + + get size() { + return this.raw.length; + } + + get list() { + return this.raw.map(x => x.full); + } +} diff --git a/core/modules/Database/dao/actions.ts b/core/modules/Database/dao/actions.ts new file mode 100644 index 0000000..16379ab --- /dev/null +++ b/core/modules/Database/dao/actions.ts @@ -0,0 +1,240 @@ +import { cloneDeep } from 'lodash-es'; +import { DbInstance, SavePriority } from "../instance"; +import { DatabaseActionBanType, DatabaseActionType, DatabaseActionWarnType } from "../databaseTypes"; +import { genActionID } from "../dbUtils"; +import { now } from '@lib/misc'; +import consoleFactory from '@lib/console'; +const console = consoleFactory('DatabaseDao'); + + +/** + * Data access object for the database "actions" collection. + */ +export default class ActionsDao { + constructor(private readonly db: DbInstance) { } + + private get dbo() { + if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`); + return this.db.obj; + } + + private get chain() { + if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`); + return this.db.obj.chain; + } + + + /** + * Searches for an action in the database by the id, returns action or null if not found + */ + findOne(actionId: string): DatabaseActionType | null { + if (typeof actionId !== 'string' || !actionId.length) throw new Error('Invalid actionId.'); + + //Performing search + const a = this.chain.get('actions') + .find({ id: actionId }) + .cloneDeep() + .value(); + return (typeof a === 'undefined') ? null : a; + } + + + /** + * Searches for any registered action in the database by a list of identifiers and optional filters + * Usage example: findMany(['license:xxx'], undefined, {type: 'ban', revocation.timestamp: null}) + */ + findMany( + idsArray: string[], + hwidsArray?: string[], + customFilter: ((action: DatabaseActionType) => action is T) | object = {} + ): T[] { + if (!Array.isArray(idsArray)) throw new Error('idsArray should be an array'); + if (hwidsArray && !Array.isArray(hwidsArray)) throw new Error('hwidsArray should be an array or undefined'); + const idsFilter = (action: DatabaseActionType) => idsArray.some((fi) => action.ids.includes(fi)) + const hwidsFilter = (action: DatabaseActionType) => { + if ('hwids' in action && action.hwids) { + const count = hwidsArray!.filter((fi) => action.hwids?.includes(fi)).length; + return count >= txConfig.banlist.requiredHwidMatches; + } else { + return false; + } + } + + try { + //small optimization + const idsMatchFilter = hwidsArray && hwidsArray.length && txConfig.banlist.requiredHwidMatches + ? (a: DatabaseActionType) => idsFilter(a) || hwidsFilter(a) + : (a: DatabaseActionType) => idsFilter(a) + + return this.chain.get('actions') + .filter(customFilter as (a: DatabaseActionType) => a is T) + .filter(idsMatchFilter) + .cloneDeep() + .value(); + } catch (error) { + const msg = `Failed to search for a registered action database with error: ${(error as Error).message}`; + console.verbose.error(msg); + throw new Error(msg); + } + } + + + /** + * Registers a ban action and returns its id + */ + registerBan( + ids: string[], + author: string, + reason: string, + expiration: number | false, + playerName: string | false = false, + hwids?: string[], //only used for bans + ): string { + //Sanity check + if (!Array.isArray(ids) || !ids.length) throw new Error('Invalid ids array.'); + if (typeof author !== 'string' || !author.length) throw new Error('Invalid author.'); + if (typeof reason !== 'string' || !reason.length) throw new Error('Invalid reason.'); + if (expiration !== false && (typeof expiration !== 'number')) throw new Error('Invalid expiration.'); + if (playerName !== false && (typeof playerName !== 'string' || !playerName.length)) throw new Error('Invalid playerName.'); + if (hwids && !Array.isArray(hwids)) throw new Error('Invalid hwids array.'); + + //Saves it to the database + const timestamp = now(); + try { + const actionID = genActionID(this.dbo, 'ban'); + const toDB: DatabaseActionBanType = { + id: actionID, + type: 'ban', + ids, + hwids, + playerName, + reason, + author, + timestamp, + expiration, + revocation: { + timestamp: null, + author: null, + }, + }; + this.chain.get('actions') + .push(toDB) + .value(); + this.db.writeFlag(SavePriority.HIGH); + return actionID; + } catch (error) { + let msg = `Failed to register ban to database with message: ${(error as Error).message}`; + console.error(msg); + console.verbose.dir(error); + throw error; + } + } + + + /** + * Registers a warn action and returns its id + */ + registerWarn( + ids: string[], + author: string, + reason: string, + playerName: string | false = false, + ): string { + //Sanity check + if (!Array.isArray(ids) || !ids.length) throw new Error('Invalid ids array.'); + if (typeof author !== 'string' || !author.length) throw new Error('Invalid author.'); + if (typeof reason !== 'string' || !reason.length) throw new Error('Invalid reason.'); + if (playerName !== false && (typeof playerName !== 'string' || !playerName.length)) throw new Error('Invalid playerName.'); + + //Saves it to the database + const timestamp = now(); + try { + const actionID = genActionID(this.dbo, 'warn'); + const toDB: DatabaseActionWarnType = { + id: actionID, + type: 'warn', + ids, + playerName, + reason, + author, + timestamp, + expiration: false, + acked: false, + revocation: { + timestamp: null, + author: null, + }, + }; + this.chain.get('actions') + .push(toDB) + .value(); + this.db.writeFlag(SavePriority.HIGH); + return actionID; + } catch (error) { + let msg = `Failed to register warn to database with message: ${(error as Error).message}`; + console.error(msg); + console.verbose.dir(error); + throw error; + } + } + + /** + * Marks a warning as acknowledged + */ + ackWarn(actionId: string) { + if (typeof actionId !== 'string' || !actionId.length) throw new Error('Invalid actionId.'); + + try { + const action = this.chain.get('actions') + .find({ id: actionId }) + .value(); + if (!action) throw new Error(`action not found`); + if (action.type !== 'warn') throw new Error(`action is not a warn`); + action.acked = true; + this.db.writeFlag(SavePriority.MEDIUM); + } catch (error) { + const msg = `Failed to ack warn with message: ${(error as Error).message}`; + console.error(msg); + console.verbose.dir(error); + throw error; + } + } + + + /** + * Revoke an action (ban, warn) + */ + revoke( + actionId: string, + author: string, + allowedTypes: string[] | true = true + ): DatabaseActionType { + if (typeof actionId !== 'string' || !actionId.length) throw new Error('Invalid actionId.'); + if (typeof author !== 'string' || !author.length) throw new Error('Invalid author.'); + if (allowedTypes !== true && !Array.isArray(allowedTypes)) throw new Error('Invalid allowedTypes.'); + + try { + const action = this.chain.get('actions') + .find({ id: actionId }) + .value(); + + if (!action) throw new Error(`action not found`); + if (allowedTypes !== true && !allowedTypes.includes(action.type)) { + throw new Error(`you do not have permission to revoke this action`); + } + + action.revocation = { + timestamp: now(), + author, + }; + this.db.writeFlag(SavePriority.HIGH); + return cloneDeep(action); + + } catch (error) { + const msg = `Failed to revoke action with message: ${(error as Error).message}`; + console.error(msg); + console.verbose.dir(error); + throw error; + } + } +} diff --git a/core/modules/Database/dao/cleanup.ts b/core/modules/Database/dao/cleanup.ts new file mode 100644 index 0000000..ec220ab --- /dev/null +++ b/core/modules/Database/dao/cleanup.ts @@ -0,0 +1,140 @@ +import { DbInstance, SavePriority } from "../instance"; +import consoleFactory from '@lib/console'; +import { DatabasePlayerType, DatabaseWhitelistApprovalsType, DatabaseWhitelistRequestsType } from '../databaseTypes'; +import { now } from '@lib/misc'; +const console = consoleFactory('DatabaseDao'); + + +/** + * Data access object for cleaning up the database. + */ +export default class CleanupDao { + constructor(private readonly db: DbInstance) { } + + private get dbo() { + if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`); + return this.db.obj; + } + + private get chain() { + if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`); + return this.db.obj.chain; + } + + + /** + * Cleans the database by removing every entry that matches the provided filter function. + * @returns {number} number of removed items + */ + bulkRemove( + tableName: 'players' | 'actions' | 'whitelistApprovals' | 'whitelistRequests', + filterFunc: Function + ): number { + if (!Array.isArray(this.dbo.data[tableName])) throw new Error('Table selected isn\'t an array.'); + if (typeof filterFunc !== 'function') throw new Error('filterFunc must be a function.'); + + try { + this.db.writeFlag(SavePriority.HIGH); + const removed = this.chain.get(tableName) + .remove(filterFunc as any) + .value(); + return removed.length; + } catch (error) { + const msg = `Failed to clean database with error: ${(error as Error).message}`; + console.verbose.error(msg); + throw new Error(msg); + } + } + + + /** + * Cleans the hwids from the database. + * @returns {number} number of removed HWIDs + */ + wipeHwids( + fromPlayers: boolean, + fromBans: boolean, + ): number { + if (!Array.isArray(this.dbo.data.players)) throw new Error('Players table isn\'t an array yet.'); + if (!Array.isArray(this.dbo.data.players)) throw new Error('Actions table isn\'t an array yet.'); + if (typeof fromPlayers !== 'boolean' || typeof fromBans !== 'boolean') throw new Error('The parameters should be booleans.'); + + try { + this.db.writeFlag(SavePriority.HIGH); + let removed = 0; + if (fromPlayers) { + this.chain.get('players') + .map(player => { + removed += player.hwids.length; + player.hwids = []; + return player; + }) + .value(); + } + if (fromBans) + this.chain.get('actions') + .map(action => { + if (action.type !== 'ban' || !action.hwids) { + return action; + } else { + removed += action.hwids.length; + action.hwids = []; + return action; + } + }) + .value(); + return removed; + } catch (error) { + const msg = `Failed to clean database with error: ${(error as Error).message}`; + console.verbose.error(msg); + throw new Error(msg); + } + } + + + /** + * Cron func to optimize the database removing players and whitelist reqs/approvals + */ + runDailyOptimizer() { + const oneDay = 24 * 60 * 60; + + //Optimize players + //Players that have not joined the last 16 days, and have less than 2 hours of playtime + let playerRemoved; + try { + const sixteenDaysAgo = now() - (16 * oneDay); + const filter = (p: DatabasePlayerType) => { + return (p.tsLastConnection < sixteenDaysAgo && p.playTime < 120); + } + playerRemoved = this.bulkRemove('players', filter); + } catch (error) { + const msg = `Failed to optimize players database with error: ${(error as Error).message}`; + console.error(msg); + } + + //Optimize whitelistRequests + whitelistApprovals + //Removing the ones older than 7 days + let wlRequestsRemoved, wlApprovalsRemoved; + const sevenDaysAgo = now() - (7 * oneDay); + try { + const wlRequestsFilter = (req: DatabaseWhitelistRequestsType) => { + return (req.tsLastAttempt < sevenDaysAgo); + } + wlRequestsRemoved = txCore.database.whitelist.removeManyRequests(wlRequestsFilter).length; + + const wlApprovalsFilter = (req: DatabaseWhitelistApprovalsType) => { + return (req.tsApproved < sevenDaysAgo); + } + wlApprovalsRemoved = txCore.database.whitelist.removeManyApprovals(wlApprovalsFilter).length; + } catch (error) { + const msg = `Failed to optimize players database with error: ${(error as Error).message}`; + console.error(msg); + } + + this.db.writeFlag(SavePriority.LOW); + console.ok(`Internal Database optimized. This applies only for the txAdmin internal database, and does not affect your MySQL or framework (ESX/QBCore/etc) databases.`); + console.ok(`- ${playerRemoved} players that haven't connected in the past 16 days and had less than 2 hours of playtime.`); + console.ok(`- ${wlRequestsRemoved} whitelist requests older than a week.`); + console.ok(`- ${wlApprovalsRemoved} whitelist approvals older than a week.`); + } +} diff --git a/core/modules/Database/dao/players.ts b/core/modules/Database/dao/players.ts new file mode 100644 index 0000000..12730f6 --- /dev/null +++ b/core/modules/Database/dao/players.ts @@ -0,0 +1,110 @@ +import { cloneDeep } from 'lodash-es'; +import { DbInstance, SavePriority } from "../instance"; +import { DatabasePlayerType } from "../databaseTypes"; +import { DuplicateKeyError } from "../dbUtils"; +import consoleFactory from '@lib/console'; +const console = consoleFactory('DatabaseDao'); + + +/** + * Data access object for the database "players" collection. + */ +export default class PlayersDao { + constructor(private readonly db: DbInstance) { } + + private get dbo() { + if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`); + return this.db.obj; + } + + private get chain() { + if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`); + return this.db.obj.chain; + } + + + /** + * Searches for a player in the database by the license, returns null if not found or false in case of error + */ + findOne(license: string): DatabasePlayerType | null { + //Performing search + const p = this.chain.get('players') + .find({ license }) + .cloneDeep() + .value(); + return (typeof p === 'undefined') ? null : p; + } + + + /** + * Register a player to the database + */ + findMany(filter: object | Function): DatabasePlayerType[] { + return this.chain.get('players') + .filter(filter as any) + .cloneDeep() + .value(); + } + + + /** + * Register a player to the database + */ + register(player: DatabasePlayerType): void { + //TODO: validate player data vs DatabasePlayerType props + + //Check for duplicated license + const found = this.chain.get('players') + .filter({ license: player.license }) + .value(); + if (found.length) throw new DuplicateKeyError(`this license is already registered`); + + this.db.writeFlag(SavePriority.LOW); + this.chain.get('players') + .push(player) + .value(); + } + + + /** + * Updates a player setting assigning srcData props to the database player. + * The source data object is deep cloned to prevent weird side effects. + */ + update(license: string, srcData: object, srcUniqueId: Symbol): DatabasePlayerType { + if (typeof (srcData as any).license !== 'undefined') { + throw new Error(`cannot license field`); + } + + const playerDbObj = this.chain.get('players').find({ license }); + if (!playerDbObj.value()) throw new Error('Player not found in database'); + this.db.writeFlag(SavePriority.LOW); + const newData = playerDbObj + .assign(cloneDeep(srcData)) + .cloneDeep() + .value(); + txCore.fxPlayerlist.handleDbDataSync(newData, srcUniqueId); + return newData; + } + + + /** + * Revokes whitelist status of all players that match a filter function + * @returns the number of revoked whitelists + */ + bulkRevokeWhitelist(filterFunc: Function): number { + if (typeof filterFunc !== 'function') throw new Error('filterFunc must be a function.'); + + let cntChanged = 0; + const srcSymbol = Symbol('bulkRevokePlayerWhitelist'); + this.dbo.data!.players.forEach((player) => { + if (player.tsWhitelisted && filterFunc(player)) { + cntChanged++; + player.tsWhitelisted = undefined; + txCore.fxPlayerlist.handleDbDataSync(cloneDeep(player), srcSymbol); + } + }); + + this.db.writeFlag(SavePriority.HIGH); + return cntChanged; + } +} diff --git a/core/modules/Database/dao/stats.ts b/core/modules/Database/dao/stats.ts new file mode 100644 index 0000000..66bd74a --- /dev/null +++ b/core/modules/Database/dao/stats.ts @@ -0,0 +1,111 @@ +import { DbInstance } from "../instance"; +import consoleFactory from '@lib/console'; +import { MultipleCounter } from '@modules/Metrics/statsUtils'; +import { now } from '@lib/misc'; +const console = consoleFactory('DatabaseDao'); + + +/** + * Data access object for collecting stats from the database. + */ +export default class StatsDao { + constructor(private readonly db: DbInstance) { } + + private get dbo() { + if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`); + return this.db.obj; + } + + private get chain() { + if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`); + return this.db.obj.chain; + } + + + /** + * Returns players stats for the database (for Players page callouts) + */ + getPlayersStats() { + const oneDayAgo = now() - (24 * 60 * 60); + const sevenDaysAgo = now() - (7 * 24 * 60 * 60); + const startingValue = { + total: 0, + playedLast24h: 0, + joinedLast24h: 0, + joinedLast7d: 0, + }; + const playerStats = this.chain.get('players') + .reduce((acc, p, ind) => { + acc.total++; + if (p.tsLastConnection > oneDayAgo) acc.playedLast24h++; + if (p.tsJoined > oneDayAgo) acc.joinedLast24h++; + if (p.tsJoined > sevenDaysAgo) acc.joinedLast7d++; + return acc; + }, startingValue) + .value(); + + return playerStats; + } + + + /** + * Returns players stats for the database (for Players page callouts) + */ + getActionStats() { + const sevenDaysAgo = now() - (7 * 24 * 60 * 60); + const startingValue = { + totalWarns: 0, + warnsLast7d: 0, + totalBans: 0, + bansLast7d: 0, + groupedByAdmins: new MultipleCounter(), + }; + const actionStats = this.chain.get('actions') + .reduce((acc, action, ind) => { + if (action.type == 'ban') { + acc.totalBans++; + if (action.timestamp > sevenDaysAgo) acc.bansLast7d++; + } else if (action.type == 'warn') { + acc.totalWarns++; + if (action.timestamp > sevenDaysAgo) acc.warnsLast7d++; + } + acc.groupedByAdmins.count(action.author); + return acc; + }, startingValue) + .value(); + + return { + ...actionStats, + groupedByAdmins: actionStats.groupedByAdmins.toJSON(), + }; + } + + + /** + * Returns actions/players stats for the database + * NOTE: used by diagnostics and reporting + */ + getDatabaseStats() { + const actionStats = this.chain.get('actions') + .reduce((acc, a, ind) => { + if (a.type == 'ban') { + acc.bans++; + } else if (a.type == 'warn') { + acc.warns++; + } + return acc; + }, { bans: 0, warns: 0 }) + .value(); + + const playerStats = this.chain.get('players') + .reduce((acc, p, ind) => { + acc.players++; + acc.playTime += p.playTime; + if (p.tsWhitelisted) acc.whitelists++; + return acc; + }, { players: 0, playTime: 0, whitelists: 0 }) + .value(); + + return { ...actionStats, ...playerStats } + } +} diff --git a/core/modules/Database/dao/whitelist.ts b/core/modules/Database/dao/whitelist.ts new file mode 100644 index 0000000..9e4b13c --- /dev/null +++ b/core/modules/Database/dao/whitelist.ts @@ -0,0 +1,133 @@ +import { cloneDeep } from 'lodash-es'; +import { DbInstance, SavePriority } from "../instance"; +import consoleFactory from '@lib/console'; +import { DatabaseWhitelistApprovalsType, DatabaseWhitelistRequestsType } from '../databaseTypes'; +import { DuplicateKeyError, genWhitelistRequestID } from '../dbUtils'; +const console = consoleFactory('DatabaseDao'); + + +/** + * Data access object for the database whitelist collections. + */ +export default class WhitelistDao { + constructor(private readonly db: DbInstance) { } + + private get dbo() { + if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`); + return this.db.obj; + } + + private get chain() { + if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`); + return this.db.obj.chain; + } + + + /** + * Returns all whitelist approvals, which can be optionally filtered + */ + findManyApprovals( + filter?: object | Function + ): DatabaseWhitelistApprovalsType[] { + return this.chain.get('whitelistApprovals') + .filter(filter as any) + .cloneDeep() + .value(); + } + + + /** + * Removes whitelist approvals based on a filter. + */ + removeManyApprovals( + filter: object | Function + ): DatabaseWhitelistApprovalsType[] { + this.db.writeFlag(SavePriority.MEDIUM); + return this.chain.get('whitelistApprovals') + .remove(filter as any) + .value(); + } + + + /** + * Register a whitelist request to the database + */ + registerApproval(approval: DatabaseWhitelistApprovalsType): void { + //TODO: validate player data vs DatabaseWhitelistApprovalsType props + + //Check for duplicated license + const found = this.chain.get('whitelistApprovals') + .filter({ identifier: approval.identifier }) + .value(); + if (found.length) throw new DuplicateKeyError(`this identifier is already whitelisted`); + + //Register new + this.db.writeFlag(SavePriority.LOW); + this.chain.get('whitelistApprovals') + .push(cloneDeep(approval)) + .value(); + } + + + /** + * Returns all whitelist approvals, which can be optionally filtered + */ + findManyRequests( + filter?: object | Function + ): DatabaseWhitelistRequestsType[] { + return this.chain.get('whitelistRequests') + .filter(filter as any) + .cloneDeep() + .value(); + } + + + /** + * Removes whitelist requests based on a filter. + */ + removeManyRequests( + filter: object | Function + ): DatabaseWhitelistRequestsType[] { + this.db.writeFlag(SavePriority.LOW); + return this.chain.get('whitelistRequests') + .remove(filter as any) + .value(); + } + + + /** + * Updates a whitelist request setting assigning srcData props to the database object. + * The source data object is deep cloned to prevent weird side effects. + */ + updateRequest(license: string, srcData: object): DatabaseWhitelistRequestsType { + if (typeof (srcData as any).id !== 'undefined' || typeof (srcData as any).license !== 'undefined') { + throw new Error(`cannot update id or license fields`); + } + + const requestDbObj = this.chain.get('whitelistRequests').find({ license }); + if (!requestDbObj.value()) throw new Error('Request not found in database'); + this.db.writeFlag(SavePriority.LOW); + return requestDbObj + .assign(cloneDeep(srcData)) + .cloneDeep() + .value(); + } + + + /** + * Register a whitelist request to the database + */ + registerRequest(request: Omit): string { + //TODO: validate player data vs DatabaseWhitelistRequestsType props + if (typeof (request as any).id !== 'undefined') { + throw new Error(`cannot manually set the id field`); + } + + const id = genWhitelistRequestID(this.dbo); + this.db.writeFlag(SavePriority.LOW); + this.chain.get('whitelistRequests') + .push({ id, ...cloneDeep(request) }) + .value(); + return id; + } +} diff --git a/core/modules/Database/databaseTypes.ts b/core/modules/Database/databaseTypes.ts new file mode 100644 index 0000000..aae2850 --- /dev/null +++ b/core/modules/Database/databaseTypes.ts @@ -0,0 +1,68 @@ +export type DatabasePlayerType = { + license: string; + ids: string[]; + hwids: string[]; + displayName: string; + pureName: string; + playTime: number; + tsLastConnection: number; + tsJoined: number; + tsWhitelisted?: number; + notes?: { + text: string; + lastAdmin: string | null; + tsLastEdit: number | null; + }; +}; + +export type DatabaseActionBaseType = { + id: string; + ids: string[]; + playerName: string | false; + reason: string; + author: string; + timestamp: number; + //FIXME: the revocation object itself should be optional instead of nullable properties + //BUT DO REMEMBER THE `'XXX' IN YYY` ISSUE! + revocation: { + timestamp: number | null; + author: string | null; + }; +}; +export type DatabaseActionBanType = { + type: 'ban'; + hwids?: string[]; + expiration: number | false; +} & DatabaseActionBaseType; +export type DatabaseActionWarnType = { + type: 'warn'; + expiration: false; //FIXME: remove - BUT DO REMEMBER THE `'XXX' IN YYY` ISSUE! + acked: boolean; //if the player has acknowledged the warning +} & DatabaseActionBaseType; +export type DatabaseActionType = DatabaseActionBanType | DatabaseActionWarnType; + +export type DatabaseWhitelistApprovalsType = { + identifier: string; + playerName: string; //always filled, even with `unknown` or license `xxxxxx...xxxxxx` + playerAvatar: string | null, + tsApproved: number, + approvedBy: string +}; + +export type DatabaseWhitelistRequestsType = { + id: string, //R#### + license: string, + playerDisplayName: string, + playerPureName: string, + discordTag?: string, + discordAvatar?: string, //first try to get from GuildMember, then client.users.fetch() + tsLastAttempt: number, +}; + +export type DatabaseDataType = { + version: number, + players: DatabasePlayerType[], + actions: DatabaseActionType[], + whitelistApprovals: DatabaseWhitelistApprovalsType[], + whitelistRequests: DatabaseWhitelistRequestsType[], +}; diff --git a/core/modules/Database/dbUtils.ts b/core/modules/Database/dbUtils.ts new file mode 100644 index 0000000..cc1162d --- /dev/null +++ b/core/modules/Database/dbUtils.ts @@ -0,0 +1,124 @@ +const modulename = 'IDGen'; +import fsp from 'node:fs/promises'; +import * as nanoidSecure from 'nanoid'; +import * as nanoidNonSecure from 'nanoid/non-secure'; +import consts from '@shared/consts'; +import getOsDistro from '@lib/host/getOsDistro.js'; +import { txEnv, txHostConfig } from '@core/globalData'; +import type { DatabaseObjectType } from './instance'; +import consoleFactory from '@lib/console'; +import { msToDuration } from '@lib/misc'; +const console = consoleFactory(modulename); + +//Consts +type IdStorageTypes = DatabaseObjectType | Set; +const maxAttempts = 10; +const noIdErrorMessage = 'Unnable to generate new Random ID possibly due to the decreased available entropy. Please send a screenshot of the detailed information in the terminal for the txAdmin devs.'; + + +/** + * Prints a diagnostics message to the console that should help us identify what is the problem and the potential solution + */ +const printDiagnostics = async () => { + let uptime; + let entropy; + try { + uptime = msToDuration(process.uptime() * 1000); + entropy = (await fsp.readFile('/proc/sys/kernel/random/entropy_avail', 'utf8')).trim(); + } catch (error) { + entropy = (error as Error).message; + } + + const secureStorage = new Set(); + for (let i = 0; i < 100; i++) { + const randID = nanoidSecure.customAlphabet(consts.actionIdAlphabet, 4)(); + if (!secureStorage.has(randID)) secureStorage.add(randID); + } + + const nonsecureStorage = new Set(); + for (let i = 0; i < 100; i++) { + const randID = nanoidNonSecure.customAlphabet(consts.actionIdAlphabet, 4)(); + if (!nonsecureStorage.has(randID)) nonsecureStorage.add(randID); + } + + const osDistro = await getOsDistro(); + console.error(noIdErrorMessage); + console.error(`Uptime: ${uptime}`); + console.error(`Entropy: ${entropy}`); + console.error(`Distro: ${osDistro}`); + console.error(`txAdmin: ${txEnv.txaVersion}`); + console.error(`FXServer: ${txEnv.fxsVersionTag}`); + console.error(`Provider: ${txHostConfig.providerName ?? 'none'}`); + console.error(`Unique Test: secure ${secureStorage.size}/100, non-secure ${nonsecureStorage.size}/100`); +}; + + +/** + * Check in a storage weather the ID is unique or not. + * @param storage the Set or lowdb instance + * @param id the ID to check + * @param lowdbTable the lowdb table to check + * @returns if is unique + */ +const checkUniqueness = (storage: IdStorageTypes, id: string, lowdbTable: string) => { + if (storage instanceof Set) { + return !storage.has(id); + } else { + //@ts-ignore: typing as ('actions' | 'whitelistRequests') did not work + return !storage.chain.get(lowdbTable).find({ id }).value(); + } +}; + + +/** + * Generates an unique whitelist ID, or throws an error + * @param storage set or lowdb instance + * @returns id + */ +export const genWhitelistRequestID = (storage: IdStorageTypes) => { + let attempts = 0; + while (attempts < maxAttempts) { + attempts++; + const randFunc = (attempts <= 5) ? nanoidSecure : nanoidNonSecure; + const id = 'R' + randFunc.customAlphabet(consts.actionIdAlphabet, 4)(); + if (checkUniqueness(storage, id, 'whitelistRequests')) { + return id; + } + } + + printDiagnostics().catch((e) => { }); + throw new Error(noIdErrorMessage); +}; + + +/** + * Generates an unique action ID, or throws an error + */ +export const genActionID = (storage: IdStorageTypes, actionType: string) => { + let attempts = 0; + while (attempts < maxAttempts) { + attempts++; + const randFunc = (attempts <= 5) ? nanoidSecure : nanoidNonSecure; + const id = actionType[0].toUpperCase() + + randFunc.customAlphabet(consts.actionIdAlphabet, 3)() + + '-' + + randFunc.customAlphabet(consts.actionIdAlphabet, 4)(); + if (checkUniqueness(storage, id, 'actions')) { + return id; + } + } + + printDiagnostics().catch((e) => { }); + throw new Error(noIdErrorMessage); +}; + + +/** + * Error class for key uniqueness violations + */ +export class DuplicateKeyError extends Error { + readonly code = 'DUPLICATE_KEY'; + constructor(message: string) { + super(message); + } +} diff --git a/core/modules/Database/index.ts b/core/modules/Database/index.ts new file mode 100644 index 0000000..5bfb74a --- /dev/null +++ b/core/modules/Database/index.ts @@ -0,0 +1,76 @@ +const modulename = 'Database'; +import { DbInstance } from './instance'; +import consoleFactory from '@lib/console'; + +import PlayersDao from './dao/players'; +import ActionsDao from './dao/actions'; +import WhitelistDao from './dao/whitelist'; +import StatsDao from './dao/stats'; +import CleanupDao from './dao/cleanup'; +import { TxConfigState } from '@shared/enums'; +const console = consoleFactory(modulename); + + +/** + * This module is a hub for all database-related operations. + */ +export default class Database { + readonly #db: DbInstance; + + //Database Methods + readonly players: PlayersDao; + readonly actions: ActionsDao; + readonly whitelist: WhitelistDao; + readonly stats: StatsDao; + readonly cleanup: CleanupDao; + + constructor() { + this.#db = new DbInstance(); + this.players = new PlayersDao(this.#db); + this.actions = new ActionsDao(this.#db); + this.whitelist = new WhitelistDao(this.#db); + this.stats = new StatsDao(this.#db); + this.cleanup = new CleanupDao(this.#db); + + //Database optimization cron function + const optimizerTask = () => { + if(txManager.configState === TxConfigState.Ready) { + this.cleanup.runDailyOptimizer(); + } + } + setTimeout(optimizerTask, 30_000); + setInterval(optimizerTask, 24 * 60 * 60_000); + } + + + /** + * Graceful shutdown handler - passing down to the db instance + */ + public handleShutdown() { + this.#db.handleShutdown(); + } + + + /** + * Returns if the lowdb instance is ready + */ + get isReady() { + return this.#db.isReady; + } + + /** + * Returns if size of the database file + */ + get fileSize() { + return (this.#db.obj?.adapter as any)?.fileSize; + } + + + /** + * Returns the entire lowdb object. Please be careful with it :) + */ + getDboRef() { + if (!this.#db.obj) throw new Error(`database not ready yet`); + return this.#db.obj; + } +}; diff --git a/core/modules/Database/instance.ts b/core/modules/Database/instance.ts new file mode 100644 index 0000000..473216f --- /dev/null +++ b/core/modules/Database/instance.ts @@ -0,0 +1,260 @@ +const modulename = 'Database'; +import fsp from 'node:fs/promises'; +import { ExpChain } from 'lodash'; +//@ts-ignore: I haven o idea why this errors, but I couldn't solve it +import lodash from 'lodash-es'; +import { Low, Adapter } from 'lowdb'; +import { TextFile } from 'lowdb/node'; +import { txDevEnv, txEnv } from '@core/globalData'; +import { DatabaseDataType } from './databaseTypes.js'; +import migrations from './migrations.js'; +import consoleFactory from '@lib/console.js'; +import fatalError from '@lib/fatalError.js'; +import { TimeCounter } from '@modules/Metrics/statsUtils.js'; +const console = consoleFactory(modulename); + +//Consts & helpers +export const DATABASE_VERSION = 5; +export const defaultDatabase = { + version: DATABASE_VERSION, + actions: [], + players: [], + whitelistApprovals: [], + whitelistRequests: [], +}; + +export enum SavePriority { + STANDBY, + LOW, + MEDIUM, + HIGH, +} + +const SAVE_CONFIG = { + [SavePriority.STANDBY]: { + name: 'standby', + interval: 5 * 60 * 1000, + }, + [SavePriority.LOW]: { + name: 'low', + interval: 60 * 1000, + }, + [SavePriority.MEDIUM]: { + name: 'medium', + interval: 30 * 1000, + }, + [SavePriority.HIGH]: { + name: 'high', + interval: 15 * 1000, + }, +} as Record; + + +//Reimplementing the adapter to minify json onm prod builds +class JSONFile implements Adapter { + private readonly adapter: TextFile; + private readonly serializer: Function; + public fileSize: number = 0; + + constructor(filename: string) { + this.adapter = new TextFile(filename); + this.serializer = (txDevEnv.ENABLED) + ? (obj: any) => JSON.stringify(obj, null, 4) + : JSON.stringify; + } + + async read(): Promise { + const data = await this.adapter.read(); + if (data === null) { + return null; + } else { + return JSON.parse(data) as T; + } + } + + write(obj: T): Promise { + const serialized = this.serializer(obj); + this.fileSize = serialized.length; + return this.adapter.write(serialized); + } +} + + +// Extend Low class with a new `chain` field +//NOTE: lodash-es doesn't have ExpChain exported, so we need it from the original lodash +class LowWithLodash extends Low { + chain: ExpChain = lodash.chain(this).get('data') +} +export type DatabaseObjectType = LowWithLodash; + + +export class DbInstance { + readonly dbPath: string; + readonly backupPath: string; + obj: DatabaseObjectType | undefined = undefined; + #writePending: SavePriority = SavePriority.STANDBY; + lastWrite: number = 0; + isReady: boolean = false; + + constructor() { + this.dbPath = `${txEnv.profilePath}/data/playersDB.json`; + this.backupPath = `${txEnv.profilePath}/data/playersDB.backup.json`; + + //Start database instance + this.setupDatabase(); + + //Cron functions + setInterval(() => { + this.checkWriteNeeded(); + }, SAVE_CONFIG[SavePriority.HIGH].interval); + setInterval(() => { + this.backupDatabase(); + }, SAVE_CONFIG[SavePriority.STANDBY].interval); + } + + + /** + * Start lowdb instance and set defaults + */ + async setupDatabase() { + //Tries to load the database + let dbo; + try { + const adapterAsync = new JSONFile(this.dbPath); + dbo = new LowWithLodash(adapterAsync, defaultDatabase); + await dbo.read(); + } catch (errorMain) { + const errTitle = 'Your txAdmin player/actions database could not be loaded.'; + try { + await fsp.copyFile(this.backupPath, this.dbPath); + const adapterAsync = new JSONFile(this.dbPath); + dbo = new LowWithLodash(adapterAsync, defaultDatabase); + await dbo.read(); + console.warn(errTitle); + console.warn('The database file was restored with the automatic backup file.'); + console.warn('A five minute rollback is expected.'); + } catch (errorBackup) { + fatalError.Database(0, [ + errTitle, + 'It was also not possible to load the automatic backup file.', + ['Main error', (errorMain as Error).message], + ['Backup error', (errorBackup as Error).message], + ['Database path', this.dbPath], + 'If there is a file in that location, you may try to delete or restore it manually.', + ]); + } + } + + //Setting up loaded database + try { + //Need to write the database, in case it is new + await dbo.write(); + + //Need to chain after setting defaults + dbo.chain = lodash.chain(dbo.data); + + //If old database + if (dbo.data.version !== DATABASE_VERSION) { + await this.backupDatabase(`${txEnv.profilePath}/data/playersDB.backup.v${dbo.data.version}.json`); + this.obj = await migrations(dbo); + } else { + this.obj = dbo; + } + + //Checking basic structure integrity + if ( + !Array.isArray(this.obj!.data.actions) + || !Array.isArray(this.obj!.data.players) + || !Array.isArray(this.obj!.data.whitelistApprovals) + || !Array.isArray(this.obj!.data.whitelistRequests) + ) { + fatalError.Database(2, [ + 'Your txAdmin player/actions database is corrupted!', + 'It is missing one of the required arrays (players, actions, whitelistApprovals, whitelistRequests).', + 'If you modified the database file manually, you may try to restore it from the automatic backup file.', + ['Database path', this.dbPath], + ]); + } + + this.lastWrite = Date.now(); + this.isReady = true; + } catch (error) { + fatalError.Database(1, 'Failed to setup database object.', error); + } + } + + + /** + * Writes the database to the disk if pending. + */ + public handleShutdown() { + if (this.#writePending !== SavePriority.STANDBY) { + this.writeDatabase(); + } + } + + + /** + * Creates a copy of the database file + */ + async backupDatabase(targetPath?: string) { + try { + await fsp.copyFile(this.dbPath, targetPath ?? this.backupPath); + // console.verbose.debug('Database file backed up.'); + } catch (error) { + console.error(`Failed to backup database file '${this.dbPath}'`); + console.verbose.dir(error); + } + } + + + /** + * Set write pending flag + */ + writeFlag(flag = SavePriority.MEDIUM) { + if (flag < SavePriority.LOW || flag > SavePriority.HIGH) { + throw new Error('unknown priority flag!'); + } + if (flag > this.#writePending) { + const flagName = SAVE_CONFIG[flag].name; + console.verbose.debug(`writeFlag > ${flagName}`); + this.#writePending = flag; + } + } + + + /** + * Checks if it's time to write the database to disk, taking in consideration the priority flag + */ + private async checkWriteNeeded() { + //Check if the database is ready + if (!this.obj) return; + + const timeStart = Date.now(); + const sinceLastWrite = timeStart - this.lastWrite; + + if (this.#writePending === SavePriority.HIGH || sinceLastWrite > SAVE_CONFIG[this.#writePending].interval) { + const writeTime = new TimeCounter(); + await this.writeDatabase(); + const timeElapsed = writeTime.stop(); + this.#writePending = SavePriority.STANDBY; + this.lastWrite = timeStart; + // console.verbose.debug(`DB file saved, took ${timeElapsed.milliseconds}ms.`); + txCore.metrics.txRuntime.databaseSaveTime.count(timeElapsed.milliseconds); + } + } + + + /** + * Writes the database to the disk NOW + * NOTE: separate function so it can also be called by the shutdown handler + */ + private async writeDatabase() { + try { + await this.obj?.write(); + } catch (error) { + console.error(`Failed to save players database with error: ${(error as Error).message}`); + console.verbose.dir(error); + } + } +} diff --git a/core/modules/Database/migrations.js b/core/modules/Database/migrations.js new file mode 100644 index 0000000..273e9d7 --- /dev/null +++ b/core/modules/Database/migrations.js @@ -0,0 +1,173 @@ +const modulename = 'DBMigration'; +import { genActionID } from './dbUtils.js'; +import cleanPlayerName from '@shared/cleanPlayerName.js'; +import { DATABASE_VERSION, defaultDatabase } from './instance.js'; //FIXME: circular_dependency +import { now } from '@lib/misc.js'; +import consoleFactory from '@lib/console.js'; +import fatalError from '@lib/fatalError.js'; +const console = consoleFactory(modulename); + + +/** + * Handles the migration of the database + */ +export default async (dbo) => { + if (dbo.data.version === DATABASE_VERSION) { + return dbo; + } + if (typeof dbo.data.version !== 'number') { + fatalError.Database(50, 'Your players database version is not a number!'); + } + if (dbo.data.version > DATABASE_VERSION) { + fatalError.Database(51, [ + `Your players database is on v${dbo.data.version}, and this txAdmin supports up to v${DATABASE_VERSION}.`, + 'This means you likely downgraded your txAdmin or FXServer.', + 'Please make sure your txAdmin is updated!', + '', + 'If you want to downgrade FXServer (the "artifact") but keep txAdmin updated,', + 'you can move the updated "citizen/system_resources/monitor" folder', + 'to older FXserver artifact, replacing the old files.', + `Alternatively, you can restore the database v${dbo.data.version} backup on the data folder.`, + ]); + } + + //Migrate database + if (dbo.data.version < 1) { + console.warn(`Updating your players database from v${dbo.data.version} to v1. Wiping all the data.`); + dbo.data = lodash.cloneDeep(defaultDatabase); + dbo.data.version = 1; + await dbo.write(); + } + + + if (dbo.data.version === 1) { + console.warn('Updating your players database from v1 to v2.'); + console.warn('This process will change any duplicated action ID and wipe pending whitelist.'); + const actionIDStore = new Set(); + const actionsToFix = []; + dbo.chain.get('actions').forEach((a) => { + if (!actionIDStore.has(a.id)) { + actionIDStore.add(a.id); + } else { + actionsToFix.push(a); + } + }).value(); + console.warn(`Actions to fix: ${actionsToFix.length}`); + for (let i = 0; i < actionsToFix.length; i++) { + const action = actionsToFix[i]; + action.id = genActionID(actionIDStore, action.type); + actionIDStore.add(action.id); + } + dbo.data.pendingWL = []; + dbo.data.version = 2; + await dbo.write(); + } + + if (dbo.data.version === 2) { + console.warn('Updating your players database from v2 to v3.'); + console.warn('This process will:'); + console.warn('\t- process player names for better readability/searchability'); + console.warn('\t- allow txAdmin to save old player identifiers'); + console.warn('\t- remove the whitelist action in favor of player property'); + console.warn('\t- remove empty notes'); + console.warn('\t- improve whitelist handling'); + console.warn('\t- changing warn action prefix from A to W'); + + //Removing all whitelist actions + const ts = now(); + const whitelists = new Map(); + dbo.data.actions = dbo.data.actions.filter((action) => { + if (action.type !== 'whitelist') return true; + if ( + (!action.expiration || action.expiration > ts) + && (!action.revocation.timestamp) + && action.identifiers.length + && typeof action.identifiers[0] === 'string' + && action.identifiers[0].startsWith('license:') + ) { + const license = action.identifiers[0].substring(8); + whitelists.set(license, action.timestamp); + } + return false; + }); + + //Changing Warn actions id prefix to W + dbo.data.actions.forEach((action) => { + if (action.type === 'warn') { + action.id = `W${action.id.substring(1)}`; + } + }); + + //Migrating players + for (const player of dbo.data.players) { + const { displayName, pureName } = cleanPlayerName(player.name); + player.displayName = displayName; + player.pureName = pureName; + player.name = undefined; + player.ids = [`license:${player.license}`]; + + //adding whitelist + const tsWhitelisted = whitelists.get(player.license); + if (tsWhitelisted) player.tsWhitelisted = tsWhitelisted; + + //removing empty notes + if (!player.notes.text) player.notes = undefined; + } + + //Setting new whitelist schema + dbo.data.pendingWL = undefined; + dbo.data.whitelistApprovals = []; + dbo.data.whitelistRequests = []; + + //Saving db + dbo.data.version = 3; + await dbo.write(); + } + + if (dbo.data.version === 3) { + console.warn('Updating your players database from v3 to v4.'); + console.warn('This process will add a HWIDs array to the player data.'); + console.warn('As well as rename \'action[].identifiers\' to \'action[].ids\'.'); + + //Migrating players + for (const player of dbo.data.players) { + player.hwids = []; + } + + //Migrating actions + for (const action of dbo.data.actions) { + action.ids = action.identifiers; + action.identifiers = undefined; + } + + //Saving db + dbo.data.version = 4; + await dbo.write(); + } + + if (dbo.data.version === 4) { + console.warn('Updating your players database from v4 to v5.'); + console.warn('This process will allow for offline warns.'); + + //Migrating actions + for (const action of dbo.data.actions) { + if (action.type === 'warn') { + action.acked = true; + } + } + + //Saving db + dbo.data.version = 5; + await dbo.write(); + } + + if (dbo.data.version !== DATABASE_VERSION) { + fatalError.Database(52, [ + 'Unexpected migration error: Did not reach the expected database version.', + `Your players database is on v${dbo.data.version}, but the expected version is v${DATABASE_VERSION}.`, + 'Please make sure your txAdmin is on the most updated version!', + ]); + } + console.ok('Database migrated successfully'); + return dbo; +}; diff --git a/core/modules/DiscordBot/commands/info.ts b/core/modules/DiscordBot/commands/info.ts new file mode 100644 index 0000000..c304553 --- /dev/null +++ b/core/modules/DiscordBot/commands/info.ts @@ -0,0 +1,147 @@ +const modulename = 'DiscordBot:cmd:info'; +import { APIEmbedField, CommandInteraction, EmbedBuilder, EmbedData } from 'discord.js'; +import { parsePlayerId } from '@lib/player/idUtils'; +import { embedder } from '../discordHelpers'; +import { findPlayersByIdentifier } from '@lib/player/playerFinder'; +import { txEnv } from '@core/globalData'; +import humanizeDuration from 'humanize-duration'; +import consoleFactory from '@lib/console'; +import { msToShortishDuration } from '@lib/misc'; +const console = consoleFactory(modulename); + + +//Consts +const footer = { + iconURL: 'https://cdn.discordapp.com/emojis/1062339910654246964.webp?size=96&quality=lossless', + text: `txAdmin ${txEnv.txaVersion}`, +} + + +/** + * Handler for /info + */ +export default async (interaction: CommandInteraction) => { + const tsToLocaleDate = (ts: number) => { + return new Date(ts * 1000).toLocaleDateString( + txCore.translator.canonical, + { dateStyle: 'long' } + ); + } + + //Check for admininfo & permission + let includeAdminInfo = false; + //@ts-ignore: somehow vscode is resolving interaction as CommandInteraction + const adminInfoFlag = interaction.options.getBoolean('admininfo'); + if (adminInfoFlag) { + const admin = txCore.adminStore.getAdminByProviderUID(interaction.user.id); + if (!admin) { + return await interaction.reply(embedder.danger('You cannot use the `admininfo` option if you are not a txAdmin admin.')); + } else { + includeAdminInfo = true; + } + } + + //Detect search identifier + let searchId; + //@ts-ignore: somehow vscode is resolving interaction as CommandInteraction + const subcommand = interaction.options.getSubcommand(); + if (subcommand === 'self') { + const targetId = interaction.member?.user.id; + if (!targetId) { + return await interaction.reply(embedder.danger('Could not resolve your Discord ID.')); + } + searchId = `discord:${targetId}`; + + } else if (subcommand === 'member') { + const member = interaction.options.getMember('member'); + if(!member || !('user' in member)){ + return await interaction.reply(embedder.danger(`Failed to resolve member ID.`)); + } + searchId = `discord:${member.user.id}`; + + } else if (subcommand === 'id') { + //@ts-ignore: somehow vscode is resolving interaction as CommandInteraction + const input = interaction.options.getString('id', true).trim(); + if (!input.length) { + return await interaction.reply(embedder.danger('Invalid identifier.')); + } + + const { isIdValid, idType, idValue, idlowerCased } = parsePlayerId(input); + if (!isIdValid || !idType || !idValue || !idlowerCased) { + return await interaction.reply(embedder.danger(`The provided identifier (\`${input}\`) does not seem to be valid.`)); + } + searchId = idlowerCased; + + } else { + throw new Error(`Subcommand ${subcommand} not found.`); + } + + //Searching for players + const players = findPlayersByIdentifier(searchId); + if (!players.length) { + return await interaction.reply(embedder.warning(`Identifier (\`${searchId}\`) does not seem to be associated to any player in the txAdmin Database.`)); + } else if (players.length > 10) { + return await interaction.reply(embedder.warning(`The identifier (\`${searchId}\`) is associated with more than 10 players, please use the txAdmin Web Panel to search for it.`)); + } + + //Format players + const embeds = []; + for (const player of players) { + const dbData = player.getDbData(); + if (!dbData) continue; + + //Basic data + const bodyText: Record = { + 'Play time': msToShortishDuration(dbData.playTime * 60 * 1000), + 'Join date': tsToLocaleDate(dbData.tsJoined), + 'Last connection': tsToLocaleDate(dbData.tsLastConnection), + 'Whitelisted': (dbData.tsWhitelisted) + ? tsToLocaleDate(dbData.tsWhitelisted) + : 'not yet', + }; + + //If admin query + let fields: APIEmbedField[] | undefined; + if (includeAdminInfo) { + //Counting bans/warns + const actionHistory = player.getHistory(); + const actionCount = { ban: 0, warn: 0 }; + for (const log of actionHistory) { + actionCount[log.type]++; + } + const banText = (actionCount.ban === 1) ? '1 ban' : `${actionCount.ban} bans`; + const warnText = (actionCount.warn === 1) ? '1 warn' : `${actionCount.warn} warns`; + bodyText['Log'] = `${banText}, ${warnText}`; + + //Filling notes + identifiers + const notesText = (dbData.notes) ? dbData.notes.text : 'nothing here'; + const idsText = (dbData.ids.length) ? dbData.ids.join('\n') : 'nothing here'; + fields = [ + { + name: '• Notes:', + value: `\`\`\`${notesText}\`\`\`` + }, + { + name: '• Identifiers:', + value: `\`\`\`${idsText}\`\`\`` + }, + ]; + + } + + //Preparing embed + const description = Object.entries(bodyText) + .map(([label, value]) => `**• ${label}:** \`${value}\``) + .join('\n') + const embedData: EmbedData = { + title: player.displayName, + fields, + description, + footer, + }; + embeds.push(new EmbedBuilder(embedData).setColor('#4262e2')); + } + + //Send embeds :) + return await interaction.reply({ embeds }); +} diff --git a/core/modules/DiscordBot/commands/status.ts b/core/modules/DiscordBot/commands/status.ts new file mode 100644 index 0000000..c6e0630 --- /dev/null +++ b/core/modules/DiscordBot/commands/status.ts @@ -0,0 +1,294 @@ +const modulename = 'DiscordBot:cmd:status'; +import humanizeDuration from 'humanize-duration'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, ChatInputCommandInteraction, ColorResolvable, EmbedBuilder } from 'discord.js'; +import { txEnv } from '@core/globalData'; +import { cloneDeep } from 'lodash-es'; +import { embedder, ensurePermission, isValidButtonEmoji, isValidEmbedUrl, logDiscordAdminAction } from '../discordHelpers'; +import consoleFactory from '@lib/console'; +import { msToShortishDuration } from '@lib/misc'; +import { FxMonitorHealth } from '@shared/enums'; +const console = consoleFactory(modulename); + + +const isValidButtonConfig = (btn: any) => { + const btnType = typeof btn; + return ( + btn !== null && btnType === 'object' + && typeof btn.label === 'string' + && btn.label.length + && typeof btn.url === 'string' + // && btn.url.length //let the function handle it + && (typeof btn.emoji === 'string' || btn.emoji === undefined) + ); +} + +const invalidUrlMessage = `Every URL must start with one of (\`http://\`, \`https://\`, \`discord://\`). +URLs cannot be empty, if you do not want a URL then remove the URL line.`; + +const invalidPlaceholderMessage = `Your URL starts with \`{{\`, try removing it. +If you just tried to edit a placeholder like \`{{serverBrowserUrl}}\` or \`{{serverJoinUrl}}\`, remember that those placeholders are replaced automatically by txAdmin, meaning you do not need to edit them at all.` + +const invalidEmojiMessage = `All emojis must be one of: +- UTF-8 emoji ('😄') +- Valid emoji ID ('1062339910654246964') +- Discord custom emoji (\`<:name:id>\` or \`\`). +To get the full emoji code, insert it into discord, and add \`\\\` before it then send the message` + + +export const generateStatusMessage = ( + rawEmbedJson: string = txConfig.discordBot.embedJson, + rawEmbedConfigJson: string = txConfig.discordBot.embedConfigJson +) => { + //Parsing decoded JSONs + let embedJson; + try { + embedJson = JSON.parse(rawEmbedJson); + if (!(embedJson instanceof Object)) throw new Error(`not an Object`); + } catch (error) { + throw new Error(`Embed JSON Error: ${(error as Error).message}`); + } + + let embedConfigJson; + try { + embedConfigJson = JSON.parse(rawEmbedConfigJson); + if (!(embedConfigJson instanceof Object)) throw new Error(`not an Object`); + } catch (error) { + throw new Error(`Embed Config JSON Error: ${(error as Error).message}`); + } + + //Prepare placeholders + //NOTE: serverCfxId can be undefined, breaking the URLs, but there is no easy clean way to deal with this issue + const serverCfxId = txCore.cacheStore.get('fxsRuntime:cfxId'); + const fxMonitorStatus = txCore.fxMonitor.status; + const placeholders = { + serverName: txConfig.general.serverName, + statusString: 'Unknown', + statusColor: '#4C3539', + serverCfxId, + serverBrowserUrl: `https://servers.fivem.net/servers/detail/${serverCfxId}`, + serverJoinUrl: `https://cfx.re/join/${serverCfxId}`, + serverMaxClients: txCore.cacheStore.get('fxsRuntime:maxClients') ?? 'unknown', + serverClients: txCore.fxPlayerlist.onlineCount, + nextScheduledRestart: 'unknown', + uptime: (fxMonitorStatus.uptime > 0) + ? msToShortishDuration(fxMonitorStatus.uptime) + : '--', + } + + //Prepare scheduler placeholder + const schedule = txCore.fxScheduler.getStatus(); + if (typeof schedule.nextRelativeMs !== 'number') { + placeholders.nextScheduledRestart = 'not scheduled'; + } else if (schedule.nextSkip) { + placeholders.nextScheduledRestart = 'skipped'; + } else { + const tempFlag = (schedule.nextIsTemp) ? '(tmp)' : ''; + const relativeTime = msToShortishDuration(schedule.nextRelativeMs); + const isLessThanMinute = schedule.nextRelativeMs < 60_000; + if (isLessThanMinute) { + placeholders.nextScheduledRestart = `right now ${tempFlag}`; + } else { + placeholders.nextScheduledRestart = `in ${relativeTime} ${tempFlag}`; + } + } + + //Prepare status placeholders + if (fxMonitorStatus.health === FxMonitorHealth.ONLINE) { + placeholders.statusString = embedConfigJson?.onlineString ?? '🟢 Online'; + placeholders.statusColor = embedConfigJson?.onlineColor ?? "#0BA70B"; + } else if (fxMonitorStatus.health === FxMonitorHealth.PARTIAL) { + placeholders.statusString = embedConfigJson?.partialString ?? '🟡 Partial'; + placeholders.statusColor = embedConfigJson?.partialColor ?? "#FFF100"; + } else if (fxMonitorStatus.health === FxMonitorHealth.OFFLINE) { + placeholders.statusString = embedConfigJson?.offlineString ?? '🔴 Offline'; + placeholders.statusColor = embedConfigJson?.offlineColor ?? "#A70B28"; + } + + //Processing embed + function replacePlaceholders(inputString: string) { + Object.entries(placeholders).forEach(([key, value]) => { + inputString = inputString.replaceAll(`{{${key}}}`, String(value)); + }); + return inputString; + } + function processValue(inputValue: any): any { + if (typeof inputValue === 'string') { + return replacePlaceholders(inputValue); + } else if (Array.isArray(inputValue)) { + return inputValue.map((arrValue) => processValue(arrValue)); + } else if (inputValue !== null && typeof inputValue === 'object') { + return processObject(inputValue); + } else { + return inputValue; + } + } + function processObject(inputData: object) { + const input = cloneDeep(inputData); + const out: any = {}; + for (const [key, value] of Object.entries(input)) { + const processed = processValue(value); + if (key === 'url' && !isValidEmbedUrl(processed)) { + const messageHead = processed.length + ? `Invalid URL \`${processed}\`.` + : `Empty URL.`; + const badPlaceholderMessage = processed.startsWith('{{') + ? invalidPlaceholderMessage + : ''; + throw new Error([ + messageHead, + invalidUrlMessage, + badPlaceholderMessage + ].join('\n')); + } + out[key] = processed; + } + return out; + } + const processedEmbedData = processObject(embedJson); + + //Attempting to instantiate embed class + let embed; + try { + embed = new EmbedBuilder(processedEmbedData); + embed.setColor(placeholders.statusColor as ColorResolvable); + embed.setTimestamp(); + embed.setFooter({ + iconURL: 'https://cdn.discordapp.com/emojis/1062339910654246964.webp?size=96&quality=lossless', + text: `txAdmin ${txEnv.txaVersion} • Updated every minute`, + + }); + } catch (error) { + throw new Error(`**Embed Class Error:** ${(error as Error).message}`); + } + + //Attempting to instantiate buttons + let buttonsRow: ActionRowBuilder | undefined; + try { + if (Array.isArray(embedConfigJson?.buttons) && embedConfigJson.buttons.length) { + if (embedConfigJson.buttons.length > 5) { + throw new Error(`Over limit of 5 buttons.`); + } + buttonsRow = new ActionRowBuilder(); + for (const cfgButton of embedConfigJson.buttons) { + if (!isValidButtonConfig(cfgButton)) { + throw new Error(`Invalid button in Discord Status Embed Config. + All buttons must have: + - Label: string, not empty + - URL: string, not empty, valid URL`); + } + const processedUrl = processValue(cfgButton.url); + if (!isValidEmbedUrl(processedUrl)) { + const messageHead = processedUrl.length + ? `Invalid URL \`${processedUrl}\`` + : `Empty URL`; + const badPlaceholderMessage = processedUrl.startsWith('{{') + ? invalidPlaceholderMessage + : ''; + throw new Error([ + `${messageHead} for button \`${cfgButton.label}\`.`, + invalidUrlMessage, + badPlaceholderMessage + ].join('\n')); + } + const btn = new ButtonBuilder({ + style: ButtonStyle.Link, + label: processValue(cfgButton.label), + url: processedUrl, + }); + if (cfgButton.emoji !== undefined) { + if (!isValidButtonEmoji(cfgButton.emoji)) { + throw new Error(`Invalid emoji for button \`${cfgButton.label}\`.\n${invalidEmojiMessage}`); + } + btn.setEmoji(cfgButton.emoji); + } + buttonsRow.addComponents(btn); + } + } + } catch (error) { + throw new Error(`**Embed Buttons Error:** ${(error as Error).message}`); + } + + return { + embeds: [embed], + components: buttonsRow ? [buttonsRow] : undefined, + }; +} + +export const removeOldEmbed = async (interaction: ChatInputCommandInteraction) => { + const oldChannelId = txCore.cacheStore.get('discord:status:channelId'); + const oldMessageId = txCore.cacheStore.get('discord:status:messageId'); + if (typeof oldChannelId === 'string' && typeof oldMessageId === 'string') { + const oldChannel = await interaction.client.channels.fetch(oldChannelId); + if (oldChannel?.type === ChannelType.GuildText || oldChannel?.type === ChannelType.GuildAnnouncement) { + await oldChannel.messages.delete(oldMessageId); + } else { + throw new Error(`oldChannel is not a guild text or announcement channel`); + } + } else { + throw new Error(`no old message id saved, maybe was never sent, maybe it was removed`); + } +} + +export default async (interaction: ChatInputCommandInteraction) => { + //Check permissions + const adminName = await ensurePermission(interaction, 'settings.write'); + if (typeof adminName !== 'string') return; + + //Attempt to remove old message + const isRemoveOnly = (interaction.options.getSubcommand() === 'remove'); + try { + await removeOldEmbed(interaction); + txCore.cacheStore.delete('discord:status:channelId'); + txCore.cacheStore.delete('discord:status:messageId'); + if (isRemoveOnly) { + const msg = `Old status embed removed.`; + logDiscordAdminAction(adminName, msg); + return await interaction.reply(embedder.success(msg, true)); + } + } catch (error) { + if (isRemoveOnly) { + return await interaction.reply( + embedder.warning(`**Failed to remove old status embed:**\n${(error as Error).message}`, true) + ); + } + } + + //Generate new message + let newStatusMessage; + try { + newStatusMessage = generateStatusMessage(); + } catch (error) { + return await interaction.reply( + embedder.warning(`**Failed to generate new embed:**\n${(error as Error).message}`, true) + ); + } + + //Attempt to send new message + try { + if (interaction.channel?.type !== ChannelType.GuildText && interaction.channel?.type !== ChannelType.GuildAnnouncement) { + throw new Error(`channel type not supported`); + } + const placeholderEmbed = new EmbedBuilder({ + description: '_placeholder message, attempting to edit with embed..._\n**Note:** If you are seeing this message, it probably means that something was wrong with the configured Embed JSONs and Discord\'s API rejected the request to replace this placeholder.' + }) + const newMessage = await interaction.channel.send({ embeds: [placeholderEmbed] }); + await newMessage.edit(newStatusMessage); + txCore.cacheStore.set('discord:status:channelId', interaction.channelId); + txCore.cacheStore.set('discord:status:messageId', newMessage.id); + } catch (error) { + let msg: string; + if ((error as any).code === 50013) { + msg = `This bot does not have permission to send embed messages in this channel. + Please change the channel permissions and give this bot the \`Embed Links\` and \`Send Messages\` permissions.` + } else { + msg = (error as Error).message; + } + return await interaction.reply( + embedder.warning(`**Failed to send new embed:**\n${msg}`, true) + ); + } + + const msg = `Status embed saved.`; + logDiscordAdminAction(adminName, msg); + return await interaction.reply(embedder.success(msg, true)); +} diff --git a/core/modules/DiscordBot/commands/whitelist.ts b/core/modules/DiscordBot/commands/whitelist.ts new file mode 100644 index 0000000..bd04f70 --- /dev/null +++ b/core/modules/DiscordBot/commands/whitelist.ts @@ -0,0 +1,120 @@ +const modulename = 'DiscordBot:cmd:whitelist'; +import { CommandInteraction as ChatInputCommandInteraction, ImageURLOptions } from 'discord.js'; +import { now } from '@lib/misc'; +import { DuplicateKeyError } from '@modules/Database/dbUtils'; +import { embedder, ensurePermission, logDiscordAdminAction } from '../discordHelpers'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + + +/** + * Command /whitelist member + */ +const handleMemberSubcommand = async (interaction: ChatInputCommandInteraction, adminName: string) => { + //Preparing player id/name/avatar + const member = interaction.options.getMember('member'); + if(!member || !('user' in member)){ + return await interaction.reply(embedder.danger(`Failed to resolve member ID.`)); + } + const identifier = `discord:${member.id}`; + const playerName = member.nickname ?? member.user.username; + const avatarOptions: ImageURLOptions = { size: 64, forceStatic: true }; + const playerAvatar = member.displayAvatarURL(avatarOptions) ?? member.user.displayAvatarURL(avatarOptions); + + //Registering approval + try { + txCore.database.whitelist.registerApproval({ + identifier, + playerName, + playerAvatar, + tsApproved: now(), + approvedBy: adminName, + }); + txCore.fxRunner.sendEvent('whitelistPreApproval', { + action: 'added', + identifier, + playerName, + adminName, + }); + } catch (error) { + return await interaction.reply(embedder.danger(`Failed to save whitelist approval: ${(error as Error).message}`)); + } + + const msg = `Added whitelist approval for ${playerName}.`; + logDiscordAdminAction(adminName, msg); + return await interaction.reply(embedder.success(msg)); +} + + +/** + * Command /whitelist request + */ +const handleRequestSubcommand = async (interaction: ChatInputCommandInteraction, adminName: string) => { + //@ts-ignore: somehow vscode is resolving interaction as CommandInteraction + const input = interaction.options.getString('id', true); + const reqId = input.trim().toUpperCase(); + if (reqId.length !== 5 || reqId[0] !== 'R') { + return await interaction.reply(embedder.danger('Invalid request ID.')); + } + + //Find request + const requests = txCore.database.whitelist.findManyRequests({ id: reqId }); + if (!requests.length) { + return await interaction.reply(embedder.warning(`Whitelist request ID \`${reqId}\` not found.`)); + } + const req = requests[0]; //just getting the first + + //Register whitelistApprovals + const identifier = `license:${req.license}`; + const playerName = req.discordTag ?? req.playerDisplayName; + try { + txCore.database.whitelist.registerApproval({ + identifier, + playerName, + playerAvatar: (req.discordAvatar) ? req.discordAvatar : null, + tsApproved: now(), + approvedBy: adminName, + }); + txCore.fxRunner.sendEvent('whitelistRequest', { + action: 'approved', + playerName, + requestId: req.id, + license: req.license, + adminName, + }); + } catch (error) { + if (!(error instanceof DuplicateKeyError)) { + return await interaction.reply(embedder.danger(`Failed to save wl approval: ${(error as Error).message}`)); + } + } + + //Remove record from whitelistRequests + try { + txCore.database.whitelist.removeManyRequests({ id: reqId }); + } catch (error) { + return await interaction.reply(embedder.danger(`Failed to remove wl request: ${(error as Error).message}`)); + } + + const msg = `Approved whitelist request \`${reqId}\` from ${playerName}.`; + logDiscordAdminAction(adminName, msg); + return await interaction.reply(embedder.success(msg)); +} + + +/** + * Handler for /whitelist + */ +export default async (interaction: ChatInputCommandInteraction) => { + //Check permissions + const adminName = await ensurePermission(interaction, 'players.whitelist'); + if (typeof adminName !== 'string') return; + + //@ts-ignore: somehow vscode is resolving interaction as CommandInteraction + const subcommand = interaction.options.getSubcommand(); + if (subcommand === 'member') { + return await handleMemberSubcommand(interaction, adminName); + } else if (subcommand === 'request') { + return await handleRequestSubcommand(interaction, adminName); + } + throw new Error(`Subcommand ${subcommand} not found.`); +} diff --git a/core/modules/DiscordBot/defaultJsons.ts b/core/modules/DiscordBot/defaultJsons.ts new file mode 100644 index 0000000..e4182eb --- /dev/null +++ b/core/modules/DiscordBot/defaultJsons.ts @@ -0,0 +1,65 @@ +import { txEnv } from "@core/globalData"; + +export const defaultEmbedJson = JSON.stringify({ + "title": "{{serverName}}", + "url": "{{serverBrowserUrl}}", + "description": "You can configure this embed in `txAdmin > Settings > Discord Bot`, and edit everything from it (except footer).", + "fields": [ + { + "name": "> STATUS", + "value": "```\n{{statusString}}\n```", + "inline": true + }, + { + "name": "> PLAYERS", + "value": "```\n{{serverClients}}/{{serverMaxClients}}\n```", + "inline": true + }, + { + "name": "> F8 CONNECT COMMAND", + "value": "```\nconnect 123.123.123.123\n```" + }, + { + "name": "> NEXT RESTART", + "value": "```\n{{nextScheduledRestart}}\n```", + "inline": true + }, + { + "name": "> UPTIME", + "value": "```\n{{uptime}}\n```", + "inline": true + } + ], + "image": { + "url": "https://forum-cfx-re.akamaized.net/original/5X/e/e/c/b/eecb4664ee03d39e34fcd82a075a18c24add91ed.png" + }, + "thumbnail": { + "url": "https://forum-cfx-re.akamaized.net/original/5X/9/b/d/7/9bd744dc2b21804e18c3bb331e8902c930624e44.png" + } +}); + +export const defaultEmbedConfigJson = JSON.stringify({ + "onlineString": "🟢 Online", + "onlineColor": "#0BA70B", + "partialString": "🟡 Partial", + "partialColor": "#FFF100", + "offlineString": "🔴 Offline", + "offlineColor": "#A70B28", + "buttons": [ + { + "emoji": "1062338355909640233", + "label": "Connect", + "url": "{{serverJoinUrl}}" + }, + { + "emoji": "1062339910654246964", + "label": "txAdmin Discord", + "url": "https://discord.gg/txAdmin" + }, + txEnv.displayAds ? { + "emoji": "😏", + "label": "ZAP-Hosting", + "url": "https://zap-hosting.com/txadmin6" + } : undefined, + ].filter(Boolean) +}); diff --git a/core/modules/DiscordBot/discordHelpers.ts b/core/modules/DiscordBot/discordHelpers.ts new file mode 100644 index 0000000..bd35451 --- /dev/null +++ b/core/modules/DiscordBot/discordHelpers.ts @@ -0,0 +1,113 @@ +const modulename = 'DiscordBot:cmd'; +import orderedEmojis from 'unicode-emoji-json/data-ordered-emoji'; +import { ColorResolvable, CommandInteraction, EmbedBuilder, InteractionReplyOptions } from "discord.js"; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); +const allEmojis = new Set(orderedEmojis); + + + +/** + * Generic embed generation functions + */ +const genericEmbed = ( + msg: string, + ephemeral = false, + color: ColorResolvable | null = null, + emoji?: string +): InteractionReplyOptions => { + return { + ephemeral, + embeds: [new EmbedBuilder({ + description: emoji ? `:${emoji}: ${msg}` : msg, + }).setColor(color)], + } +} + +export const embedColors = { + info: '#1D76C9', + success: '#0BA70B', + warning: '#FFF100', + danger: '#A70B28', +} as const; + +export const embedder = { + generic: genericEmbed, + info: (msg: string, ephemeral = false) => genericEmbed(msg, ephemeral, embedColors.info, 'information_source'), + success: (msg: string, ephemeral = false) => genericEmbed(msg, ephemeral, embedColors.success, 'white_check_mark'), + warning: (msg: string, ephemeral = false) => genericEmbed(msg, ephemeral, embedColors.warning, 'warning'), + danger: (msg: string, ephemeral = false) => genericEmbed(msg, ephemeral, embedColors.danger, 'no_entry_sign'), +} + + +/** + * Ensure that the discord interaction author has the required permission + */ +export const ensurePermission = async (interaction: CommandInteraction, reqPerm: string) => { + const admin = txCore.adminStore.getAdminByProviderUID(interaction.user.id); + if (!admin) { + await interaction.reply( + embedder.warning(`**Your account does not have txAdmin access.** :face_with_monocle:\nIf you are already registered in txAdmin, visit the Admin Manager page, and make sure the Discord ID for your user is set to \`${interaction.user.id}\`.`, true) + ); + return false; + } + if ( + admin.master !== true + && !admin.permissions.includes('all_permissions') + && !admin.permissions.includes(reqPerm) + ) { + //@ts-ignore: not important + const permName = txCore.adminStore.registeredPermissions[reqPerm] ?? 'Unknown'; + await interaction.reply( + embedder.danger(`Your txAdmin account does not have the "${permName}" permissions required for this action.`, true) + ); + return false; + } + + return admin.name; +} + + +/** + * Equivalent to ctx.admin.logAction() + */ +export const logDiscordAdminAction = async (adminName: string, message: string) => { + txCore.logger.admin.write(adminName, message); +} + + +/** + * Tests if an embed url is valid or not + * + */ +export const isValidEmbedUrl = (url: unknown) => { + return typeof url === 'string' && /^(https?|discord):\/\//.test(url); +} + + +/** + * Tests if an emoji STRING is valid or not. + * Acceptable options: + * - UTF-8 emoji ('😄') + * - Valid emoji ID ('1062339910654246964') + * - Discord custom emoji (`<:name:id>` or ``) + */ +export const isValidButtonEmoji = (emoji: unknown) => { + if (typeof emoji !== 'string') return false; + if (/^\d{17,19}$/.test(emoji)) return true; + if (/^$/.test(emoji)) return true; + return allEmojis.has(emoji); +} + + +//Works +// console.dir(isValidEmoji('<:txicon:1062339910654246964>')) +// console.dir(isValidEmoji('1062339910654246964')) +// console.dir(isValidEmoji('😄')) +// console.dir(isValidEmoji('🇵🇼')) +// console.dir(isValidEmoji('\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F48B}\u{200D}\u{1F469}')) + +//Discord throws api error +// console.dir(isValidEmoji(':smile:')) +// console.dir(isValidEmoji('smile')) +// console.dir(isValidEmoji({name: 'smile'})) diff --git a/core/modules/DiscordBot/index.ts b/core/modules/DiscordBot/index.ts new file mode 100644 index 0000000..2594513 --- /dev/null +++ b/core/modules/DiscordBot/index.ts @@ -0,0 +1,504 @@ +const modulename = 'DiscordBot'; +import Discord, { ActivityType, ChannelType, Client, EmbedBuilder, GatewayIntentBits } from 'discord.js'; +import slashCommands from './slash'; +import interactionCreateHandler from './interactionCreateHandler'; +import { generateStatusMessage } from './commands/status'; +import consoleFactory from '@lib/console'; +import { embedColors } from './discordHelpers'; +import { DiscordBotStatus } from '@shared/enums'; +import { UpdateConfigKeySet } from '@modules/ConfigStore/utils'; +const console = consoleFactory(modulename); + + +//Types +type MessageTranslationType = { + key: string; + data?: object; +} +type AnnouncementType = { + title?: string | MessageTranslationType; + description: string | MessageTranslationType; + type: keyof typeof embedColors; +} + +type SpawnConfig = Pick< + TxConfigs['discordBot'], + 'enabled' | 'token' | 'guild' | 'warningsChannel' +>; + + +/** + * Module that handles the discord bot, provides methods to resolve members and send announcements, as well as + * providing discord slash commands. + */ +export default class DiscordBot { + //NOTE: only listening to embed changes, as the settings page boots the bot if enabled + static readonly configKeysWatched = [ + 'discordBot.embedJson', + 'discordBot.embedConfigJson', + ]; + + readonly #clientOptions: Discord.ClientOptions = { + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + ], + allowedMentions: { + parse: ['users'], + repliedUser: true, + }, + //FIXME: fixme + // http: { + // agent: { + // localAddress: txHostConfig.netInterface, + // } + // } + } + readonly cooldowns = new Map(); + #client: Client | undefined; + guild: Discord.Guild | undefined; + guildName: string | undefined; + announceChannel: Discord.TextBasedChannel | undefined; + #lastDisallowedIntentsError: number = 0; //ms + #lastGuildMembersCacheRefresh: number = 0; //ms + #lastStatus = DiscordBotStatus.Disabled; + #lastExplicitStatus = DiscordBotStatus.Disabled; + + + constructor() { + // FIXME: Hacky solution to fix the issue with disallowed intents + // Remove this when issue below is fixed + // https://github.com/discordjs/discord.js/issues/9621 + process.on('unhandledRejection', (error: Error) => { + if (error.message === 'Used disallowed intents') { + this.#lastDisallowedIntentsError = Date.now(); + } + }); + + setImmediate(() => { + if (txConfig.discordBot.enabled) { + this.startBot()?.catch((e) => { }); + } + }); + + //Cron + setInterval(() => { + if (txConfig.discordBot.enabled) { + this.updateBotStatus().catch((e) => { }); + } + }, 60_000); + //Not sure how often do we want to do this, or if we need to do this at all, + //but previously this was indirectly refreshed every 5 seconds by the health monitor + setInterval(() => { + this.refreshWsStatus(); + }, 7500); + } + + + /** + * Handle updates to the config by resetting the required metrics + */ + public handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) { + return this.updateBotStatus(); + } + + + /** + * Called by settings save to attempt to restart the bot with new settings + */ + async attemptBotReset(botCfg: SpawnConfig | false) { + this.#lastGuildMembersCacheRefresh = 0; + if (this.#client) { + console.warn('Stopping Discord Bot'); + this.#client.destroy(); + this.refreshWsStatus(); + setTimeout(() => { + if (!botCfg || !botCfg.enabled) this.#client = undefined; + }, 1000); + } + + if (botCfg && botCfg.enabled) { + return await this.startBot(botCfg); + } else { + return true; + } + } + + /** + * Passthrough to discord.js isReady() + */ + get isClientReady() { + return (this.#client) ? this.#client.isReady() : false; + } + + /** + * Passthrough to discord.js websocket status + */ + get status(): DiscordBotStatus { + if (!txConfig.discordBot.enabled) { + return DiscordBotStatus.Disabled; + } else if (this.#client?.ws.status === Discord.Status.Ready) { + return DiscordBotStatus.Ready; + } else { + return this.#lastExplicitStatus; + } + } + + /** + * Updates the bot client status and pushes to the websocket + */ + refreshWsStatus() { + if (this.#lastStatus !== this.status) { + this.#lastStatus = this.status; + txCore.webServer.webSocket.pushRefresh('status'); + } + } + + + /** + * Send an announcement to the configured channel + */ + async sendAnnouncement(content: AnnouncementType) { + if (!txConfig.discordBot.enabled) return; + if ( + !txConfig.discordBot.warningsChannel + || !this.#client?.isReady() + || !this.announceChannel + ) { + console.verbose.warn('not ready yet to send announcement'); + return false; + } + + try { + let title; + if (content.title) { + title = (typeof content.title === 'string') + ? content.title + : txCore.translator.t(content.title.key, content.title.data); + } + let description; + if (content.description) { + description = (typeof content.description === 'string') + ? content.description + : txCore.translator.t(content.description.key, content.description.data); + } + + const embed = new EmbedBuilder({ title, description }).setColor(embedColors[content.type]); + await this.announceChannel.send({ embeds: [embed] }); + } catch (error) { + console.error(`Error sending Discord announcement: ${(error as Error).message}`); + } + } + + + /** + * Update persistent status and activity + */ + async updateBotStatus() { + if (!this.#client?.isReady() || !this.#client.user) { + console.verbose.warn('not ready yet to update status'); + return false; + } + + //Updating bot activity + try { + const serverClients = txCore.fxPlayerlist.onlineCount; + const serverMaxClients = txCore.cacheStore.get('fxsRuntime:maxClients') ?? '??'; + const serverName = txConfig.general.serverName; + const message = `[${serverClients}/${serverMaxClients}] on ${serverName}`; + this.#client.user.setActivity(message, { type: ActivityType.Watching }); + } catch (error) { + console.verbose.warn(`Failed to set bot activity: ${(error as Error).message}`); + } + + //Updating server status embed + try { + const oldChannelId = txCore.cacheStore.get('discord:status:channelId'); + const oldMessageId = txCore.cacheStore.get('discord:status:messageId'); + + if (typeof oldChannelId === 'string' && typeof oldMessageId === 'string') { + const oldChannel = await this.#client.channels.fetch(oldChannelId); + if (!oldChannel) throw new Error(`oldChannel could not be resolved`); + if (oldChannel.type !== ChannelType.GuildText && oldChannel.type !== ChannelType.GuildAnnouncement) { + throw new Error(`oldChannel is not guild text or announcement channel`); + } + await oldChannel.messages.edit(oldMessageId, generateStatusMessage()); + } + } catch (error) { + console.verbose.warn(`Failed to update status embed: ${(error as Error).message}`); + } + } + + + /** + * Starts the discord client + */ + startBot(botCfg?: SpawnConfig) { + const isConfigSaveAttempt = !!botCfg; + botCfg ??= { + enabled: txConfig.discordBot.enabled, + token: txConfig.discordBot.token, + guild: txConfig.discordBot.guild, + warningsChannel: txConfig.discordBot.warningsChannel, + } + if (!botCfg.enabled) return; + + return new Promise((resolve, reject) => { + type ErrorOptData = { + code?: string; + clientId?: string; + prohibitedPermsInUse?: string[]; + } + const sendError = (msg: string, data: ErrorOptData = {}) => { + console.error(msg); + const e = new Error(msg); + Object.assign(e, data); + console.warn('Stopping Discord Bot'); + this.#client?.destroy(); + setImmediate(() => { + this.#lastExplicitStatus = DiscordBotStatus.Error; + this.refreshWsStatus(); + this.#client = undefined; + }); + return reject(e); + } + + //Check for configs + if (typeof botCfg.token !== 'string' || !botCfg.token.length) { + return sendError('Discord bot enabled while token is not set.'); + } + if (typeof botCfg.guild !== 'string' || !botCfg.guild.length) { + return sendError('Discord bot enabled while guild id is not set.'); + } + + //State check + if (this.#client && this.#client.ws.status !== 3 && this.#client.ws.status !== 5) { + console.verbose.warn('Destroying client before restart.'); + this.#client.destroy(); + } + + //Setting up client object + this.#lastExplicitStatus = DiscordBotStatus.Starting; + this.refreshWsStatus(); + this.#client = new Client(this.#clientOptions); + + //Setup disallowed intents unhandled rejection watcher + const lastKnownDisallowedIntentsError = this.#lastDisallowedIntentsError; + const disallowedIntentsWatcherId = setInterval(() => { + if (this.#lastDisallowedIntentsError !== lastKnownDisallowedIntentsError) { + clearInterval(disallowedIntentsWatcherId); + return sendError( + `This bot does not have a required privileged intent.`, + { code: 'DisallowedIntents' } + ); + } + }, 250); + + + //Setup Ready listener + this.#client.on('ready', async () => { + clearInterval(disallowedIntentsWatcherId); + if (!this.#client?.isReady() || !this.#client.user) throw new Error(`ready event while not being ready`); + + //Fetching guild + const guild = this.#client.guilds.cache.find((guild) => guild.id === botCfg.guild); + if (!guild) { + return sendError( + `Discord bot could not resolve guild/server ID ${botCfg.guild}.`, + { + code: 'CustomNoGuild', + clientId: this.#client.user.id + } + ); + } + this.guild = guild; + this.guildName = guild.name; + + //Checking for dangerous permissions + // https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags + // These are the same perms that require 2fa enabled - although it doesn't apply here + const prohibitedPerms = [ + 'Administrator', //'ADMINISTRATOR', + 'BanMembers', //'BAN_MEMBERS' + 'KickMembers', //'KICK_MEMBERS' + 'ManageChannels', //'MANAGE_CHANNELS', + 'ManageGuildExpressions', //'MANAGE_GUILD_EXPRESSIONS' + 'ManageGuild', //'MANAGE_GUILD', + 'ManageMessages', //'MANAGE_MESSAGES' + 'ManageRoles', //'MANAGE_ROLES', + 'ManageThreads', //'MANAGE_THREADS' + 'ManageWebhooks', //'MANAGE_WEBHOOKS' + 'ViewCreatorMonetizationAnalytics', //'VIEW_CREATOR_MONETIZATION_ANALYTICS' + ] + const botPerms = this.guild.members.me?.permissions.serialize(); + if (!botPerms) { + return sendError(`Discord bot could not detect its own permissions.`); + } + const prohibitedPermsInUse = Object.entries(botPerms) + .filter(([permName, permEnabled]) => prohibitedPerms.includes(permName) && permEnabled) + .map((x) => x[0]) + if (prohibitedPermsInUse.length) { + const name = this.#client.user.username; + const perms = prohibitedPermsInUse.includes('Administrator') + ? 'Administrator' + : prohibitedPermsInUse.join(', '); + return sendError( + `This bot (${name}) has dangerous permissions (${perms}) and for your safety the bot has been disabled.`, + { code: 'DangerousPermission' } + ); + } + + //Fetching announcements channel + if (botCfg.warningsChannel) { + const fetchedChannel = this.#client.channels.cache.find((x) => x.id === botCfg.warningsChannel); + if (!fetchedChannel) { + return sendError(`Channel ${botCfg.warningsChannel} not found.`); + } else if (fetchedChannel.type !== ChannelType.GuildText && fetchedChannel.type !== ChannelType.GuildAnnouncement) { + return sendError(`Channel ${botCfg.warningsChannel} - ${(fetchedChannel as any)?.name} is not a text or announcement channel.`); + } else { + this.announceChannel = fetchedChannel; + } + } + + // if previously registered by tx before v6 or other bot + this.guild.commands.set(slashCommands).catch(console.dir); + this.#client.application?.commands.set([]).catch(console.dir); + + //The settings save will the updateBotStatus, so no need to call it here + if (!isConfigSaveAttempt) { + this.updateBotStatus().catch((e) => { }); + } + + const successMsg = `Discord bot running as \`${this.#client.user.tag}\` on \`${guild.name}\`.`; + console.ok(successMsg); + this.refreshWsStatus(); + return resolve(successMsg); + }); + + //Setup remaining event listeners + this.#client.on('error', (error) => { + this.refreshWsStatus(); + clearInterval(disallowedIntentsWatcherId); + console.error(`Error from Discord.js client: ${error.message}`); + return reject(error); + }); + this.#client.on('resume', () => { + console.verbose.ok('Connection with Discord API server resumed'); + this.updateBotStatus().catch((e) => { }); + this.refreshWsStatus(); + }); + this.#client.on('interactionCreate', interactionCreateHandler); + // this.#client.on('debug', console.verbose.debug); + + //Start bot + this.#client.login(botCfg.token).catch((error) => { + clearInterval(disallowedIntentsWatcherId); + + //for some reason, this is not throwing unhandled rejection anymore /shrug + if (error.message === 'Used disallowed intents') { + return sendError( + `This bot does not have a required privileged intent.`, + { code: 'DisallowedIntents' } + ); + } + + //set status to error + this.#lastExplicitStatus = DiscordBotStatus.Error; + this.refreshWsStatus(); + + //if no message, create one + if (!('message' in error) || !error.message) { + error.message = 'no reason available - ' + JSON.stringify(error); + } + console.error(`Discord login failed with error: ${error.message}`); + return reject(error); + }); + }); + } + + /** + * Refreshes the bot guild member cache + */ + async refreshMemberCache() { + if (!txConfig.discordBot.enabled) throw new Error(`discord bot is disabled`); + if (!this.#client?.isReady()) throw new Error(`discord bot not ready yet`); + if (!this.guild) throw new Error(`guild not resolved`); + + //Check when the cache was last refreshed + const currTs = Date.now(); + if (currTs - this.#lastGuildMembersCacheRefresh > 60_000) { + try { + await this.guild.members.fetch(); + this.#lastGuildMembersCacheRefresh = currTs; + return true; + } catch (error) { + return false; + } + } + return false; + } + + + /** + * Return if an ID is a guild member, and their roles + */ + async resolveMemberRoles(uid: string) { + if (!txConfig.discordBot.enabled) throw new Error(`discord bot is disabled`); + if (!this.#client?.isReady()) throw new Error(`discord bot not ready yet`); + if (!this.guild) throw new Error(`guild not resolved`); + + //Try to get member from cache or refresh cache then try again + let member = this.guild.members.cache.find(m => m.id === uid); + if (!member && await this.refreshMemberCache()) { + member = this.guild.members.cache.find(m => m.id === uid); + } + + //Return result + if (member) { + return { + isMember: true, + memberRoles: member.roles.cache.map((role) => role.id), + }; + } else { + return { isMember: false } + } + } + + + /** + * Resolves a user by its discord identifier. + */ + async resolveMemberProfile(uid: string) { + if (!this.#client?.isReady()) throw new Error(`discord bot not ready yet`); + const avatarOptions: Discord.ImageURLOptions = { size: 64, forceStatic: true }; + + //Check if in guild member + if (this.guild) { + //Try to get member from cache or refresh cache then try again + let member = this.guild.members.cache.find(m => m.id === uid); + if (!member && await this.refreshMemberCache()) { + member = this.guild.members.cache.find(m => m.id === uid); + } + + if (member) { + return { + tag: member.nickname ?? member.user.username, + avatar: member.displayAvatarURL(avatarOptions) ?? member.user.displayAvatarURL(avatarOptions), + }; + } + } + + //Checking if user resolvable + //NOTE: this one might still spam the API + // https://discord.js.org/#/docs/discord.js/14.11.0/class/UserManager?scrollTo=fetch + const user = await this.#client.users.fetch(uid); + if (user) { + return { + tag: user.username, + avatar: user.displayAvatarURL(avatarOptions), + }; + } else { + throw new Error(`could not resolve discord user`); + } + } +}; diff --git a/core/modules/DiscordBot/interactionCreateHandler.ts b/core/modules/DiscordBot/interactionCreateHandler.ts new file mode 100644 index 0000000..ca834c7 --- /dev/null +++ b/core/modules/DiscordBot/interactionCreateHandler.ts @@ -0,0 +1,82 @@ +const modulename = 'DiscordBot:interactionHandler'; +import { Interaction, InteractionType } from 'discord.js'; +import infoCommandHandler from './commands/info'; +import statusCommandHandler from './commands/status'; +import whitelistCommandHandler from './commands/whitelist'; +import { embedder } from './discordHelpers'; +import { cloneDeep } from 'lodash-es'; //DEBUG +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + + +//All commands +const handlers = { + status: statusCommandHandler, + whitelist: whitelistCommandHandler, + info: infoCommandHandler, +} + +const noHandlerResponse = async (interaction: Interaction) => { + if (interaction.isRepliable()) { + //@ts-ignore + const identifier = interaction?.commandName ?? interaction?.customId ?? 'unknown'; + await interaction.reply({ + content: `No handler available for this interaction (${InteractionType[interaction.type]} > ${identifier})`, + ephemeral: true, + }); + } +} + + +export default async (interaction: Interaction) => { + //DEBUG + // const copy = Object.assign(cloneDeep(interaction), { user: false, member: false }); + // console.dir(copy); + // return; + + //Handler filter + if (interaction.user.bot) return; + + //Process buttons + if (interaction.isButton()) { + // //Get interaction + // const [iid, ...args] = interaction.customId.split(':'); + // const handler = txChungus.interactionsManager.cache.get(`button:${iid}`); + // if (!handler) { + // console.error(`No handler available for button interaction ${interaction.customId}`); + // return; + // } + // txCore.metrics.txRuntime.botCommands.count(???); + // //Executes interaction + // try { + // return await handler.execute(interaction, args, txChungus); + // } catch (error) { + // return await console.error(`Error executing ${interaction.customId}: ${error.message}`); + // } + } + + //Process Slash commands + if (interaction.isChatInputCommand()) { + //Get interaction + const handler = handlers[interaction.commandName as keyof typeof handlers]; + if (!handler) { + noHandlerResponse(interaction).catch((e) => {}); + return; + } + txCore.metrics.txRuntime.botCommands.count(interaction.commandName); + + //Executes interaction + try { + await handler(interaction); + return; + } catch (error) { + const msg = `Error executing ${interaction.commandName}: ${(error as Error).message}`; + console.error(msg); + await interaction.reply(embedder.danger(msg, true)); + return ; + } + } + + //Unknown type + noHandlerResponse(interaction).catch((e) => {}); +}; diff --git a/core/modules/DiscordBot/slash.ts b/core/modules/DiscordBot/slash.ts new file mode 100644 index 0000000..e8f43dd --- /dev/null +++ b/core/modules/DiscordBot/slash.ts @@ -0,0 +1,115 @@ +import { ApplicationCommandDataResolvable, ApplicationCommandOptionType, ApplicationCommandType } from 'discord.js'; + + +const statusCommand: ApplicationCommandDataResolvable = { + type: ApplicationCommandType.ChatInput, + name: 'status', + description: 'Adds or removes the configurable, persistent, auto-updated embed.', + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: 'add', + description: 'Creates a configurable, persistent, auto-updated embed with server status.' + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: 'remove', + description: 'Removes the configured persistent txAdmin status embed.' + } + ] +} + +const whitelistCommand: ApplicationCommandDataResolvable = { + type: ApplicationCommandType.ChatInput, + name: 'whitelist', + description: 'Whitelist embed commands.', + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: 'member', + description: 'Adds a member to the whitelist approvals.', + options: [ + { + type: ApplicationCommandOptionType.User, + name: 'member', + description: 'The member that will be whitelisted.', + required: true, + } + ] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: 'request', + description: 'Approves a whitelist request ID (eg R1234).', + options: [ + { + type: ApplicationCommandOptionType.String, + name: 'id', + description: 'The ID of the request (eg R1234).', + required: true, + minLength: 5, + maxLength: 5, + } + ] + } + ] +} + +const infoCommand: ApplicationCommandDataResolvable = { + type: ApplicationCommandType.ChatInput, + name: 'info', + description: 'Searches for a player in the txAdmin Database and prints information.', + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: 'self', + description: 'Searches for whomever is using the command.', + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: 'member', + description: 'Searches for a player with matching Discord ID.', + options: [ + { + type: ApplicationCommandOptionType.User, + name: 'member', + description: 'The member that will be searched for.', + required: true, + }, + { + type: ApplicationCommandOptionType.Boolean, + name: 'admininfo', + description: 'For admins to show identifiers and history information.' + } + ] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: 'id', + description: 'Searches for an identifier.', + options: [ + { + type: ApplicationCommandOptionType.String, + name: 'id', + description: 'The ID to search for (eg fivem:271816).', + required: true, + minLength: 5, + }, + { + type: ApplicationCommandOptionType.Boolean, + name: 'admininfo', + description: 'For admins to show identifiers and history information.' + } + ] + }, + ] +} + +/** + * Exported commands + */ +export default [ + statusCommand, + whitelistCommand, + infoCommand, +] as ApplicationCommandDataResolvable[]; diff --git a/core/modules/FxMonitor/index.ts b/core/modules/FxMonitor/index.ts new file mode 100644 index 0000000..029899a --- /dev/null +++ b/core/modules/FxMonitor/index.ts @@ -0,0 +1,610 @@ +const modulename = 'FxMonitor'; +import crypto from 'node:crypto'; +import chalk from 'chalk'; +import { txHostConfig } from '@core/globalData'; +import { MonitorState, getMonitorTimeTags, HealthEventMonitor, MonitorIssue, Stopwatch, fetchDynamicJson, fetchInfoJson, cleanMonitorIssuesArray, type VerboseErrorData } from './utils'; +import consoleFactory from '@lib/console'; +import { SYM_SYSTEM_AUTHOR } from '@lib/symbols'; +import { ChildProcessState } from '@modules/FxRunner/ProcessManager'; +import { secsToShortestDuration } from '@lib/misc'; +import { setRuntimeFile } from '@lib/fxserver/runtimeFiles'; +import { FxMonitorHealth } from '@shared/enums'; +import cleanPlayerName from '@shared/cleanPlayerName'; +const console = consoleFactory(modulename); + + +//MARK: Consts +const HC_CONFIG = { + //before first success: + bootInitAfterHbLimit: 45, + + //after first success: + delayLimit: 10, + fatalLimit: 180, //since playerConnecting hangs now are HB, reduced from 5 to 3 minutes + + //other stuff + requestTimeout: 1500, + requestInterval: 1000, +}; +const HB_CONFIG = { + //before first success: + bootNoEventLimit: 30, //scanning resources, license auth, etc + bootResEventGapLimit: 45, //time between one 'started' and one the next event (started or server booted) + bootResNominalStartTime: 10, //if a resource has been starting for this long, don't even mention it + + //after first success: + delayLimit: 10, + fatalLimit: 60, +}; +const MAX_LOG_ENTRIES = 300; //5 minutes in 1s intervals +const MIN_WARNING_INTERVAL = 10; + + +//MARK:Types +export type MonitorIssuesArray = (string | MonitorIssue | undefined)[]; +type MonitorRestartCauses = 'bootTimeout' | 'close' | 'healthCheck' | 'heartBeat' | 'both'; +type ProcessStatusResult = { + action: 'SKIP'; + reason: string; +} | { + action: 'WARN'; + times?: string; + reason: string; + issues?: MonitorIssuesArray; +} | { + action: 'RESTART'; + times?: string; + reason: string; + issues?: MonitorIssuesArray; + cause: MonitorRestartCauses; +} +type ProcessStatusNote = { + action: 'NOTE'; + reason: string; +} +type StatusLogEntry = (ProcessStatusResult | ProcessStatusNote) & { x?: number }; + + +//MARK: Utils +//This is inneficient, so wont put this in utils +class LimitedArray extends Array { + constructor(public readonly limit: number) { + super(); + } + + push(...items: T[]): number { + while (this.length + items.length > this.limit) { + this.shift(); + } + return super.push(...items); + } +} + + +//MARK: Main Class +/** + * Module responsible for monitoring the FXServer health and status, restarting it if necessary. + */ +export default class FxMonitor { + public readonly timers: NodeJS.Timer[] = []; + + //Status tracking + private readonly statusLog = new LimitedArray(MAX_LOG_ENTRIES); + private currentStatus: FxMonitorHealth = FxMonitorHealth.OFFLINE; + private lastHealthCheckError: VerboseErrorData | null = null; //to print warning + private isAwaitingRestart = false; //to prevent spamming while the server restarts (5s) + private tsServerBooted: number | null = null; + + //to prevent DDoS crash false positive + private readonly swLastStatusUpdate = new Stopwatch(false); + + //to prevent spamming + private readonly swLastPartialHangRestartWarning = new Stopwatch(true); + private readonly swLastStatusWarning = new Stopwatch(true); + + //to see if its above limit + private readonly heartBeatMonitor = new HealthEventMonitor(HB_CONFIG.delayLimit, HB_CONFIG.fatalLimit); + private readonly healthCheckMonitor = new HealthEventMonitor(HC_CONFIG.delayLimit, HC_CONFIG.fatalLimit); + private readonly swLastFD3 = new Stopwatch(); + private readonly swLastHTTP = new Stopwatch(); + + + constructor() { + // Cron functions + this.timers.push(setInterval(() => this.performHealthCheck(), HC_CONFIG.requestInterval)); + this.timers.push(setInterval(() => this.updateStatus(), 1000)); + } + + + //MARK: State Helpers + /** + * Sets the current status and propagates the change to the Discord Bot and WebServer + */ + private setCurrentStatus(newStatus: FxMonitorHealth) { + if (newStatus === this.currentStatus) return; + + //Set state + this.currentStatus = newStatus; + txCore.discordBot.updateBotStatus().catch((e) => { }); + txCore.webServer.webSocket.pushRefresh('status'); + } + + + /** + * Reset Health Monitor Stats. + * This is called internally and by FxRunner + */ + public resetState() { + this.setCurrentStatus(FxMonitorHealth.OFFLINE); + this.lastHealthCheckError = null; + this.isAwaitingRestart = false; + this.tsServerBooted = null; + + this.swLastStatusUpdate.reset(); + this.swLastStatusWarning.reset(); + this.swLastPartialHangRestartWarning.reset(); //for partial hang + + this.heartBeatMonitor.reset(); + this.healthCheckMonitor.reset(); + this.swLastFD3.reset(); + this.swLastHTTP.reset(); + } + + + /** + * Processes the status and then deals with the results. + * MARK: UPDATE STATUS + */ + private updateStatus() { + //Get and cleanup the result + const result = this.calculateMonitorStatus(); + const cleanIssues = 'issues' in result ? cleanMonitorIssuesArray(result.issues) : []; + if (result.reason.endsWith('.')) { + result.reason = result.reason.slice(0, -1); + } + + //Merge with last entry if possible + const lastEntry = this.statusLog.at(-1); + if (lastEntry && lastEntry.action === result.action && lastEntry.reason === result.reason) { + lastEntry.x = (lastEntry.x ?? 1) + 1; + if ('times' in lastEntry && 'times' in result) lastEntry.times = result.times; + if ('issues' in lastEntry && cleanIssues.length) lastEntry.issues = cleanIssues; + } else { + this.statusLog.push(result); + } + + //Right now we only care about WARN and RESTART + if (result.action !== 'WARN' && result.action !== 'RESTART') return; + + //Prepare reason + if (!result.reason) { + console.error('Missing reason in status update.'); + return; + } + const styledIssues = cleanIssues.map((i) => chalk.dim('- ' + i)); + + //Warning - check if last warning was long enough ago + if (result.action === 'WARN') { + if (this.swLastStatusWarning.isOver(MIN_WARNING_INTERVAL)) { + this.swLastStatusWarning.restart(); + const timesPrefix = result.times ? `${result.times} ` : ''; + console.warn(timesPrefix + result.reason + '.'); + for (const line of styledIssues) { + console.warn(line); + } + } + return; + } + + //Restart - log to the required channels + restart the server + if (result.action === 'RESTART') { + if (txCore.fxRunner.isIdle) return; //should never happen + + //Logging + const timesSuffix = result.times ? ` ${result.times}.` : '.'; + const logMessage = `Restarting server: ${result.reason}` + timesSuffix; + console.error(logMessage); + txCore.logger.admin.writeSystem('MONITOR', logMessage); + txCore.logger.fxserver.logInformational(logMessage); //just for better visibility + for (const line of styledIssues) { + txCore.logger.fxserver.logInformational(line); + console.error(line); + } + if (this.lastHealthCheckError) { + console.verbose.debug('Last HealthCheck error debug data:'); + console.verbose.dir(this.lastHealthCheckError.debugData); + console.verbose.debug('-'.repeat(40)); + } + + //Restarting the server + this.resetState(); //will set the status to OFFLINE + this.isAwaitingRestart = true; + txCore.fxRunner.restartServer( + txCore.translator.t('restarter.server_unhealthy_kick_reason'), + SYM_SYSTEM_AUTHOR, + ); + txCore.metrics.txRuntime.registerFxserverRestart(result.cause); + } + } + + + /** + * MARK: CALCULATE STATUS + * Refreshes the Server Status and calls for a restart if neccessary. + * - HealthCheck: performing an GET to the /dynamic.json file + * - HeartBeat: receiving an intercom POST or FD3 txAdminHeartBeat event + */ + private calculateMonitorStatus(): ProcessStatusResult { + //Check if the server is supposed to be offline + if (txCore.fxRunner.isIdle) { + this.resetState(); + return { + action: 'SKIP', + reason: 'Server is idle', + }; + } + + //Ignore check while server is restarting + if (this.isAwaitingRestart) { + return { + action: 'SKIP', + reason: 'Server is restarting', + }; + } + + //Check if process was frozen + if (this.swLastStatusUpdate.isOver(10)) { + console.error(`txAdmin was frozen for ${this.swLastStatusUpdate.elapsed - 1} seconds for unknown reason (random issue, VPS Lag, DDoS, etc).`); + this.swLastStatusUpdate.restart(); + return { + action: 'SKIP', + reason: 'txAdmin was frozen', + } + } + this.swLastStatusUpdate.restart(); + + //Get elapsed times & process status + const heartBeat = this.heartBeatMonitor.status; + const healthCheck = this.healthCheckMonitor.status; + const processUptime = Math.floor((txCore.fxRunner.child?.uptime ?? 0) / 1000); + const timeTags = getMonitorTimeTags(heartBeat, healthCheck, processUptime) + // console.dir({ + // timeTag: timeTags.withProc, + // heartBeat, + // healthCheck, + // child: txCore.fxRunner.child?.status, + // }, { compact: true }); //DEBUG + const secs = (s: number) => Number.isFinite(s) ? secsToShortestDuration(s, { round: false }) : '--'; + + //Don't wait for the fail counts if the child was destroyed + if (txCore.fxRunner.child?.status === ChildProcessState.Destroyed) { + //No need to set any status, this.updateStatus will trigger this.resetState + return { + action: 'RESTART', + reason: 'Server process close detected', + cause: 'close', + }; + } + + //Check if its online and return + if (heartBeat.state === MonitorState.HEALTHY && healthCheck.state === MonitorState.HEALTHY) { + this.setCurrentStatus(FxMonitorHealth.ONLINE); + if (!this.tsServerBooted) { + this.tsServerBooted = Date.now(); + this.handleBootCompleted(processUptime).catch(() => { }); + } + return { + action: 'SKIP', + reason: 'Server is healthy', + } + } + + //At least one monitor is unhealthy + const currentStatusString = ( + heartBeat.state !== MonitorState.HEALTHY + && healthCheck.state !== MonitorState.HEALTHY + ) ? FxMonitorHealth.OFFLINE : FxMonitorHealth.PARTIAL + this.setCurrentStatus(currentStatusString); + + //Check if still in grace period + if (processUptime < txConfig.restarter.bootGracePeriod) { + return { + action: 'SKIP', + reason: `FXServer is ${currentStatusString}, still in grace period of ${txConfig.restarter.bootGracePeriod}s.`, + }; + } + + //Checking all conditions and either filling FailReason or restarting it outright + //NOTE: This part MUST restart if boot timed out, but FATAL will be dealt with down below + let heartBeatIssue: MonitorIssue | undefined; + let healthCheckIssue: MonitorIssue | undefined; + + //If HealthCheck failed + if (healthCheck.state !== MonitorState.HEALTHY) { + healthCheckIssue = new MonitorIssue('Impossible HealthCheck failure.'); + healthCheckIssue.addDetail(this.lastHealthCheckError?.error); + + //No HealthCheck received yet + if (healthCheck.state === MonitorState.PENDING) { + healthCheckIssue.setTitle('No successful HealthCheck yet.'); + + //Check if heartbeats started but no healthchecks yet + if (heartBeat.state !== MonitorState.PENDING) { + healthCheckIssue.addInfo(`Resources started ${secs(heartBeat.secsSinceFirst)} ago but the HTTP endpoint is still unresponsive.`); + if (heartBeat.secsSinceFirst > HC_CONFIG.bootInitAfterHbLimit) { + return { + action: 'RESTART', + times: timeTags.withProc, + reason: 'Server boot timed out', + issues: [healthCheckIssue], + cause: 'bootTimeout', + } + } + } + } else if (healthCheck.state === MonitorState.DELAYED || healthCheck.state === MonitorState.FATAL) { + //Started, but failing + healthCheckIssue.setTitle(`HealthChecks failing for the past ${secs(healthCheck.secsSinceLast)}.`); + } else { + throw new Error(`Unexpected HealthCheck state: ${healthCheck.state}`); + } + } + + //If HeartBeat failed + if (heartBeat.state !== MonitorState.HEALTHY) { + heartBeatIssue = new MonitorIssue('Impossible HeartBeat failure.'); + + //No HeartBeat received yet + if (heartBeat.state === MonitorState.PENDING) { + heartBeatIssue.setTitle('No successful HeartBeat yet.'); + const resBoot = txCore.fxResources.bootStatus; + if (resBoot.current?.time.isOver(txConfig.restarter.resourceStartingTolerance)) { + //Resource boot timed out + return { + action: 'RESTART', + times: timeTags.withProc, + reason: `Resource "${resBoot.current.name}" failed to start within the ${secs(txConfig.restarter.resourceStartingTolerance)} time limit`, + cause: 'bootTimeout', + } + } else if (resBoot.current?.time.isOver(HB_CONFIG.bootResNominalStartTime)) { + //Resource booting for too long, but still within tolerance + heartBeatIssue.addInfo(`Resource "${resBoot.current.name}" has been loading for ${secs(resBoot.current.time.elapsed)}.`); + } else if (resBoot.current) { + //Resource booting at nominal time + heartBeatIssue.addInfo(`Resources are still booting at nominal time.`); + } else { + // No resource currently booting + heartBeatIssue.addInfo(`No resource currently loading.`); + if (resBoot.elapsedSinceLast === null) { + //No event received yet + if (processUptime > HB_CONFIG.bootNoEventLimit) { + heartBeatIssue.addInfo(`No resource event received within the ${secs(HB_CONFIG.bootNoEventLimit)} limit.`); + return { + action: 'RESTART', + times: timeTags.withProc, + reason: 'Server boot timed out', + issues: [heartBeatIssue], + cause: 'bootTimeout', + } + } else { + heartBeatIssue.addInfo(`No resource event received yet.`); + } + } else { + //Last event was recent + heartBeatIssue.addInfo(`Last resource finished loading ${secs(resBoot.elapsedSinceLast)} ago.`); + if (resBoot.elapsedSinceLast > HB_CONFIG.bootResEventGapLimit) { + //Last event was too long ago + return { + action: 'RESTART', + times: timeTags.withProc, + reason: 'Server boot timed out', + issues: [heartBeatIssue], + cause: 'bootTimeout', + } + } + } + } + } else if (heartBeat.state === MonitorState.DELAYED || heartBeat.state === MonitorState.FATAL) { + //HeartBeat started, but failing + heartBeatIssue.setTitle(`Stopped receiving HeartBeats ${secs(heartBeat.secsSinceLast)} ago.`); + } else { + throw new Error(`Unexpected HeartBeat state: ${heartBeat.state}`); + } + } + + //Check if either HB or HC are FATAL, restart the server + if (heartBeat.state === MonitorState.FATAL || healthCheck.state === MonitorState.FATAL) { + let cause: MonitorRestartCauses; + if (heartBeat.state === MonitorState.FATAL && healthCheck.state === MonitorState.FATAL) { + cause = 'both'; + } else if (heartBeat.state === MonitorState.FATAL) { + cause = 'heartBeat'; + } else if (healthCheck.state === MonitorState.FATAL) { + cause = 'healthCheck'; + } else { + throw new Error(`Unexpected fatal state: HB:${heartBeat.state} HC:${healthCheck.state}`); + } + + return { + action: 'RESTART', + cause, + times: timeTags.simple, + reason: 'Server is not responding', + issues: [heartBeatIssue, healthCheckIssue], + } + } + + //If http-only hang, warn 1 minute before restart + if ( + heartBeat.state === MonitorState.HEALTHY + && this.swLastPartialHangRestartWarning.isOver(120) + && healthCheck.secsSinceLast > (HC_CONFIG.fatalLimit - 60) + ) { + this.swLastPartialHangRestartWarning.restart(); + + //Sending discord announcement + txCore.discordBot.sendAnnouncement({ + type: 'danger', + description: { + key: 'restarter.partial_hang_warn_discord', + data: { servername: txConfig.general.serverName }, + }, + }); + + // Dispatch `txAdmin:events:announcement` + txCore.fxRunner.sendEvent('announcement', { + author: 'txAdmin', + message: txCore.translator.t('restarter.partial_hang_warn'), + }); + } + + //Return warning + const isStillBooting = heartBeat.state === MonitorState.PENDING || healthCheck.state === MonitorState.PENDING; + return { + action: 'WARN', + times: isStillBooting ? timeTags.withProc : timeTags.simple, + reason: isStillBooting ? 'Server is taking too long to start' : 'Server is not responding', + issues: [heartBeatIssue, healthCheckIssue], + } + } + + + //MARK: HC & HB + /** + * Sends a HTTP GET request to the /dynamic.json endpoint of FXServer to check if it's healthy. + */ + private async performHealthCheck() { + //Check if the server is supposed to be offline + const childState = txCore.fxRunner.child; + if (!childState?.isAlive || !childState?.netEndpoint) return; + + //Make request + const reqResult = await fetchDynamicJson(childState.netEndpoint, HC_CONFIG.requestTimeout); + if (!reqResult.success) { + this.lastHealthCheckError = reqResult; + return; + } + const dynamicJson = reqResult.data; + + //Successfull healthcheck + this.lastHealthCheckError = null; + this.healthCheckMonitor.markHealthy(); + + //Checking for the maxClients + if (dynamicJson.sv_maxclients) { + const maxClients = dynamicJson.sv_maxclients; + txCore.cacheStore.set('fxsRuntime:maxClients', maxClients); + if (txHostConfig.forceMaxClients && maxClients > txHostConfig.forceMaxClients) { + txCore.fxRunner.sendCommand( + 'sv_maxclients', + [txHostConfig.forceMaxClients], + SYM_SYSTEM_AUTHOR + ); + console.error(`${txHostConfig.sourceName}: Detected that the server has sv_maxclients above the limit (${txHostConfig.forceMaxClients}). Changing back to the limit.`); + txCore.logger.admin.writeSystem('SYSTEM', `changing sv_maxclients back to ${txHostConfig.forceMaxClients}`); + } + } + } + + + /** + * Handles the HeartBeat event from the server. + */ + public handleHeartBeat(source: 'fd3' | 'http') { + this.heartBeatMonitor.markHealthy(); + if (source === 'fd3') { + if ( + this.swLastHTTP.started + && this.swLastHTTP.elapsed > 15 + && this.swLastFD3.elapsed < 5 + ) { + txCore.metrics.txRuntime.registerFxserverHealthIssue('http'); + } + this.swLastFD3.restart(); + } else if (source === 'http') { + if ( + this.swLastFD3.started + && this.swLastFD3.elapsed > 15 + && this.swLastHTTP.elapsed < 5 + ) { + txCore.metrics.txRuntime.registerFxserverHealthIssue('fd3'); + } + this.swLastHTTP.restart(); + } + } + + + //MARK: ON BOOT + /** + * Handles the HeartBeat event from the server. + */ + private async handleBootCompleted(bootDuration: number) { + //Check if the server is supposed to be offline + const childState = txCore.fxRunner.child; + if (!childState?.isAlive || !childState?.netEndpoint) return; + + //Registering the boot + txCore.metrics.txRuntime.registerFxserverBoot(bootDuration); + txCore.metrics.svRuntime.logServerBoot(bootDuration); + txCore.fxRunner.signalSpawnBackoffRequired(false); + + //Fetching runtime data + const infoJson = await fetchInfoJson(childState.netEndpoint); + if (!infoJson) return; + + //Save icon base64 to file + const iconCacheKey = 'fxsRuntime:iconFilename'; + if (infoJson.icon) { + try { + const iconHash = crypto + .createHash('shake256', { outputLength: 8 }) + .update(infoJson.icon) + .digest('hex') + .padStart(16, '0'); + const iconFilename = `icon-${iconHash}.png`; + await setRuntimeFile(iconFilename, Buffer.from(infoJson.icon, 'base64')); + txCore.cacheStore.set(iconCacheKey, iconFilename); + } catch (error) { + console.error(`Failed to save server icon: ${(error as any).message ?? 'Unknown error'}`); + } + } else { + txCore.cacheStore.delete(iconCacheKey); + } + + //Upserts the runtime data + txCore.cacheStore.upsert('fxsRuntime:bannerConnecting', infoJson.bannerConnecting); + txCore.cacheStore.upsert('fxsRuntime:bannerDetail', infoJson.bannerDetail); + txCore.cacheStore.upsert('fxsRuntime:locale', infoJson.locale); + txCore.cacheStore.upsert('fxsRuntime:projectDesc', infoJson.projectDesc); + if (infoJson.projectName) { + txCore.cacheStore.set('fxsRuntime:projectName', cleanPlayerName(infoJson.projectName).displayName); + } else { + txCore.cacheStore.delete('fxsRuntime:projectName'); + } + txCore.cacheStore.upsert('fxsRuntime:tags', infoJson.tags); + } + + + //MARK: Getters + /** + * Returns the current status object that is sent to the host status endpoint + */ + public get status() { + let healthReason = 'Unknown - no log entries.'; + const lastEntry = this.statusLog.at(-1); + if (lastEntry) { + const healthReasonLines = [ + lastEntry.reason + '.', + ]; + if ('issues' in lastEntry) { + const cleanIssues = cleanMonitorIssuesArray(lastEntry.issues); + healthReasonLines.push(...cleanIssues); + } + healthReason = healthReasonLines.join('\n'); + } + return { + health: this.currentStatus, + healthReason, + uptime: this.tsServerBooted ? Date.now() - this.tsServerBooted : 0, + } + } +}; diff --git a/core/modules/FxMonitor/utils.ts b/core/modules/FxMonitor/utils.ts new file mode 100644 index 0000000..1ea3277 --- /dev/null +++ b/core/modules/FxMonitor/utils.ts @@ -0,0 +1,393 @@ +import got from "@lib/got"; +import { secsToShortestDuration } from "@lib/misc"; +import bytes from "bytes"; +import { RequestError, TimeoutError, type Response as GotResponse } from "got"; +import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; +import type { MonitorIssuesArray } from "./index"; + + +/** + * Class to easily check elapsed time. + * Seconds precision, rounded down, consistent. + */ +export class Stopwatch { + private readonly autoStart: boolean = false; + private tsStart: number | null = null; + + constructor(autoStart?: boolean) { + if (autoStart) { + this.autoStart = true; + this.restart(); + } + } + + /** + * Reset the stopwatch (stop and clear). + */ + reset() { + if (this.autoStart) { + this.restart(); + } else { + this.tsStart = null; + } + } + + /** + * Start or restart the stopwatch. + */ + restart() { + this.tsStart = Date.now(); + } + + /** + * Returns if the timer is over a certain amount of time. + * Always false if not started. + */ + isOver(secs: number) { + const elapsed = this.elapsed; + if (elapsed === Infinity) { + return false; + } else { + return elapsed >= secs; + } + } + + /** + * Returns true if the stopwatch is running. + */ + get started() { + return this.tsStart !== null; + } + + /** + * Returns the elapsed time in seconds or Infinity if not started. + */ + get elapsed() { + if (this.tsStart === null) { + return Infinity; + } else { + const elapsedMs = Date.now() - this.tsStart; + return Math.floor(elapsedMs / 1000); + } + } +} + + +/** + * Exported enum + */ +export enum MonitorState { + PENDING = 'PENDING', + HEALTHY = 'HEALTHY', + DELAYED = 'DELAYED', + FATAL = 'FATAL', +} + + +/** + * Class to easily check elapsed time. + * Seconds precision, rounded down, consistent. + */ +export class HealthEventMonitor { + private readonly swLastHealthyEvent = new Stopwatch(); + private firstHealthyEvent: number | undefined; + + constructor( + private readonly delayLimit: number, + private readonly fatalLimit: number, + ) { } + + /** + * Resets the state of the monitor. + */ + public reset() { + this.swLastHealthyEvent.reset(); + this.firstHealthyEvent = undefined; + } + + /** + * Register a successful event + */ + public markHealthy() { + this.swLastHealthyEvent.restart(); + this.firstHealthyEvent ??= Date.now(); + } + + /** + * Returns the current status of the monitor. + */ + public get status() { + let state: MonitorState; + if (!this.swLastHealthyEvent.started) { + state = MonitorState.PENDING; + } else if (this.swLastHealthyEvent.isOver(this.fatalLimit)) { + state = MonitorState.FATAL; + } else if (this.swLastHealthyEvent.isOver(this.delayLimit)) { + state = MonitorState.DELAYED; + } else { + state = MonitorState.HEALTHY; + } + return { + state, + secsSinceLast: this.swLastHealthyEvent.elapsed, + secsSinceFirst: this.firstHealthyEvent + ? Math.floor((Date.now() - this.firstHealthyEvent) / 1000) + : Infinity, + } + } +} + +type HealthEventMonitorStatus = HealthEventMonitor['status']; + + +/** + * Helper to get the time tags for error messages + */ +export const getMonitorTimeTags = ( + heartBeat: HealthEventMonitorStatus, + healthCheck: HealthEventMonitorStatus, + processUptime: number, +) => { + const secs = (s: number) => Number.isFinite(s) ? secsToShortestDuration(s, { round: false }) : '--'; + const procTime = secsToShortestDuration(processUptime); + const hbTime = secs(heartBeat.secsSinceLast); + const hcTime = secs(healthCheck.secsSinceLast); + return { + simple: `(HB:${hbTime}|HC:${hcTime})`, + withProc: `(P:${procTime}|HB:${hbTime}|HC:${hcTime})`, + } +} + + +/** + * Processes a MonitorIssuesArray and returns a clean array of strings. + */ +export const cleanMonitorIssuesArray = (issues: MonitorIssuesArray | undefined) => { + if (!issues || !Array.isArray(issues)) return []; + + let cleanIssues: string[] = []; + for (const issue of issues) { + if (!issue) continue; + if (typeof issue === 'string') { + cleanIssues.push(issue); + } else { + cleanIssues.push(...issue.all.filter(Boolean)); + } + } + return cleanIssues; +} + + +/** + * Helper class to organize monitor issues. + */ +export class MonitorIssue { + private readonly infos: string[] = []; + private readonly details: string[] = []; + constructor(public title: string) { } + setTitle(title: string) { + this.title = title; + } + addInfo(info: string | undefined) { + if (!info) return; + this.infos.push(info); + } + addDetail(detail: string | undefined) { + if (!detail) return; + this.details.push(detail); + } + get all() { + return [this.title, ...this.infos, ...this.details]; + } +} + + +/** + * Helper to get debug data from a Got error + */ +const getRespDebugData = (resp?: GotResponse) => { + if (!resp) return { error: 'Response object is undefined.' }; + try { + let truncatedBody = '[resp.body is not a string]'; + if (typeof resp?.body === 'string') { + const bodyCutoff = 512; + truncatedBody = resp.body.length > bodyCutoff + ? resp.body.slice(0, bodyCutoff) + '[...]' + : resp.body; + } + return { + URL: String(resp?.url), + Status: `${resp?.statusCode} ${resp?.statusMessage}`, + Server: String(resp?.headers?.['server']), + Location: String(resp?.headers?.['location']), + ContentType: String(resp?.headers?.['content-type']), + ContentLength: String(resp?.headers?.['content-length']), + BodyLength: bytes(resp?.body?.length), + Body: truncatedBody, + } as Record; + } catch (error) { + return { + error: `Error getting debug data: ${(error as any).message}`, + }; + } +} + + +/** + * Do a HTTP GET to the /dynamic.json endpoint and parse the JSON response. + */ +export const fetchDynamicJson = async ( + netEndpoint: string, + timeout: number +): Promise => { + let resp: GotResponse | undefined; + try { + resp = await got.get({ + url: `http://${netEndpoint}/dynamic.json`, + maxRedirects: 0, + timeout: { request: timeout }, + retry: { limit: 0 }, + throwHttpErrors: false, + }); + } catch (error) { + let msg, code; + if (error instanceof RequestError) { + msg = error.message; + code = error.code; + } else if (error instanceof TimeoutError) { + msg = error.message; + code = error.code; + } else { + const err = error as any; + msg = err?.message ?? ''; + code = err?.code ?? ''; + } + + return { + success: false, + error: `HealthCheck Request error: ${msg}`, + debugData: { + message: msg, + code: code, + ...getRespDebugData(resp), + }, + }; + } + + //Precalculating error message + const debugData = getRespDebugData(resp); + + //Checking response status + if (resp.statusCode !== 200) { + return { + success: false, + error: `HealthCheck HTTP status: ${debugData.Status}`, + debugData, + } + } + + //Parsing response + if (typeof resp.body !== 'string') { + return { + success: false, + error: `HealthCheck response body is not a string.`, + debugData, + } + } + if (!resp.body.length) { + return { + success: false, + error: `HealthCheck response body is empty.`, + debugData, + } + } + if (resp.body.toLocaleLowerCase().includes(', +} +type FetchDynamicJsonError = { + success: false; +} & VerboseErrorData; +type FetchDynamicJsonSuccess = { + success: true; + data: z.infer; +}; + + +/** + * Do a HTTP GET to the /info.json endpoint and parse the JSON response. + */ +export const fetchInfoJson = async (netEndpoint: string) => { + let info: any; + try { + info = await got.get({ + url: `http://${netEndpoint}/info.json`, + maxRedirects: 0, + timeout: { request: 15_000 }, + retry: { limit: 6 }, + }).json(); + } catch (error) { + return; + } + + const schemas = { + icon: z.string().base64(), + locale: z.string().nonempty(), + projectName: z.string().nonempty(), + projectDesc: z.string().nonempty(), + bannerConnecting: z.string().url(), + bannerDetail: z.string().url(), + tags: z.string().nonempty(), + }; + + return { + icon: schemas.icon.safeParse(info.icon)?.data, + locale: schemas.locale.safeParse(info.vars?.locale)?.data, + projectName: schemas.projectName.safeParse(info.vars?.sv_projectName)?.data, + projectDesc: schemas.projectDesc.safeParse(info.vars?.sv_projectDesc)?.data, + bannerConnecting: schemas.bannerConnecting.safeParse(info.vars?.banner_connecting)?.data, + bannerDetail: schemas.bannerDetail.safeParse(info.vars?.banner_detail)?.data, + tags: schemas.tags.safeParse(info.vars?.tags)?.data, + }; +} diff --git a/core/modules/FxPlayerlist/index.ts b/core/modules/FxPlayerlist/index.ts new file mode 100644 index 0000000..117c7f9 --- /dev/null +++ b/core/modules/FxPlayerlist/index.ts @@ -0,0 +1,237 @@ +const modulename = 'FxPlayerlist'; +import { cloneDeep } from 'lodash-es'; +import { ServerPlayer } from '@lib/player/playerClasses.js'; +import { DatabaseActionWarnType, DatabasePlayerType } from '@modules/Database/databaseTypes'; +import consoleFactory from '@lib/console'; +import { PlayerDroppedEventType, PlayerJoiningEventType } from '@shared/socketioTypes'; +import { SYM_SYSTEM_AUTHOR } from '@lib/symbols'; +const console = consoleFactory(modulename); + + +export type PlayerDropEvent = { + type: 'txAdminPlayerlistEvent', + event: 'playerDropped', + id: number, + reason: string, //need to check if this is always a string + resource?: string, + category?: number, +} + + +/** + * Module that holds the server playerlist to mirrors FXServer's internal playerlist state, as well as recently + * disconnected players' licenses for quick searches. This also handles the playerJoining and playerDropped events. + * + * NOTE: licenseCache will keep an array of ['mutex#id', license], to be used for searches from server log clicks. + * The licenseCache will contain only the licenses from last 50k disconnected players, which should be one entire + * session for the q99.9 servers out there and weight around 4mb. + * The idea is: all players with license will be in the database, so storing only license is enough to find them. + * + * NOTE: #playerlist keeps all players in this session, a heap snapshot revealed that an + * average player (no actions) will weight about 520 bytes, and the q9999 of max netid is ~22k, + * meaning that for 99.99 of the servers, the list will be under 11mb. + * A list with 50k connected players will weight around 26mb, meaning no optimization is required there. + */ +export default class FxPlayerlist { + #playerlist: (ServerPlayer | undefined)[] = []; + licenseCache: [mutexid: string, license: string][] = []; + licenseCacheLimit = 50_000; //mutex+id+license * 50_000 = ~4mb + joinLeaveLog: [ts: number, isJoin: boolean][] = []; + joinLeaveLogLimitTime = 30 * 60 * 1000; //30 mins, [ts+isJoin] * 100_000 = ~4.3mb + + + /** + * Number of online/connected players. + */ + get onlineCount() { + return this.#playerlist.filter(p => p && p.isConnected).length; + } + + + /** + * Number of players that joined/left in the last hour. + */ + get joinLeaveTally() { + let toRemove = 0; + const out = { joined: 0, left: 0 }; + const tsWindowStart = Date.now() - this.joinLeaveLogLimitTime; + for (const [ts, isJoin] of this.joinLeaveLog) { + if (ts > tsWindowStart) { + out[isJoin ? 'joined' : 'left']++; + } else { + toRemove++; + } + } + this.joinLeaveLog.splice(0, toRemove); + return out; + } + + + /** + * Handler for server restart - it will kill all players + * We MUST do .disconnect() for all players to clear the timers. + * NOTE: it's ok for us to overfill before slicing the licenseCache because it's at most ~4mb + */ + handleServerClose(oldMutex: string) { + for (const player of this.#playerlist) { + if (player) { + player.disconnect(); + if (player.license) { + this.licenseCache.push([`${oldMutex}#${player.netid}`, player.license]); + } + } + } + this.licenseCache = this.licenseCache.slice(-this.licenseCacheLimit); + this.#playerlist = []; + this.joinLeaveLog = []; + txCore.webServer.webSocket!.buffer('playerlist', { + mutex: oldMutex, + type: 'fullPlayerlist', + playerlist: [], + }); + } + + + /** + * To guarantee multiple instances of the same player license have their dbData synchronized, + * this function (called by database.players.update) goes through every matching player + * (except the source itself) to update their dbData. + */ + handleDbDataSync(dbData: DatabasePlayerType, srcUniqueId: Symbol) { + for (const player of this.#playerlist) { + if ( + player instanceof ServerPlayer + && player.isRegistered + && player.license === dbData.license + && player.uniqueId !== srcUniqueId + ) { + player.syncUpstreamDbData(dbData); + } + } + } + + + /** + * Returns a playerlist array with ServerPlayer data of all connected players. + * The data is cloned to prevent pollution. + */ + getPlayerList() { + return this.#playerlist + .filter(p => p?.isConnected) + .map((p) => { + return cloneDeep({ + netid: p!.netid, + displayName: p!.displayName, + pureName: p!.pureName, + ids: p!.ids, + license: p!.license, + }); + }); + } + + /** + * Returns a specifc ServerPlayer or undefined. + * NOTE: this returns the actual object and not a deep clone! + */ + getPlayerById(netid: number) { + return this.#playerlist[netid]; + } + + /** + * Returns a specifc ServerPlayer or undefined. + * NOTE: this returns the actual object and not a deep clone! + */ + getOnlinePlayersByLicense(searchLicense: string) { + return this.#playerlist.filter(p => p && p.license === searchLicense && p.isConnected) as ServerPlayer[]; + } + + /** + * Returns a set of all online players' licenses. + */ + getOnlinePlayersLicenses() { + return new Set(this.#playerlist.filter(p => p && p.isConnected).map(p => p!.license)); + } + + /** + * Receives initial data callback from ServerPlayer and dispatches to the server as stdin. + */ + dispatchInitialPlayerData(playerId: number, pendingWarn: DatabaseActionWarnType) { + const cmdData = { + netId: playerId, + pendingWarn: { + author: pendingWarn.author, + reason: pendingWarn.reason, + actionId: pendingWarn.id, + targetNetId: playerId, + targetIds: pendingWarn.ids, //not used in the playerWarned handler + targetName: pendingWarn.playerName, + } + } + txCore.fxRunner.sendCommand('txaInitialData', [cmdData], SYM_SYSTEM_AUTHOR); + } + + + /** + * Handler for all txAdminPlayerlistEvent structured trace events + * TODO: use zod for type safety + */ + async handleServerEvents(payload: any, mutex: string) { + const currTs = Date.now(); + if (payload.event === 'playerJoining') { + try { + if (typeof payload.id !== 'number') throw new Error(`invalid player id`); + if (this.#playerlist[payload.id] !== undefined) { + this.#playerlist[payload.id]!.disconnect(); + this.#playerlist[payload.id] = undefined; + }; + const svPlayer = new ServerPlayer(payload.id, payload.player, this); + this.#playerlist[payload.id] = svPlayer; + this.joinLeaveLog.push([currTs, true]); + txCore.logger.server.write([{ + type: 'playerJoining', + src: payload.id, + ts: currTs, + data: { ids: this.#playerlist[payload.id]!.ids } + }], mutex); + txCore.webServer.webSocket.buffer('playerlist', { + mutex, + type: 'playerJoining', + netid: svPlayer.netid, + displayName: svPlayer.displayName, + pureName: svPlayer.pureName, + ids: svPlayer.ids, + license: svPlayer.license, + }); + } catch (error) { + console.log(`playerJoining event error: ${(error as Error).message}`); + } + + } else if (payload.event === 'playerDropped') { + try { + if (typeof payload.id !== 'number') throw new Error(`invalid player id`); + if (!(this.#playerlist[payload.id] instanceof ServerPlayer)) throw new Error(`player id not found`); + this.#playerlist[payload.id]!.disconnect(); + this.joinLeaveLog.push([currTs, false]); + const reasonCategory = txCore.metrics.playerDrop.handlePlayerDrop(payload); + if (reasonCategory !== false) { + txCore.logger.server.write([{ + type: 'playerDropped', + src: payload.id, + ts: currTs, + data: { reason: payload.reason } + }], mutex); + } + txCore.webServer.webSocket.buffer('playerlist', { + mutex, + type: 'playerDropped', + netid: this.#playerlist[payload.id]!.netid, + reasonCategory: reasonCategory ? reasonCategory : undefined, + }); + } catch (error) { + console.verbose.warn(`playerDropped event error: ${(error as Error).message}`); + } + } else { + console.warn(`Invalid event: ${payload?.event}`); + } + } +}; diff --git a/core/modules/FxResources.ts b/core/modules/FxResources.ts new file mode 100644 index 0000000..4fc7b3b --- /dev/null +++ b/core/modules/FxResources.ts @@ -0,0 +1,143 @@ +const modulename = 'FxResources'; +import consoleFactory from '@lib/console'; +import { Stopwatch } from './FxMonitor/utils'; +const console = consoleFactory(modulename); + + +type ResourceEventType = { + type: 'txAdminResourceEvent'; + resource: string; + event: 'onResourceStarting' + | 'onResourceStart' + | 'onServerResourceStart' + | 'onResourceListRefresh' + | 'onResourceStop' + | 'onServerResourceStop'; +}; + +type ResourceReportType = { + ts: Date, + resources: any[] +} + +type ResPendingStartState = { + name: string; + time: Stopwatch; +} + +type ResBootLogEntry = { + tsBooted: number; + resource: string; + duration: number; +}; + + +/** + * Module responsible to track FXServer resource states. + * NOTE: currently it is not tracking the state during runtime, and it is just being used + * to assist with tracking the boot process. + */ +export default class FxResources { + public resourceReport?: ResourceReportType; + private resBooting: ResPendingStartState | null = null; + private resBootLog: ResBootLogEntry[] = []; + + + /** + * Reset boot state on server close + */ + handleServerClose() { + this.resBooting = null; + this.resBootLog = []; + } + + + /** + * Handler for all txAdminResourceEvent FD3 events + */ + handleServerEvents(payload: ResourceEventType, mutex: string) { + const { resource, event } = payload; + if (!resource || !event) { + console.verbose.error(`Invalid txAdminResourceEvent payload: ${JSON.stringify(payload)}`); + } else if (event === 'onResourceStarting') { + //Resource will start + this.resBooting = { + name: resource, + time: new Stopwatch(true), + } + } else if (event === 'onResourceStart') { + //Resource started + this.resBootLog.push({ + resource, + duration: this.resBooting?.time.elapsed ?? 0, + tsBooted: Date.now(), + }) + } + } + + + /** + * Returns the status of the resource boot process + */ + public get bootStatus() { + let elapsedSinceLast = null; + if (this.resBootLog.length > 0) { + const tsMs = this.resBootLog[this.resBootLog.length - 1].tsBooted; + elapsedSinceLast = Math.floor((Date.now() - tsMs) / 1000); + } + return { + current: this.resBooting, + elapsedSinceLast, + } + } + + + /** + * Handle resource report. + * NOTE: replace this when we start tracking resource states internally + */ + tmpUpdateResourceList(resources: any[]) { + this.resourceReport = { + ts: new Date(), + resources, + } + } +}; + +/* +NOTE Resource load scenarios knowledge base: +- resource lua error: + - `onResourceStarting` sourceRes + - print lua error + - `onResourceStart` sourceRes +- resource lua crash/hang: + - `onResourceStarting` sourceRes + - crash/hang +- dependency missing: + - `onResourceStarting` sourceRes + - does not get to `onResourceStart` +- dependency success: + - `onResourceStarting` sourceRes + - `onResourceStarting` dependency + - `onResourceStart` dependency + - `onResourceStart` sourceRes +- webpack/yarn fail: + - `onResourceStarting` sourceRes + - does not get to `onResourceStart` +- webpack/yarn success: + - `onResourceStarting` chat + - `onResourceStarting` yarn + - `onResourceStart` yarn + - `onResourceStarting` webpack + - `onResourceStart` webpack + - server first tick + - wait for build + - `onResourceStarting` chat + - `onResourceStart` chat +- ensure started resource: + - `onResourceStop` sourceRes + - `onResourceStarting` sourceRes + - `onResourceStart` sourceRes + - `onServerResourceStop` sourceRes + - `onServerResourceStart` sourceRes +*/ diff --git a/core/modules/FxRunner/ProcessManager.ts b/core/modules/FxRunner/ProcessManager.ts new file mode 100644 index 0000000..b4fb7e0 --- /dev/null +++ b/core/modules/FxRunner/ProcessManager.ts @@ -0,0 +1,220 @@ +// The objective of this file is to isolate the process management logic from the main process. +// Here no references to txCore, txConfig, or txManager should happen. +import { childProcessEventBlackHole, type ValidChildProcess } from "./utils"; +import consoleFactory, { processStdioEnsureEol } from "@lib/console"; + + +/** + * Returns a string with the exit/close code & signal of a child process, properly formatted + */ +const getFxChildCodeSignalString = (code?: number | null, signal?: string | null) => { + const details = []; + if (typeof code === 'number') { + details.push(`0x${code.toString(16).toUpperCase()}`); + } + if (typeof signal === 'string') { + details.push(signal.toUpperCase()); + } + if (!details.length) return '--'; + return details.join(', '); +} + + +/** + * Manages the lifecycle of a child process, isolating process management logic from the main application. + */ +export default class ProcessManager { + public readonly pid: number; + public readonly mutex: string; + public readonly netEndpoint: string; + private readonly statusCallback: () => void; + + public readonly tsStart = Date.now(); + private tsKill: number | undefined; + private tsExit: number | undefined; + private tsClose: number | undefined; + private fxs: ValidChildProcess | null; + private exitCallback: (() => void) | undefined; + + //TODO: register input/output stats? good for debugging + + constructor(fxs: ValidChildProcess, props: ChildStateProps) { + //Sanity check + if (!fxs?.stdin?.writable) throw new Error(`Child process stdin is not writable.`); + if (!props.mutex) throw new Error(`Invalid mutex.`); + if (!props.netEndpoint) throw new Error(`Invalid netEndpoint.`); + if (!props.onStatusUpdate) throw new Error(`Invalid status callback.`); + + //Instance properties + this.pid = fxs.pid; + this.fxs = fxs; + this.mutex = props.mutex; + this.netEndpoint = props.netEndpoint; + this.statusCallback = props.onStatusUpdate; + const console = consoleFactory(`FXProc][${this.pid}`); + + //The 'exit' event is emitted after the child process ends, + // but the stdio streams might still be open. + this.fxs.once('exit', (code, signal) => { + this.tsExit = Date.now(); + const info = getFxChildCodeSignalString(code, signal); + processStdioEnsureEol(); + console.warn(`FXServer Exited (${info}).`); + this.exitCallback && this.exitCallback(); + this.triggerStatusUpdate(); + if (this.tsExit - this.tsStart <= 5000) { + console.defer(500).warn('FXServer didn\'t start. This is not an issue with txAdmin.'); + } + }); + + //The 'close' event is emitted after a process has ended _and_ + // the stdio streams of the child process have been closed. + // This event will never be emitted before 'exit'. + this.fxs.once('close', (code, signal) => { + this.tsClose = Date.now(); + const info = getFxChildCodeSignalString(code, signal); + processStdioEnsureEol(); + console.warn(`FXServer Closed (${info}).`); + this.destroy(); + }); + + //The 'error' event is only relevant for `.kill()` method errors. + this.fxs.on('error', (err) => { + console.error(`FXServer Error Event:`); + console.dir(err); + }); + + //Signaling the start of the server + console.ok(`FXServer Started!`); + this.triggerStatusUpdate(); + } + + + /** + * Safely triggers the status update callback + */ + private triggerStatusUpdate() { + try { + this.statusCallback(); + } catch (error) { + childProcessEventBlackHole('ProcessManager:statusCallback', error); + } + } + + + /** + * Ensures we did everything we can to send a kill signal to the child process + * and that we are freeing up resources. + */ + public destroy() { + if (!this.fxs) return; //Already disposed + try { + this.tsKill = Date.now(); + this.fxs?.kill(); + } catch (error) { + childProcessEventBlackHole('ProcessManager:destroy', error); + } finally { + this.fxs = null; + this.triggerStatusUpdate(); + } + } + + + /** + * Registers a callback to be called when the child process is destroyed + */ + public onExit(callback: () => void) { + this.exitCallback = callback; + } + + /** + * Get the proc info/stats for the history + */ + public get stateInfo(): ChildProcessStateInfo { + return { + pid: this.pid, + mutex: this.mutex, + netEndpoint: this.netEndpoint, + + tsStart: this.tsStart, + tsKill: this.tsKill, + tsExit: this.tsExit, + tsClose: this.tsClose, + + isAlive: this.isAlive, + status: this.status, + uptime: this.uptime, + }; + } + + + /** + * If the child process is alive, meaning it has process running and the pipes open + */ + public get isAlive() { + return !!this.fxs && !this.tsExit && !this.tsClose; + } + + + /** + * The overall state of the child process + */ + public get status(): ChildProcessState { + if (!this.fxs) return ChildProcessState.Destroyed; + if (this.tsExit) return ChildProcessState.Exited; //should be awaiting close + return ChildProcessState.Alive; + } + + + /** + * Uptime of the child process, until now or the last event + */ + public get uptime() { + const now = Date.now(); + return Math.min( + this.tsKill ?? now, + this.tsExit ?? now, + this.tsClose ?? now, + ) - this.tsStart; + } + + + /** + * The stdin stream of the child process, SHOULD be writable if this.isAlive + */ + public get stdin() { + return this.fxs?.stdin; + } +} + + +export enum ChildProcessState { + Alive = 'ALIVE', + Exited = 'EXITED', + Destroyed = 'DESTROYED', +} + +type ChildStateProps = { + mutex: string; + netEndpoint: string; + onStatusUpdate: () => void; +} + + +export type ChildProcessStateInfo = { + //Input + pid: number; + mutex: string; + netEndpoint: string; + + //Timings + tsStart: number; + tsKill?: number; + tsExit?: number; + tsClose?: number; + + //Status + isAlive: boolean; //Same as ChildState.Alive for convenience + status: ChildProcessState; + uptime: number; +} diff --git a/core/modules/FxRunner/handleFd3Messages.ts b/core/modules/FxRunner/handleFd3Messages.ts new file mode 100644 index 0000000..25a53b3 --- /dev/null +++ b/core/modules/FxRunner/handleFd3Messages.ts @@ -0,0 +1,159 @@ +import { anyUndefined } from '@lib/misc'; +import consoleFactory from '@lib/console'; +const console = consoleFactory('FXProc:FD3'); + + +//Types +type StructuredTraceType = { + key: number; + value: { + channel: string; + data: any; + file: string; + func: string; + line: number; + } +} + + +/** + * Handles bridged commands from txResource. + * TODO: use zod for type safety + */ +const handleBridgedCommands = (payload: any) => { + if (payload.command === 'announcement') { + try { + //Validate input + if (typeof payload.author !== 'string') throw new Error(`invalid author`); + if (typeof payload.message !== 'string') throw new Error(`invalid message`); + const message = (payload.message ?? '').trim(); + if (!message.length) throw new Error(`empty message`); + + //Resolve admin + const author = payload.author; + txCore.logger.admin.write(author, `Sending announcement: ${message}`); + + // Dispatch `txAdmin:events:announcement` + txCore.fxRunner.sendEvent('announcement', { message, author }); + + // Sending discord announcement + const publicAuthor = txCore.adminStore.getAdminPublicName(payload.author, 'message'); + txCore.discordBot.sendAnnouncement({ + type: 'info', + title: { + key: 'nui_menu.misc.announcement_title', + data: { author: publicAuthor } + }, + description: message + }); + } catch (error) { + console.verbose.warn(`handleBridgedCommands handler error:`); + console.verbose.dir(error); + } + } else { + console.warn(`Command bridge received invalid command:`); + console.dir(payload); + } +} + + +/** + * Processes FD3 Messages + * + * Mapped message types: + * - nucleus_connected + * - watchdog_bark + * - bind_error + * - script_log + * - script_structured_trace (handled by server logger) + */ +const handleFd3Messages = (mutex: string, trace: StructuredTraceType) => { + //Filter valid and fresh packages + if (!mutex || mutex !== txCore.fxRunner.child?.mutex) return; + if (anyUndefined(trace, trace.value, trace.value.data, trace.value.channel)) return; + const { channel, data } = trace.value; + + //Handle bind errors + if (channel === 'citizen-server-impl' && data?.type === 'bind_error') { + try { + const newDelayBackoffMs = txCore.fxRunner.signalSpawnBackoffRequired(true); + const [_ip, port] = data.address.split(':'); + const secs = Math.floor(newDelayBackoffMs / 1000); + console.defer().error(`Detected FXServer error: Port ${port} is busy! Setting backoff delay to ${secs}s.`); + } catch (e) { } + return; + } + + //Handle nucleus auth + if (channel === 'citizen-server-impl' && data.type === 'nucleus_connected') { + if (typeof data.url !== 'string') { + console.error(`FD3 nucleus_connected event without URL.`); + } else { + try { + const matches = /^(https:\/\/)?.*-([0-9a-z]{6,})\.users\.cfx\.re\/?$/.exec(data.url); + if (!matches || !matches[2]) throw new Error(`invalid cfxid`); + txCore.cacheStore.set('fxsRuntime:cfxId', matches[2]); + } catch (error) { + console.error(`Error decoding server nucleus URL.`); + } + } + return; + } + + //Handle watchdog + if (channel === 'citizen-server-impl' && data.type === 'watchdog_bark') { + setTimeout(() => { + const thread = data?.thread ?? 'UNKNOWN'; + if(!data?.stack || data.stack.trim() === 'root'){ + console.error(`Detected server thread ${thread} hung without a stack trace.`); + } else { + console.error(`Detected server thread ${thread} hung with stack:`); + console.error(`- ${data.stack}`); + console.error('Please check the resource above to prevent server restarts.'); + } + }, 250); + return; + } + + // if (data.type == 'script_log') { + // return console.dir(data); + // } + + //Handle script traces + if ( + channel === 'citizen-server-impl' + && data.type === 'script_structured_trace' + && data.resource === 'monitor' + ) { + if (data.payload.type === 'txAdminHeartBeat') { + txCore.fxMonitor.handleHeartBeat('fd3'); + } else if (data.payload.type === 'txAdminLogData') { + txCore.logger.server.write(data.payload.logs, mutex); + } else if (data.payload.type === 'txAdminLogNodeHeap') { + txCore.metrics.svRuntime.logServerNodeMemory(data.payload); + } else if (data.payload.type === 'txAdminResourceEvent') { + txCore.fxResources.handleServerEvents(data.payload, mutex); + } else if (data.payload.type === 'txAdminPlayerlistEvent') { + txCore.fxPlayerlist.handleServerEvents(data.payload, mutex); + } else if (data.payload.type === 'txAdminCommandBridge') { + handleBridgedCommands(data.payload); + } else if (data.payload.type === 'txAdminAckWarning') { + txCore.database.actions.ackWarn(data.payload.actionId); + } + } + +} + + +/** + * Handles all the FD3 traces from the FXServer + * NOTE: this doesn't need to be a class, but might need to hold state in the future + */ +export default (mutex: string, trace: StructuredTraceType) => { + try { + handleFd3Messages(mutex, trace); + } catch (error) { + console.verbose.error('Error processing FD3 stream output:'); + console.verbose.dir(error); + } +}; diff --git a/core/modules/FxRunner/index.ts b/core/modules/FxRunner/index.ts new file mode 100644 index 0000000..4a2c6e9 --- /dev/null +++ b/core/modules/FxRunner/index.ts @@ -0,0 +1,530 @@ +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; +import StreamValues from 'stream-json/streamers/StreamValues'; +import { customAlphabet } from 'nanoid/non-secure'; +import dict49 from 'nanoid-dictionary/nolookalikes'; +import consoleFactory from '@lib/console'; +import { resolveCFGFilePath, validateFixServerConfig } from '@lib/fxserver/fxsConfigHelper'; +import { msToShortishDuration } from '@lib/misc'; +import { SYM_SYSTEM_AUTHOR } from '@lib/symbols'; +import { UpdateConfigKeySet } from '@modules/ConfigStore/utils'; +import { childProcessEventBlackHole, getFxSpawnVariables, getMutableConvars, isValidChildProcess, mutableConvarConfigDependencies, setupCustomLocaleFile, stringifyConsoleArgs } from './utils'; +import ProcessManager, { ChildProcessStateInfo } from './ProcessManager'; +import handleFd3Messages from './handleFd3Messages'; +import ConsoleLineEnum from '@modules/Logger/FXServerLogger/ConsoleLineEnum'; +import { txHostConfig } from '@core/globalData'; +import path from 'node:path'; +const console = consoleFactory('FxRunner'); +const genMutex = customAlphabet(dict49, 5); + +const MIN_KILL_DELAY = 250; + + +/** + * Module responsible for handling the FXServer process. + * + * FIXME: the methods that return error string should either throw or return + * a more detailed and better formatted object + */ +export default class FxRunner { + static readonly configKeysWatched = [...mutableConvarConfigDependencies]; + + public readonly history: ChildProcessStateInfo[] = []; + private proc: ProcessManager | null = null; + private isAwaitingShutdownNoticeDelay = false; + private isAwaitingRestartSpawnDelay = false; + private restartSpawnBackoffDelay = 0; + + + //MARK: SIGNALS + /** + * Triggers a convar update + */ + public handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) { + this.updateMutableConvars().catch(() => { }); + } + + + /** + * Gracefully shutdown when txAdmin gets an exit event. + * There is no time for a more graceful shutdown with announcements and events. + * Will only use the quit command and wait for the process to exit. + */ + public handleShutdown() { + if (!this.proc?.isAlive || !this.proc.stdin) return null; + this.proc.stdin.write('quit "host shutting down"\n'); + return new Promise((resolve) => { + this.proc?.onExit(resolve); //will let fxserver finish by itself + }); + } + + + /** + * Receives the signal that all the start banner was already printed and other modules loaded + */ + public signalStartReady() { + if (!txConfig.server.autoStart) return; + + if (!this.isConfigured) { + return console.warn('Please open txAdmin on the browser to configure your server.'); + } + + if (!txCore.adminStore.hasAdmins()) { + return console.warn('The server will not auto start because there are no admins configured.'); + } + + if (txConfig.server.quiet || txHostConfig.forceQuietMode) { + console.defer(1000).warn('FXServer Quiet mode is enabled. Access the Live Console to see the logs.'); + } + + this.spawnServer(true); + } + + + /** + * Handles boot signals related to bind errors and sets the backoff delay. + * On successfull bind, the backoff delay is reset to 0. + * On bind error, the backoff delay is increased by 5s, up to 45s. + * @returns the new backoff delay in ms + */ + public signalSpawnBackoffRequired(required: boolean) { + if (required) { + this.restartSpawnBackoffDelay = Math.min( + this.restartSpawnBackoffDelay + 5_000, + 45_000 + ); + } else { + if (this.restartSpawnBackoffDelay) { + console.verbose.debug('Server booted successfully, resetting spawn backoff delay.'); + } + this.restartSpawnBackoffDelay = 0; + } + return this.restartSpawnBackoffDelay; + } + + + //MARK: SPAWN + /** + * Spawns the FXServer and sets up all the event handlers. + * NOTE: Don't use txConfig in here to avoid race conditions. + */ + public async spawnServer(shouldAnnounce = false) { + //If txAdmin is shutting down + if(txManager.isShuttingDown) { + const msg = `Cannot start the server while txAdmin is shutting down.`; + console.error(msg); + return msg; + } + + //If the server is already alive + if (this.proc !== null) { + const msg = `The server has already started.`; + console.error(msg); + return msg; + } + + //Setup spawn variables & locale file + let fxSpawnVars; + const newServerMutex = genMutex(); + try { + txCore.webServer.resetToken(); + fxSpawnVars = getFxSpawnVariables(); + // debugPrintSpawnVars(fxSpawnVars); //DEBUG + } catch (error) { + const errMsg = `Error setting up spawn variables: ${(error as any).message}`; + console.error(errMsg); + return errMsg; + } + try { + await setupCustomLocaleFile(); + } catch (error) { + const errMsg = `Error copying custom locale: ${(error as any).message}`; + console.error(errMsg); + return errMsg; + } + + //If there is any FXServer configuration missing + if (!this.isConfigured) { + const msg = `Cannot start the server with missing configuration (serverDataPath || cfgPath).`; + console.error(msg); + return msg; + } + + //Validating server.cfg & configuration + let netEndpointDetected: string; + try { + const result = await validateFixServerConfig(fxSpawnVars.cfgPath, fxSpawnVars.dataPath); + if (result.errors || !result.connectEndpoint) { + const msg = `**Unable to start the server due to error(s) in your config file(s):**\n${result.errors}`; + console.error(msg); + return msg; + } + if (result.warnings) { + const msg = `**Warning regarding your configuration file(s):**\n${result.warnings}`; + console.warn(msg); + } + + netEndpointDetected = result.connectEndpoint; + } catch (error) { + const errMsg = `server.cfg error: ${(error as any).message}`; + console.error(errMsg); + if ((error as any).message.includes('unreadable')) { + console.error('That is the file where you configure your server and start resources.'); + console.error('You likely moved/deleted your server files or copied the txData folder from another server.'); + console.error('To fix this issue, open the txAdmin web interface then go to "Settings > FXServer" and fix the "Server Data Folder" and "CFG File Path".'); + } + return errMsg; + } + + //Reseting monitor stats + txCore.fxMonitor.resetState(); + + //Resetting frontend playerlist + txCore.webServer.webSocket.buffer('playerlist', { + mutex: newServerMutex, + type: 'fullPlayerlist', + playerlist: [], + }); + + //Announcing + if (shouldAnnounce) { + txCore.discordBot.sendAnnouncement({ + type: 'success', + description: { + key: 'server_actions.spawning_discord', + data: { servername: fxSpawnVars.serverName }, + }, + }); + } + + //Starting server + const childProc = spawn( + fxSpawnVars.bin, + fxSpawnVars.args, + { + cwd: fxSpawnVars.dataPath, + stdio: ['pipe', 'pipe', 'pipe', 'pipe'], + }, + ); + if (!isValidChildProcess(childProc)) { + const errMsg = `Failed to run \n${fxSpawnVars.bin}`; + console.error(errMsg); + return errMsg; + } + this.proc = new ProcessManager(childProc, { + mutex: newServerMutex, + netEndpoint: netEndpointDetected, + onStatusUpdate: () => { + txCore.webServer.webSocket.pushRefresh('status'); + } + }); + txCore.logger.fxserver.logFxserverSpawn(this.proc.pid.toString()); + + //Setting up StdIO + childProc.stdout.setEncoding('utf8'); + childProc.stdout.on('data', + txCore.logger.fxserver.writeFxsOutput.bind( + txCore.logger.fxserver, + ConsoleLineEnum.StdOut, + ), + ); + childProc.stderr.on('data', + txCore.logger.fxserver.writeFxsOutput.bind( + txCore.logger.fxserver, + ConsoleLineEnum.StdErr, + ), + ); + const jsoninPipe = childProc.stdio[3].pipe(StreamValues.withParser() as any); + jsoninPipe.on('data', handleFd3Messages.bind(null, newServerMutex)); + + //_Almost_ don't care + childProc.stdin.on('error', childProcessEventBlackHole); + childProc.stdin.on('data', childProcessEventBlackHole); + childProc.stdout.on('error', childProcessEventBlackHole); + childProc.stderr.on('error', childProcessEventBlackHole); + childProc.stdio[3].on('error', childProcessEventBlackHole); + + //FIXME: return a more detailed object + return null; + } + + + //MARK: CONTROL + /** + * Restarts the FXServer + */ + public async restartServer(reason: string, author: string | typeof SYM_SYSTEM_AUTHOR) { + //Prevent concurrent restart request + const respawnDelay = this.restartSpawnDelay; + if (this.isAwaitingRestartSpawnDelay) { + const durationStr = msToShortishDuration( + respawnDelay.ms, + { units: ['m', 's', 'ms'] } + ); + return `A restart is already in progress, with a delay of ${durationStr}.`; + } + + try { + //Restart server + const killError = await this.killServer(reason, author, true); + if (killError) return killError; + + //Give time for the OS to release the ports + + if (respawnDelay.isBackoff) { + console.warn(`Restarting the fxserver with backoff delay of ${respawnDelay.ms}ms`); + } + this.isAwaitingRestartSpawnDelay = true; + await sleep(respawnDelay.ms); + this.isAwaitingRestartSpawnDelay = false; + + //Start server again :) + return await this.spawnServer(); + } catch (error) { + const errMsg = `Couldn't restart the server.`; + console.error(errMsg); + console.verbose.dir(error); + return errMsg; + } finally { + //Make sure the flag is reset + this.isAwaitingRestartSpawnDelay = false; + } + } + + + /** + * Kills the FXServer child process. + * NOTE: isRestarting might be true even if not called by this.restartServer(). + */ + public async killServer(reason: string, author: string | typeof SYM_SYSTEM_AUTHOR, isRestarting = false) { + if (!this.proc) return null; //nothing to kill + + //Prepare vars + const shutdownDelay = Math.max(txConfig.server.shutdownNoticeDelayMs, MIN_KILL_DELAY); + const reasonString = reason ?? 'no reason provided'; + const messageType = isRestarting ? 'restarting' : 'stopping'; + const messageColor = isRestarting ? 'warning' : 'danger'; + const tOptions = { + servername: txConfig.general.serverName, + reason: reasonString, + }; + + //Prevent concurrent kill request + if (this.isAwaitingShutdownNoticeDelay) { + const durationStr = msToShortishDuration( + shutdownDelay, + { units: ['m', 's', 'ms'] } + ); + return `A shutdown is already in progress, with a delay of ${durationStr}.`; + } + + try { + //If the process is alive, send warnings event and await the delay + if (this.proc.isAlive) { + this.sendEvent('serverShuttingDown', { + delay: txConfig.server.shutdownNoticeDelayMs, + author: typeof author === 'string' ? author : 'txAdmin', + message: txCore.translator.t(`server_actions.${messageType}`, tOptions), + }); + this.isAwaitingShutdownNoticeDelay = true; + await sleep(shutdownDelay); + this.isAwaitingShutdownNoticeDelay = false; + } + + //Stopping server + this.proc.destroy(); + const debugInfo = this.proc.stateInfo; + this.history.push(debugInfo); + this.proc = null; + + //Cleanup + txCore.fxScheduler.handleServerClose(); + txCore.fxResources.handleServerClose(); + txCore.fxPlayerlist.handleServerClose(debugInfo.mutex); + txCore.metrics.svRuntime.logServerClose(reasonString); + txCore.discordBot.sendAnnouncement({ + type: messageColor, + description: { + key: `server_actions.${messageType}_discord`, + data: tOptions, + }, + }).catch(() => { }); + return null; + } catch (error) { + const msg = `Couldn't kill the server. Perhaps What Is Dead May Never Die.`; + console.error(msg); + console.verbose.dir(error); + this.proc = null; + return msg; + } finally { + //Make sure the flag is reset + this.isAwaitingShutdownNoticeDelay = false; + } + } + + + //MARK: COMMANDS + /** + * Resets the convars in the server. + * Useful for when we change txAdmin settings and want it to reflect on the server. + * This will also fire the `txAdmin:event:configChanged` + */ + private async updateMutableConvars() { + console.log('Updating FXServer ConVars.'); + try { + await setupCustomLocaleFile(); + const convarList = getMutableConvars(false); + for (const [set, convar, value] of convarList) { + this.sendCommand(set, [convar, value], SYM_SYSTEM_AUTHOR); + } + return this.sendEvent('configChanged'); + } catch (error) { + console.verbose.error('Error updating FXServer ConVars'); + console.verbose.dir(error); + return false; + } + } + + + /** + * Fires an `txAdmin:event` inside the server via srvCmd > stdin > command > lua broadcaster. + * @returns true if the command was sent successfully, false otherwise. + */ + public sendEvent(eventType: string, data = {}) { + if (typeof eventType !== 'string' || !eventType) throw new Error('invalid eventType'); + try { + return this.sendCommand('txaEvent', [eventType, data], SYM_SYSTEM_AUTHOR); + } catch (error) { + console.verbose.error(`Error writing firing server event ${eventType}`); + console.verbose.dir(error); + return false; + } + } + + + /** + * Formats and sends commands to fxserver's stdin. + */ + public sendCommand( + cmdName: string, + cmdArgs: (string | number | object)[], + author: string | typeof SYM_SYSTEM_AUTHOR + ) { + if (!this.proc?.isAlive) return false; + if (typeof cmdName !== 'string' || !cmdName.length) throw new Error('cmdName is empty'); + if (!Array.isArray(cmdArgs)) throw new Error('cmdArgs is not an array'); + //NOTE: technically fxserver accepts anything but space and ; in the command name + if (!/^\w+$/.test(cmdName)) { + throw new Error('invalid cmdName string'); + } + + // Send the command to the server + const rawInput = `${cmdName} ${stringifyConsoleArgs(cmdArgs)}`; + return this.sendRawCommand(rawInput, author); + } + + + /** + * Writes to fxchild's stdin. + * NOTE: do not send commands with \n at the end, this function will add it. + */ + public sendRawCommand(command: string, author: string | typeof SYM_SYSTEM_AUTHOR) { + if (!this.proc?.isAlive) return false; + if (typeof command !== 'string') throw new Error('Expected command as String!'); + if (author !== SYM_SYSTEM_AUTHOR && (typeof author !== 'string' || !author.length)) { + throw new Error('Expected non-empty author as String or Symbol!'); + } + try { + const success = this.proc.stdin?.write(command + '\n'); + if (author === SYM_SYSTEM_AUTHOR) { + txCore.logger.fxserver.logSystemCommand(command); + } else { + txCore.logger.fxserver.logAdminCommand(author, command); + } + return success; + } catch (error) { + console.error('Error writing to fxChild\'s stdin.'); + console.verbose.dir(error); + return false; + } + } + + + //MARK: GETTERS + /** + * The ChildProcessStateInfo of the current FXServer, or null + */ + public get child() { + return this.proc?.stateInfo; + } + + + /** + * If the server is _supposed to_ not be running. + * It takes into consideration the RestartSpawnDelay. + * - TRUE: server never started, or failed during a start/restart. + * - FALSE: server started, but might have been killed or crashed. + */ + public get isIdle() { + return !this.proc && !this.isAwaitingRestartSpawnDelay; + } + + + /** + * True if both the serverDataPath and cfgPath are configured + */ + public get isConfigured() { + return typeof txConfig.server.dataPath === 'string' + && txConfig.server.dataPath.length > 0 + && typeof txConfig.server.cfgPath === 'string' + && txConfig.server.cfgPath.length > 0; + } + + + /** + * The resolved paths of the server + * FIXME: check where those paths are needed and only calculate what is relevant + */ + public get serverPaths() { + if (!this.isConfigured) return; + return { + dataPath: path.normalize(txConfig.server.dataPath!), //to maintain consistency + cfgPath: resolveCFGFilePath(txConfig.server.cfgPath, txConfig.server.dataPath!), + } + // return { + // data: { + // absolute: 'xxx', + // }, + // //TODO: cut paste logic from resolveCFGFilePath + // resources: { + // //??? + // }, + // cfg: { + // fileName: 'xxx', + // relativePath: 'xxx', + // absolutePath: 'xxx', + // } + // }; + } + + + /** + * The duration in ms that FxRunner should wait between killing the server and starting it again. + * This delay is present to avoid weird issues with the OS not releasing the endpoint in time. + * NOTE: reminder that the config might be 0ms + */ + public get restartSpawnDelay() { + let ms = txConfig.server.restartSpawnDelayMs; + let isBackoff = false; + if (this.restartSpawnBackoffDelay >= ms) { + ms = this.restartSpawnBackoffDelay; + isBackoff = true; + } + + return { + ms, + isBackoff, + // isDefault: ms === ConfigStore.SchemaDefaults.server.restartSpawnDelayMs + } + } +}; diff --git a/core/modules/FxRunner/utils.test.ts b/core/modules/FxRunner/utils.test.ts new file mode 100644 index 0000000..966fadc --- /dev/null +++ b/core/modules/FxRunner/utils.test.ts @@ -0,0 +1,159 @@ +import { suite, it, expect, test } from 'vitest'; +import { sanitizeConsoleArgString, stringifyConsoleArgs } from './utils'; + +const fxsArgsParser = (str: string) => { + const args: string[] = []; + let current = '' + let inQuotes = false + for (let i = 0; i < str.length; i++) { + const char = str[i] + if (char === '\\' && i + 1 < str.length && str[i + 1] === '"') { + current += '"' + i++ + continue + } + if (char === '"') { + inQuotes = !inQuotes + continue + } + if (/\s/.test(char) && !inQuotes) { + if (current.length) { + args.push(current) + current = '' + } + continue + } + //test for semicolon - if odd number of escaped double quotes + //NOTE: you can escape " but not a \ + if (char === ';') { + if (inQuotes) { + const escapedQuotes = current.match(/(? { + expect(fxsArgsParser('')).toEqual(['']); + expect(fxsArgsParser('a')).toEqual(['a']); + expect(fxsArgsParser('ab')).toEqual(['ab']); + + expect(fxsArgsParser('a b')).toEqual(['a', 'b']); + expect(fxsArgsParser('"a b"')).toEqual(['a b']); + expect(fxsArgsParser('"a b\\"')).toEqual(['a b"']); + expect(fxsArgsParser('"a b')).toEqual(['a b']); + expect(fxsArgsParser('"a\\ b"')).toEqual(['a\\ b']); + expect(fxsArgsParser('"a\\" b"')).toEqual(['a" b']); + expect(fxsArgsParser('"a\\"\\" b"')).toEqual(['a"" b']); + expect(fxsArgsParser('"a;b"')).toEqual(['a;b']); + + expect(fxsArgsParser('"a\\b"')).toEqual(['a\\b']); + expect(fxsArgsParser('"a\\\\b"')).toEqual(['a\\\\b']); + + expect(fxsArgsParser('"a;b;c"')).toEqual(['a;b;c']); + expect(() => fxsArgsParser('"a\\";b;c"')).toThrow(); //cmd b + cmd c + expect(() => fxsArgsParser('"a;b\\";c"')).toThrow(); //cmd c only + + expect(() => fxsArgsParser('a;b')).toThrow(); //cmd + expect(() => fxsArgsParser('"a\\";b"')).toThrow(); //cmd + expect(fxsArgsParser('"a\\"\\";b"')).toEqual(['a"";b']); + expect(fxsArgsParser('"a;\\"b"')).toEqual(['a;"b']); + expect(fxsArgsParser('"a;\\"\\"b"')).toEqual(['a;""b']); + expect(() => fxsArgsParser('"a\\";\\"b"')).toThrow(); //cmd + expect(fxsArgsParser('"a\\"\\";\\"b"')).toEqual(['a"";"b']); + expect(() => fxsArgsParser('"a\\"\\"\\";b"')).toThrow(); //cmd + + //testing separators + expect(fxsArgsParser('a')).toEqual(['a']); + expect(fxsArgsParser('a.b')).toEqual(['a.b']); + expect(fxsArgsParser('a,b')).toEqual(['a,b']); + expect(fxsArgsParser('a!b')).toEqual(['a!b']); + expect(fxsArgsParser('a-b')).toEqual(['a-b']); + expect(fxsArgsParser('a_b')).toEqual(['a_b']); + expect(fxsArgsParser('a:b')).toEqual(['a:b']); + expect(fxsArgsParser('a^b')).toEqual(['a^b']); + expect(fxsArgsParser('a~b')).toEqual(['a~b']); +}); + + +suite('sanitizeConsoleArgString', () => { + it('should throw for non-strings', () => { + //@ts-expect-error + expect(() => sanitizeConsoleArgString()).toThrow(); + //@ts-expect-error + expect(() => sanitizeConsoleArgString(123)).toThrow(); + //@ts-expect-error + expect(() => sanitizeConsoleArgString([])).toThrow(); + //@ts-expect-error + expect(() => sanitizeConsoleArgString({})).toThrow(); + //@ts-expect-error + expect(() => sanitizeConsoleArgString(null)).toThrow(); + }); + + it('should correctly stringify simple strings', () => { + expect(sanitizeConsoleArgString('')).toBe(''); + expect(sanitizeConsoleArgString('test')).toBe('test'); + expect(sanitizeConsoleArgString('aa bb')).toBe('aa bb'); + }); + it('should correctly handle strings with double quotes', () => { + expect(sanitizeConsoleArgString('te"st')).toBe('te"st'); + expect(sanitizeConsoleArgString('"quoted"')).toBe('"quoted"'); + }); + it('should handle semicolons', () => { + expect(sanitizeConsoleArgString(';')).toBe('\u037e'); + expect(sanitizeConsoleArgString('a;b')).toBe('a\u037eb'); + }); +}); + + +suite('stringifyConsoleArgs', () => { + suite('string args', () => { + it('should correctly stringify simple strings', () => { + expect(stringifyConsoleArgs([''])).toEqual('""'); + expect(stringifyConsoleArgs(['test'])).toEqual('"test"'); + expect(stringifyConsoleArgs(['aa bb'])).toEqual('"aa bb"'); + }); + it('should correctly handle strings with double quotes', () => { + expect(stringifyConsoleArgs(['te"st'])).toEqual('"te\\"st"'); + expect(stringifyConsoleArgs(['"quoted"'])).toEqual('"\\"quoted\\""'); + }); + it('should handle semicolons', () => { + expect(stringifyConsoleArgs([';'])).toEqual('"\u037e"'); + expect(stringifyConsoleArgs(['a;b'])).toEqual('"a\u037eb"'); + }); + }); + + suite('non-string arg', () => { + //The decoder mimics fxserver + lua behavior + const nonStringDecoder = (str: string) => { + const args = fxsArgsParser(str); + if (!args.length) throw new Error('no args'); + const cleanedUp = args[0].replaceAll('\u037e', ';'); + return JSON.parse(cleanedUp); + } + const both = (input: any) => nonStringDecoder(stringifyConsoleArgs([input])); + + it('should correctly stringify numbers', () => { + expect(both(123)).toEqual(123); + expect(both(123.456)).toEqual(123.456); + }); + it('should correctly handle simple objects', () => { + expect(both({ a: true })).toEqual({ a: true }); + expect(both({ a: true, b: "bb", c: 123 })).toEqual({ a: true, b: "bb", c: 123 }); + expect(both({ a: { test: true } })).toEqual({ a: { test: true } }); + }); + it('should correctly handle gotcha objects', () => { + expect(both({ a: 'aa"bb' })).toEqual({ a: 'aa"bb' }); + expect(both({ a: 'aa;bb' })).toEqual({ a: 'aa;bb' }); + }); + }); + +}); diff --git a/core/modules/FxRunner/utils.ts b/core/modules/FxRunner/utils.ts new file mode 100644 index 0000000..1d4670e --- /dev/null +++ b/core/modules/FxRunner/utils.ts @@ -0,0 +1,240 @@ +import fsp from 'node:fs/promises'; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import { txEnv, txHostConfig } from "@core/globalData"; +import { redactStartupSecrets } from "@lib/misc"; +import path from "path"; + + +/** + * Blackhole event logger + */ +let lastBlackHoleSpewTime = 0; +const blackHoleSpillMaxInterval = 5000; +export const childProcessEventBlackHole = (...args: any[]) => { + const currentTime = Date.now(); + if (currentTime - lastBlackHoleSpewTime > blackHoleSpillMaxInterval) { + //Let's call this "hawking radiation" + console.verbose.error('ChildProcess unexpected event:'); + console.verbose.dir(args); + lastBlackHoleSpewTime = currentTime; + } +}; + + +/** + * Returns a tuple with the convar name and value, formatted for the server command line + */ +export const getMutableConvars = (isCmdLine = false) => { + const checkPlayerJoin = txConfig.banlist.enabled || txConfig.whitelist.mode !== 'disabled'; + const convars: RawConvarSetTuple[] = [ + ['setr', 'locale', txConfig.general.language ?? 'en'], + ['set', 'serverName', txConfig.general.serverName ?? 'txAdmin'], + ['set', 'checkPlayerJoin', checkPlayerJoin], + ['set', 'menuAlignRight', txConfig.gameFeatures.menuAlignRight], + ['set', 'menuPageKey', txConfig.gameFeatures.menuPageKey], + ['set', 'playerModePtfx', txConfig.gameFeatures.playerModePtfx], + ['set', 'hideAdminInPunishments', txConfig.gameFeatures.hideAdminInPunishments], + ['set', 'hideAdminInMessages', txConfig.gameFeatures.hideAdminInMessages], + ['set', 'hideDefaultAnnouncement', txConfig.gameFeatures.hideDefaultAnnouncement], + ['set', 'hideDefaultDirectMessage', txConfig.gameFeatures.hideDefaultDirectMessage], + ['set', 'hideDefaultWarning', txConfig.gameFeatures.hideDefaultWarning], + ['set', 'hideDefaultScheduledRestartWarning', txConfig.gameFeatures.hideDefaultScheduledRestartWarning], + + // //NOTE: no auto update, maybe we shouldn't tie core and server verbosity anyways + // ['setr', 'verbose', console.isVerbose], + ]; + return convars.map((c) => polishConvarSetTuple(c, isCmdLine)); +}; + +type RawConvarSetTuple = [setter: string, name: string, value: any]; +type ConvarSetTuple = [setter: string, name: string, value: string]; + +const polishConvarSetTuple = ([setter, name, value]: RawConvarSetTuple, isCmdLine = false): ConvarSetTuple => { + return [ + isCmdLine ? `+${setter}` : setter, + 'txAdmin-' + name, + value.toString(), + ]; +} + +export const mutableConvarConfigDependencies = [ + 'general.*', + 'gameFeatures.*', + 'banlist.enabled', + 'whitelist.mode', +]; + + +/** + * Pre calculating HOST dependent spawn variables + */ +const txCoreEndpoint = txHostConfig.netInterface + ? `${txHostConfig.netInterface}:${txHostConfig.txaPort}` + : `127.0.0.1:${txHostConfig.txaPort}`; +let osSpawnVars: OsSpawnVars; +if (txEnv.isWindows) { + osSpawnVars = { + bin: `${txEnv.fxsPath}/FXServer.exe`, + args: [], + }; +} else { + const alpinePath = path.resolve(txEnv.fxsPath, '../../'); + osSpawnVars = { + bin: `${alpinePath}/opt/cfx-server/ld-musl-x86_64.so.1`, + args: [ + '--library-path', `${alpinePath}/usr/lib/v8/:${alpinePath}/lib/:${alpinePath}/usr/lib/`, + '--', + `${alpinePath}/opt/cfx-server/FXServer`, + '+set', 'citizen_dir', `${alpinePath}/opt/cfx-server/citizen/`, + ], + }; +} + +type OsSpawnVars = { + bin: string; + args: string[]; +} + + +/** + * Returns the variables needed to spawn the server + */ +export const getFxSpawnVariables = (): FxSpawnVariables => { + if (!txConfig.server.dataPath) throw new Error('Missing server data path'); + + const cmdArgs = [ + ...osSpawnVars.args, + getMutableConvars(true), //those are the ones that can change without restart + txConfig.server.startupArgs, + '+set', 'onesync', txConfig.server.onesync, + '+sets', 'txAdmin-version', txEnv.txaVersion, + '+setr', 'txAdmin-menuEnabled', txConfig.gameFeatures.menuEnabled, + '+set', 'txAdmin-luaComHost', txCoreEndpoint, + '+set', 'txAdmin-luaComToken', txCore.webServer.luaComToken, + '+set', 'txAdminServerMode', 'true', //Can't change this one due to fxserver code compatibility + '+exec', txConfig.server.cfgPath, + ].flat(2).map(String); + + return { + bin: osSpawnVars.bin, + args: cmdArgs, + serverName: txConfig.general.serverName, + dataPath: txConfig.server.dataPath, + cfgPath: txConfig.server.cfgPath, + } +} + +type FxSpawnVariables = OsSpawnVars & { + dataPath: string; + cfgPath: string; + serverName: string; +} + + +/** + * Print debug information about the spawn variables + */ +export const debugPrintSpawnVars = (fxSpawnVars: FxSpawnVariables) => { + if (!console.verbose) return; //can't console.verbose.table + + console.debug('Spawn Bin:', fxSpawnVars.bin); + const args = redactStartupSecrets(fxSpawnVars.args) + console.debug('Spawn Args:'); + const argsTable = []; + let currArgs: string[] | undefined; + for (const arg of args) { + if (arg.startsWith('+')) { + if (currArgs) argsTable.push(currArgs); + currArgs = [arg]; + } else { + if (!currArgs) currArgs = []; + currArgs.push(arg); + } + } + if (currArgs) argsTable.push(currArgs); + console.table(argsTable); +} + + +/** + * Type guard for a valid child process + */ +export const isValidChildProcess = (p: any): p is ValidChildProcess => { + if (!p) return false; + if (typeof p.pid !== 'number') return false; + if (!Array.isArray(p.stdio)) return false; + if (p.stdio.length < 4) return false; + if (!(p.stdio[3] instanceof Readable)) return false; + return true; +}; +export type ValidChildProcess = ChildProcessWithoutNullStreams & { + pid: number; + readonly stdio: [ + Writable, + Readable, + Readable, + Readable, + Readable | Writable | null | undefined, // extra + ]; +}; + + +/** + * Sanitizes an argument for console input. + */ +export const sanitizeConsoleArgString = (arg: string) => { + if (typeof arg !== 'string') throw new Error('unexpected type'); + return arg.replaceAll(/(? { + const cleanArgs: string[] = []; + for (const arg of args) { + if (typeof arg === 'string') { + cleanArgs.push(sanitizeConsoleArgString(JSON.stringify(arg))); + } else if (typeof arg === 'number') { + cleanArgs.push(sanitizeConsoleArgString(JSON.stringify(arg.toString()))); + } else if (typeof arg === 'object' && arg !== null) { + const json = JSON.stringify(arg); + const escaped = json.replaceAll(/"/g, '\\"'); + cleanArgs.push(`"${sanitizeConsoleArgString(escaped)}"`); + } else { + throw new Error('arg expected to be string or object'); + } + } + + return cleanArgs.join(' '); +} + + +/** + * Copies the custom locale file from txData to the 'monitor' path, due to sandboxing. + * FIXME: move to core/lib/fxserver/runtimeFiles.ts + */ +export const setupCustomLocaleFile = async () => { + if (txConfig.general.language !== 'custom') return; + const srcPath = txCore.translator.customLocalePath; + const destRuntimePath = path.resolve(txEnv.txaPath, '.runtime'); + const destFilePath = path.resolve(destRuntimePath, 'locale.json'); + try { + await fsp.mkdir(destRuntimePath, { recursive: true }); + await fsp.copyFile(srcPath, destFilePath); + } catch (error) { + console.tag('FXRunner').error(`Failed to copy custom locale file: ${(error as any).message}`); + } +} diff --git a/core/modules/FxScheduler.ts b/core/modules/FxScheduler.ts new file mode 100644 index 0000000..45a9395 --- /dev/null +++ b/core/modules/FxScheduler.ts @@ -0,0 +1,319 @@ +const modulename = 'FxScheduler'; +import { parseSchedule } from '@lib/misc'; +import consoleFactory from '@lib/console'; +import { SYM_SYSTEM_AUTHOR } from '@lib/symbols'; +import type { UpdateConfigKeySet } from './ConfigStore/utils'; +const console = consoleFactory(modulename); + + +//Types +type RestartInfo = { + string: string; + minuteFloorTs: number; +} +type ParsedTime = { + string: string; + hours: number; + minutes: number; +} + + +//Consts +const scheduleWarnings = [30, 15, 10, 5, 4, 3, 2, 1]; + + +/** + * Processes an array of HH:MM, gets the next timestamp (sorted by closest). + * When time matches, it will be dist: 0, distMins: 0, and nextTs likely in the + * past due to seconds and milliseconds being 0. + */ +const getNextScheduled = (parsedSchedule: ParsedTime[]): RestartInfo => { + const thisMinuteTs = new Date().setSeconds(0, 0); + const processed = parsedSchedule.map((t) => { + const nextDate = new Date(); + let minuteFloorTs = nextDate.setHours(t.hours, t.minutes, 0, 0); + if (minuteFloorTs < thisMinuteTs) { + minuteFloorTs = nextDate.setHours(t.hours + 24, t.minutes, 0, 0); + } + return { + string: t.string, + minuteFloorTs, + }; + }); + return processed.sort((a, b) => a.minuteFloorTs - b.minuteFloorTs)[0]; +}; + + +/** + * Module responsible for restarting the FXServer on a schedule defined in the config, + * or a temporary schedule set by the user at runtime. + */ +export default class FxScheduler { + static readonly configKeysWatched = ['restarter.schedule']; + private nextTempSchedule: RestartInfo | false = false; + private calculatedNextRestartMinuteFloorTs: number | false = false; + private nextSkip: number | false = false; + + constructor() { + //Initial check to update status + setImmediate(() => { + this.checkSchedule(); + }); + + //Cron Function + setInterval(() => { + this.checkSchedule(); + txCore.webServer.webSocket.pushRefresh('status'); + }, 60 * 1000); + } + + + /** + * Refresh configs, resets skip and temp scheduled, runs checkSchedule. + */ + handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) { + this.nextSkip = false; + this.nextTempSchedule = false; + this.checkSchedule(); + txCore.webServer.webSocket.pushRefresh('status'); + } + + + /** + * Updates state when server closes. + * Clear temp skips and skips next scheduled if it's in less than 2 hours. + */ + handleServerClose() { + //Clear temp schedule, recalculates next restart + if (this.nextTempSchedule) this.nextTempSchedule = false; + this.checkSchedule(true); + + //Check if next scheduled restart is in less than 2 hours + const inTwoHours = Date.now() + 2 * 60 * 60 * 1000; + if ( + this.calculatedNextRestartMinuteFloorTs + && this.calculatedNextRestartMinuteFloorTs < inTwoHours + && this.nextSkip !== this.calculatedNextRestartMinuteFloorTs + ) { + console.warn('Server closed, skipping next scheduled restart because it\'s in less than 2 hours.'); + this.nextSkip = this.calculatedNextRestartMinuteFloorTs; + } + this.checkSchedule(true); + + //Push UI update + txCore.webServer.webSocket.pushRefresh('status'); + } + + + /** + * Returns the current status of scheduler + * NOTE: sending relative because server might have clock skew + */ + getStatus() { + if (this.calculatedNextRestartMinuteFloorTs) { + const thisMinuteTs = new Date().setSeconds(0, 0); + return { + nextRelativeMs: this.calculatedNextRestartMinuteFloorTs - thisMinuteTs, + nextSkip: this.nextSkip === this.calculatedNextRestartMinuteFloorTs, + nextIsTemp: !!this.nextTempSchedule, + }; + } else { + return { + nextRelativeMs: false, + nextSkip: false, + nextIsTemp: false, + } as const; + } + } + + + /** + * Sets this.nextSkip. + * Cancel scheduled button -> setNextSkip(true) + * Enable scheduled button -> setNextSkip(false) + */ + setNextSkip(enabled: boolean, author?: string) { + if (enabled) { + let prevMinuteFloorTs, temporary; + if (this.nextTempSchedule) { + prevMinuteFloorTs = this.nextTempSchedule.minuteFloorTs; + temporary = true; + this.nextTempSchedule = false; + } else if (this.calculatedNextRestartMinuteFloorTs) { + prevMinuteFloorTs = this.calculatedNextRestartMinuteFloorTs; + temporary = false; + this.nextSkip = this.calculatedNextRestartMinuteFloorTs; + } + + if (prevMinuteFloorTs) { + //Dispatch `txAdmin:events:scheduledRestartSkipped` + txCore.fxRunner.sendEvent('scheduledRestartSkipped', { + secondsRemaining: Math.floor((prevMinuteFloorTs - Date.now()) / 1000), + temporary, + author, + }); + + //FIXME: deprecate + txCore.fxRunner.sendEvent('skippedNextScheduledRestart', { + secondsRemaining: Math.floor((prevMinuteFloorTs - Date.now()) / 1000), + temporary + }); + } + } else { + this.nextSkip = false; + } + + //This is needed to refresh this.calculatedNextRestartMinuteFloorTs + this.checkSchedule(); + + //Refresh UI + txCore.webServer.webSocket.pushRefresh('status'); + } + + + /** + * Sets this.nextTempSchedule. + * The value MUST be before the next setting scheduled time. + */ + setNextTempSchedule(timeString: string) { + //Process input + if (typeof timeString !== 'string') throw new Error('expected string'); + const thisMinuteTs = new Date().setSeconds(0, 0); + let scheduledString, scheduledMinuteFloorTs; + + if (timeString.startsWith('+')) { + const minutes = parseInt(timeString.slice(1)); + if (isNaN(minutes) || minutes < 1 || minutes >= 1440) { + throw new Error('invalid minutes'); + } + const nextDate = new Date(thisMinuteTs + (minutes * 60 * 1000)); + scheduledMinuteFloorTs = nextDate.getTime(); + scheduledString = nextDate.getHours().toString().padStart(2, '0') + ':' + nextDate.getMinutes().toString().padStart(2, '0'); + } else { + const [hours, minutes] = timeString.split(':', 2).map((x) => parseInt(x)); + if (typeof hours === 'undefined' || isNaN(hours) || hours < 0 || hours > 23) throw new Error('invalid hours'); + if (typeof minutes === 'undefined' || isNaN(minutes) || minutes < 0 || minutes > 59) throw new Error('invalid minutes'); + + const nextDate = new Date(); + scheduledMinuteFloorTs = nextDate.setHours(hours, minutes, 0, 0); + if (scheduledMinuteFloorTs === thisMinuteTs) { + throw new Error('Due to the 1 minute precision of the restart scheduler, you cannot schedule a restart in the same minute.'); + } + if (scheduledMinuteFloorTs < thisMinuteTs) { + scheduledMinuteFloorTs = nextDate.setHours(hours + 24, minutes, 0, 0); + } + scheduledString = hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0'); + } + + //Check validity + if (Array.isArray(txConfig.restarter.schedule) && txConfig.restarter.schedule.length) { + const { valid } = parseSchedule(txConfig.restarter.schedule); + const nextSettingRestart = getNextScheduled(valid); + if (nextSettingRestart.minuteFloorTs < scheduledMinuteFloorTs) { + throw new Error(`You already have one restart scheduled for ${nextSettingRestart.string}, which is before the time you specified.`); + } + } + + // Set next temp schedule + this.nextTempSchedule = { + string: scheduledString, + minuteFloorTs: scheduledMinuteFloorTs, + }; + + //This is needed to refresh this.calculatedNextRestartMinuteFloorTs + this.checkSchedule(); + + //Refresh UI + txCore.webServer.webSocket.pushRefresh('status'); + } + + + /** + * Checks the schedule to see if it's time to announce or restart the server + */ + async checkSchedule(calculateOnly = false) { + //Check settings and temp scheduled restart + let nextRestart: RestartInfo; + if (this.nextTempSchedule) { + nextRestart = this.nextTempSchedule; + } else if (Array.isArray(txConfig.restarter.schedule) && txConfig.restarter.schedule.length) { + const { valid } = parseSchedule(txConfig.restarter.schedule); + nextRestart = getNextScheduled(valid); + } else { + //nothing scheduled + this.calculatedNextRestartMinuteFloorTs = false; + return; + } + this.calculatedNextRestartMinuteFloorTs = nextRestart.minuteFloorTs; + if (calculateOnly) return; + + //Checking if skipped + if (this.nextSkip === this.calculatedNextRestartMinuteFloorTs) { + console.verbose.log(`Skipping next scheduled restart`); + return; + } + + //Calculating dist + const thisMinuteTs = new Date().setSeconds(0, 0); + const nextDistMs = nextRestart.minuteFloorTs - thisMinuteTs; + const nextDistMins = Math.floor(nextDistMs / 60_000); + + //Checking if server restart or warning time + if (nextDistMins === 0) { + //restart server + this.triggerServerRestart( + `scheduled restart at ${nextRestart.string}`, + txCore.translator.t('restarter.schedule_reason', { time: nextRestart.string }), + ); + + //Check if server is in boot cooldown + const processUptime = Math.floor((txCore.fxRunner.child?.uptime ?? 0) / 1000); + if (processUptime < txConfig.restarter.bootGracePeriod) { + console.verbose.log(`Server is in boot cooldown, skipping scheduled restart.`); + return; + } + + //reset next scheduled + this.nextTempSchedule = false; + + } else if (scheduleWarnings.includes(nextDistMins)) { + const tOptions = { + smart_count: nextDistMins, + servername: txConfig.general.serverName, + }; + + //Send discord warning + txCore.discordBot.sendAnnouncement({ + type: 'warning', + description: { + key: 'restarter.schedule_warn_discord', + data: tOptions + } + }); + + //Dispatch `txAdmin:events:scheduledRestart` + txCore.fxRunner.sendEvent('scheduledRestart', { + secondsRemaining: nextDistMins * 60, + translatedMessage: txCore.translator.t('restarter.schedule_warn', tOptions) + }); + } + } + + + /** + * Triggers FXServer restart and logs the reason. + */ + async triggerServerRestart(reasonInternal: string, reasonTranslated: string) { + //Sanity check + if (txCore.fxRunner.isIdle || !txCore.fxRunner.child?.isAlive) { + console.verbose.warn('Server not running, skipping scheduled restart.'); + return false; + } + + //Restart server + const logMessage = `Restarting server: ${reasonInternal}`; + txCore.logger.admin.write('SCHEDULER', logMessage); + txCore.logger.fxserver.logInformational(logMessage); //just for better visibility + txCore.fxRunner.restartServer(reasonTranslated, SYM_SYSTEM_AUTHOR); + } +}; diff --git a/core/modules/Logger/FXServerLogger/ConsoleLineEnum.ts b/core/modules/Logger/FXServerLogger/ConsoleLineEnum.ts new file mode 100644 index 0000000..db03030 --- /dev/null +++ b/core/modules/Logger/FXServerLogger/ConsoleLineEnum.ts @@ -0,0 +1,10 @@ +//In a separate file to avoid circular dependencies +enum ConsoleLineEnum { + StdOut, + StdErr, + MarkerAdminCmd, + MarkerSystemCmd, + MarkerInfo, +} + +export default ConsoleLineEnum; diff --git a/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts b/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts new file mode 100644 index 0000000..188e922 --- /dev/null +++ b/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts @@ -0,0 +1,213 @@ +import ConsoleLineEnum from './ConsoleLineEnum'; +import { prefixMultiline, splitFirstLine, stripLastEol } from './fxsLoggerUtils'; +import chalk, { ChalkInstance } from 'chalk'; + + +//Types +export type MultiBuffer = { + webBuffer: string; + stdoutBuffer: string; + fileBuffer: string; +} + +type StylesLibrary = { + [key in ConsoleLineEnum]: StyleConfig | null; +}; +type StyleConfig = { + web: StyleChannelConfig; + stdout: StyleChannelConfig | false; +} +type StyleChannelConfig = { + prefix?: ChalkInstance; + line?: ChalkInstance; +} + + +//Precalculating some styles +const chalkToStr = (color: ChalkInstance) => color('\x00').split('\x00')[0]; +const precalcMarkerAdminCmd = chalkToStr(chalk.bgHex('#e6b863').black); +const precalcMarkerSystemCmd = chalkToStr(chalk.bgHex('#36383D').hex('#CCCCCC')); +const precalcMarkerInfo = chalkToStr(chalk.bgBlueBright.black); +const ANSI_RESET = '\x1B[0m'; +const ANSI_ERASE_LINE = '\x1b[K'; + +const STYLES = { + [ConsoleLineEnum.StdOut]: null, //fully shortcircuited + [ConsoleLineEnum.StdErr]: { + web: { + prefix: chalk.bgRedBright.bold.black, + line: chalk.bold.redBright, + }, + stdout: { + prefix: chalk.bgRedBright.bold.black, + line: chalk.bold.redBright, + }, + }, + [ConsoleLineEnum.MarkerAdminCmd]: { + web: { + prefix: chalk.bold, + line: x => `${precalcMarkerAdminCmd}${x}${ANSI_ERASE_LINE}${ANSI_RESET}`, + }, + stdout: false, + }, + [ConsoleLineEnum.MarkerSystemCmd]: { + web: { + prefix: chalk.bold, + line: x => `${precalcMarkerSystemCmd}${x}${ANSI_ERASE_LINE}${ANSI_RESET}`, + }, + stdout: false, + }, + [ConsoleLineEnum.MarkerInfo]: { + web: { + prefix: chalk.bold, + line: x => `${precalcMarkerInfo}${x}${ANSI_ERASE_LINE}${ANSI_RESET}`, + }, + stdout: { + prefix: chalk.bgBlueBright.black, + line: chalk.bgBlueBright.black, + }, + }, +} as StylesLibrary; + +export const FORCED_EOL = '\u21A9\n'; //used in test file only + +//NOTE: [jan/2025] Changed from [] to make it easier to find tx stdin in the log files +const prefixChar = '║' //Alternatives: | & ┇ +const getConsoleLinePrefix = (prefix: string) => prefixChar + prefix.padStart(20, ' ') + prefixChar; + +//NOTE: the \n must come _after_ the color so LiveConsolePage.tsx can know when it's an incomplete line +const colorLines = (str: string, color: ChalkInstance | undefined) => { + if (!color) return str; + const stripped = stripLastEol(str); + return color(stripped.str) + stripped.eol; +}; + + +/** + * Handles fxserver stdio and turns it into a cohesive textual output + */ +export default class ConsoleTransformer { + public lastEol = true; + private lastSrc = '0:undefined'; + private lastMarkerTs = 0; //in seconds + private STYLES = STYLES; + private PREFIX_SYSTEM = getConsoleLinePrefix('TXADMIN'); + private PREFIX_STDERR = getConsoleLinePrefix('STDERR'); + + public process(type: ConsoleLineEnum, data: string, context?: string): MultiBuffer { + //Shortcircuiting for empty strings + if (!data.length) return { webBuffer: '', stdoutBuffer: '', fileBuffer: '' }; + const src = `${type}:${context}`; + if (data === '\n' || data === '\r\n') { + this.lastEol = true; + this.lastSrc = src; + return { webBuffer: '\n', stdoutBuffer: '\n', fileBuffer: '\n' }; + } + let webBuffer = ''; + let stdoutBuffer = ''; + let fileBuffer = ''; + + //incomplete + if (!this.lastEol) { + //diff source + if (src !== this.lastSrc) { + webBuffer += FORCED_EOL; + stdoutBuffer += FORCED_EOL; + fileBuffer += FORCED_EOL; + const prefixed = this.prefixChunk(type, data, context); + webBuffer += this.getTimeMarker() + prefixed.webBuffer; + stdoutBuffer += prefixed.stdoutBuffer; + fileBuffer += prefixed.fileBuffer; + this.lastEol = data[data.length - 1] === '\n'; + this.lastSrc = src; + return { webBuffer, stdoutBuffer, fileBuffer }; + } + //same source + const parts = splitFirstLine(data); + fileBuffer += parts.first; + const style = this.STYLES[type]; + if (style === null) { + webBuffer += parts.first; + stdoutBuffer += parts.first; + } else { + webBuffer += colorLines( + parts.first, + style.web.line, + ); + stdoutBuffer += style.stdout ? colorLines( + parts.first, + style.stdout.line, + ) : ''; + } + if (parts.rest) { + const prefixed = this.prefixChunk(type, parts.rest, context); + webBuffer += this.getTimeMarker() + prefixed.webBuffer; + stdoutBuffer += prefixed.stdoutBuffer; + fileBuffer += prefixed.fileBuffer; + } + this.lastEol = parts.eol; + return { webBuffer, stdoutBuffer, fileBuffer }; + } + + //complete + const prefixed = this.prefixChunk(type, data, context); + webBuffer += this.getTimeMarker() + prefixed.webBuffer; + stdoutBuffer += prefixed.stdoutBuffer; + fileBuffer += prefixed.fileBuffer; + this.lastEol = data[data.length - 1] === '\n'; + this.lastSrc = src; + + return { webBuffer, stdoutBuffer, fileBuffer }; + } + + private getTimeMarker() { + const currMarkerTs = Math.floor(Date.now() / 1000); + if (currMarkerTs !== this.lastMarkerTs) { + this.lastMarkerTs = currMarkerTs; + return `{§${currMarkerTs.toString(16)}}` + } + return ''; + } + + private prefixChunk(type: ConsoleLineEnum, chunk: string, context?: string): MultiBuffer { + //NOTE: as long as stdout is shortcircuited, the other ones don't need to be micro-optimized + const style = this.STYLES[type]; + if (style === null) { + return { + webBuffer: chunk, + stdoutBuffer: chunk, + fileBuffer: chunk, + }; + } + + //selecting prefix and color + let prefix = ''; + if (type === ConsoleLineEnum.StdErr) { + prefix = this.PREFIX_STDERR; + } else if (type === ConsoleLineEnum.MarkerAdminCmd) { + prefix = getConsoleLinePrefix(context ?? '?'); + } else if (type === ConsoleLineEnum.MarkerSystemCmd) { + prefix = this.PREFIX_SYSTEM; + } else if (type === ConsoleLineEnum.MarkerInfo) { + prefix = this.PREFIX_SYSTEM; + } + + const webPrefix = style.web.prefix ? style.web.prefix(prefix) : prefix; + const webBuffer = colorLines( + prefixMultiline(chunk, webPrefix + ' '), + style.web.line, + ); + + let stdoutBuffer = ''; + if (style.stdout) { + const stdoutPrefix = style.stdout.prefix ? style.stdout.prefix(prefix) : prefix; + stdoutBuffer = colorLines( + prefixMultiline(chunk, stdoutPrefix + ' '), + style.stdout.line, + ); + } + const fileBuffer = prefixMultiline(chunk, prefix + ' '); + + return { webBuffer, stdoutBuffer, fileBuffer }; + } +}; diff --git a/core/modules/Logger/FXServerLogger/fxsLogger.test.ts b/core/modules/Logger/FXServerLogger/fxsLogger.test.ts new file mode 100644 index 0000000..0bf985f --- /dev/null +++ b/core/modules/Logger/FXServerLogger/fxsLogger.test.ts @@ -0,0 +1,290 @@ +//@ts-nocheck +import { test, expect, suite, it, vitest, vi } from 'vitest'; +import { prefixMultiline, splitFirstLine, stripLastEol } from './fxsLoggerUtils'; +import ConsoleTransformer, { FORCED_EOL } from './ConsoleTransformer'; +import ConsoleLineEnum from './ConsoleLineEnum'; + + +//MARK: splitFirstLine +suite('splitFirstLine', () => { + it('normal single full line', () => { + const result = splitFirstLine('Hello\n'); + expect(result).toEqual({ first: 'Hello\n', rest: undefined, eol: true }); + }); + it('should split a string with a newline into two parts', () => { + const result = splitFirstLine('Hello\nWorld'); + expect(result).toEqual({ first: 'Hello\n', rest: 'World', eol: false }); + }); + it('should return the whole string as the first part if there is no newline', () => { + const result = splitFirstLine('HelloWorld'); + expect(result).toEqual({ first: 'HelloWorld', rest: undefined, eol: false }); + }); + it('should handle an empty string', () => { + const result = splitFirstLine(''); + expect(result).toEqual({ first: '', rest: undefined, eol: false }); + }); + it('should handle multiple newlines correctly', () => { + const result = splitFirstLine('Hello\nWorld\nAgain'); + expect(result).toEqual({ first: 'Hello\n', rest: 'World\nAgain', eol: false }); + }); +}); + + +//MARK: stripLastEol +suite('stripLastEol', () => { + it('should strip \\r\\n from the end of the string', () => { + const result = stripLastEol('Hello World\r\n'); + expect(result).toEqual({ str: 'Hello World', eol: '\r\n' }); + }); + + it('should strip \\n from the end of the string', () => { + const result = stripLastEol('Hello World\n'); + expect(result).toEqual({ str: 'Hello World', eol: '\n' }); + }); + + it('should return the same string if there is no EOL character', () => { + const result = stripLastEol('Hello World'); + expect(result).toEqual({ str: 'Hello World', eol: '' }); + }); + + it('should return the same string if it ends with other characters', () => { + const result = stripLastEol('Hello World!'); + expect(result).toEqual({ str: 'Hello World!', eol: '' }); + }); +}); + + +//MARK: prefixMultiline +suite('prefixMultiline', () => { + it('should prefix every line in a multi-line string', () => { + const result = prefixMultiline('Hello\nWorld\nAgain', '!!'); + expect(result).toBe('!!Hello\n!!World\n!!Again'); + }); + it('should prefix a single-line string', () => { + const result = prefixMultiline('HelloWorld', '!!'); + expect(result).toBe('!!HelloWorld'); + }); + it('should handle an empty string', () => { + const result = prefixMultiline('', '!!'); + expect(result).toBe(''); + }); + it('should handle a string with a newline at the end', () => { + const result = prefixMultiline('Hello\n', '!!'); + expect(result).toBe('!!Hello\n'); + }); + it('should handle a string with multiple newlines at the end', () => { + const result = prefixMultiline('Hello\nWorld\n\n', '!!'); + expect(result).toBe('!!Hello\n!!World\n!!\n'); + }); + it('should handle a string with newlines only', () => { + const result = prefixMultiline('\n\n\n', '!!'); + expect(result).toBe('!!\n!!\n!!\n'); + }); + it('should handle a string with two full lines', () => { + const result = prefixMultiline('test\nabcde\n', '!!'); + expect(result).toBe('!!test\n!!abcde\n'); + }); +}); + + + +//MARK: Transformer parts +suite('transformer: prefixChunk', () => { + const transformer = new ConsoleTransformer(); + test('shortcut stdout string', () => { + const result = transformer.prefixChunk(ConsoleLineEnum.StdOut, 'xxxx\nxxx\n'); + expect(result.fileBuffer).toEqual('xxxx\nxxx\n'); + }); + test('empty string', () => { + const result = transformer.prefixChunk(ConsoleLineEnum.StdOut, ''); + expect(result.fileBuffer).toEqual(''); + }); +}); + + +suite('transformer: marker', () => { + test('0ms delay', () => { + const transformer = new ConsoleTransformer(); + vi.spyOn(Date, 'now').mockReturnValue(0); + const res = transformer.getTimeMarker(); + expect(res).toBe(''); + expect(transformer.lastMarkerTs).toEqual(0); + vi.restoreAllMocks(); + }); + test('250ms delay', () => { + const transformer = new ConsoleTransformer(); + vi.spyOn(Date, 'now').mockReturnValue(250); + const res = transformer.getTimeMarker(); + expect(res).toBe(''); + expect(transformer.lastMarkerTs).toEqual(0); + vi.restoreAllMocks(); + }); + test('1250ms delay', () => { + const transformer = new ConsoleTransformer(); + vi.spyOn(Date, 'now').mockReturnValue(1250); + const res = transformer.getTimeMarker(); + expect(res).toBeTruthy(); + expect(transformer.lastMarkerTs).toEqual(1); + vi.restoreAllMocks(); + }); + test('2250ms delay', () => { + const transformer = new ConsoleTransformer(); + vi.spyOn(Date, 'now').mockReturnValue(2250); + const res = transformer.getTimeMarker(); + expect(res).toBeTruthy(); + expect(transformer.lastMarkerTs).toEqual(2); + vi.restoreAllMocks(); + }); +}); + +//MARK: Transformer ingest +const jp = (arr: string[]) => arr.join(''); +const getPatchedTransformer = () => { + const t = new ConsoleTransformer(); + t.STYLES = { + [ConsoleLineEnum.StdOut]: null, + [ConsoleLineEnum.StdErr]: { web: {} }, + [ConsoleLineEnum.MarkerAdminCmd]: { web: {} }, + [ConsoleLineEnum.MarkerSystemCmd]: { web: {} }, + [ConsoleLineEnum.MarkerInfo]: { web: {} }, + }; + t.PREFIX_SYSTEM = '-'; + t.PREFIX_STDERR = '-'; + return t; +} +suite('transformer: source', () => { + suite('incomplete', () => { + test('StdOut', () => { + const transformer = getPatchedTransformer(); + transformer.lastEol = false; + transformer.process(ConsoleLineEnum.StdOut, 'x'); + expect(transformer.lastSrc).toEqual('0:undefined'); + }); + test('StdErr', () => { + const transformer = getPatchedTransformer(); + transformer.lastEol = false; + transformer.process(ConsoleLineEnum.StdErr, 'x'); + expect(transformer.lastSrc).toEqual('1:undefined'); + }); + }); + suite('no context', () => { + test('StdOut', () => { + const transformer = getPatchedTransformer(); + transformer.process(ConsoleLineEnum.StdOut, 'x'); + expect(transformer.lastSrc).toEqual('0:undefined'); + }); + test('StdErr', () => { + const transformer = getPatchedTransformer(); + transformer.process(ConsoleLineEnum.StdErr, 'x'); + expect(transformer.lastSrc).toEqual('1:undefined'); + }); + }); + suite('context', () => { + test('StdOut', () => { + const transformer = getPatchedTransformer(); + transformer.process(ConsoleLineEnum.StdOut, 'x', 'y'); + expect(transformer.lastSrc).toEqual('0:y'); + }); + test('StdErr', () => { + const transformer = getPatchedTransformer(); + transformer.process(ConsoleLineEnum.StdErr, 'x', 'y'); + expect(transformer.lastSrc).toEqual('1:y'); + }); + }); +}); +suite('transformer: shortcuts', () => { + test('empty string', () => { + const transformer = getPatchedTransformer(); + const result = transformer.process(ConsoleLineEnum.StdOut, ''); + expect(result.webBuffer).toEqual(''); + }); + test('\\n', () => { + const transformer = getPatchedTransformer(); + const result = transformer.process(ConsoleLineEnum.StdErr, '\n'); + expect(result.webBuffer).toEqual('\n'); + }); + test('\\r\\n', () => { + const transformer = getPatchedTransformer(); + const result = transformer.process(ConsoleLineEnum.StdErr, '\r\n'); + expect(result.webBuffer).toEqual('\n'); + }); +}); + +suite('transformer: new line', () => { + const expectedTimeMarker = `{§${Math.floor(Date.now() / 1000).toString(16)}}`; + + test('single line same src', () => { + const transformer = getPatchedTransformer(); + const result = transformer.process(ConsoleLineEnum.StdOut, 'test'); + expect(result.webBuffer).toEqual(jp([expectedTimeMarker, 'test'])); + expect(transformer.lastEol).toEqual(false); + }); + test('single line diff src', () => { + const transformer = getPatchedTransformer(); + const result = transformer.process(ConsoleLineEnum.StdErr, 'test'); + expect(result.webBuffer).toEqual(jp([expectedTimeMarker, '- ', 'test'])); + expect(transformer.lastEol).toEqual(false); + }); + test('multi line same src', () => { + const transformer = getPatchedTransformer(); + const result = transformer.process(ConsoleLineEnum.StdOut, 'test\ntest2'); + expect(result.webBuffer).toEqual(jp([expectedTimeMarker, 'test\ntest2'])); + expect(transformer.lastEol).toEqual(false); + }); + test('multi line diff src', () => { + const transformer = getPatchedTransformer(); + const result = transformer.process(ConsoleLineEnum.StdErr, 'test\ntest2'); + expect(result.webBuffer).toEqual(jp([expectedTimeMarker, '- ', 'test\n', '- ', 'test2'])); + expect(transformer.lastEol).toEqual(false); + }); +}); + + +suite('transformer: postfix', () => { + const expectedTimeMarker = `{§${Math.floor(Date.now() / 1000).toString(16)}}`; + + test('same source incomplete line', () => { + const transformer = getPatchedTransformer(); + transformer.lastEol = false; + const result = transformer.process(ConsoleLineEnum.StdOut, 'test'); + expect(result.webBuffer).toEqual(jp(['test'])); + expect(transformer.lastEol).toEqual(false); + }); + test('same source complete line', () => { + const transformer = getPatchedTransformer(); + transformer.lastEol = false; + const result = transformer.process(ConsoleLineEnum.StdOut, 'test\n'); + expect(result.webBuffer).toEqual(jp(['test\n'])); + expect(transformer.lastEol).toEqual(true); + }); + test('same source multi line', () => { + const transformer = getPatchedTransformer(); + transformer.lastEol = false; + const result = transformer.process(ConsoleLineEnum.StdOut, 'test\nxx\n'); + // console.dir(result); return; + expect(result.webBuffer).toEqual(jp(['test\n', expectedTimeMarker, 'xx\n'])); + expect(transformer.lastEol).toEqual(true); + }); + + test('diff source incomplete line', () => { + const transformer = getPatchedTransformer(); + transformer.lastEol = false; + const result = transformer.process(ConsoleLineEnum.StdErr, 'test'); + expect(result.webBuffer).toEqual(jp([FORCED_EOL, expectedTimeMarker, '- ', 'test'])); + expect(transformer.lastEol).toEqual(false); + }); + test('diff source complete line', () => { + const transformer = getPatchedTransformer(); + transformer.lastEol = false; + const result = transformer.process(ConsoleLineEnum.StdErr, 'test\n'); + expect(result.webBuffer).toEqual(jp([FORCED_EOL, expectedTimeMarker, '- ', 'test\n'])); + expect(transformer.lastEol).toEqual(true); + }); + test('diff source multi line', () => { + const transformer = getPatchedTransformer(); + transformer.lastEol = false; + const result = transformer.process(ConsoleLineEnum.StdErr, 'test\nabcde\n'); + expect(result.webBuffer).toEqual(jp([FORCED_EOL, expectedTimeMarker, '- ', 'test\n', '- ', 'abcde\n'])); + expect(transformer.lastEol).toEqual(true); + }); +}); diff --git a/core/modules/Logger/FXServerLogger/fxsLoggerUtils.ts b/core/modules/Logger/FXServerLogger/fxsLoggerUtils.ts new file mode 100644 index 0000000..0cbd3cb --- /dev/null +++ b/core/modules/Logger/FXServerLogger/fxsLoggerUtils.ts @@ -0,0 +1,75 @@ +/** + * Splits a string into two parts: the first line and the rest of the string. + * Returns an object indicating whether an end-of-line (EOL) character was found. + * If the string ends with a line break, `rest` is set to `undefined`. + * Supports both Unix (`\n`) and Windows (`\r\n`) line breaks. + */ +export const splitFirstLine = (str: string): SplitFirstLineResult => { + const firstEolIndex = str.indexOf('\n'); + if (firstEolIndex === -1) { + return { first: str, rest: undefined, eol: false }; + } + + const isEolCrLf = firstEolIndex > 0 && str[firstEolIndex - 1] === '\r'; + const foundEolLength = isEolCrLf ? 2 : 1; + const firstEolAtEnd = firstEolIndex === str.length - foundEolLength; + if (firstEolAtEnd) { + return { first: str, rest: undefined, eol: true }; + } + + const first = str.substring(0, firstEolIndex + foundEolLength); + const rest = str.substring(firstEolIndex + foundEolLength); + const eol = rest[rest.length - 1] === '\n'; + return { first, rest, eol }; +}; +type SplitFirstLineResult = { + first: string; + rest: string | undefined; + eol: boolean; +}; + + +/** + * Strips the last end-of-line (EOL) character from a string. + */ +export const stripLastEol = (str: string) => { + if (str.endsWith('\r\n')) { + return { + str: str.slice(0, -2), + eol: '\r\n', + } + } else if (str.endsWith('\n')) { + return { + str: str.slice(0, -1), + eol: '\n', + } + } + return { str, eol: '' }; +} + + +/** + * Adds a given prefix to each line in the input string. + * Does not add a prefix to the very last empty line, if it exists. + * Efficiently handles strings without line breaks by returning the prefixed string. + */ +export const prefixMultiline = (str: string, prefix: string): string => { + if (str.length === 0 || str === '\n') return ''; + let newlineIndex = str.indexOf('\n'); + + // If there is no newline, append the whole string and return + if (newlineIndex === -1 || newlineIndex === str.length - 1) { + return prefix + str; + } + + let result = prefix; // Start by prefixing the first line + let start = 0; + while (newlineIndex !== -1 && newlineIndex !== str.length - 1) { + result += str.substring(start, newlineIndex + 1) + prefix; + start = newlineIndex + 1; + newlineIndex = str.indexOf('\n', start); + } + + // Append the remaining part of the string after the last newline + return result + str.substring(start); +}; diff --git a/core/modules/Logger/FXServerLogger/index.ts b/core/modules/Logger/FXServerLogger/index.ts new file mode 100644 index 0000000..eeed9b9 --- /dev/null +++ b/core/modules/Logger/FXServerLogger/index.ts @@ -0,0 +1,178 @@ +const modulename = 'Logger:FXServer'; +import bytes from 'bytes'; +import type { Options as RfsOptions } from 'rotating-file-stream'; +import { getLogDivider } from '../loggerUtils.js'; +import consoleFactory, { processStdioWriteRaw } from '@lib/console.js'; +import { LoggerBase } from '../LoggerBase.js'; +import ConsoleTransformer from './ConsoleTransformer.js'; +import ConsoleLineEnum from './ConsoleLineEnum.js'; +import { txHostConfig } from '@core/globalData.js'; +const console = consoleFactory(modulename); + + +//This regex was done in the first place to prevent fxserver output to be interpreted as txAdmin output by the host terminal +//IIRC the issue was that one user with a TM on their nick was making txAdmin's console to close or freeze. I couldn't reproduce the issue. +// \x00-\x08 Control characters in the ASCII table. +// allow \r and \t +// \x0B-\x1A Vertical tab and control characters from shift out to substitute. +// allow \x1B (escape for colors n stuff) +// \x1C-\x1F Control characters (file separator, group separator, record separator, unit separator). +// allow all printable +// \x7F Delete character. +const regexControls = /[\x00-\x08\x0B-\x1A\x1C-\x1F\x7F]|(?:\x1B\[|\x9B)[\d;]+[@-K]/g; +const regexColors = /\x1B[^m]*?m/g; + + +export default class FXServerLogger extends LoggerBase { + private readonly transformer = new ConsoleTransformer(); + private fileBuffer = ''; + private recentBuffer = ''; + private readonly recentBufferMaxSize = 256 * 1024; //kb + private readonly recentBufferTrimSliceSize = 32 * 1024; //how much will be cut when overflows + + constructor(basePath: string, lrProfileConfig: RfsOptions | false) { + const lrDefaultOptions = { + path: basePath, + intervalBoundary: true, + initialRotation: true, + history: 'fxserver.history', + // compress: 'gzip', + interval: '1d', + maxFiles: 7, + maxSize: '5G', + }; + super(basePath, 'fxserver', lrDefaultOptions, lrProfileConfig); + + setInterval(() => { + this.flushFileBuffer(); + }, 5000); + } + + + /** + * Returns a string with short usage stats + */ + getUsageStats() { + return `Buffer: ${bytes(this.recentBuffer.length)}, lrErrors: ${this.lrErrors}`; + } + + + /** + * Returns the recent fxserver buffer containing HTML markers, and not XSS escaped. + * The size of this buffer is usually above 64kb, never above 128kb. + */ + getRecentBuffer() { + return this.recentBuffer; + } + + + /** + * Strips color of the file buffer and flushes it. + * FIXME: this will still allow colors to be written to the file if the buffer cuts + * in the middle of a color sequence, but less often since we are buffering more data. + */ + flushFileBuffer() { + this.lrStream.write(this.fileBuffer.replace(regexColors, '')); + this.fileBuffer = ''; + } + + + /** + * Receives the assembled console blocks, stringifies, marks, colors them and dispatches it to + * lrStream, websocket, and process stdout. + */ + private ingest(type: ConsoleLineEnum, data: string, context?: string) { + //Process the data + const { webBuffer, stdoutBuffer, fileBuffer } = this.transformer.process(type, data, context); + + //To file + this.fileBuffer += fileBuffer; + + //For the terminal + if (!txConfig.server.quiet && !txHostConfig.forceQuietMode) { + processStdioWriteRaw(stdoutBuffer); + } + + //For the live console + txCore.webServer.webSocket.buffer('liveconsole', webBuffer); + this.appendRecent(webBuffer); + } + + + /** + * Writes to the log an informational message + */ + public logInformational(msg: string) { + this.ingest(ConsoleLineEnum.MarkerInfo, msg + '\n'); + } + + + /** + * Writes to the log that the server is booting + */ + public logFxserverSpawn(pid: string) { + //force line skip to create separation + if (this.recentBuffer.length) { + const lineBreak = this.transformer.lastEol ? '\n' : '\n\n'; + this.ingest(ConsoleLineEnum.MarkerInfo, lineBreak); + } + //need to break line + const multiline = getLogDivider(`[${pid}] FXServer Starting`); + for (const line of multiline.split('\n')) { + if (!line.length) break; + this.ingest(ConsoleLineEnum.MarkerInfo, line + '\n'); + } + } + + + /** + * Writes to the log an admin command + */ + public logAdminCommand(author: string, cmd: string) { + this.ingest(ConsoleLineEnum.MarkerAdminCmd, cmd + '\n', author); + } + + + /** + * Writes to the log a system command. + */ + public logSystemCommand(cmd: string) { + if(cmd.startsWith('txaEvent "consoleCommand"')) return; + // if (/^txaEvent \w+ /.test(cmd)) { + // const [event, payload] = cmd.substring(9).split(' ', 2); + // cmd = chalk.italic(``); + // } + this.ingest(ConsoleLineEnum.MarkerSystemCmd, cmd + '\n'); + } + + + /** + * Handles all stdio data. + */ + public writeFxsOutput( + source: ConsoleLineEnum.StdOut | ConsoleLineEnum.StdErr, + data: string | Buffer + ) { + if (typeof data !== 'string') { + data = data.toString(); + } + this.ingest(source, data.replace(regexControls, '')); + } + + + /** + * Appends data to the recent buffer and recycles it when necessary + */ + private appendRecent(data: string) { + this.recentBuffer += data; + if (this.recentBuffer.length > this.recentBufferMaxSize) { + this.recentBuffer = this.recentBuffer.slice(this.recentBufferTrimSliceSize - this.recentBufferMaxSize); + this.recentBuffer = this.recentBuffer.substring(this.recentBuffer.indexOf('\n')); + //FIXME: precisa encontrar o próximo tsMarker ao invés de \n + //usar String.prototype.search() com regex + + //FIXME: salvar em 8 blocos de 32kb + // quando atingir 32, quebrar no primeiro tsMarker + } + } +}; diff --git a/core/modules/Logger/LoggerBase.ts b/core/modules/Logger/LoggerBase.ts new file mode 100644 index 0000000..b70f100 --- /dev/null +++ b/core/modules/Logger/LoggerBase.ts @@ -0,0 +1,79 @@ +const modulename = 'Logger:Base'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as rfs from 'rotating-file-stream'; +import { cloneDeep, defaultsDeep } from 'lodash-es'; +import consoleFactory from '@lib/console'; +import { getLogSizes, getLogDivider } from './loggerUtils'; +import { getTimeFilename } from '@lib/misc'; +const console = consoleFactory(modulename); + + +/** + * Default class for logger instances. + * Implements log-rotate, listLogFiles() and getLogFile() + */ +export class LoggerBase { + lrStream: rfs.RotatingFileStream; + lrErrors = 0; + public activeFilePath: string; + private lrLastError: string | undefined; + private basePath: string; + private logNameRegex: RegExp; + + constructor( + basePath: string, + logName: string, + lrDefaultOptions: rfs.Options, + lrProfileConfig: rfs.Options | false = false + ) { + //Sanity check + if (!basePath || !logName) throw new Error('Missing constructor parameters'); + this.basePath = basePath; + this.activeFilePath = path.join(basePath, `${logName}.log`); + this.logNameRegex = new RegExp(`^${logName}(_\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}(_\\d+)?)?.log$`); + + //If disabled + if (lrProfileConfig === false) { + console.warn('persistent logging disabled for:', logName); + this.lrStream = { + write: () => { }, + } as any as rfs.RotatingFileStream; + return; + } + + //Setting log rotate up + const lrOptions = cloneDeep(lrProfileConfig); + if (typeof lrProfileConfig === 'object') { + defaultsDeep(lrOptions, lrDefaultOptions); + } + + const filenameGenerator: rfs.Generator = (time, index) => { + return time + ? `${logName}_${getTimeFilename(time)}_${index}.log` + : `${logName}.log`; + }; + + this.lrStream = rfs.createStream(filenameGenerator, lrOptions); + this.lrStream.on('rotated', (filename) => { + this.lrStream.write(getLogDivider('Log Rotated')); + console.verbose.log(`Rotated file ${filename}`); + }); + this.lrStream.on('error', (error) => { + if ((error as any).code !== 'ERR_STREAM_DESTROYED') { + console.verbose.error(error, logName); + this.lrErrors++; + this.lrLastError = error.message; + } + }); + } + + listLogFiles() { + return getLogSizes(this.basePath, this.logNameRegex); + } + + getLogFile(fileName: string) { + if (!this.logNameRegex.test(fileName)) throw new Error('fileName doesn\'t match regex'); + return fs.createReadStream(path.join(this.basePath, fileName)); + } +}; diff --git a/core/modules/Logger/handlers/admin.js b/core/modules/Logger/handlers/admin.js new file mode 100644 index 0000000..0c52975 --- /dev/null +++ b/core/modules/Logger/handlers/admin.js @@ -0,0 +1,77 @@ +const modulename = 'Logger:Admin'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { getBootDivider } from '../loggerUtils'; +import consoleFactory from '@lib/console'; +import { LoggerBase } from '../LoggerBase'; +import { chalkInversePad, getTimeHms } from '@lib/misc'; +const console = consoleFactory(modulename); + + +export default class AdminLogger extends LoggerBase { + constructor(basePath, lrProfileConfig) { + const lrDefaultOptions = { + path: basePath, + intervalBoundary: true, + initialRotation: true, + history: 'admin.history', + interval: '7d', + + }; + super(basePath, 'admin', lrDefaultOptions, lrProfileConfig); + this.lrStream.write(getBootDivider()); + + this.writeCounter = 0; + } + + /** + * Returns a string with short usage stats + */ + getUsageStats() { + return `Writes: ${this.writeCounter}, lrErrors: ${this.lrErrors}`; + } + + /** + * Returns an string with everything in admin.log (the active log rotate file) + */ + async getRecentBuffer() { + try { + return await fsp.readFile(path.join(this.basePath, 'admin.log'), 'utf8'); + } catch (error) { + return false; + } + } + + /** + * Handles the input of log data + * + * @param {string} author + * @param {string} message + */ + writeSystem(author, message) { + const timestamp = getTimeHms(); + this.lrStream.write(`[${timestamp}][${author}] ${message}\n`); + this.writeCounter++; + } + + + /** + * Handles the input of log data + * TODO: add here discord log forwarding + * + * @param {string} author + * @param {string} action + * @param {'default'|'command'} type + */ + write(author, action, type = 'default') { + let saveMsg; + if (type === 'command') { + saveMsg = `[${author}] executed "${action}"`; + console.log(`${author} executed ` + chalkInversePad(action)); + } else { + saveMsg = action; + console.log(saveMsg); + } + this.writeSystem(author, saveMsg); + } +}; diff --git a/core/modules/Logger/handlers/server.js b/core/modules/Logger/handlers/server.js new file mode 100644 index 0000000..2d29b1c --- /dev/null +++ b/core/modules/Logger/handlers/server.js @@ -0,0 +1,308 @@ +/* eslint-disable padded-blocks */ +const modulename = 'Logger:Server'; +import { QuantileArray, estimateArrayJsonSize } from '@modules/Metrics/statsUtils'; +import { LoggerBase } from '../LoggerBase'; +import { getBootDivider } from '../loggerUtils'; +import consoleFactory from '@lib/console'; +import bytes from 'bytes'; +import { summarizeIdsArray } from '@lib/player/idUtils'; +const console = consoleFactory(modulename); + +/* +NOTE: Expected time cap based on log size cap to prevent memory leak +Big server: 300 events/min (freeroam/dm with 100+ players) +Medium servers: 30 events/min (rp with up to 64 players) + +64k cap: 3.5h big, 35.5h medium, 24mb, 620ms/1000 seek time +32k cap: 1.7h big, 17.7h medium, 12mb, 307ms/1000 seek time +16k cap: 0.9h big, 9h medium, 6mb, 150ms/1000 seek time + +> Seek time based on getting 500 items older than cap - 1000 (so near the end of the array) run 1k times +> Memory calculated with process.memoryUsage().heapTotal considering every event about 300 bytes + +NOTE: Although we could comfortably do 64k cap, even if showing 500 lines per page, nobody would +navigate through 128 pages, so let's do 16k cap since there is not even a way for the admin to skip +pages since it's all relative (older/newer) just like github's tags/releases page. + +NOTE: Final code after 2.5h at 2400 events/min with websocket client the memory usage was 135mb + + +TODO: maybe a way to let big servers filter what is logged or not? That would be an export in fxs, +before sending it to fd3 +*/ + +//DEBUG testing stuff +// let cnt = 0; +// setInterval(() => { +// cnt++; +// if (cnt > 84) cnt = 1; +// const mtx = txCore.fxRunner.child?.mutex ?? 'UNKNW'; +// const payload = [ +// { +// src: 'tx', +// ts: Date.now(), +// type: 'DebugMessage', +// data: cnt + '='.repeat(cnt), +// }, +// ]; +// txCore.logger.server.write(mtx, payload); +// }, 750); + + +export default class ServerLogger extends LoggerBase { + constructor(basePath, lrProfileConfig) { + const lrDefaultOptions = { + path: basePath, + intervalBoundary: true, + initialRotation: true, + history: 'server.history', + // compress: 'gzip', + interval: '1d', + maxFiles: 7, + maxSize: '10G', + + }; + super(basePath, 'server', lrDefaultOptions, lrProfileConfig); + this.lrStream.write(getBootDivider()); + + this.recentBuffer = []; + this.recentBufferMaxSize = 32e3; + + //stats stuff + this.eventsPerMinute = new QuantileArray(24 * 60, 6 * 60); //max 1d, min 6h + this.eventsThisMinute = 0; + setInterval(() => { + this.eventsPerMinute.count(this.eventsThisMinute); + this.eventsThisMinute = 0; + }, 60_000); + } + + + /** + * Returns a string with short usage stats + */ + getUsageStats() { + // Get events/min + const eventsPerMinRes = this.eventsPerMinute.resultSummary(); + const eventsPerMinStr = eventsPerMinRes.enoughData + ? eventsPerMinRes.summary + : 'LowCount'; + + //Buffer JSON size (8k min buffer, 1k samples) + const bufferJsonSizeRes = estimateArrayJsonSize(this.recentBuffer, 4e3); + const bufferJsonSizeStr = bufferJsonSizeRes.enoughData + ? `${bytes(bufferJsonSizeRes.bytesPerElement)}/e` + : 'LowCount'; + + return `Buffer: ${this.recentBuffer.length}, lrErrors: ${this.lrErrors}, mem: ${bufferJsonSizeStr}, rate: ${eventsPerMinStr}`; + } + + + /*** + * Returns the recent fxserver buffer containing HTML markers, and not XSS escaped. + * The size of this buffer is usually above 64kb, never above 128kb. + * @param {Number} lastN + * @returns the recent buffer, optionally only the last N elements + */ + getRecentBuffer(lastN) { + return (lastN) ? this.recentBuffer.slice(-lastN) : this.recentBuffer; + } + + + /** + * Processes the FD3 log array + * @param {Object[]} data + * @param {string} [mutex] + */ + write(data, mutex) { + if (!Array.isArray(data)) { + console.verbose.warn(`write() expected array, got ${typeof data}`); + return false; + } + mutex ??= txCore.fxRunner.child.mutex ?? 'UNKNW'; + + //Processing events + for (let i = 0; i < data.length; i++) { + try { + const { eventObject, eventString } = this.processEvent(data[i], mutex); + if (!eventObject || !eventString) { + console.verbose.warn('Failed to parse event:'); + console.verbose.dir(data[i]); + continue; + } + + //Add to recent buffer + this.eventsThisMinute++; + this.recentBuffer.push(eventObject); + if (this.recentBuffer.length > this.recentBufferMaxSize) this.recentBuffer.shift(); + + //Send to websocket + txCore.webServer.webSocket.buffer('serverlog', eventObject); + + //Write to file + this.lrStream.write(`${eventString}\n`); + } catch (error) { + console.verbose.error('Error processing FD3 txAdminLogData:'); + console.verbose.dir(error); + } + } + } + + + /** + * Processes an event and returns both the string for log file, and object for the web ui + * @param {Object} eventData + * @param {String} mutex + */ + processEvent(eventData, mutex) { + //Get source + let srcObject; //to be sent to the UI + let srcString; //to ve saved to the log file + if (eventData.src === 'tx') { + srcObject = { id: false, name: 'txAdmin' }; + srcString = 'txAdmin'; + + } else if (typeof eventData.src === 'number' && eventData.src > 0) { + const player = txCore.fxPlayerlist.getPlayerById(eventData.src); + if (player) { + //FIXME: playermutex must be a ServerPlayer prop, already considering mutex, netid and rollover + const playerID = `${mutex}#${eventData.src}`; + srcObject = { id: playerID, name: player.displayName }; + srcString = `[${playerID}] ${player.displayName}`; + } else { + srcObject = { id: false, name: 'UNKNOWN PLAYER' }; + srcString = 'UNKNOWN PLAYER'; + console.verbose.warn('Unknown numeric event source from object:'); + console.verbose.dir(eventData); + } + } else { + srcObject = { id: false, name: 'UNKNOWN' }; + srcString = 'UNKNOWN'; + } + + //Process event types + //TODO: normalize/padronize actions + let eventMessage; //to be sent to the UI + saved to the log + if (eventData.type === 'playerJoining') { + const idsString = summarizeIdsArray(eventData?.data?.ids); + eventMessage = `joined with identifiers ${idsString}`; + + } else if (eventData.type === 'playerDropped') { + const reason = eventData.data.reason || 'UNKNOWN REASON'; + eventMessage = `disconnected (${reason})`; + + } else if (eventData.type === 'playerJoinDenied') { + const reason = eventData.data.reason ?? 'UNKNOWN REASON'; + eventMessage = `player join denied due to ${reason}`; + + } else if (eventData.type === 'ChatMessage') { + const text = (typeof eventData.data.text === 'string') ? eventData.data.text.replace(/\^([0-9])/g, '') : 'unknown message'; + eventMessage = (typeof eventData.data.author === 'string' && eventData.data.author !== srcObject.name) + ? `(${eventData.data.author}): said "${text}"` + : `said "${text}"`; + + } else if (eventData.type === 'DeathNotice') { + const cause = eventData.data.cause || 'unknown'; + if (typeof eventData.data.killer === 'number' && eventData.data.killer > 0) { + const killer = txCore.fxPlayerlist.getPlayerById(eventData.data.killer); + if (killer) { + eventMessage = `died from ${cause} by ${killer.displayName}`; + } else { + eventMessage = `died from ${cause} by unknown killer`; + } + } else { + eventMessage = `died from ${cause}`; + } + + } else if (eventData.type === 'explosionEvent') { + const expType = eventData.data.explosionType || 'UNKNOWN'; + eventMessage = `caused an explosion (${expType})`; + + } else if (eventData.type === 'CommandExecuted') { + const command = eventData.data || 'unknown'; + eventMessage = `executed: /${command}`; + + } else if (eventData.type === 'LoggerStarted') { + eventMessage = 'Logger started'; + txCore.metrics.playerDrop.handleServerBootData(eventData.data); + if (typeof eventData.data?.gameName === 'string' && eventData.data.gameName.length) { + if(eventData.data.gameName === 'gta5'){ + txCore.cacheStore.set('fxsRuntime:gameName', 'fivem'); + } else if (eventData.data.gameName === 'rdr3') { + txCore.cacheStore.set('fxsRuntime:gameName', 'redm'); + } else { + txCore.cacheStore.delete('fxsRuntime:gameName'); + } + } + + } else if (eventData.type === 'DebugMessage') { + eventMessage = (typeof eventData.data === 'string') + ? `Debug Message: ${eventData.data}` + : 'Debug Message: unknown'; + + } else if (eventData.type === 'MenuEvent') { + txCore.metrics.txRuntime.menuCommands.count(eventData.data?.action ?? 'unknown'); + eventMessage = (typeof eventData.data.message === 'string') + ? `${eventData.data.message}` + : 'did unknown action'; + + } else { + console.verbose.warn(`Unrecognized event: ${eventData.type}`); + console.verbose.dir(eventData); + eventMessage = eventData.type; + } + + + //Prepare output + const localeTime = new Date(eventData.ts).toLocaleTimeString(); + eventMessage = eventMessage.replace(/\n/g, '\t'); //Just to make sure no event is injecting line breaks + return { + eventObject: { + ts: eventData.ts, + type: eventData.type, + src: srcObject, + msg: eventMessage, + }, + eventString: `[${localeTime}] ${srcString}: ${eventMessage}`, + }; + } + + + /** + * Returns a slice of the recent buffer OLDER than a reference timestamp. + * @param {Number} timestamp + * @param {Number} sliceLength + */ + readPartialNewer(timestamp, sliceLength) { + //FIXME: use d3 bissect to optimize this + const limitIndex = this.recentBuffer.findIndex((x) => x.ts > timestamp); + return this.recentBuffer.slice(limitIndex, limitIndex + sliceLength); + } + + + /** + * Returns a slice of the recent buffer NEWER than a reference timestamp. + * @param {Number} timestamp + * @param {Number} sliceLength + */ + readPartialOlder(timestamp, sliceLength) { + //FIXME: use d3 bissect to optimize this + const limitIndex = this.recentBuffer.findIndex((x) => x.ts >= timestamp); + + if (limitIndex === -1) { + //everything is older, return last few + return this.recentBuffer.slice(-sliceLength); + } else { + //not everything is older + return this.recentBuffer.slice(Math.max(0, limitIndex - sliceLength), limitIndex); + } + } + + + /** + * TODO: filter function, so we can search for all log from a specific player + */ + readFiltered() { + throw new Error('Not yet implemented.'); + } +}; diff --git a/core/modules/Logger/index.ts b/core/modules/Logger/index.ts new file mode 100644 index 0000000..2412e71 --- /dev/null +++ b/core/modules/Logger/index.ts @@ -0,0 +1,47 @@ +const modulename = 'Logger'; +import type { Options as RfsOptions } from 'rotating-file-stream'; +import AdminLogger from './handlers/admin'; +import FXServerLogger from './FXServerLogger'; +import ServerLogger from './handlers/server'; +import { getLogSizes } from './loggerUtils.js'; +import consoleFactory from '@lib/console'; +import { txEnv } from '@core/globalData'; +const console = consoleFactory(modulename); + + +/** + * Logger module that holds the scope-specific loggers and provides some utility functions. + */ +export default class Logger { + private readonly basePath = `${txEnv.profilePath}/logs/`; + public readonly admin: AdminLogger; + public readonly fxserver: FXServerLogger; + public readonly server: ServerLogger; + + constructor() { + this.admin = new AdminLogger(this.basePath, txConfig.logger.admin); + this.fxserver = new FXServerLogger(this.basePath, txConfig.logger.fxserver); + this.server = new ServerLogger(this.basePath, txConfig.logger.server); + } + + + /** + * Returns the total size of the log files used. + */ + getUsageStats() { + //{loggerName: statsString} + throw new Error('Not yet implemented.'); + } + + + /** + * Return the total size of the log files used. + * FIXME: this regex is kinda redundant with the one from loggerUtils.js + */ + async getStorageSize() { + return await getLogSizes( + this.basePath, + /^(admin|fxserver|server)(_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(_\d+)?)?.log$/, + ); + } +}; diff --git a/core/modules/Logger/loggerUtils.ts b/core/modules/Logger/loggerUtils.ts new file mode 100644 index 0000000..a0c399a --- /dev/null +++ b/core/modules/Logger/loggerUtils.ts @@ -0,0 +1,59 @@ +import fsp from 'node:fs/promises'; +import path, { sep } from 'node:path'; +import bytes from 'bytes'; +import { txEnv } from '@core/globalData'; + + +/** + * Returns an associative array of files in the log folder and it's sizes (human readable) + */ +export const getLogSizes = async (basePath: string, filterRegex: RegExp) => { + //Reading path + const files = await fsp.readdir(basePath, { withFileTypes: true }); + const statOps = []; + const statNames: string[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const filePath = path.join(basePath, file.name); + if (!filterRegex.test(file.name) || !file.isFile()) continue; + statNames.push(file.name); + statOps.push(fsp.stat(filePath)); + } + + //Processing files + let totalBytes = 0; + const fileStatsSizes = await Promise.allSettled(statOps); + const fileStatsArray = fileStatsSizes.map((op, index) => { + if (op.status === 'fulfilled') { + totalBytes += op.value.size; + return [statNames[index], bytes(op.value.size)]; + } else { + return [statNames[index], false]; + } + }); + return { + total: bytes(totalBytes), + files: Object.fromEntries(fileStatsArray), + }; +}; + + +/** + * Generates a multiline separator string with 1 line padding + */ +export const getLogDivider = (msg: string) => { + const sepLine = '='.repeat(64); + const timestamp = new Date().toLocaleString(); + let out = sepLine + '\n'; + out += `======== ${msg} - ${timestamp}`.padEnd(64, ' ') + '\n'; + out += sepLine + '\n'; + return out; +}; + + +/** + * Returns the boot divider, with the txAdmin and FXServer versions + */ +export const getBootDivider = () => { + return getLogDivider(`txAdmin v${txEnv.txaVersion} atop fxserver ${txEnv.fxsVersion} Starting`); +} diff --git a/core/modules/Metrics/index.ts b/core/modules/Metrics/index.ts new file mode 100644 index 0000000..d586051 --- /dev/null +++ b/core/modules/Metrics/index.ts @@ -0,0 +1,55 @@ +const modulename = 'Metrics'; +import consoleFactory from '@lib/console'; +import SvRuntimeMetrics from './svRuntime'; +import TxRuntimeMetrics from './txRuntime'; +import PlayerDropMetrics from './playerDrop'; +import type { UpdateConfigKeySet } from '@modules/ConfigStore/utils'; +const console = consoleFactory(modulename); + + +/** + * Module responsible to collect statistics and data. + * It is broken down into sub-modules for each specific area. + */ +export default class Metrics { + static readonly configKeysWatched = [ + 'server.dataPath', + 'server.cfgPath', + 'whitelist.mode', + ]; + + public readonly svRuntime: SvRuntimeMetrics; + public readonly txRuntime: TxRuntimeMetrics; + public readonly playerDrop: PlayerDropMetrics; + + constructor() { + this.svRuntime = new SvRuntimeMetrics(); + this.txRuntime = new TxRuntimeMetrics(); + this.playerDrop = new PlayerDropMetrics(); + } + + /** + * Handle updates to the config by resetting the required metrics + */ + public handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) { + //TxRuntime + if(updatedConfigs.hasMatch('whitelist.mode')){ + txCore.metrics.txRuntime.whitelistCheckTime.clear(); + } + + //PlayerDrop + const hasServerDataChanged = updatedConfigs.hasMatch('server.dataPath'); + const hasServerCfgChanged = updatedConfigs.hasMatch('server.cfgPath'); + let pdlResetMsgPart: string | undefined; + if(hasServerDataChanged && hasServerCfgChanged){ + pdlResetMsgPart = 'Data Path and CFG Path'; + } else if (hasServerDataChanged){ + pdlResetMsgPart = 'Data Path'; + } else if (hasServerCfgChanged){ + pdlResetMsgPart = 'CFG Path'; + } + if (pdlResetMsgPart) { + this.playerDrop.resetLog(`Server ${pdlResetMsgPart} changed.`); + } + } +}; diff --git a/core/modules/Metrics/playerDrop/classifyDropReason.test.ts b/core/modules/Metrics/playerDrop/classifyDropReason.test.ts new file mode 100644 index 0000000..bb43cba --- /dev/null +++ b/core/modules/Metrics/playerDrop/classifyDropReason.test.ts @@ -0,0 +1,181 @@ +//@ts-nocheck +import { expect, it, suite } from 'vitest'; +import { classifyDrop } from './classifyDropReason'; +import { PDL_CRASH_REASON_CHAR_LIMIT, PDL_UNKNOWN_REASON_CHAR_LIMIT } from './config'; + + +const playerInitiatedExamples = [ + `Exiting`, + `Quit: safasdfsadfasfd`, + `Entering Rockstar Editor`, + `Could not find requested level (%s) - loaded the default level instead.`, + `Reloading game.`, + `Reconnecting`, + `Connecting to another server.`, + `Disconnected.`, +]; +const serverInitiatedExamples = [ + `Disconnected by server: %s`, + `Server shutting down: %s`, + `[txAdmin] Server restarting (scheduled restart at 03:00).`, //not so sure about this +]; +const timeoutExamples = [ + `Server->client connection timed out. Pending commands: %d.\nCommand list:\n%s`, + `Server->client connection timed out. Last seen %d msec ago.`, + `Connection timed out.`, + `Timed out after 60 seconds (1, %d)`, + `Timed out after 60 seconds (2)`, +]; +const securityExamples = [ + `Unreliable network event overflow.`, + `Reliable server command overflow.`, + `Reliable network event overflow.`, + `Reliable network event size overflow: %s`, + `Reliable state bag packet overflow.`, + `Connection to CNL timed out.`, + `Server Command Overflow`, + `Invalid Client configuration. Restart your Game and reconnect.`, +] +const crashExamples = [ + `Game crashed: Recursive error: An exception occurred (c0000005 at 0x7ff6bb17f1c9) during loading of resources:/cars/data/[limiteds]/xmas 4/carvariations.meta in data file mounter 0x141a22350. The game will be terminated.`, + `O jogo crashou: %s`, +]; +const crashExceptionExamples = [ + `Game crashed: Unhandled exception: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, + `Game crashed: Exceção não tratada: %s`, +]; + + +suite('classifyDrop legacy mode', () => { + const fnc = (reason: string) => classifyDrop({ + type: 'txAdminPlayerlistEvent', + event: 'playerDropped', + id: 0, + reason, + }); + it('should handle invalid reasons', () => { + expect(fnc(undefined as any)).toEqual({ + category: 'unknown', + cleanReason: '[tx:invalid-reason]', + }); + expect(fnc('')).toEqual({ + category: 'unknown', + cleanReason: '[tx:empty-reason]', + }); + expect(fnc(' ')).toEqual({ + category: 'unknown', + cleanReason: '[tx:empty-reason]', + }); + }); + it('should classify player-initiated reasons', () => { + for (const reason of playerInitiatedExamples) { + expect(fnc(reason).category).toBe('player'); + } + }); + it('should classify server-initiated reasons', () => { + for (const reason of serverInitiatedExamples) { + expect(fnc(reason).category).toBe(false); + } + }); + it('should classify timeout reasons', () => { + for (const reason of timeoutExamples) { + expect(fnc(reason).category).toBe('timeout'); + } + }); + it('should classify security reasons', () => { + for (const reason of securityExamples) { + expect(fnc(reason).category).toBe('security'); + } + }); + it('should classify crash reasons', () => { + for (const reason of [...crashExamples, ...crashExceptionExamples]) { + expect(fnc(reason).category).toBe('crash'); + } + }); + it('should translate crash exceptions', () => { + for (const reason of [...crashExceptionExamples]) { + const resp = fnc(reason); + expect(resp.cleanReason).toBeTypeOf('string'); + expect(resp.cleanReason).toSatisfy((x: string) => { + return x.startsWith('Unhandled exception: ') + }); + } + }); + it('should handle long crash reasons', () => { + const resp = fnc(crashExamples[0] + 'a'.repeat(1000)); + expect(resp.cleanReason).toBeTypeOf('string'); + expect(resp.cleanReason!.length).toBe(PDL_CRASH_REASON_CHAR_LIMIT); + }); + it('should handle unknown reasons', () => { + const resp = fnc('a'.repeat(1000)); + expect(resp.cleanReason).toBeTypeOf('string'); + expect(resp.cleanReason!.length).toBe(PDL_UNKNOWN_REASON_CHAR_LIMIT); + expect(resp.category).toBe('unknown'); + }); +}); + + +suite('classifyDrop new mode', () => { + const fnc = (reason: string, resource: string, category: number) => classifyDrop({ + type: 'txAdminPlayerlistEvent', + event: 'playerDropped', + id: 0, + reason, + category, + resource, + }); + it('should handle invalid categories', () => { + expect(fnc('rsn', 'res', null)).toEqual({ + category: 'unknown', + cleanReason: '[tx:invalid-category] rsn', + }); + expect(fnc('rsn', 'res', -1)).toEqual({ + category: 'unknown', + cleanReason: '[tx:invalid-category] rsn', + }); + expect(fnc('rsn', 'res', 999)).toEqual({ + category: 'unknown', + cleanReason: '[tx:unknown-category] rsn', + }); + }); + + it('should handle the resource category', () => { + expect(fnc('rsn', 'res', 1)).toEqual({ + category: 'resource', + resource: 'res', + }); + expect(fnc('rsn', '', 1)).toEqual({ + category: 'resource', + resource: 'unknown', + }); + expect(fnc('rsn', 'monitor', 1)).toEqual({ + category: 'resource', + resource: 'txAdmin', + }); + expect(fnc('server_shutting_down', 'monitor', 1)).toEqual({ + category: false, + }); + }); + it('should handle the client category', () => { + expect(fnc('rsn', 'res', 2)).toEqual({ + category: 'player', + }); + expect(fnc(crashExamples[0], 'res', 2).category).toEqual('crash'); + }); + it('should handle the timeout category', () => { + expect(fnc('rsn', 'res', 5)).toEqual({category: 'timeout'}); + expect(fnc('rsn', 'res', 6)).toEqual({category: 'timeout'}); + expect(fnc('rsn', 'res', 12)).toEqual({category: 'timeout'}); + }); + it('should handle the security category', () => { + expect(fnc('rsn', 'res', 3)).toEqual({category: 'security'}); + expect(fnc('rsn', 'res', 4)).toEqual({category: 'security'}); + expect(fnc('rsn', 'res', 8)).toEqual({category: 'security'}); + expect(fnc('rsn', 'res', 9)).toEqual({category: 'security'}); + expect(fnc('rsn', 'res', 10)).toEqual({category: 'security'}); + expect(fnc('rsn', 'res', 11)).toEqual({category: 'security'}); + }); + it('should handle the shutdown category', () => { + expect(fnc('rsn', 'res', 7)).toEqual({category: false}); + }); +}); diff --git a/core/modules/Metrics/playerDrop/classifyDropReason.ts b/core/modules/Metrics/playerDrop/classifyDropReason.ts new file mode 100644 index 0000000..5c2a6da --- /dev/null +++ b/core/modules/Metrics/playerDrop/classifyDropReason.ts @@ -0,0 +1,296 @@ +import { PlayerDropEvent } from "@modules/FxPlayerlist"; +import { PDL_CRASH_REASON_CHAR_LIMIT, PDL_UNKNOWN_REASON_CHAR_LIMIT } from "./config"; + +const playerInitiatedRules = [ + `exiting`,//basically only see this one + `disconnected.`, //need to keep the dot + `connecting to another server`, + `could not find requested level`, + `entering rockstar editor`, + `quit:`, + `reconnecting`, + `reloading game`, +]; + +//FIXME: remover essa categoria, checar com XXXX se os security podem vir como security ao invés de server-initiated +const serverInitiatedRules = [ + //NOTE: this is not a real drop reason prefix, but something that the client sees only + `disconnected by server:`, + + //NOTE: Happens only when doing "quit xxxxxx" in live console + `server shutting down:`, + + //NOTE: Happens when txAdmin players - but soon specific player kicks (instead of kick all) + // will not fall under this category anymore + `[txadmin]`, +]; +const timeoutRules = [ + `server->client connection timed out`, //basically only see this one - FIXME: falta espaço? + `connection timed out`, + `timed out after 60 seconds`, //onesync timeout +]; +const securityRules = [ + `reliable network event overflow`, + `reliable network event size overflow:`, + `reliable server command overflow`, + `reliable state bag packet overflow`, + `unreliable network event overflow`, + `connection to cnl timed out`, + `server command overflow`, + `invalid client configuration. restart your game and reconnect`, +]; + +//The crash message prefix is translated, so we need to lookup the translations. +//This list covers the top 20 languages used in FiveM, with exceptions languages with no translations. +const crashRulesIntl = [ + // (en) English - 18.46% + `game crashed: `, + // (pt) Portuguese - 15.67% + `o jogo crashou: `, + // (fr) French - 9.58% + `le jeu a cessé de fonctionner : `, + // (de) German - 9.15% + `spielabsturz: `, + // (es) Spanish - 6.08% + `el juego crasheó: `, + // (ar) Arabic - 6.04% + `تعطلت العبة: `, + // (nl) Dutch - 2.46% + `spel werkt niet meer: `, + // (tr) Turkish - 1.37% + `oyun çöktü: `, + // (hu) Hungarian - 1.31% + `a játék összeomlott: `, + // (it) Italian - 1.20% + `il gioco ha smesso di funzionare: `, + // (zh) Chinese - 1.02% + `游戏发生崩溃:`, + `遊戲已崩潰: `, + // (cs) Czech - 0.92% + `pád hry: `, + // (sv) Swedish - 0.69% + `spelet kraschade: `, +]; +const exceptionPrefixesIntl = [ + // (en) English - 18.46% + `unhandled exception: `, + // (pt) Portuguese - 15.67% + `exceção não tratada: `, + // (fr) French - 9.58% + `exception non-gérée : `, + // (de) German - 9.15% + `unbehandelte ausnahme: `, + // (es) Spanish - 6.08% + `excepción no manejada: `, + // (ar) Arabic - 6.04% + `استثناء غير معالج: `, + // (nl) Dutch - 2.46% + // NOTE: Dutch doesn't have a translation for "unhandled exception" + // (tr) Turkish - 1.37% + `i̇şlenemeyen özel durum: `, + // (hu) Hungarian - 1.31% + `nem kezelt kivétel: `, + // (it) Italian - 1.20% + `eccezione non gestita: `, + // (zh) Chinese - 1.02% + `未处理的异常:`, + `未處理的異常: `, + // (cs) Czech - 0.92% + `neošetřená výjimka: `, + // (sv) Swedish - 0.69% + `okänt fel: `, +]; + +const truncateReason = (reason: string, maxLength: number, prefix?: string) => { + prefix = prefix ? `${prefix} ` : ''; + if (prefix) { + maxLength -= prefix.length; + } + const truncationSuffix = '[truncated]'; + if (!reason.length) { + return prefix + '[tx:empty-reason]'; + }else if (reason.length > maxLength) { + return prefix + reason.slice(0, maxLength - truncationSuffix.length) + truncationSuffix; + } else { + return prefix + reason; + } +} + +const cleanCrashReason = (reason: string) => { + const cutoffIdx = reason.indexOf(': ') + 2; + const msg = reason.slice(cutoffIdx); + const exceptionPrefix = exceptionPrefixesIntl.find((prefix) => msg.toLocaleLowerCase().startsWith(prefix)); + const saveMsg = exceptionPrefix + ? 'Unhandled exception: ' + msg.slice(exceptionPrefix.length) + : msg; + return truncateReason(saveMsg, PDL_CRASH_REASON_CHAR_LIMIT); +} + +/** + * Classifies a drop reason into a category, and returns a cleaned up version of it. + * The cleaned up version is truncated to a certain length. + * The cleaned up version of crash reasons have the prefix translated back into English. + */ +const guessDropReasonCategory = (reason: string): ClassifyDropReasonResponse => { + if (typeof reason !== 'string') { + return { + category: 'unknown' as const, + cleanReason: '[tx:invalid-reason]', + }; + } + const reasonToMatch = reason.trim().toLocaleLowerCase(); + + if (!reasonToMatch.length) { + return { + category: 'unknown' as const, + cleanReason: '[tx:empty-reason]', + }; + } else if (playerInitiatedRules.some((rule) => reasonToMatch.startsWith(rule))) { + return { category: 'player' }; + } else if (serverInitiatedRules.some((rule) => reasonToMatch.startsWith(rule))) { + return { category: false }; + } else if (timeoutRules.some((rule) => reasonToMatch.includes(rule))) { + return { category: 'timeout' }; + } else if (securityRules.some((rule) => reasonToMatch.includes(rule))) { + return { category: 'security' }; + } else if (crashRulesIntl.some((rule) => reasonToMatch.includes(rule))) { + return { + category: 'crash' as const, + cleanReason: cleanCrashReason(reason), + }; + } else { + return { + category: 'unknown' as const, + cleanReason: truncateReason(reason, PDL_UNKNOWN_REASON_CHAR_LIMIT), + }; + } +} + +//NOTE: From fivem/code/components/citizen-server-impl/include/ClientDropReasons.h +export enum FxsDropReasonGroups { + //1 resource dropped the client + RESOURCE = 1, + //2 client initiated a disconnect + CLIENT, + //3 server initiated a disconnect + SERVER, + //4 client with same guid connected and kicks old client + CLIENT_REPLACED, + //5 server -> client connection timed out + CLIENT_CONNECTION_TIMED_OUT, + //6 server -> client connection timed out with pending commands + CLIENT_CONNECTION_TIMED_OUT_WITH_PENDING_COMMANDS, + //7 server shutdown triggered the client drop + SERVER_SHUTDOWN, + //8 state bag rate limit exceeded + STATE_BAG_RATE_LIMIT, + //9 net event rate limit exceeded + NET_EVENT_RATE_LIMIT, + //10 latent net event rate limit exceeded + LATENT_NET_EVENT_RATE_LIMIT, + //11 command rate limit exceeded + COMMAND_RATE_LIMIT, + //12 too many missed frames in OneSync + ONE_SYNC_TOO_MANY_MISSED_FRAMES, +}; + +const timeoutCategory = [ + FxsDropReasonGroups.CLIENT_CONNECTION_TIMED_OUT, + FxsDropReasonGroups.CLIENT_CONNECTION_TIMED_OUT_WITH_PENDING_COMMANDS, + FxsDropReasonGroups.ONE_SYNC_TOO_MANY_MISSED_FRAMES, +]; +const securityCategory = [ + FxsDropReasonGroups.SERVER, + FxsDropReasonGroups.CLIENT_REPLACED, + FxsDropReasonGroups.STATE_BAG_RATE_LIMIT, + FxsDropReasonGroups.NET_EVENT_RATE_LIMIT, + FxsDropReasonGroups.LATENT_NET_EVENT_RATE_LIMIT, + FxsDropReasonGroups.COMMAND_RATE_LIMIT, +]; + + +/** + * Classifies a drop reason into a category, and returns a cleaned up version of it. + * The cleaned up version is truncated to a certain length. + * The cleaned up version of crash reasons have the prefix translated back into English. + */ +export const classifyDrop = (payload: PlayerDropEvent): ClassifyDropReasonResponse => { + if (typeof payload.reason !== 'string') { + return { + category: 'unknown', + cleanReason: '[tx:invalid-reason]', + }; + } else if (payload.category === undefined || payload.resource === undefined) { + return guessDropReasonCategory(payload.reason); + } + + if (typeof payload.category !== 'number' || payload.category <= 0) { + return { + category: 'unknown', + cleanReason: truncateReason( + payload.reason, + PDL_UNKNOWN_REASON_CHAR_LIMIT, + '[tx:invalid-category]' + ), + }; + } else if (payload.category === FxsDropReasonGroups.RESOURCE) { + if (payload.resource === 'monitor') { + //if server shutting down, return ignore, otherwise return server-initiated + if (payload.reason === 'server_shutting_down') { + return { category: false }; + } else { + return { + category: 'resource', + resource: 'txAdmin' + }; + } + } else { + return { + category: 'resource', + resource: payload.resource ? payload.resource : 'unknown', + } + } + } else if (payload.category === FxsDropReasonGroups.CLIENT) { + //check if it's crash + const reasonToMatch = payload.reason.trim().toLocaleLowerCase(); + if (crashRulesIntl.some((rule) => reasonToMatch.includes(rule))) { + return { + category: 'crash', + cleanReason: cleanCrashReason(payload.reason), + } + } else { + return { category: 'player' }; + } + } else if (timeoutCategory.includes(payload.category)) { + return { category: 'timeout' }; + } else if (securityCategory.includes(payload.category)) { + return { category: 'security' }; + } else if (payload.category === FxsDropReasonGroups.SERVER_SHUTDOWN) { + return { category: false }; + } else { + return { + category: 'unknown', + cleanReason: truncateReason( + payload.reason, + PDL_UNKNOWN_REASON_CHAR_LIMIT, + '[tx:unknown-category]' + ), + }; + } +} +type SimpleDropCategory = 'player' | 'timeout' | 'security'; +// type DetailedDropCategory = 'resource' | 'crash' | 'unknown'; +// type DropCategories = SimpleDropCategory | DetailedDropCategory; + + +type ClassifyDropReasonResponse = { + category: SimpleDropCategory; +} | { + category: 'resource'; + resource: string; +} | { + category: 'crash' | 'unknown'; + cleanReason: string; +} | { + category: false; //server shutting down, ignore +} diff --git a/core/modules/Metrics/playerDrop/config.ts b/core/modules/Metrics/playerDrop/config.ts new file mode 100644 index 0000000..54e14c5 --- /dev/null +++ b/core/modules/Metrics/playerDrop/config.ts @@ -0,0 +1,8 @@ +/** + * Configs + */ +const daysMs = 24 * 60 * 60 * 1000; +export const PDL_CRASH_REASON_CHAR_LIMIT = 512; +export const PDL_UNKNOWN_REASON_CHAR_LIMIT = 320; +export const PDL_UNKNOWN_LIST_SIZE_LIMIT = 200; //at most 62.5kb (200*320/1024) +export const PDL_RETENTION = 14 * daysMs; diff --git a/core/modules/Metrics/playerDrop/index.ts b/core/modules/Metrics/playerDrop/index.ts new file mode 100644 index 0000000..62b3025 --- /dev/null +++ b/core/modules/Metrics/playerDrop/index.ts @@ -0,0 +1,351 @@ +const modulename = 'PlayerDropMetrics'; +import fsp from 'node:fs/promises'; +import consoleFactory from '@lib/console'; +import { PDLChangeEventType, PDLFileSchema, PDLFileType, PDLHourlyRawType, PDLHourlyType, PDLServerBootDataSchema } from './playerDropSchemas'; +import { classifyDrop } from './classifyDropReason'; +import { PDL_RETENTION, PDL_UNKNOWN_LIST_SIZE_LIMIT } from './config'; +import { ZodError } from 'zod'; +import { getDateHourEnc, parseDateHourEnc } from './playerDropUtils'; +import { MultipleCounter } from '../statsUtils'; +import { throttle } from 'throttle-debounce'; +import { PlayerDropsDetailedWindow, PlayerDropsSummaryHour } from '@routes/playerDrops'; +import { migratePlayerDropsFile } from './playerDropMigrations'; +import { parseFxserverVersion } from '@lib/fxserver/fxsVersionParser'; +import { PlayerDropEvent } from '@modules/FxPlayerlist'; +import { txEnv } from '@core/globalData'; +const console = consoleFactory(modulename); + + +//Consts +export const LOG_DATA_FILE_VERSION = 2; +const LOG_DATA_FILE_NAME = 'stats_playerDrop.json'; + + +/** + * Stores player drop logs, and also logs other information that might be relevant to player crashes, + * such as changes to the detected game/server version, resources, etc. + * + * NOTE: PDL = PlayerDropLog + */ +export default class PlayerDropMetrics { + private readonly logFilePath = `${txEnv.profilePath}/data/${LOG_DATA_FILE_NAME}`; + private eventLog: PDLHourlyType[] = []; + private lastGameVersion: string | undefined; + private lastServerVersion: string | undefined; + private lastResourceList: string[] | undefined; + private lastUnknownReasons: string[] = []; + private queueSaveEventLog = throttle( + 15_000, + this.saveEventLog.bind(this), + { noLeading: true } + ); + + constructor() { + setImmediate(() => { + this.loadEventLog(); + }); + } + + + /** + * Get the recent category count for player drops in the last X hours + */ + public getRecentDropTally(windowHours: number) { + const logCutoff = (new Date).setUTCMinutes(0, 0, 0) - (windowHours * 60 * 60 * 1000) - 1; + const flatCounts = this.eventLog + .filter((entry) => entry.hour.dateHourTs >= logCutoff) + .map((entry) => entry.dropTypes.toSortedValuesArray()) + .flat(); + const cumulativeCounter = new MultipleCounter(); + cumulativeCounter.merge(flatCounts); + return cumulativeCounter.toSortedValuesArray(); + } + + + /** + * Get the recent log with drop/crash/changes for the last X hours + */ + public getRecentSummary(windowHours: number): PlayerDropsSummaryHour[] { + const logCutoff = (new Date).setUTCMinutes(0, 0, 0) - (windowHours * 60 * 60 * 1000); + const windowSummary = this.eventLog + .filter((entry) => entry.hour.dateHourTs >= logCutoff) + .map((entry) => ({ + hour: entry.hour.dateHourStr, + changes: entry.changes.length, + dropTypes: entry.dropTypes.toSortedValuesArray(), + })); + return windowSummary; + } + + + /** + * Get the data for the player drops drilldown card within a inclusive time window + */ + public getWindowData(windowStart: number, windowEnd: number): PlayerDropsDetailedWindow { + const allChanges: PDLChangeEventType[] = []; + const crashTypes = new MultipleCounter(); + const dropTypes = new MultipleCounter(); + const resKicks = new MultipleCounter(); + const filteredLogs = this.eventLog.filter((entry) => { + return entry.hour.dateHourTs >= windowStart && entry.hour.dateHourTs <= windowEnd; + }); + for (const log of filteredLogs) { + allChanges.push(...log.changes); + crashTypes.merge(log.crashTypes); + dropTypes.merge(log.dropTypes); + resKicks.merge(log.resKicks); + } + return { + changes: allChanges, + crashTypes: crashTypes.toSortedValuesArray(true), + dropTypes: dropTypes.toSortedValuesArray(true), + resKicks: resKicks.toSortedValuesArray(true), + }; + } + + + /** + * Returns the object of the current hour object in log. + * Creates one if doesn't exist one for the current hour. + */ + private getCurrentLogHourRef() { + const { dateHourTs, dateHourStr } = getDateHourEnc(); + const currentHourLog = this.eventLog.find((entry) => entry.hour.dateHourStr === dateHourStr); + if (currentHourLog) return currentHourLog; + const newHourLog: PDLHourlyType = { + hour: { + dateHourTs: dateHourTs, + dateHourStr: dateHourStr, + }, + changes: [], + crashTypes: new MultipleCounter(), + dropTypes: new MultipleCounter(), + resKicks: new MultipleCounter(), + }; + this.eventLog.push(newHourLog); + return newHourLog; + } + + + /** + * Handles receiving the data sent to the logger as soon as the server boots + */ + public handleServerBootData(rawPayload: any) { + const logRef = this.getCurrentLogHourRef(); + + //Parsing data + const validation = PDLServerBootDataSchema.safeParse(rawPayload); + if (!validation.success) { + console.warn(`Invalid server boot data: ${validation.error.errors}`); + return; + } + const { gameName, gameBuild, fxsVersion, resources } = validation.data; + let shouldSave = false; + + //Game version change + const gameString = `${gameName}:${gameBuild}`; + if (gameString) { + if (!this.lastGameVersion) { + shouldSave = true; + } else if (gameString !== this.lastGameVersion) { + shouldSave = true; + logRef.changes.push({ + ts: Date.now(), + type: 'gameChanged', + oldVersion: this.lastGameVersion, + newVersion: gameString, + }); + } + this.lastGameVersion = gameString; + } + + //Server version change + let { build: serverBuild, platform: serverPlatform } = parseFxserverVersion(fxsVersion); + const fxsVersionString = `${serverPlatform}:${serverBuild}`; + if (fxsVersionString) { + if (!this.lastServerVersion) { + shouldSave = true; + } else if (fxsVersionString !== this.lastServerVersion) { + shouldSave = true; + logRef.changes.push({ + ts: Date.now(), + type: 'fxsChanged', + oldVersion: this.lastServerVersion, + newVersion: fxsVersionString, + }); + } + this.lastServerVersion = fxsVersionString; + } + + //Resource list change - if no resources, ignore as that's impossible + if (resources.length) { + if (!this.lastResourceList || !this.lastResourceList.length) { + shouldSave = true; + } else { + const resAdded = resources.filter(r => !this.lastResourceList!.includes(r)); + const resRemoved = this.lastResourceList.filter(r => !resources.includes(r)); + if (resAdded.length || resRemoved.length) { + shouldSave = true; + logRef.changes.push({ + ts: Date.now(), + type: 'resourcesChanged', + resAdded, + resRemoved, + }); + } + } + this.lastResourceList = resources; + } + + //Saving if needed + if (shouldSave) { + this.queueSaveEventLog(); + } + } + + + /** + * Handles receiving the player drop event, and returns the category of the drop + */ + public handlePlayerDrop(event: PlayerDropEvent) { + const drop = classifyDrop(event); + + //Ignore server shutdown drops + if (drop.category === false) return false; + + //Log the drop + const logRef = this.getCurrentLogHourRef(); + logRef.dropTypes.count(drop.category); + if (drop.category === 'resource' && drop.resource) { + logRef.resKicks.count(drop.resource); + } else if (drop.category === 'crash' && drop.cleanReason) { + logRef.crashTypes.count(drop.cleanReason); + } else if (drop.category === 'unknown' && drop.cleanReason) { + if (!this.lastUnknownReasons.includes(drop.cleanReason)) { + this.lastUnknownReasons.push(drop.cleanReason); + } + } + this.queueSaveEventLog(); + return drop.category; + } + + + /** + * Resets the player drop stats log + */ + public resetLog(reason: string) { + if (typeof reason !== 'string' || !reason) throw new Error(`reason required`); + this.eventLog = []; + this.lastGameVersion = undefined; + this.lastServerVersion = undefined; + this.lastResourceList = undefined; + this.lastUnknownReasons = []; + this.queueSaveEventLog.cancel({ upcomingOnly: true }); + this.saveEventLog(reason); + } + + + /** + * Loads the stats database/cache/history + */ + private async loadEventLog() { + try { + const rawFileData = await fsp.readFile(this.logFilePath, 'utf8'); + const fileData = JSON.parse(rawFileData); + let statsData: PDLFileType; + if (fileData.version === LOG_DATA_FILE_VERSION) { + statsData = PDLFileSchema.parse(fileData); + } else { + try { + statsData = await migratePlayerDropsFile(fileData); + } catch (error) { + throw new Error(`Failed to migrate ${LOG_DATA_FILE_NAME} from ${fileData?.version} to ${LOG_DATA_FILE_VERSION}: ${(error as Error).message}`); + } + } + this.lastGameVersion = statsData.lastGameVersion; + this.lastServerVersion = statsData.lastServerVersion; + this.lastResourceList = statsData.lastResourceList; + this.lastUnknownReasons = statsData.lastUnknownReasons; + this.eventLog = statsData.log.map((entry): PDLHourlyType => { + return { + hour: parseDateHourEnc(entry.hour), + changes: entry.changes, + crashTypes: new MultipleCounter(entry.crashTypes), + dropTypes: new MultipleCounter(entry.dropTypes), + resKicks: new MultipleCounter(entry.resKicks), + } + }); + console.verbose.ok(`Loaded ${this.eventLog.length} log entries from cache`); + this.optimizeStatsLog(); + } catch (error) { + if ((error as any)?.code === 'ENOENT') { + console.verbose.debug(`${LOG_DATA_FILE_NAME} not found, starting with empty stats.`); + this.resetLog('File was just created, no data yet'); + return; + } + if (error instanceof ZodError) { + console.warn(`Failed to load ${LOG_DATA_FILE_NAME} due to invalid data.`); + this.resetLog('Failed to load log file due to invalid data'); + } else { + console.warn(`Failed to load ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`); + this.resetLog('Failed to load log file due to unknown error'); + } + console.warn('Since this is not a critical file, it will be reset.'); + } + } + + + /** + * Optimizes the event log by removing old entries + */ + private optimizeStatsLog() { + if (this.lastUnknownReasons.length > PDL_UNKNOWN_LIST_SIZE_LIMIT) { + this.lastUnknownReasons = this.lastUnknownReasons.slice(-PDL_UNKNOWN_LIST_SIZE_LIMIT); + } + + const maxAge = Date.now() - PDL_RETENTION; + const cutoffIdx = this.eventLog.findIndex((entry) => entry.hour.dateHourTs > maxAge); + if (cutoffIdx > 0) { + this.eventLog = this.eventLog.slice(cutoffIdx); + } + } + + + /** + * Saves the stats database/cache/history + */ + private async saveEventLog(emptyReason?: string) { + try { + const sizeBefore = this.eventLog.length; + this.optimizeStatsLog(); + if (!this.eventLog.length) { + if (sizeBefore) { + emptyReason ??= 'Cleared due to retention policy'; + } + } else { + emptyReason = undefined; + } + + const savePerfData: PDLFileType = { + version: LOG_DATA_FILE_VERSION, + emptyReason, + lastGameVersion: this.lastGameVersion ?? 'unknown', + lastServerVersion: this.lastServerVersion ?? 'unknown', + lastResourceList: this.lastResourceList ?? [], + lastUnknownReasons: this.lastUnknownReasons, + log: this.eventLog.map((entry): PDLHourlyRawType => { + return { + hour: entry.hour.dateHourStr, + changes: entry.changes, + crashTypes: entry.crashTypes.toArray(), + dropTypes: entry.dropTypes.toArray(), + resKicks: entry.resKicks.toArray(), + } + }), + }; + await fsp.writeFile(this.logFilePath, JSON.stringify(savePerfData)); + } catch (error) { + console.warn(`Failed to save ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`); + } + } +}; diff --git a/core/modules/Metrics/playerDrop/playerDropMigrations.ts b/core/modules/Metrics/playerDrop/playerDropMigrations.ts new file mode 100644 index 0000000..4c70e9d --- /dev/null +++ b/core/modules/Metrics/playerDrop/playerDropMigrations.ts @@ -0,0 +1,59 @@ +import { LOG_DATA_FILE_VERSION } from './index'; //FIXME: circular_dependency +import { PDLChangeEventType, PDLFileSchema, PDLFileSchema_v1, type PDLFileType } from './playerDropSchemas'; + +export const migratePlayerDropsFile = async (fileData: any): Promise => { + //Migrate from v1 to v2 + //- adding oldVersion to fxsChanged and gameChanged events + //- remove the "Game crashed: " prefix from crash reasons + //- renamed "user-initiated" to "player-initiated" + //- add the "resources" counter to hourly log + if (fileData.version === 1) { + console.warn('Migrating your player drops stats v1 to v2.'); + const data = PDLFileSchema_v1.parse(fileData); + const crashPrefix = 'Game crashed: '; + let lastFxsVersion = 'unknown'; + let lastGameVersion = 'unknown'; + for (const log of data.log) { + for (const event of log.changes as PDLChangeEventType[]) { + if (event.type === 'fxsChanged') { + event.oldVersion = lastFxsVersion; + lastFxsVersion = event.newVersion; + } else if (event.type === 'gameChanged') { + event.oldVersion = lastGameVersion; + lastGameVersion = event.newVersion; + } + } + log.crashTypes = log.crashTypes.map(([reason, count]) => { + const newReason = reason.startsWith(crashPrefix) + ? reason.slice(crashPrefix.length) + : reason; + return [newReason, count]; + }); + //@ts-ignore + log.dropTypes = log.dropTypes.map(([type, count]): [string, number] | false => { + if (type === 'user-initiated') { + return ['player', count] + } else if (type === 'server-initiated') { + //Mostly server shutdowns + return false; + } else { + return [type, count]; + } + }).filter(Array.isArray); + //@ts-ignore + log.resKicks = []; + } + + fileData = { + ...data, + version: LOG_DATA_FILE_VERSION + } + } + + //Final check + if (fileData.version === LOG_DATA_FILE_VERSION) { + return PDLFileSchema.parse(fileData); + } else { + throw new Error(`Unknown file version: ${fileData.version}`); + } +} diff --git a/core/modules/Metrics/playerDrop/playerDropSchemas.ts b/core/modules/Metrics/playerDrop/playerDropSchemas.ts new file mode 100644 index 0000000..3534024 --- /dev/null +++ b/core/modules/Metrics/playerDrop/playerDropSchemas.ts @@ -0,0 +1,114 @@ +import * as z from 'zod'; +import type { MultipleCounter } from '../statsUtils'; +import { parseDateHourEnc } from './playerDropUtils'; +import { DeepReadonly } from 'utility-types'; + + +//Generic schemas +const zIntNonNegative = z.number().int().nonnegative(); + +//handleServerBootData that comes from txAdmin.loggers.server when the server boots +export const PDLServerBootDataSchema = z.object({ + gameName: z.string().min(1).default('unknown'), + gameBuild: z.string().min(1).default('unknown'), + fxsVersion: z.string().min(1).default('unknown'), + resources: z.array(z.string().min(1)), + projectName: z.string().optional(), +}); + + +//Log stuff +export const PDLFxsChangedEventSchema = z.object({ + ts: zIntNonNegative, + type: z.literal('fxsChanged'), + oldVersion: z.string(), + newVersion: z.string(), +}); +export const PDLGameChangedEventSchema = z.object({ + ts: zIntNonNegative, + type: z.literal('gameChanged'), + oldVersion: z.string(), + newVersion: z.string(), +}); +export const PDLResourcesChangedEventSchema = z.object({ + ts: zIntNonNegative, + type: z.literal('resourcesChanged'), + resAdded: z.array(z.string().min(1)), + resRemoved: z.array(z.string().min(1)), +}); + +export const PDLHourlyRawSchema = z.object({ + hour: z.string(), + changes: z.array(z.union([ + PDLFxsChangedEventSchema, + PDLGameChangedEventSchema, + PDLResourcesChangedEventSchema, + ])), + crashTypes: z.array(z.tuple([z.string(), z.number()])), + dropTypes: z.array(z.tuple([z.string(), z.number()])), + resKicks: z.array(z.tuple([z.string(), z.number()])), +}); + +export const PDLFileSchema = z.object({ + version: z.literal(2), + emptyReason: z.string().optional(), //If the log is empty, this will be the reason + lastGameVersion: z.string(), + lastServerVersion: z.string(), + lastResourceList: z.array(z.string()), + lastUnknownReasons: z.array(z.string()), //store the last few for potential analysis + log: z.array(PDLHourlyRawSchema), +}); + + +//Exporting types +export type PDLFileType = z.infer; +export type PDLHourlyRawType = z.infer; +export type PDLFxsChangedEventType = z.infer; +export type PDLGameChangedEventType = z.infer; +export type PDLResourcesChangedEventType = z.infer; +// export type PDLClientChangedEventType = z.infer; +export type PDLChangeEventType = (PDLFxsChangedEventType | PDLGameChangedEventType | PDLResourcesChangedEventType); +export type PDLHourlyChanges = PDLHourlyRawType['changes']; + +//Used after parsing (getCurrentLogHourRef) +export type PDLHourlyType = { + hour: DeepReadonly>; + changes: PDLHourlyChanges; + crashTypes: MultipleCounter; + dropTypes: MultipleCounter; + resKicks: MultipleCounter; +}; + + +/** + * Migration schemas from v1 to v2 with changes: + * - added "oldVersion" to the fxsChanged and gameChanged events + * - removed the "Game crashed: " prefix from crash reasons + */ +export const PDLFxsChangedEventSchema_v1 = PDLFxsChangedEventSchema.omit({ + oldVersion: true, +}); +export const PDLGameChangedEventSchema_v1 = PDLGameChangedEventSchema.omit({ + oldVersion: true, +}); +export const PDLHourlyRawSchema_v1 = PDLHourlyRawSchema.extend({ + changes: z.array(z.union([ + PDLFxsChangedEventSchema_v1, + PDLGameChangedEventSchema_v1, + PDLResourcesChangedEventSchema, + ])), +}).omit({ + resKicks: true, +}); +export const PDLFileSchema_v1 = PDLFileSchema.extend({ + version: z.literal(1), + log: z.array(PDLHourlyRawSchema_v1), +}); +export type PDLFileType_v1 = z.infer; + +//Used only in scripts/dev/makeOldStatsFile.ts +export type PDLChangeEventType_V1 = ( + z.infer + | z.infer + | PDLResourcesChangedEventType +); diff --git a/core/modules/Metrics/playerDrop/playerDropUtils.ts b/core/modules/Metrics/playerDrop/playerDropUtils.ts new file mode 100644 index 0000000..97dce15 --- /dev/null +++ b/core/modules/Metrics/playerDrop/playerDropUtils.ts @@ -0,0 +1,17 @@ +export const getDateHourEnc = () => { + const now = new Date(); + const dateHourTs = now.setUTCMinutes(0, 0, 0); + return { + dateHourTs, + dateHourStr: now.toISOString(), + } +} + +export const parseDateHourEnc = (dateHourStr: string) => { + const date = new Date(dateHourStr); + const dateHourTs = date.setUTCMinutes(0, 0, 0); + return { + dateHourTs, + dateHourStr, + } +} diff --git a/core/modules/Metrics/statsUtils.test.ts b/core/modules/Metrics/statsUtils.test.ts new file mode 100644 index 0000000..6fe57a1 --- /dev/null +++ b/core/modules/Metrics/statsUtils.test.ts @@ -0,0 +1,198 @@ +//@ts-nocheck +import { test, expect, suite, it } from 'vitest'; +import { + MultipleCounter, + QuantileArray, + TimeCounter, + estimateArrayJsonSize, + isWithinMargin, +} from './statsUtils'; + + +suite('MultipleCounter', () => { + it('should instantiate empty correctly', () => { + const counter = new MultipleCounter(); + expect(counter).toBeInstanceOf(MultipleCounter); + expect(counter.toArray()).toEqual([]); + expect(counter.toJSON()).toEqual({}); + }); + + it('should instantiate locked if specified', () => { + const lockedCounter = new MultipleCounter(undefined, true); + expect(() => lockedCounter.count('a')).toThrowError('is locked'); + expect(() => lockedCounter.clear()).toThrowError('is locked'); + }); + + it('should handle instantiation data error', () => { + expect(() => new MultipleCounter({ a: 'b' as any })).toThrowError('only integer'); + }); + + const counterWithData = new MultipleCounter({ a: 1, b: 2 }); + it('should instantiate with object correctly', () => { + expect(counterWithData.toArray()).toEqual([['a', 1], ['b', 2]]); + expect(counterWithData.toJSON()).toEqual({ a: 1, b: 2 }); + }); + it('should count and clear', () => { + counterWithData.count('a'); + expect(counterWithData.toJSON()).toEqual({ a: 2, b: 2 }); + counterWithData.count('b'); + counterWithData.count('c', 5); + expect(counterWithData.toJSON()).toEqual({ a: 2, b: 3, c: 5 }); + counterWithData.clear(); + expect(counterWithData.toJSON()).toEqual({}); + }); + + it('should sort the data', () => { + const counter = new MultipleCounter({ a: 3, z: 1, c: 2 }); + expect(counter.toSortedKeyObject()).toEqual({ a: 3, c: 2, z: 1 }); + expect(counter.toSortedKeyObject(true)).toEqual({ z: 1, c: 2, a: 3 }); + expect(counter.toSortedValuesObject()).toEqual({ a: 3, c: 2, z: 1 }); + expect(counter.toSortedValuesObject(true)).toEqual({ z: 1, c: 2, a: 3 }); + expect(counter.toSortedKeysArray()).toEqual([['a', 3], ['c', 2], ['z', 1]]); + expect(counter.toSortedKeysArray(true)).toEqual([['z', 1], ['c', 2], ['a', 3]]); + expect(counter.toSortedValuesArray()).toEqual([['z', 1], ['c', 2], ['a', 3]]); + expect(counter.toSortedValuesArray(true)).toEqual([['a', 3], ['c', 2], ['z', 1]]); + }); + + suite('should handle merging counters', () => { + it('with another counter', () => { + const ogCounter = new MultipleCounter({ a: 1, b: 2 }); + const newCounter = new MultipleCounter({ b: 3, c: 4 }); + ogCounter.merge(newCounter); + expect(ogCounter.toJSON()).toEqual({ a: 1, b: 5, c: 4 }); + }); + it('with an array', () => { + const ogCounter = new MultipleCounter({ a: 1, b: 2 }); + ogCounter.merge([['b', 3], ['c', 4]]); + expect(ogCounter.toJSON()).toEqual({ a: 1, b: 5, c: 4 }); + }); + it('with an object', () => { + const ogCounter = new MultipleCounter({ a: 1, b: 2 }); + ogCounter.merge({ b: 3, c: 4 }); + expect(ogCounter.toJSON()).toEqual({ a: 1, b: 5, c: 4 }); + }); + it('with invalid data', () => { + const ogCounter = new MultipleCounter(); + expect(() => ogCounter.merge('a' as any)).toThrowError('Invalid data type for merge'); + }); + }); +}); + + +suite('QuantileArray', () => { + const array = new QuantileArray(4, 2); + test('min data', () => { + array.count(0); + expect(array.result()).toEqual({ enoughData: false }); + }); + test('zeros only', () => { + array.count(0); + array.count(0); + array.count(0); + expect(array.result()).toEqual({ + enoughData: true, + count: 4, + p5: 0, + p25: 0, + p50: 0, + p75: 0, + p95: 0, + }); + }); + const repeatedExpectedResult = { + enoughData: true, + count: 4, + p5: 0, + p25: 0, + p50: 0.5, + p75: 1, + p95: 1, + } + test('calc quantile', () => { + array.count(1); + array.count(1); + expect(array.result()).toEqual(repeatedExpectedResult); + }); + test('summary', () => { + expect(array.resultSummary('ms')).toEqual({ + ...repeatedExpectedResult, + summary: 'p5:0ms/p25:0ms/p50:1ms/p75:1ms/p95:1ms (x4)', + }); + expect(array.resultSummary()).toEqual({ + ...repeatedExpectedResult, + summary: 'p5:0/p25:0/p50:1/p75:1/p95:1 (x4)', + }); + }); + test('clear', () => { + array.clear(); + expect(array.result()).toEqual({ enoughData: false }); + expect(array.resultSummary()).toEqual({ + enoughData: false, + summary: 'not enough data available', + }); + }); +}); + + +suite('TimeCounter', async () => { + const counter = new TimeCounter(); + await new Promise((resolve) => setTimeout(resolve, 150)); + const duration = counter.stop(); + + // Check if the duration is a valid object + test('duration is valid', () => { + expect(duration.seconds).toBeTypeOf('number'); + expect(duration.milliseconds).toBeTypeOf('number'); + expect(duration.nanoseconds).toBeTypeOf('number'); + expect(counter.toJSON()).toEqual(duration); + }); + + // Check if the duration is within the expected range + test('duration within range', () => { + const isCloseTo50ms = (x: number) => (x > 150 && x < 175); + expect(duration.seconds * 1000).toSatisfy(isCloseTo50ms); + expect(duration.milliseconds).toSatisfy(isCloseTo50ms); + expect(duration.nanoseconds / 1_000_000).toSatisfy(isCloseTo50ms); + }); +}); + + +suite('estimateArrayJsonSize', () => { + test('obeys minimas', () => { + const result = estimateArrayJsonSize([], 1); + expect(result).toEqual({ enoughData: false }); + + const result2 = estimateArrayJsonSize([1], 2); + expect(result2).toEqual({ enoughData: false }); + }); + + test('calculates size correctly', () => { + const array = Array.from({ length: 1000 }, (_, i) => ({ id: i, value: `value${i}` })); + const realFullSize = JSON.stringify(array).length; + const realElementSize = realFullSize / array.length; + const result = estimateArrayJsonSize(array, 100); + expect(result.enoughData).toBe(true); + expect(result.bytesTotal).toSatisfy((x: number) => isWithinMargin(x, realFullSize, 0.1)); + expect(result.bytesPerElement).toSatisfy((x: number) => isWithinMargin(x, realElementSize, 0.1)); + }); + + test('handles small arrays', () => { + const array = [{ id: 1, value: 'value1' }]; + const result = estimateArrayJsonSize(array, 0); + expect(result.enoughData).toBe(true); + expect(result.bytesTotal).toBeGreaterThan(0); + expect(result.bytesTotal).toBeLessThan(100); + expect(result.bytesPerElement).toBeGreaterThan(0); + expect(result.bytesTotal).toBeLessThan(100); + }); + + test('handles large arrays', () => { + const array = Array.from({ length: 20000 }, (_, i) => ({ id: i, value: `value${i}` })); + const realFullSize = JSON.stringify(array).length; + const realElementSize = realFullSize / array.length; + const result = estimateArrayJsonSize(array, 100); + expect(result.enoughData).toBe(true); + expect(result.bytesTotal).toSatisfy((x: number) => isWithinMargin(x, realFullSize, 0.1)); + expect(result.bytesPerElement).toSatisfy((x: number) => isWithinMargin(x, realElementSize, 0.1)); + }); +}); diff --git a/core/modules/Metrics/statsUtils.ts b/core/modules/Metrics/statsUtils.ts new file mode 100644 index 0000000..9cc6cfd --- /dev/null +++ b/core/modules/Metrics/statsUtils.ts @@ -0,0 +1,303 @@ +import { inspect } from 'node:util'; +import CircularBuffer from 'mnemonist/circular-buffer'; +import * as d3array from 'd3-array'; + + +/** + * Helper class to count different options + */ +export class MultipleCounter extends Map { + public locked: boolean; + private _clear: () => void; + + constructor(initialData?: [string, number][] | null | Record, locked = false) { + let initialDataIterable: any; + if (initialData !== undefined && initialData !== null || typeof initialData === 'object') { + if (Array.isArray(initialData)) { + initialDataIterable = initialData; + } else { + initialDataIterable = Object.entries(initialData!); + if (initialDataIterable.some(([k, v]: [string, number]) => typeof k !== 'string' || typeof v !== 'number')) { + throw new Error(`Initial data must be an object with only integer values.`) + } + } + } + super(initialDataIterable ?? initialData); + this.locked = locked; + this._clear = super.clear; + } + + //Clears the counter + clear() { + if (this.locked) throw new Error(`This MultipleCounter is locked to modifications.`); + this._clear(); + }; + + //Returns the sum of all values + sum() { + return [...this.values()].reduce((a, b) => a + b, 0); + } + + //Increments the count of a key by a value + count(key: string, val = 1) { + if (this.locked) throw new Error(`This MultipleCounter is locked to modifications.`); + + const currentValue = this.get(key); + if (currentValue !== undefined) { + const newVal = currentValue + val; + this.set(key, newVal); + return newVal; + } else { + this.set(key, val); + return val; + } + }; + + //Merges another counter into this one + merge(newData: MultipleCounter | [string, number][] | Record) { + if (this.locked) throw new Error(`This MultipleCounter is locked to modifications.`); + let iterable; + if (newData instanceof MultipleCounter || Array.isArray(newData)) { + iterable = newData; + } else if (typeof newData === 'object' && newData !== null) { + iterable = Object.entries(newData); + } else { + throw new Error(`Invalid data type for merge`); + } + for (const [key, value] of iterable) { + this.count(key, value); + } + } + + //Returns an array with sorted keys in asc or desc order + toSortedKeysArray(desc?: boolean) { + return [...this.entries()] + .sort((a, b) => desc + ? b[0].localeCompare(a[0]) + : a[0].localeCompare(b[0]) + ); + } + + // Returns an array with sorted values in asc or desc order + toSortedValuesArray(desc?: boolean) { + return [...this.entries()] + .sort((a, b) => desc ? b[1] - a[1] : a[1] - b[1]); + } + + //Returns an object with sorted keys in asc or desc order + toSortedKeyObject(desc?: boolean) { + return Object.fromEntries(this.toSortedKeysArray(desc)); + } + + //Returns an object with sorted values in asc or desc order + toSortedValuesObject(desc?: boolean) { + return Object.fromEntries(this.toSortedValuesArray(desc)); + } + + toArray(): [string, number][] { + return [...this]; + } + + toJSON(): MultipleCounterOutput { + return Object.fromEntries(this); + } + + [inspect.custom]() { + return this.toSortedKeyObject(); + } +} +export type MultipleCounterOutput = Record; + + +/** + * Helper calculate quantiles out of a circular buffer of numbers + */ +export class QuantileArray { + readonly #cache: CircularBuffer; + readonly #minSize: number; + + constructor(sizeLimit: number, minSize = 1) { + if (typeof sizeLimit !== 'number' || !Number.isInteger(sizeLimit) || sizeLimit < 1) { + throw new Error(`sizeLimit must be a positive integer over 1.`); + } + if (typeof minSize !== 'number' || !Number.isInteger(minSize) || minSize < 1) { + throw new Error(`minSize must be a positive integer or one.`); + } + this.#cache = new CircularBuffer(Array, sizeLimit); + this.#minSize = minSize; + } + + /** + * Clears the cached data (wipe counts). + */ + clear() { + this.#cache.clear(); + }; + + /** + * Adds a value to the cache. + */ + count(value: number) { + if (typeof value !== 'number') throw new Error(`value must be a number`); + this.#cache.push(value); + }; + + /** + * Processes the cache and returns the count and quantiles, if enough data. + */ + result(): QuantileArrayOutput { + if (this.#cache.size < this.#minSize) { + return { + enoughData: false, + } + } else { + return { + enoughData: true, + count: this.#cache.size, + p5: d3array.quantile(this.#cache.values(), 0.05)!, + p25: d3array.quantile(this.#cache.values(), 0.25)!, + p50: d3array.quantile(this.#cache.values(), 0.50)!, + p75: d3array.quantile(this.#cache.values(), 0.75)!, + p95: d3array.quantile(this.#cache.values(), 0.95)!, + }; + } + } + + /** + * Returns a human readable summary of the data. + */ + resultSummary(unit = ''): QuantileArraySummary { + const result = this.result(); + if (!result.enoughData) { + return { + ...result, + summary: 'not enough data available', + }; + } + // const a = Object.entries(result) + const percentiles = (Object.entries(result) as [string, number][]) + .filter((el): el is [string, number] => el[0].startsWith('p')) + .map(([key, val]) => `${key}:${Math.ceil(val)}${unit}`); + return { + ...result, + summary: percentiles.join('/') + ` (x${this.#cache.size})`, + }; + } + + toJSON() { + return this.result(); + } + + [inspect.custom]() { + return this.result(); + } +} +type QuantileArrayOutput = { + enoughData: true; + count: number; + p5: number; + p25: number; + p50: number; + p75: number; + p95: number; +} | { + enoughData: false; +}; //if less than min size + +type QuantileArraySummary = QuantileArrayOutput & { + summary: string, +}; + + +/** + * Helper class to count time durations and convert them to human readable values + */ +export class TimeCounter { + readonly #timeStart: bigint; + #timeEnd: bigint | undefined; + public duration: { + nanoseconds: number + milliseconds: number, + seconds: number, + } | undefined; + + constructor() { + this.#timeStart = process.hrtime.bigint(); + } + + stop() { + this.#timeEnd = process.hrtime.bigint(); + const nanoseconds = this.#timeEnd - this.#timeStart; + const asNumber = Number(nanoseconds); + this.duration = { + nanoseconds: asNumber, + seconds: asNumber / 1_000_000_000, + milliseconds: asNumber / 1_000_000, + }; + return this.duration; + }; + + toJSON() { + return this.duration; + } + + [inspect.custom]() { + return this.toJSON(); + } +} + + +/** + * Estimates the JSON size in bytes of an array based on a simple heuristic + */ +export const estimateArrayJsonSize = (srcArray: any[], minLength: number): JsonEstimateResult => { + // Check if the buffer has enough data + if (srcArray.length <= minLength) { + return { enoughData: false }; + } + + // Determine a reasonable sample size: + // - At least 100 elements + // - Up to 10% of the buffer length + // - Capped at 1000 elements to limit CPU usage + const sourceArrayLength = srcArray.length; + const sampleSize = Math.min(1000, Math.max(100, Math.floor(sourceArrayLength * 0.1))); + const sampleArray: any[] = []; + + // Randomly sample elements from the buffer + for (let i = 0; i < sampleSize; i++) { + const randomIndex = Math.floor(Math.random() * sourceArrayLength); + sampleArray.push(srcArray[randomIndex]); + } + + // Serialize the sample to JSON + const jsonString = JSON.stringify(sampleArray); + const sampleSizeBytes = Buffer.byteLength(jsonString, 'utf-8'); // More accurate byte count + + // Estimate the total size based on the sample + const estimatedTotalBytes = (sampleSizeBytes / sampleSize) * sourceArrayLength; + const bytesPerElement = estimatedTotalBytes / sourceArrayLength; + + return { + enoughData: true, + bytesTotal: Math.round(estimatedTotalBytes), + bytesPerElement: Math.ceil(bytesPerElement), + }; +}; + +type JsonEstimateResult = { + enoughData: false; +} | { + enoughData: true; + bytesTotal: number; + bytesPerElement: number; +}; + + +/** + * Checks if a value is within a fraction margin of an expected value. + */ +export const isWithinMargin = (value: number, expectedValue: number, marginFraction: number) => { + const margin = expectedValue * marginFraction; + return Math.abs(value - expectedValue) <= margin; +} diff --git a/core/modules/Metrics/svRuntime/config.ts b/core/modules/Metrics/svRuntime/config.ts new file mode 100644 index 0000000..27ae608 --- /dev/null +++ b/core/modules/Metrics/svRuntime/config.ts @@ -0,0 +1,62 @@ +import { ValuesType } from "utility-types"; + + +/** + * Configs + */ +const minutesMs = 60 * 1000; +const hoursMs = 60 * minutesMs; +export const PERF_DATA_BUCKET_COUNT = 15; +export const PERF_DATA_MIN_TICKS = 600; //less than that and the data is not reliable - 30s for svMain +export const PERF_DATA_INITIAL_RESOLUTION = 5 * minutesMs; +export const STATS_RESOLUTION_TABLE = [ + //00~12h = 5min = 12/h = 144 snaps + //12~24h = 15min = 4/h = 48 snaps + //24~96h = 30min = 2/h = 144 snaps + { maxAge: 12 * hoursMs, resolution: PERF_DATA_INITIAL_RESOLUTION }, + { maxAge: 24 * hoursMs, resolution: 15 * minutesMs }, + { maxAge: 96 * hoursMs, resolution: 30 * minutesMs }, +]; +export const STATS_LOG_SIZE_LIMIT = 720; //144+48+144 (max data snaps) + 384 (1 reboot every 30 mins) +export const PERF_DATA_THREAD_NAMES = ['svNetwork', 'svSync', 'svMain'] as const; +export type SvRtPerfThreadNamesType = ValuesType; + + +// // @ts-ignore Typescript Pseudocode: + +// type SnapType = { +// dateStart: Date; +// dateEnd: Date; +// value: number; +// } + +// const snapshots: SnapType[] = [/*data*/]; +// const fixedDesiredResolution = 15 * 60 * 1000; // 15 minutes in milliseconds +// const processedSnapshots: SnapType[] = []; +// let pendingSnapshots: SnapType[] = []; +// for (const snap of snapshots) { +// if (pendingSnapshots.length === 0) { +// pendingSnapshots.push(snap); +// continue; +// } + +// const pendingStart = pendingSnapshots[0].dateStart; +// const currSnapEnd = snap.dateEnd; +// const totalDuration = currSnapEnd.getTime() - pendingStart.getTime(); +// if (totalDuration <= fixedDesiredResolution) { +// pendingSnapshots.push(snap); +// } else { +// const sumValue = pendingSnapshots.reduce((acc, curr) => { +// const snapDuration = curr.dateEnd.getTime() - curr.dateStart.getTime(); +// return acc + curr.value * snapDuration; +// }, 0); +// processedSnapshots.push({ +// dateStart: pendingStart, +// dateEnd: currSnapEnd, +// value: sumValue / totalDuration, +// }); +// pendingSnapshots = []; +// } +// } + +// //processedSnapshots contains the snapshots with the fixed resolution diff --git a/core/modules/Metrics/svRuntime/index.ts b/core/modules/Metrics/svRuntime/index.ts new file mode 100644 index 0000000..952957f --- /dev/null +++ b/core/modules/Metrics/svRuntime/index.ts @@ -0,0 +1,390 @@ +const modulename = 'SvRuntimeMetrics'; +import fsp from 'node:fs/promises'; +import * as d3array from 'd3-array'; +import consoleFactory from '@lib/console'; +import { SvRtFileSchema, isSvRtLogDataType, isValidPerfThreadName, SvRtNodeMemorySchema } from './perfSchemas'; +import type { SvRtFileType, SvRtLogDataType, SvRtLogType, SvRtNodeMemoryType, SvRtPerfBoundariesType, SvRtPerfCountsType } from './perfSchemas'; +import { didPerfReset, diffPerfs, fetchFxsMemory, fetchRawPerfData } from './perfUtils'; +import { optimizeSvRuntimeLog } from './logOptimizer'; +import { txDevEnv, txEnv } from '@core/globalData'; +import { ZodError } from 'zod'; +import { PERF_DATA_BUCKET_COUNT, PERF_DATA_INITIAL_RESOLUTION, PERF_DATA_MIN_TICKS } from './config'; +import { PerfChartApiResp } from '@routes/perfChart'; +import got from '@lib/got'; +import { throttle } from 'throttle-debounce'; +import { TimeCounter } from '../statsUtils'; +import { FxMonitorHealth } from '@shared/enums'; +const console = consoleFactory(modulename); + + +//Consts +const LOG_DATA_FILE_VERSION = 1; +const LOG_DATA_FILE_NAME = 'stats_svRuntime.json'; + + +/** + * This module is reponsiple to collect many statistics from the server runtime + * Most of those will be displayed on the Dashboard. + */ +export default class SvRuntimeMetrics { + private readonly logFilePath = `${txEnv.profilePath}/data/${LOG_DATA_FILE_NAME}`; + private statsLog: SvRtLogType = []; + private lastFxsMemory: number | undefined; + private lastNodeMemory: SvRtNodeMemoryType | undefined; + private lastPerfBoundaries: SvRtPerfBoundariesType | undefined; + private lastRawPerfData: SvRtPerfCountsType | undefined; + private lastDiffPerfData: SvRtPerfCountsType | undefined; + private lastRawPerfSaved: { + ts: number, + data: SvRtPerfCountsType, + } | undefined; + private queueSaveStatsHistory = throttle( + 15_000, + this.saveStatsHistory.bind(this), + { noLeading: true } + ); + + constructor() { + setImmediate(() => { + this.loadStatsHistory(); + }); + + //Cron functions + setInterval(() => { + this.collectStats().catch((error) => { + console.verbose.warn('Error while collecting server stats.'); + console.verbose.dir(error); + }); + }, 60 * 1000); + } + + + /** + * Reset the last perf data except boundaries + */ + private resetPerfState() { + this.lastRawPerfData = undefined; + this.lastDiffPerfData = undefined; + this.lastRawPerfSaved = undefined; + } + + + /** + * Reset the last perf data except boundaries + */ + private resetMemoryState() { + this.lastNodeMemory = undefined; + this.lastFxsMemory = undefined; + } + + + /** + * Registers that fxserver has BOOTED (FxMonitor is ONLINE) + */ + public logServerBoot(duration: number) { + this.resetPerfState(); + this.resetMemoryState(); + txCore.webServer.webSocket.pushRefresh('dashboard'); + + //If last log is a boot, remove it as the server didn't really start + // otherwise it would have lived long enough to have stats logged + if (this.statsLog.length && this.statsLog.at(-1)!.type === 'svBoot') { + this.statsLog.pop(); + } + this.statsLog.push({ + ts: Date.now(), + type: 'svBoot', + duration, + }); + this.queueSaveStatsHistory(); + } + + + /** + * Registers that fxserver has CLOSED (fxRunner killing the process) + */ + public logServerClose(reason: string) { + this.resetPerfState(); + this.resetMemoryState(); + txCore.webServer.webSocket.pushRefresh('dashboard'); + + if (this.statsLog.length) { + if (this.statsLog.at(-1)!.type === 'svClose') { + //If last log is a close, skip saving a new one + return; + } else if (this.statsLog.at(-1)!.type === 'svBoot') { + //If last log is a boot, remove it as the server didn't really start + this.statsLog.pop(); + return; + } + } + this.statsLog.push({ + ts: Date.now(), + type: 'svClose', + reason, + }); + this.queueSaveStatsHistory(); + } + + + /** + * Stores the last server Node.JS memory usage for later use in the data log + */ + public logServerNodeMemory(payload: SvRtNodeMemoryType) { + const validation = SvRtNodeMemorySchema.safeParse(payload); + if (!validation.success) { + console.verbose.warn('Invalid LogNodeHeapEvent payload:'); + console.verbose.dir(validation.error.errors); + return; + } + this.lastNodeMemory = { + used: payload.used, + limit: payload.limit, + }; + txCore.webServer.webSocket.pushRefresh('dashboard'); + } + + + /** + * Get recent stats + */ + public getRecentStats() { + return { + fxsMemory: this.lastFxsMemory, + nodeMemory: this.lastNodeMemory, + perfBoundaries: this.lastPerfBoundaries, + perfBucketCounts: this.lastDiffPerfData ? { + svMain: this.lastDiffPerfData.svMain.buckets, + svNetwork: this.lastDiffPerfData.svNetwork.buckets, + svSync: this.lastDiffPerfData.svSync.buckets, + } : undefined, + } + } + + + /** + * Cron function to collect all the stats and save it to the cache file + */ + private async collectStats() { + //Precondition checks + const monitorStatus = txCore.fxMonitor.status; + if (monitorStatus.health === FxMonitorHealth.OFFLINE) return; //collect even if partial + if (monitorStatus.uptime < 30_000) return; //server barely booted + if (!txCore.fxRunner.child?.isAlive) return; + + //Get performance data + const netEndpoint = txDevEnv.EXT_STATS_HOST ?? txCore.fxRunner.child.netEndpoint; + if (!netEndpoint) throw new Error(`Invalid netEndpoint: ${netEndpoint}`); + + const stopwatch = new TimeCounter(); + const [fetchRawPerfDataRes, fetchFxsMemoryRes] = await Promise.allSettled([ + fetchRawPerfData(netEndpoint), + fetchFxsMemory(txCore.fxRunner.child.pid), + ]); + const collectionTime = stopwatch.stop(); + + if (fetchFxsMemoryRes.status === 'fulfilled') { + this.lastFxsMemory = fetchFxsMemoryRes.value; + } else { + this.lastFxsMemory = undefined; + } + if (fetchRawPerfDataRes.status === 'rejected') throw fetchRawPerfDataRes.reason; + + const { perfBoundaries, perfMetrics } = fetchRawPerfDataRes.value; + txCore.metrics.txRuntime.perfCollectionTime.count(collectionTime.milliseconds); + + //Check for min tick count + if ( + perfMetrics.svMain.count < PERF_DATA_MIN_TICKS || + perfMetrics.svNetwork.count < PERF_DATA_MIN_TICKS || + perfMetrics.svSync.count < PERF_DATA_MIN_TICKS + ) { + console.verbose.warn('Not enough ticks to log. Skipping this collection.'); + return; + } + + //Check if first collection, boundaries changed + if (!this.lastPerfBoundaries) { + console.verbose.debug('First perf collection.'); + this.lastPerfBoundaries = perfBoundaries; + this.resetPerfState(); + } else if (JSON.stringify(perfBoundaries) !== JSON.stringify(this.lastPerfBoundaries)) { + console.warn('Performance boundaries changed. Resetting history.'); + this.statsLog = []; + this.lastPerfBoundaries = perfBoundaries; + this.resetPerfState(); + } + + //Checking if the counter (somehow) reset + if (this.lastRawPerfData && didPerfReset(perfMetrics, this.lastRawPerfData)) { + console.warn('Performance counter reset. Resetting lastPerfCounts/lastPerfSaved.'); + this.resetPerfState(); + } else if (this.lastRawPerfSaved && didPerfReset(perfMetrics, this.lastRawPerfSaved.data)) { + console.warn('Performance counter reset. Resetting lastPerfSaved.'); + this.lastRawPerfSaved = undefined; + } + + //Calculate the tick/time counts since last collection (1m ago) + this.lastDiffPerfData = diffPerfs(perfMetrics, this.lastRawPerfData); + this.lastRawPerfData = perfMetrics; + + //Push the updated data to the dashboard ws room + txCore.webServer.webSocket.pushRefresh('dashboard'); + + //Check if enough time passed since last collection + const now = Date.now(); + let perfToSave; + if (!this.lastRawPerfSaved) { + perfToSave = this.lastDiffPerfData; + } else if (now - this.lastRawPerfSaved.ts >= PERF_DATA_INITIAL_RESOLUTION) { + perfToSave = diffPerfs(perfMetrics, this.lastRawPerfSaved.data); + } + if (!perfToSave) return; + + //Get player count locally or from external source + let playerCount = txCore.fxPlayerlist.onlineCount; + if (txDevEnv.EXT_STATS_HOST) { + try { + const playerCountResp = await got(`http://${netEndpoint}/players.json`).json(); + playerCount = playerCountResp.length; + } catch (error) { } + } + + //Update cache + this.lastRawPerfSaved = { + ts: now, + data: perfMetrics, + }; + const currSnapshot: SvRtLogDataType = { + ts: now, + type: 'data', + players: playerCount, + fxsMemory: this.lastFxsMemory ?? null, + nodeMemory: this.lastNodeMemory?.used ?? null, + perf: perfToSave, + }; + this.statsLog.push(currSnapshot); + // console.verbose.ok(`Collected performance snapshot #${this.statsLog.length}`); + + //Save perf series do file - not queued because it's priority + this.queueSaveStatsHistory.cancel({ upcomingOnly: true }); + this.saveStatsHistory(); + } + + + /** + * Loads the stats database/cache/history + */ + private async loadStatsHistory() { + try { + const rawFileData = await fsp.readFile(this.logFilePath, 'utf8'); + const fileData = JSON.parse(rawFileData); + if (fileData?.version !== LOG_DATA_FILE_VERSION) throw new Error('invalid version'); + const statsData = SvRtFileSchema.parse(fileData); + this.lastPerfBoundaries = statsData.lastPerfBoundaries; + this.statsLog = statsData.log; + this.resetPerfState(); + console.verbose.ok(`Loaded ${this.statsLog.length} performance snapshots from cache`); + await optimizeSvRuntimeLog(this.statsLog); + } catch (error) { + if ((error as any)?.code === 'ENOENT') { + console.verbose.debug(`${LOG_DATA_FILE_NAME} not found, starting with empty stats.`); + return; + } + if (error instanceof ZodError) { + console.warn(`Failed to load ${LOG_DATA_FILE_NAME} due to invalid data.`); + } else { + console.warn(`Failed to load ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`); + } + console.warn('Since this is not a critical file, it will be reset.'); + } + } + + + /** + * Saves the stats database/cache/history + */ + private async saveStatsHistory() { + try { + await optimizeSvRuntimeLog(this.statsLog); + const savePerfData: SvRtFileType = { + version: LOG_DATA_FILE_VERSION, + lastPerfBoundaries: this.lastPerfBoundaries, + log: this.statsLog, + }; + await fsp.writeFile(this.logFilePath, JSON.stringify(savePerfData)); + } catch (error) { + console.warn(`Failed to save ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`); + } + } + + + /** + * Returns the data for charting the performance of a specific thread + */ + public getChartData(threadName: string): PerfChartApiResp { + if (!isValidPerfThreadName(threadName)) return { fail_reason: 'invalid_thread_name' }; + if (!this.statsLog.length || !this.lastPerfBoundaries?.length) return { fail_reason: 'data_unavailable' }; + + //Processing data + return { + boundaries: this.lastPerfBoundaries, + threadPerfLog: this.statsLog.map((log) => { + if (!isSvRtLogDataType(log)) return log; + return { + ...log, + perf: log.perf[threadName], + }; + }) + } + } + + + /** + * Returns a summary of the collected data and returns. + * NOTE: kinda expensive + */ + public getServerPerfSummary() { + //Configs + const minSnapshots = 36; //3h of data + const tsScanWindowStart = Date.now() - 6 * 60 * 60 * 1000; //6h ago + + //that's short for cumulative buckets, if you thought otherwise, i'm judging you + const cumBuckets = Array(PERF_DATA_BUCKET_COUNT).fill(0); + let cumTicks = 0; + + //Processing each snapshot - then each bucket + let totalSnapshots = 0; + const players = []; + const fxsMemory = []; + const nodeMemory = [] + for (const log of this.statsLog) { + if (log.ts < tsScanWindowStart) continue; + if (!isSvRtLogDataType(log)) continue; + if (log.perf.svMain.count < PERF_DATA_MIN_TICKS) continue; + totalSnapshots++ + players.push(log.players); + fxsMemory.push(log.fxsMemory); + nodeMemory.push(log.nodeMemory); + for (let bIndex = 0; bIndex < PERF_DATA_BUCKET_COUNT; bIndex++) { + const tickCount = log.perf.svMain.buckets[bIndex]; + cumTicks += tickCount; + cumBuckets[bIndex] += tickCount; + } + } + + //Checking if at least 12h of data + if (totalSnapshots < minSnapshots) { + return null; //not enough data for meaningful analysis + } + + //Formatting Output + return { + snaps: totalSnapshots, + freqs: cumBuckets.map(cumAvg => cumAvg / cumTicks), + players: d3array.median(players), + fxsMemory: d3array.median(fxsMemory), + nodeMemory: d3array.median(nodeMemory), + }; + } +}; diff --git a/core/modules/Metrics/svRuntime/logOptimizer.ts b/core/modules/Metrics/svRuntime/logOptimizer.ts new file mode 100644 index 0000000..4ccbdc6 --- /dev/null +++ b/core/modules/Metrics/svRuntime/logOptimizer.ts @@ -0,0 +1,22 @@ +import { STATS_LOG_SIZE_LIMIT, STATS_RESOLUTION_TABLE } from "./config"; +import type { SvRtLogType } from "./perfSchemas"; + +//Consts +const YIELD_INTERVAL = 100; + + +/** + * Optimizes (in place) the stats log by removing old data and combining snaps to match the resolution + */ +export const optimizeSvRuntimeLog = async (statsLog: SvRtLogType) => { + statsLog.splice(0, statsLog.length - STATS_LOG_SIZE_LIMIT); + for (let i = 0; i < statsLog.length; i++) { + //FIXME: write code + //FIXME: somehow prevent recombining the 0~12h snaps + + // Yield every 100 iterations + if (i % YIELD_INTERVAL === 0) { + await new Promise((resolve) => setImmediate(resolve)); + } + } +} diff --git a/core/modules/Metrics/svRuntime/perfParser.test.ts b/core/modules/Metrics/svRuntime/perfParser.test.ts new file mode 100644 index 0000000..7cb124d --- /dev/null +++ b/core/modules/Metrics/svRuntime/perfParser.test.ts @@ -0,0 +1,127 @@ +import { test, expect, it, suite } from 'vitest'; +import { arePerfBoundariesValid, parseRawPerf, revertCumulativeBuckets } from './perfParser'; + + +test('arePerfBoundariesValid', () => { + const fnc = arePerfBoundariesValid; + expect(fnc([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, '+Inf'])).toBe(true); + expect(fnc([])).toBe(false); //length + expect(fnc([1, 2, 3])).toBe(false); //length + expect(fnc([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])).toBe(false); //last item + expect(fnc([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 'xx', 12, 13, 14, '+Inf'])).toBe(false); //always number, except last + expect(fnc([1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 11, 12, 13, 14, '+Inf'])).toBe(false); //always increasing + expect(fnc([0.1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 11, 12, 999, 14, '+Inf'])).toBe(false); //always increasing +}); + + +const perfValidExample = `# HELP tickTime Time spent on server ticks +# TYPE tickTime histogram +tickTime_count{name="svNetwork"} 1840805 +tickTime_sum{name="svNetwork"} 76.39499999999963 +tickTime_bucket{name="svNetwork",le="0.005"} 1840798 +tickTime_bucket{name="svNetwork",le="0.01"} 1840804 +tickTime_bucket{name="svNetwork",le="0.025"} 1840805 +tickTime_bucket{name="svNetwork",le="0.05"} 1840805 +tickTime_bucket{name="svNetwork",le="0.075"} 1840805 +tickTime_bucket{name="svNetwork",le="0.1"} 1840805 +tickTime_bucket{name="svNetwork",le="0.25"} 1840805 +tickTime_bucket{name="svNetwork",le="0.5"} 1840805 +tickTime_bucket{name="svNetwork",le="0.75"} 1840805 +tickTime_bucket{name="svNetwork",le="1"} 1840805 +tickTime_bucket{name="svNetwork",le="2.5"} 1840805 +tickTime_bucket{name="svNetwork",le="5"} 1840805 +tickTime_bucket{name="svNetwork",le="7.5"} 1840805 +tickTime_bucket{name="svNetwork",le="10"} 1840805 +tickTime_bucket{name="svNetwork",le="+Inf"} 1840805 +tickTime_count{name="svSync"} 2268704 +tickTime_sum{name="svSync"} 1091.617999988212 +tickTime_bucket{name="svSync",le="0.005"} 2267516 +tickTime_bucket{name="svSync",le="0.01"} 2268532 +tickTime_bucket{name="svSync",le="0.025"} 2268664 +tickTime_bucket{name="svSync",le="0.05"} 2268685 +tickTime_bucket{name="svSync",le="0.075"} 2268686 +tickTime_bucket{name="svSync",le="0.1"} 2268688 +tickTime_bucket{name="svSync",le="0.25"} 2268703 +tickTime_bucket{name="svSync",le="0.5"} 2268704 +tickTime_bucket{name="svSync",le="0.75"} 2268704 +tickTime_bucket{name="svSync",le="1"} 2268704 +tickTime_bucket{name="svSync",le="2.5"} 2268704 +tickTime_bucket{name="svSync",le="5"} 2268704 +tickTime_bucket{name="svSync",le="7.5"} 2268704 +tickTime_bucket{name="svSync",le="10"} 2268704 +tickTime_bucket{name="svSync",le="+Inf"} 2268704 +tickTime_count{name="svMain"} 355594 +tickTime_sum{name="svMain"} 1330.458999996208 +tickTime_bucket{name="svMain",le="0.005"} 299261 +tickTime_bucket{name="svMain",le="0.01"} 327819 +tickTime_bucket{name="svMain",le="0.025"} 352052 +tickTime_bucket{name="svMain",le="0.05"} 354360 +tickTime_bucket{name="svMain",le="0.075"} 354808 +tickTime_bucket{name="svMain",le="0.1"} 355262 +tickTime_bucket{name="svMain",le="0.25"} 355577 +tickTime_bucket{name="svMain",le="0.5"} 355591 +tickTime_bucket{name="svMain",le="0.75"} 355591 +tickTime_bucket{name="svMain",le="1"} 355592 +tickTime_bucket{name="svMain",le="2.5"} 355593 +tickTime_bucket{name="svMain",le="5"} 355593 +tickTime_bucket{name="svMain",le="7.5"} 355593 +tickTime_bucket{name="svMain",le="10"} 355593 +tickTime_bucket{name="svMain",le="+Inf"} 355594`; + +suite('parseRawPerf', () => { + it('should parse the perf data correctly', () => { + const result = parseRawPerf(perfValidExample); + expect(result.perfBoundaries).toEqual([0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, '+Inf']); + expect(result.perfMetrics.svNetwork.count).toBe(1840805); + expect(result.perfMetrics.svSync.count).toBe(2268704); + expect(result.perfMetrics.svMain.count).toBe(355594); + expect(result.perfMetrics.svSync.sum).toBe(1091.617999988212); + expect(result.perfMetrics.svMain.buckets).toEqual([299261, 28558, 24233, 2308, 448, 454, 315, 14, 0, 1, 1, 0, 0, 0, 1]); + }); + + it('should detect bad perf output', () => { + expect(() => parseRawPerf(null as any)).toThrow('string expected'); + expect(() => parseRawPerf('bad data')).toThrow('missing tickTime_'); + }); + + it('should detect server still booting', () => { + const perfNoMain = perfValidExample.replaceAll('svMain', 'idk'); + expect(() => parseRawPerf(perfNoMain)).toThrow('missing threads'); + }); + + it('should handle bad data', () => { + expect(() => parseRawPerf(123 as any)).toThrow('string expected'); + + let targetLine = 'tickTime_bucket{name="svMain",le="10"} 355593'; + let perfModifiedExample = perfValidExample.replace(targetLine, ''); + expect(() => parseRawPerf(perfModifiedExample)).toThrow('invalid bucket boundaries'); + + targetLine = 'tickTime_bucket{name="svNetwork",le="+Inf"} 1840805'; + perfModifiedExample = perfValidExample.replace(targetLine, ''); + expect(() => parseRawPerf(perfModifiedExample)).toThrow('invalid threads'); + + targetLine = 'tickTime_count{name="svNetwork"} 1840805'; + perfModifiedExample = perfValidExample.replace(targetLine, 'tickTime_count{name="svNetwork"} ????'); + expect(() => parseRawPerf(perfModifiedExample)).toThrow('invalid threads'); + }); +}); + + +suite('revertCumulativeBuckets', () => { + it('should convert the simplest case', () => { + const result = revertCumulativeBuckets([10, 20, 30]); + expect(result).toEqual([10, 10, 10]); + }); + + it('should convert a real case correctly', () => { + const result = revertCumulativeBuckets([299261, 327819, 352052, 354360, 354808, 355262, 355577, 355591, 355591, 355592, 355593, 355593, 355593, 355593, 355594]); + expect(result).toEqual([299261, 28558, 24233, 2308, 448, 454, 315, 14, 0, 1, 1, 0, 0, 0, 1]); + + }); + + it('should return same length', () => { + expect(revertCumulativeBuckets([]).length).toBe(0); + expect(revertCumulativeBuckets([1, 2, 3, 4, 5]).length).toBe(5); + expect(revertCumulativeBuckets(Array(9999).fill(0)).length).toBe(9999); + }); +}); diff --git a/core/modules/Metrics/svRuntime/perfParser.ts b/core/modules/Metrics/svRuntime/perfParser.ts new file mode 100644 index 0000000..6db983c --- /dev/null +++ b/core/modules/Metrics/svRuntime/perfParser.ts @@ -0,0 +1,166 @@ +import { PERF_DATA_BUCKET_COUNT } from "./config"; +import { isValidPerfThreadName, type SvRtPerfBoundariesType, type SvRtPerfCountsType } from "./perfSchemas"; + + +//Consts +const REGEX_BUCKET_BOUNDARIE = /le="(\d+(\.\d+)?|\+Inf)"/; +const REGEX_PERF_LINE = /tickTime_(count|sum|bucket)\{name="(svSync|svNetwork|svMain)"(,le="(\d+(\.\d+)?|\+Inf)")?\}\s(\S+)/; + + +/** + * Returns if the given thread name is a valid SvRtPerfThreadNamesType + */ +export const arePerfBoundariesValid = (boundaries: (number | string)[]): boundaries is SvRtPerfBoundariesType => { + // Check if the length is correct + if (boundaries.length !== PERF_DATA_BUCKET_COUNT) { + return false; + } + + // Check if the last item is +Inf + if (boundaries[boundaries.length - 1] !== '+Inf') { + return false; + } + + //Check any value is non-numeric except the last one + if (boundaries.slice(0, -1).some((val) => typeof val === 'string')) { + return false; + } + + // Check if the values only increase + for (let i = 1; i < boundaries.length - 1; i++) { + if (boundaries[i] <= boundaries[i - 1]) { + return false; + } + } + + return true; +} + + +/** + * Returns a buckets array with individual counts instead of cumulative counts + */ +export const revertCumulativeBuckets = (cumulativeCounts: number[]): number[] => { + const individualCounts = []; + for (let i = 0; i < cumulativeCounts.length; i++) { + const currCount = cumulativeCounts[i]; + if (typeof currCount !== 'number') throw new Error('number expected'); + if (!Number.isInteger(currCount)) throw new Error('integer expected'); + if (!Number.isFinite(currCount)) throw new Error('finite number expected'); + if (i === 0) { + individualCounts.push(currCount); + } else { + const lastCount = cumulativeCounts[i - 1] as number; + if (lastCount > currCount) throw new Error('retrograde cumulative count'); + individualCounts.push(currCount - lastCount); + } + } + return individualCounts; +}; + + +/** + * Parses the output of FXServer /perf/ in the proteus format + */ +export const parseRawPerf = (rawData: string) => { + if (typeof rawData !== 'string') throw new Error('string expected'); + const lines = rawData.trim().split('\n'); + const perfMetrics: SvRtPerfCountsType = { + svSync: { + count: 0, + sum: 0, + buckets: [], + }, + svNetwork: { + count: 0, + sum: 0, + buckets: [], + }, + svMain: { + count: 0, + sum: 0, + buckets: [], + }, + }; + + //Checking basic integrity + if(!rawData.includes('tickTime_')){ + throw new Error('missing tickTime_ in /perf/'); + } + if (!rawData.includes('svMain') || !rawData.includes('svNetwork') || !rawData.includes('svSync')) { + throw new Error('missing threads in /perf/'); + } + + //Extract bucket boundaries + const perfBoundaries = lines + .filter((line) => line.startsWith('tickTime_bucket{name="svMain"')) + .map((line) => { + const parsed = line.match(REGEX_BUCKET_BOUNDARIE); + if (parsed === null) { + return undefined; + } else if (parsed[1] === '+Inf') { + return '+Inf'; + } else { + return parseFloat(parsed[1]); + }; + }) + .filter((val): val is number | '+Inf' => { + return val !== undefined && (val === '+Inf' || isFinite(val)) + }) as SvRtPerfBoundariesType; //it's alright, will check later + if (!arePerfBoundariesValid(perfBoundaries)) { + throw new Error('invalid bucket boundaries'); + } + + //Parse lines + for (const line of lines) { + const parsed = line.match(REGEX_PERF_LINE); + if (parsed === null) continue; + const regType = parsed[1]; + const thread = parsed[2]; + const bucket = parsed[4]; + const value = parsed[6]; + if (!isValidPerfThreadName(thread)) continue; + + if (regType == 'count') { + const count = parseInt(value); + if (!isNaN(count)) perfMetrics[thread].count = count; + } else if (regType == 'sum') { + const sum = parseFloat(value); + if (!isNaN(sum)) perfMetrics[thread].sum = sum; + } else if (regType == 'bucket') { + //Check if the bucket is correct + const currBucketIndex = perfMetrics[thread].buckets.length; + const lastBucketIndex = PERF_DATA_BUCKET_COUNT - 1; + if (currBucketIndex === lastBucketIndex) { + if (bucket !== '+Inf') { + throw new Error(`unexpected last bucket to be +Inf and got ${bucket}`); + } + } else if (parseFloat(bucket) !== perfBoundaries[currBucketIndex]) { + throw new Error(`unexpected bucket ${bucket} at position ${currBucketIndex}`); + } + //Add the bucket + perfMetrics[thread].buckets.push(parseInt(value)); + } + } + + //Check perf validity + const invalid = Object.values(perfMetrics).filter((thread) => { + return ( + !Number.isInteger(thread.count) + || thread.count === 0 + || !Number.isFinite(thread.sum) + || thread.sum === 0 + || thread.buckets.length !== PERF_DATA_BUCKET_COUNT + ); + }); + if (invalid.length) { + throw new Error(`${invalid.length} invalid threads in /perf/`); + } + + //Reverse the cumulative buckets + perfMetrics.svSync.buckets = revertCumulativeBuckets(perfMetrics.svSync.buckets); + perfMetrics.svNetwork.buckets = revertCumulativeBuckets(perfMetrics.svNetwork.buckets); + perfMetrics.svMain.buckets = revertCumulativeBuckets(perfMetrics.svMain.buckets); + + return { perfBoundaries, perfMetrics }; +}; diff --git a/core/modules/Metrics/svRuntime/perfSchemas.ts b/core/modules/Metrics/svRuntime/perfSchemas.ts new file mode 100644 index 0000000..75dfece --- /dev/null +++ b/core/modules/Metrics/svRuntime/perfSchemas.ts @@ -0,0 +1,94 @@ +import * as z from 'zod'; +import { PERF_DATA_BUCKET_COUNT, PERF_DATA_THREAD_NAMES, SvRtPerfThreadNamesType } from './config'; +import { ValuesType } from 'utility-types'; + + +/** + * Type guards + */ +export const isValidPerfThreadName = (threadName: string): threadName is SvRtPerfThreadNamesType => { + return PERF_DATA_THREAD_NAMES.includes(threadName as SvRtPerfThreadNamesType); +} +export const isSvRtLogDataType = (log: ValuesType): log is SvRtLogDataType => { + return log.type === 'data'; +} + + +/** + * Schemas + */ +//Generic schemas +const zIntNonNegative = z.number().int().nonnegative(); +const zNumberNonNegative = z.number().nonnegative(); + +//Last perf stuff +export const SvRtPerfBoundariesSchema = z.array(z.union([ + zNumberNonNegative, + z.literal('+Inf'), +])); + +export const SvRtPerfCountsThreadSchema = z.object({ + count: zIntNonNegative, + buckets: z.array(zIntNonNegative).length(PERF_DATA_BUCKET_COUNT), + + //NOTE: the sum is literally used for nothing, + //could be used to calculate the tick time average though + sum: zNumberNonNegative, +}); + +export const SvRtPerfCountsSchema = z.object({ + svSync: SvRtPerfCountsThreadSchema, + svNetwork: SvRtPerfCountsThreadSchema, + svMain: SvRtPerfCountsThreadSchema, +}); + +//Log stuff +export const SvRtLogDataSchema = z.object({ + ts: zIntNonNegative, + type: z.literal('data'), + players: zIntNonNegative, + fxsMemory: zNumberNonNegative.nullable(), + nodeMemory: zNumberNonNegative.nullable(), + perf: SvRtPerfCountsSchema, +}); + +export const SvRtLogSvBootSchema = z.object({ + ts: zIntNonNegative, + type: z.literal('svBoot'), + duration: zIntNonNegative, +}); + +export const SvRtLogSvCloseSchema = z.object({ + ts: zIntNonNegative, + type: z.literal('svClose'), + reason: z.string(), +}); + +export const SvRtFileSchema = z.object({ + version: z.literal(1), + lastPerfBoundaries: SvRtPerfBoundariesSchema.optional(), + log: z.array(z.union([SvRtLogDataSchema, SvRtLogSvBootSchema, SvRtLogSvCloseSchema])), +}); + +export const SvRtNodeMemorySchema = z.object({ + //NOTE: technically it also has a type string, but we don't need to check it + used: zNumberNonNegative, + limit: zNumberNonNegative, +}); + + +//Exporting types +export type SvRtFileType = z.infer; +export type SvRtLogSvCloseType = z.infer; +export type SvRtLogSvBootType = z.infer; +export type SvRtLogDataType = z.infer; +export type SvRtLogType = (SvRtLogSvCloseType | SvRtLogSvBootType | SvRtLogDataType)[]; +export type SvRtPerfCountsType = z.infer; +export type SvRtPerfBoundariesType = z.infer; +export type SvRtNodeMemoryType = z.infer; + +export type SvRtPerfCountsThreadType = z.infer; +export type SvRtLogDataFilteredType = Omit & { + perf: SvRtPerfCountsThreadType +}; +export type SvRtLogFilteredType = (SvRtLogSvCloseType | SvRtLogSvBootType | SvRtLogDataFilteredType)[]; diff --git a/core/modules/Metrics/svRuntime/perfUtils.test.ts b/core/modules/Metrics/svRuntime/perfUtils.test.ts new file mode 100644 index 0000000..d9969ae --- /dev/null +++ b/core/modules/Metrics/svRuntime/perfUtils.test.ts @@ -0,0 +1,100 @@ +import { expect, it, suite } from 'vitest'; +import { diffPerfs, didPerfReset } from './perfUtils'; + + +suite('diffPerfs', () => { + it('should correctly calculate the difference between two performance snapshots', () => { + const oldPerf = { + svSync: { count: 10, sum: 20, buckets: [1, 2, 3] }, + svNetwork: { count: 15, sum: 30, buckets: [4, 5, 6] }, + svMain: { count: 20, sum: 40, buckets: [7, 8, 9] }, + }; + const newPerf = { + svSync: { count: 20, sum: 40, buckets: [2, 4, 6] }, + svNetwork: { count: 30, sum: 60, buckets: [8, 10, 12] }, + svMain: { count: 40, sum: 80, buckets: [14, 16, 18] }, + }; + const expectedDiff = { + svSync: { count: 10, sum: 20, buckets: [1, 2, 3] }, + svNetwork: { count: 15, sum: 30, buckets: [4, 5, 6] }, + svMain: { count: 20, sum: 40, buckets: [7, 8, 9] }, + }; + const result = diffPerfs(newPerf, oldPerf); + expect(result).toEqual(expectedDiff); + }); + + it('should correctly return the diff when there is no old data', () => { + const newPerf = { + shouldBeIgnored: { count: 20, sum: 40, buckets: [2, 4, 6] }, + svSync: { count: 20, sum: 40, buckets: [2, 4, 6] }, + svNetwork: { count: 30, sum: 60, buckets: [8, 10, 12] }, + svMain: { count: 40, sum: 80, buckets: [14, 16, 18] }, + }; + const expectedDiff = { + svSync: { count: 20, sum: 40, buckets: [2, 4, 6] }, + svNetwork: { count: 30, sum: 60, buckets: [8, 10, 12] }, + svMain: { count: 40, sum: 80, buckets: [14, 16, 18] }, + }; + const result = diffPerfs(newPerf); + expect(result).toEqual(expectedDiff); + }); +}); + + +suite('didPerfReset', () => { + it('should detect change in count in any thread', () => { + const oldPerf = { + svNetwork: { count: 10, sum: 0, buckets: [] }, + svSync: { count: 10, sum: 0, buckets: [] }, + svMain: { count: 10, sum: 0, buckets: [] }, + }; + const newPerf = { + svNetwork: { count: 10, sum: 0, buckets: [] }, + svSync: { count: 5, sum: 0, buckets: [] }, + svMain: { count: 10, sum: 0, buckets: [] }, + }; + expect(didPerfReset(newPerf, oldPerf)).toBe(true); + }); + + it('should detect change in sum in any thread', () => { + const oldPerf = { + svNetwork: { count: 0, sum: 10, buckets: [] }, + svSync: { count: 0, sum: 10, buckets: [] }, + svMain: { count: 0, sum: 10, buckets: [] }, + }; + const newPerf = { + svNetwork: { count: 0, sum: 10, buckets: [] }, + svSync: { count: 0, sum: 5, buckets: [] }, + svMain: { count: 0, sum: 10, buckets: [] }, + }; + expect(didPerfReset(newPerf, oldPerf)).toBe(true); + }); + + it('should detect reset - real case', () => { + const oldPerf = { + svNetwork: { count: 5940, sum: 0.08900000000000005, buckets: [] }, + svSync: { count: 7333, sum: 0.1400000000000001, buckets: [] }, + svMain: { count: 1209, sum: 0.1960000000000001, buckets: [] }, + }; + const newPerf = { + svSync: { count: 1451, sum: 0.01600000000000001, buckets: [] }, + svNetwork: { count: 1793, sum: 0.03900000000000003, buckets: [] }, + svMain: { count: 278, sum: 0.05300000000000004, buckets: [] }, + }; + expect(didPerfReset(newPerf, oldPerf)).toBe(true); + }); + + it('should detect progression - real case', () => { + const oldPerf = { + svSync: { count: 1451, sum: 0.01600000000000001, buckets: [] }, + svNetwork: { count: 1793, sum: 0.03900000000000003, buckets: [] }, + svMain: { count: 278, sum: 0.05300000000000004, buckets: [] }, + }; + const newPerf = { + svNetwork: { count: 8764, sum: 0.1030000000000001, buckets: [] }, + svSync: { count: 10815, sum: 0.1880000000000001, buckets: [] }, + svMain: { count: 1792, sum: 0.3180000000000002, buckets: [] }, + }; + expect(didPerfReset(newPerf, oldPerf)).toBe(false); + }); +}); diff --git a/core/modules/Metrics/svRuntime/perfUtils.ts b/core/modules/Metrics/svRuntime/perfUtils.ts new file mode 100644 index 0000000..29a4a11 --- /dev/null +++ b/core/modules/Metrics/svRuntime/perfUtils.ts @@ -0,0 +1,136 @@ +import pidusage from 'pidusage'; +import { cloneDeep } from 'lodash-es'; +import type { SvRtPerfCountsType } from "./perfSchemas"; +import got from '@lib/got'; +import { parseRawPerf } from './perfParser'; +import { PERF_DATA_BUCKET_COUNT } from './config'; +import { txEnv } from '@core/globalData'; + + +//Consts +const perfDataRawThreadsTemplate: SvRtPerfCountsType = { + svSync: { + count: 0, + sum: 0, + buckets: Array(PERF_DATA_BUCKET_COUNT).fill(0), + }, + svNetwork: { + count: 0, + sum: 0, + buckets: Array(PERF_DATA_BUCKET_COUNT).fill(0), + }, + svMain: { + count: 0, + sum: 0, + buckets: Array(PERF_DATA_BUCKET_COUNT).fill(0), + } +}; + + +/** + * Compares a perf snapshot with the one that came before + * NOTE: I could just clone the old perf data, but this way I guarantee the shape of the data + */ +export const diffPerfs = (newPerf: SvRtPerfCountsType, oldPerf?: SvRtPerfCountsType) => { + const basePerf = oldPerf ?? cloneDeep(perfDataRawThreadsTemplate); + return { + svSync: { + count: newPerf.svSync.count - basePerf.svSync.count, + sum: newPerf.svSync.sum - basePerf.svSync.sum, + buckets: newPerf.svSync.buckets.map((bucket, i) => bucket - basePerf.svSync.buckets[i]), + }, + svNetwork: { + count: newPerf.svNetwork.count - basePerf.svNetwork.count, + sum: newPerf.svNetwork.sum - basePerf.svNetwork.sum, + buckets: newPerf.svNetwork.buckets.map((bucket, i) => bucket - basePerf.svNetwork.buckets[i]), + }, + svMain: { + count: newPerf.svMain.count - basePerf.svMain.count, + sum: newPerf.svMain.sum - basePerf.svMain.sum, + buckets: newPerf.svMain.buckets.map((bucket, i) => bucket - basePerf.svMain.buckets[i]), + }, + }; +}; + + +/** + * Checks if any perf count/sum from any thread reset (if old > new) + */ +export const didPerfReset = (newPerf: SvRtPerfCountsType, oldPerf: SvRtPerfCountsType) => { + return ( + (oldPerf.svSync.count > newPerf.svSync.count) || + (oldPerf.svSync.sum > newPerf.svSync.sum) || + (oldPerf.svNetwork.count > newPerf.svNetwork.count) || + (oldPerf.svNetwork.sum > newPerf.svNetwork.sum) || + (oldPerf.svMain.count > newPerf.svMain.count) || + (oldPerf.svMain.sum > newPerf.svMain.sum) + ); +} + + +/** + * Transforms raw perf data into a frequency distribution (histogram) + * ForEach thread, individualize tick counts (instead of CumSum) and calculates frequency + */ +// export const perfCountsToHist = (threads: SvRtPerfCountsType) => { +// const currPerfFreqs: SvRtPerfHistType = { +// svSync: { +// count: threads.svSync.count, +// freqs: [], +// }, +// svNetwork: { +// count: threads.svNetwork.count, +// freqs: [], +// }, +// svMain: { +// count: threads.svMain.count, +// freqs: [], +// }, +// }; +// for (const [tName, tData] of Object.entries(threads)) { +// currPerfFreqs[tName as SvRtPerfThreadNamesType].freqs = tData.buckets.map((bucketValue, bucketIndex) => { +// const prevBucketValue = (bucketIndex) ? tData.buckets[bucketIndex - 1] : 0; +// return (bucketValue - prevBucketValue) / tData.count; +// }); +// } +// return currPerfFreqs; +// } + + +/** + * Requests /perf/, parses it and returns the raw perf data + */ +export const fetchRawPerfData = async (netEndpoint: string) => { + const currPerfRaw = await got(`http://${netEndpoint}/perf/`).text(); + return parseRawPerf(currPerfRaw); +} + + +/** + * Get the fxserver memory usage + * FIXME: migrate to use gwmi on windows by default + */ +export const fetchFxsMemory = async (fxsPid?: number) => { + if (!fxsPid) return; + try { + const pidUsage = await pidusage(fxsPid); + const memoryMb = pidUsage.memory / 1024 / 1024; + return parseFloat((memoryMb).toFixed(2)); + } catch (error) { + if ((error as any).code = 'ENOENT') { + console.error('Failed to get processes tree usage data.'); + if (!txCore.fxRunner.child?.isAlive) { + console.error('The server process is not running.'); + } if (txEnv.isWindows) { + console.error('This is probably because the `wmic` command is not available in your system.'); + console.error('If you are on Windows 11 or Windows Server 2025, you can enable it in the "Windows Features" settings.'); + } else { + console.error('This is probably because the `ps` command is not available in your system.'); + console.error('This command is part of the `procps` package in most Linux distributions.'); + } + return; + } else { + throw error; + } + } +} diff --git a/core/modules/Metrics/txRuntime/index.ts b/core/modules/Metrics/txRuntime/index.ts new file mode 100644 index 0000000..8a69809 --- /dev/null +++ b/core/modules/Metrics/txRuntime/index.ts @@ -0,0 +1,212 @@ +const modulename = 'TxRuntimeMetrics'; +import * as jose from 'jose'; +import consoleFactory from '@lib/console'; +import { MultipleCounter, QuantileArray } from '../statsUtils'; +import { txEnv, txHostConfig } from '@core/globalData'; +import { getHostStaticData } from '@lib/diagnostics'; +import fatalError from '@lib/fatalError'; +const console = consoleFactory(modulename); + + +//Consts +const JWE_VERSION = 13; +const statsPublicKeyPem = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2NCbB5DvpR7F8qHF9SyA +xJKv9lpGO2PiU5wYUmEQaa0IUrUZmQ8ivsoOyCZOGKN9PESsVyqZPx37fhtAIqNo +AXded6K6ortngEghqQloK3bi3hk8mclGXKmUhwimfrw77EIzd8dycSFQTwV+hiy6 +osF2150yfeGRnD1vGbc6iS7Ewer0Zh9rwghXnl/jTupVprQggrhVIg62ZxmrQ0Gd +lj9pVXSu6QV/rjNbAVIiLFBGTjsHIKORQWV32oCguXu5krNvI+2lCBpOowY2dTO/ ++TX0xXHgkGAQIdL0SdpD1SIe57hZsA2mOVitNwztE+KAhYsVBSqasGbly0lu7NDJ +oQIDAQAB +-----END PUBLIC KEY-----`; +const jweHeader = { + alg: 'RSA-OAEP-256', + enc: 'A256GCM', + kid: '2023-05-21_stats' +} satisfies jose.CompactJWEHeaderParameters; + +/** + * Responsible for collecting server runtime statistics + * NOTE: the register functions don't throw because we rather break stats than txAdmin itself + */ +export default class TxRuntimeMetrics { + #publicKey: jose.KeyLike | undefined; + + #fxServerBootSeconds: number | false = false; + public readonly loginOrigins = new MultipleCounter(); + public readonly loginMethods = new MultipleCounter(); + public readonly botCommands = new MultipleCounter(); + public readonly menuCommands = new MultipleCounter(); + public readonly banCheckTime = new QuantileArray(5000, 50); + public readonly whitelistCheckTime = new QuantileArray(5000, 50); + public readonly playersTableSearchTime = new QuantileArray(5000, 50); + public readonly historyTableSearchTime = new QuantileArray(5000, 50); + public readonly databaseSaveTime = new QuantileArray(1440, 60); + public readonly perfCollectionTime = new QuantileArray(1440, 60); + + public currHbData: string = '{"error": "not yet initialized in TxRuntimeMetrics"}'; + public monitorStats = { + healthIssues: { + fd3: 0, + http: 0, + }, + restartReasons: { + bootTimeout: 0, + close: 0, + heartBeat: 0, + healthCheck: 0, + both: 0, + }, + }; + + constructor() { + setImmediate(() => { + this.loadStatsPublicKey(); + }); + + //Delaying this because host static data takes 10+ seconds to be set + setTimeout(() => { + this.refreshHbData().catch((e) => { }); + }, 15_000); + + //Cron function + setInterval(() => { + this.refreshHbData().catch((e) => { }); + }, 60_000); + } + + + /** + * Parses the stats public key + */ + async loadStatsPublicKey() { + try { + this.#publicKey = await jose.importSPKI(statsPublicKeyPem, 'RS256'); + } catch (error) { + fatalError.StatsTxRuntime(0, 'Failed to load stats public key.', error); + } + } + + + /** + * Called by FxMonitor to keep track of the last boot time + */ + registerFxserverBoot(seconds: number) { + if (!Number.isInteger(seconds) || seconds < 0) { + this.#fxServerBootSeconds = false; + } + this.#fxServerBootSeconds = seconds; + console.verbose.debug(`FXServer booted in ${seconds} seconds.`); + } + + + /** + * Called by FxMonitor to keep track of the fxserver restart reasons + */ + registerFxserverRestart(reason: keyof typeof this.monitorStats.restartReasons) { + if (!(reason in this.monitorStats.restartReasons)) return; + this.monitorStats.restartReasons[reason]++; + } + + + /** + * Called by FxMonitor to keep track of the fxserver HB/HC failures + */ + registerFxserverHealthIssue(type: keyof typeof this.monitorStats.healthIssues) { + if (!(type in this.monitorStats.healthIssues)) return; + this.monitorStats.healthIssues[type]++; + } + + + /** + * Processes general txadmin stuff to generate the HB data. + * + * Stats Version Changelog: + * 6: added txStatsData.randIDFailures + * 7: changed web folder paths, which affect txStatsData.pageViews + * 8: removed discordBotStats and whitelistEnabled + * 9: totally new format + * 9: for tx v7, loginOrigin dropped the 'webpipe' and 'cfxre', + * and loginMethods dropped 'nui' and 'iframe' + * Did not change the version because its fully compatible. + * 10: deprecated pageViews because of the react migration + * 11: added playersTableSearchTime and historyTableSearchTime + * 12: changed perfSummary format + * 13: added providerName + * + * TODO: + * Use the average q5 and q95 to find out the buckets. + * Then start sending the buckets with counts instead of quantiles. + * Might be ok to optimize by joining both arrays, even if the buckets are not the same + * joinCheckTimes: [ + * [ban, wl], //bucket 1 + * [ban, wl], //bucket 2 + * ... + * ] + */ + async refreshHbData() { + //Make sure publicKey is loaded + if (!this.#publicKey) { + console.verbose.warn('Cannot refreshHbData because this.#publicKey is not set.'); + return; + } + + //Generate HB data + try { + const hostData = getHostStaticData(); + + //Prepare stats data + const statsData = { + //Static + providerName: txHostConfig.providerName, + isZapHosting: txEnv.isZapHosting, + isPterodactyl: txEnv.isPterodactyl, + osDistro: hostData.osDistro, + hostCpuModel: `${hostData.cpu.manufacturer} ${hostData.cpu.brand}`, + + //Passive runtime data + fxServerBootSeconds: this.#fxServerBootSeconds, + loginOrigins: this.loginOrigins, + loginMethods: this.loginMethods, + botCommands: txConfig.discordBot.enabled + ? this.botCommands + : false, + menuCommands: txConfig.gameFeatures.menuEnabled + ? this.menuCommands + : false, + banCheckTime: txConfig.banlist.enabled + ? this.banCheckTime + : false, + whitelistCheckTime: txConfig.whitelist.mode !== 'disabled' + ? this.whitelistCheckTime + : false, + playersTableSearchTime: this.playersTableSearchTime, + historyTableSearchTime: this.historyTableSearchTime, + + //Settings & stuff + adminCount: Array.isArray(txCore.adminStore.admins) ? txCore.adminStore.admins.length : 1, + banCheckingEnabled: txConfig.banlist.enabled, + whitelistMode: txConfig.whitelist.mode, + recipeName: txCore.cacheStore.get('deployer:recipe') ?? 'not_in_cache', + tmpConfigFlags: Object.entries(txConfig.gameFeatures) + .filter(([key, value]) => value) + .map(([key]) => key), + + //Processed stuff + playerDb: txCore.database.stats.getDatabaseStats(), + perfSummary: txCore.metrics.svRuntime.getServerPerfSummary(), + }; + + //Prepare output + const encodedHbData = new TextEncoder().encode(JSON.stringify(statsData)); + const jwe = await new jose.CompactEncrypt(encodedHbData) + .setProtectedHeader(jweHeader) + .encrypt(this.#publicKey); + this.currHbData = JSON.stringify({ '$statsVersion': JWE_VERSION, jwe }); + } catch (error) { + console.verbose.error('Error while updating stats data.'); + console.verbose.dir(error); + this.currHbData = JSON.stringify({ error: (error as Error).message }); + } + } +}; diff --git a/core/modules/Translator.ts b/core/modules/Translator.ts new file mode 100644 index 0000000..0a15e17 --- /dev/null +++ b/core/modules/Translator.ts @@ -0,0 +1,136 @@ +const modulename = 'Translator'; +import fs from 'node:fs'; +import Polyglot from 'node-polyglot'; +import { txEnv, txHostConfig } from '@core/globalData'; +import localeMap from '@shared/localeMap'; +import consoleFactory from '@lib/console'; +import fatalError from '@lib/fatalError'; +import type { UpdateConfigKeySet } from './ConfigStore/utils'; +import humanizeDuration, { HumanizerOptions } from 'humanize-duration'; +import { msToDuration } from '@lib/misc'; +import { z } from 'zod'; +const console = consoleFactory(modulename); + + +//Schema for the custom locale file +export const localeFileSchema = z.object({ + $meta: z.object({ + label: z.string().min(1), + humanizer_language: z.string().min(1), + }), +}).passthrough(); + + +/** + * Translation module built around Polyglot.js. + * The locale files are indexed by the localeMap in the shared folder. + */ +export default class Translator { + static readonly configKeysWatched = ['general.language']; + static readonly humanizerLanguages: string[] = humanizeDuration.getSupportedLanguages(); + + public readonly customLocalePath = txHostConfig.dataSubPath('locale.json'); + public canonical: string = 'en-GB'; //Using GB instead of US due to date/time formats + #polyglot: Polyglot | null = null; + + constructor() { + //Load language + this.setupTranslator(true); + } + + + /** + * Handle updates to the config by resetting the translator + */ + public handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) { + this.setupTranslator(false); + } + + + /** + * Setup polyglot instance + */ + setupTranslator(isFirstTime = false) { + try { + this.canonical = Intl.getCanonicalLocales(txConfig.general.language.replace(/_/g, '-'))[0]; + } catch (error) { + this.canonical = 'en-GB'; + } + + try { + const phrases = this.getLanguagePhrases(txConfig.general.language); + const polyglotOptions = { + allowMissing: false, + onMissingKey: (key: string) => { + console.error(`Missing key '${key}' from translation file.`, 'Translator'); + return key; + }, + phrases, + }; + this.#polyglot = new Polyglot(polyglotOptions); + } catch (error) { + if (isFirstTime) { + fatalError.Translator(0, 'Failed to load initial language file', error); + } else { + console.dir(error); + } + } + } + + + /** + * Loads a language file or throws Error. + */ + getLanguagePhrases(lang: string) { + if (localeMap[lang]?.$meta) { + //If its a known language + return localeMap[lang]; + + } else if (lang === 'custom') { + //If its a custom language + try { + return JSON.parse(fs.readFileSync( + this.customLocalePath, + 'utf8', + )); + } catch (error) { + throw new Error(`Failed to load '${this.customLocalePath}'. (${(error as Error).message})`); + } + + } else { + //If its an invalid language + throw new Error(`Language '${lang}' not found.`); + } + } + + + /** + * Perform a translation (polyglot.t) + */ + t(key: string, options = {}) { + if (!this.#polyglot) throw new Error(`polyglot not yet loaded`); + + try { + return this.#polyglot.t(key, options); + } catch (error) { + console.error(`Error performing a translation with key '${key}'`); + return key; + } + } + + + /** + * Humanizes & translates a duration in ms + */ + tDuration(ms: number, options: HumanizerOptions = {}) { + if (!this.#polyglot) throw new Error(`polyglot not yet loaded`); + + try { + const lang = this.#polyglot.t('$meta.humanizer_language') + return msToDuration(ms, { ...options, language: lang }); + } catch (error) { + console.error(`Error humanizing duration`, error); + return String(ms)+'ms'; + } + } +}; diff --git a/core/modules/UpdateChecker/index.ts b/core/modules/UpdateChecker/index.ts new file mode 100644 index 0000000..1edfbd5 --- /dev/null +++ b/core/modules/UpdateChecker/index.ts @@ -0,0 +1,157 @@ +const modulename = 'UpdateChecker'; +import { txEnv } from '@core/globalData'; +import consoleFactory from '@lib/console'; +import { UpdateDataType } from '@shared/otherTypes'; +import { UpdateAvailableEventType } from '@shared/socketioTypes'; +import { queryChangelogApi } from './queryChangelogApi'; +import { getUpdateRolloutDelay } from './updateRollout'; +const console = consoleFactory(modulename); + + +type CachedDelayType = { + ts: number, + diceRoll: number, +} + +/** + * Creates a cache string. + */ +const createCacheString = (delayData: CachedDelayType) => { + return `${delayData.ts},${delayData.diceRoll}`; +} + + +/** + * Parses the cached string. + * Format: "ts,diceRoll" + */ +const parseCacheString = (raw: any) => { + if (typeof raw !== 'string' || !raw) return; + const [ts, diceRoll] = raw.split(','); + const obj = { + ts: parseInt(ts), + diceRoll: parseInt(diceRoll), + } satisfies CachedDelayType; + if (isNaN(obj.ts) || isNaN(obj.diceRoll)) return; + return obj; +} + + +/** + * Rolls dice, gets integer between 0 and 100 + */ +const rollDice = () => { + return Math.floor(Math.random() * 101); +} + +const DELAY_CACHE_KEY = 'updateDelay'; + + +/** + * Module to check for updates and notify the user according to a rollout strategy randomly picked. + */ +export default class UpdateChecker { + txaUpdateData?: UpdateDataType; + fxsUpdateData?: UpdateDataType; + + constructor() { + //Check for updates ASAP + setImmediate(() => { + this.checkChangelog(); + }); + + //Check again every 15 mins + setInterval(() => { + this.checkChangelog(); + }, 15 * 60_000); + } + + + /** + * Check for txAdmin and FXServer updates + */ + async checkChangelog() { + const updates = await queryChangelogApi(); + if (!updates) return; + + //If fxserver, don't print anything, just update the data + if (updates.fxs) { + this.fxsUpdateData = { + version: updates.fxs.version, + isImportant: updates.fxs.isImportant, + } + } + + //If txAdmin update, check for delay before printing + if (updates.txa) { + //Setup delay data + const currTs = Date.now(); + let delayData: CachedDelayType; + const rawCache = txCore.cacheStore.get(DELAY_CACHE_KEY); + const cachedData = parseCacheString(rawCache); + if (cachedData) { + delayData = cachedData; + } else { + delayData = { + diceRoll: rollDice(), + ts: currTs, + } + txCore.cacheStore.set(DELAY_CACHE_KEY, createCacheString(delayData)); + } + + //Get the delay + const notifDelayDays = getUpdateRolloutDelay( + updates.txa.semverDiff, + txEnv.txaVersion.includes('-'), + delayData.diceRoll + ); + const notifDelayMs = notifDelayDays * 24 * 60 * 60 * 1000; + console.verbose.debug(`Update available, notification delayed by: ${notifDelayDays} day(s).`); + if (currTs - delayData.ts >= notifDelayMs) { + txCore.cacheStore.delete(DELAY_CACHE_KEY); + this.txaUpdateData = { + version: updates.txa.version, + isImportant: updates.txa.isImportant, + } + if (updates.txa.isImportant) { + console.error('This version of txAdmin is outdated.'); + console.error('Please update as soon as possible.'); + console.error('For more information: https://discord.gg/uAmsGa2'); + } else { + console.warn('This version of txAdmin is outdated.'); + console.warn('A patch (bug fix) update is available for txAdmin.'); + console.warn('If you are experiencing any kind of issue, please update now.'); + console.warn('For more information: https://discord.gg/uAmsGa2'); + } + } + } + + //Sending event to the UI + if (this.txaUpdateData || this.fxsUpdateData) { + txCore.webServer.webSocket.pushEvent('updateAvailable', { + fxserver: this.fxsUpdateData, + txadmin: this.txaUpdateData, + }); + } + } +}; + +/* + TODO: + Create an page with the changelog, that queries for the following endpoint and caches it for 15 minutes: + https://changelogs-live.fivem.net/api/changelog/versions/2385/2375?tag=server + Maybe even grab the data from commits: + https://changelogs-live.fivem.net/api/changelog/versions/5562 + Other relevant apis: + https://changelogs-live.fivem.net/api/changelog/versions/win32/server? (the one being used below) + https://changelogs-live.fivem.net/api/changelog/versions + https://api.github.com/repos/tabarra/txAdmin/releases (changelog in [].body) + + NOTE: old logic + if == recommended, you're fine + if > recommended && < optional, pls update to optional + if == optional, you're fine + if > optional && < latest, pls update to latest + if == latest, duh + if < critical, BIG WARNING +*/ diff --git a/core/modules/UpdateChecker/queryChangelogApi.ts b/core/modules/UpdateChecker/queryChangelogApi.ts new file mode 100644 index 0000000..dde271e --- /dev/null +++ b/core/modules/UpdateChecker/queryChangelogApi.ts @@ -0,0 +1,111 @@ +const modulename = 'UpdateChecker'; +import semver, { ReleaseType } from 'semver'; +import { z } from "zod"; +import got from '@lib/got'; +import { txEnv } from '@core/globalData'; +import consoleFactory from '@lib/console'; +import { UpdateDataType } from '@shared/otherTypes'; +import { fromError } from 'zod-validation-error'; +const console = consoleFactory(modulename); + + +//Schemas +const txVersion = z.string().refine( + (x) => x !== '0.0.0', + { message: 'must not be 0.0.0' } +); +const changelogRespSchema = z.object({ + recommended: z.coerce.number().positive(), + recommended_download: z.string().url(), + recommended_txadmin: txVersion, + optional: z.coerce.number().positive(), + optional_download: z.string().url(), + optional_txadmin: txVersion, + latest: z.coerce.number().positive(), + latest_download: z.string().url(), + latest_txadmin: txVersion, + critical: z.coerce.number().positive(), + critical_download: z.string().url(), + critical_txadmin: txVersion, +}); + +//Types +type DetailedUpdateDataType = { + semverDiff: ReleaseType; + version: string; + isImportant: boolean; +}; + +export const queryChangelogApi = async () => { + //GET changelog data + let apiResponse: z.infer; + try { + //perform request - cache busting every ~1.4h + const osTypeApiUrl = (txEnv.isWindows) ? 'win32' : 'linux'; + const cacheBuster = Math.floor(Date.now() / 5_000_000); + const reqUrl = `https://changelogs-live.fivem.net/api/changelog/versions/${osTypeApiUrl}/server?${cacheBuster}`; + const resp = await got(reqUrl).json() + apiResponse = changelogRespSchema.parse(resp); + } catch (error) { + let msg = (error as Error).message; + if(error instanceof z.ZodError){ + msg = fromError(error, { prefix: null }).message + } + console.verbose.warn(`Failed to retrieve FXServer/txAdmin update data with error: ${msg}`); + return; + } + + //Checking txAdmin version + let txaUpdateData: DetailedUpdateDataType | undefined; + try { + const isOutdated = semver.lt(txEnv.txaVersion, apiResponse.latest_txadmin); + if (isOutdated) { + const semverDiff = semver.diff(txEnv.txaVersion, apiResponse.latest_txadmin) ?? 'patch'; + const isImportant = (semverDiff === 'major' || semverDiff === 'minor'); + txaUpdateData = { + semverDiff, + isImportant, + version: apiResponse.latest_txadmin, + }; + } + } catch (error) { + console.verbose.warn('Error checking for txAdmin updates.'); + console.verbose.dir(error); + } + + //Checking FXServer version + let fxsUpdateData: UpdateDataType | undefined; + try { + if (txEnv.fxsVersion < apiResponse.critical) { + if (apiResponse.critical > apiResponse.recommended) { + fxsUpdateData = { + version: apiResponse.critical.toString(), + isImportant: true, + } + } else { + fxsUpdateData = { + version: apiResponse.recommended.toString(), + isImportant: true, + } + } + } else if (txEnv.fxsVersion < apiResponse.recommended) { + fxsUpdateData = { + version: apiResponse.recommended.toString(), + isImportant: true, + }; + } else if (txEnv.fxsVersion < apiResponse.optional) { + fxsUpdateData = { + version: apiResponse.optional.toString(), + isImportant: false, + }; + } + } catch (error) { + console.warn('Error checking for FXServer updates.'); + console.verbose.dir(error); + } + + return { + txa: txaUpdateData, + fxs: fxsUpdateData, + }; +}; diff --git a/core/modules/UpdateChecker/updateRollout.test.ts b/core/modules/UpdateChecker/updateRollout.test.ts new file mode 100644 index 0000000..a76e5ff --- /dev/null +++ b/core/modules/UpdateChecker/updateRollout.test.ts @@ -0,0 +1,65 @@ +import { it, expect, suite } from 'vitest'; +import { getUpdateRolloutDelay } from './updateRollout'; + +suite('getReleaseRolloutDelay', () => { + const fnc = getUpdateRolloutDelay; + + it('should handle invalid input', () => { + expect(fnc('minor', false, -1)).toBe(0); + expect(fnc('minor', false, 150)).toBe(0); + expect(fnc('aaaaaaa' as any, false, 5)).toBe(7); + }); + + it('should return 0 delay for 100% immediate pre-release update', () => { + expect(fnc('minor', true, 0)).toBe(0); + expect(fnc('minor', true, 50)).toBe(0); + expect(fnc('minor', true, 100)).toBe(0); + expect(fnc('major', true, 0)).toBe(0); + expect(fnc('major', true, 50)).toBe(0); + expect(fnc('major', true, 100)).toBe(0); + expect(fnc('patch', true, 0)).toBe(0); + expect(fnc('patch', true, 50)).toBe(0); + expect(fnc('patch', true, 100)).toBe(0); + expect(fnc('prepatch', true, 0)).toBe(0); + expect(fnc('prepatch', true, 50)).toBe(0); + expect(fnc('prepatch', true, 100)).toBe(0); + }); + + it('should return correct delay for major release based on dice roll', () => { + // First tier (5%) + let delay = fnc('major', false, 3); + expect(delay).toBe(0); + + // Second tier (5% < x <= 20%) + delay = fnc('major', false, 10); + expect(delay).toBe(2); + + // Third tier (remaining 80%) + delay = fnc('major', false, 50); + expect(delay).toBe(7); + }); + + it('should return correct delay for minor release based on dice roll', () => { + // First tier (10%) + let delay = fnc('minor', false, 5); + expect(delay).toBe(0); + + // Second tier (10% < x <= 40%) + delay = fnc('minor', false, 20); + expect(delay).toBe(2); + + // Third tier (remaining 60%) + delay = fnc('minor', false, 80); + expect(delay).toBe(4); + }); + + it('should return 0 delay for patch release for all dice rolls', () => { + const delay = fnc('patch', false, 50); + expect(delay).toBe(0); + }); + + it('should return 7-day delay for stable to pre-release', () => { + const delay = fnc('prerelease', false, 50); + expect(delay).toBe(7); + }); +}); diff --git a/core/modules/UpdateChecker/updateRollout.ts b/core/modules/UpdateChecker/updateRollout.ts new file mode 100644 index 0000000..8a62b3c --- /dev/null +++ b/core/modules/UpdateChecker/updateRollout.ts @@ -0,0 +1,69 @@ +import type { ReleaseType } from 'semver'; + +type RolloutStrategyType = { + pct: number, + delay: number +}[]; + + +/** + * Returns the delay in days for the update rollout based on the release type and the dice roll. + */ +export const getUpdateRolloutDelay = ( + releaseDiff: ReleaseType, + isCurrentPreRelease: boolean, + diceRoll: number, +): number => { + //Sanity check diceRoll + if (diceRoll < 0 || diceRoll > 100) { + return 0; + } + + let rolloutStrategy: RolloutStrategyType; + if (isCurrentPreRelease) { + // If you are on beta, it's probably really important to update immediately + rolloutStrategy = [ + { pct: 100, delay: 0 }, + ]; + } else if (releaseDiff === 'major') { + // 5% immediate rollout + // 20% after 2 days + // 100% after 7 days + rolloutStrategy = [ + { pct: 5, delay: 0 }, + { pct: 15, delay: 2 }, + { pct: 80, delay: 7 }, + ]; + } else if (releaseDiff === 'minor') { + // 10% immediate rollout + // 40% after 2 day + // 100% after 4 days + rolloutStrategy = [ + { pct: 10, delay: 0 }, + { pct: 30, delay: 2 }, + { pct: 60, delay: 4 }, + ]; + } else if (releaseDiff === 'patch') { + // Immediate rollout to everyone, probably correcting bugs + rolloutStrategy = [ + { pct: 100, delay: 0 }, + ]; + } else { + // Update notification from stable to pre-release should not happen, delay 7 days + rolloutStrategy = [ + { pct: 100, delay: 7 }, + ]; + } + + // Implement strategy based on diceRoll + let cumulativePct = 0; + for (const tier of rolloutStrategy) { + cumulativePct += tier.pct; + if (diceRoll <= cumulativePct) { + return tier.delay; + } + } + + // Default delay if somehow no tier is matched (which shouldn't happen) + return 0; +}; diff --git a/core/modules/WebServer/authLogic.ts b/core/modules/WebServer/authLogic.ts new file mode 100644 index 0000000..36c11ff --- /dev/null +++ b/core/modules/WebServer/authLogic.ts @@ -0,0 +1,269 @@ +const modulename = 'WebServer:AuthLogic'; +import { z } from "zod"; +import { txEnv } from '@core/globalData'; +import consoleFactory from '@lib/console'; +import type { SessToolsType } from "./middlewares/sessionMws"; +import { ReactAuthDataType } from "@shared/authApiTypes"; +const console = consoleFactory(modulename); + + +/** + * Admin class to be used as ctx.admin + */ +export class AuthedAdmin { + public readonly name: string; + public readonly isMaster: boolean; + public readonly permissions: string[]; + public readonly isTempPassword: boolean; + public readonly profilePicture: string | undefined; + public readonly csrfToken?: string; + + constructor(vaultAdmin: any, csrfToken?: string) { + this.name = vaultAdmin.name; + this.isMaster = vaultAdmin.master; + this.permissions = vaultAdmin.permissions; + this.isTempPassword = (typeof vaultAdmin.password_temporary !== 'undefined'); + this.csrfToken = csrfToken; + + const cachedPfp = txCore.cacheStore.get(`admin:picture:${vaultAdmin.name}`); + this.profilePicture = typeof cachedPfp === 'string' ? cachedPfp : undefined; + } + + /** + * Logs an action to the console and the action logger + */ + public logAction(action: string): void { + txCore.logger.admin.write(this.name, action); + }; + + /** + * Logs a command to the console and the action logger + */ + public logCommand(data: string): void { + txCore.logger.admin.write(this.name, data, 'command'); + }; + + /** + * Returns if admin has permission or not - no message is printed + */ + hasPermission(perm: string): boolean { + try { + if (perm === 'master') { + return this.isMaster; + } + return ( + this.isMaster + || this.permissions.includes('all_permissions') + || this.permissions.includes(perm) + ); + } catch (error) { + console.verbose.warn(`Error validating permission '${perm}' denied.`); + return false; + } + } + + /** + * Test for a permission and prints warn if test fails and verbose + */ + testPermission(perm: string, fromCtx: string): boolean { + if (!this.hasPermission(perm)) { + console.verbose.warn(`[${this.name}] Permission '${perm}' denied.`, fromCtx); + return false; + } + return true; + } + + /** + * Returns the data used for the frontend or sv_admins.lua + */ + getAuthData(): ReactAuthDataType { + return { + name: this.name, + permissions: this.isMaster ? ['all_permissions'] : this.permissions, + isMaster: this.isMaster, + isTempPassword: this.isTempPassword, + profilePicture: this.profilePicture, + csrfToken: this.csrfToken ?? 'not_set', + } + } +} + +export type AuthedAdminType = InstanceType; + + +/** + * Return type helper - null reason indicates nothing to print + */ +type AuthLogicReturnType = { + success: true, + admin: AuthedAdmin; +} | { + success: false; + rejectReason?: string; +}; +const successResp = (vaultAdmin: any, csrfToken?: string) => ({ + success: true, + admin: new AuthedAdmin(vaultAdmin, csrfToken), +} as const) +const failResp = (reason?: string) => ({ + success: false, + rejectReason: reason, +} as const) + + +/** + * ZOD schemas for session auth + */ +const validPassSessAuthSchema = z.object({ + type: z.literal('password'), + username: z.string(), + csrfToken: z.string(), + expiresAt: z.literal(false), + password_hash: z.string(), +}); +export type PassSessAuthType = z.infer; + +const validCfxreSessAuthSchema = z.object({ + type: z.literal('cfxre'), + username: z.string(), + csrfToken: z.string(), + expiresAt: z.number(), + identifier: z.string(), +}); +export type CfxreSessAuthType = z.infer; + +const validSessAuthSchema = z.discriminatedUnion('type', [ + validPassSessAuthSchema, + validCfxreSessAuthSchema +]); + + +/** + * Autentication logic used in both websocket and webserver, for both web and nui requests. + */ +export const checkRequestAuth = ( + reqHeader: { [key: string]: unknown }, + reqIp: string, + isLocalRequest: boolean, + sessTools: SessToolsType, +) => { + return typeof reqHeader['x-txadmin-token'] === 'string' + ? nuiAuthLogic(reqIp, isLocalRequest, reqHeader) + : normalAuthLogic(sessTools); +} + + +/** + * Autentication logic used in both websocket and webserver + */ +export const normalAuthLogic = ( + sessTools: SessToolsType +): AuthLogicReturnType => { + try { + // Getting session + const sess = sessTools.get(); + if (!sess) { + return failResp(); + } + + // Parsing session auth + const validationResult = validSessAuthSchema.safeParse(sess?.auth); + if (!validationResult.success) { + return failResp(); + } + const sessAuth = validationResult.data; + + // Checking for expiration + if (sessAuth.expiresAt !== false && Date.now() > sessAuth.expiresAt) { + return failResp(`Expired session from '${sess.auth?.username}'.`); + } + + // Searching for admin in AdminStore + const vaultAdmin = txCore.adminStore.getAdminByName(sessAuth.username); + if (!vaultAdmin) { + return failResp(`Admin '${sessAuth.username}' not found.`); + } + + // Checking for auth types + if (sessAuth.type === 'password') { + if (vaultAdmin.password_hash !== sessAuth.password_hash) { + return failResp(`Password hash doesn't match for '${sessAuth.username}'.`); + } + return successResp(vaultAdmin, sessAuth.csrfToken); + } else if (sessAuth.type === 'cfxre') { + if ( + typeof vaultAdmin.providers.citizenfx !== 'object' + || vaultAdmin.providers.citizenfx.identifier !== sessAuth.identifier + ) { + return failResp(`Cfxre identifier doesn't match for '${sessAuth.username}'.`); + } + return successResp(vaultAdmin, sessAuth.csrfToken); + } else { + return failResp('Invalid auth type.'); + } + } catch (error) { + console.debug(`Error validating session data: ${(error as Error).message}`); + return failResp('Error validating session data.'); + } +}; + + +/** + * Autentication & authorization logic used in for nui requests + */ +export const nuiAuthLogic = ( + reqIp: string, + isLocalRequest: boolean, + reqHeader: { [key: string]: unknown } +): AuthLogicReturnType => { + try { + // Check sus IPs + if ( + !isLocalRequest + && !txEnv.isZapHosting + && !txConfig.webServer.disableNuiSourceCheck + ) { + console.verbose.warn(`NUI Auth Failed: reqIp "${reqIp}" not a local or allowed address.`); + return failResp('Invalid Request: source'); + } + + // Check missing headers + if (typeof reqHeader['x-txadmin-token'] !== 'string') { + return failResp('Invalid Request: token header'); + } + if (typeof reqHeader['x-txadmin-identifiers'] !== 'string') { + return failResp('Invalid Request: identifiers header'); + } + + // Check token value + if (reqHeader['x-txadmin-token'] !== txCore.webServer.luaComToken) { + const expected = txCore.webServer.luaComToken; + const censoredExpected = expected.slice(0, 6) + '...' + expected.slice(-6); + console.verbose.warn(`NUI Auth Failed: token received '${reqHeader['x-txadmin-token']}' !== expected '${censoredExpected}'.`); + return failResp('Unauthorized: token value'); + } + + // Check identifier array + const identifiers = reqHeader['x-txadmin-identifiers'] + .split(',') + .filter((i) => i.length); + if (!identifiers.length) { + return failResp('Unauthorized: empty identifier array'); + } + + // Searching for admin in AdminStore + const vaultAdmin = txCore.adminStore.getAdminByIdentifiers(identifiers); + if (!vaultAdmin) { + if(!reqHeader['x-txadmin-identifiers'].includes('license:')) { + return failResp('Unauthorized: you do not have a license identifier, which means the server probably has sv_lan enabled. Please disable sv_lan and restart the server to use the in-game menu.'); + } else { + //this one is handled differently in resource/menu/client/cl_base.lua + return failResp('nui_admin_not_found'); + } + } + return successResp(vaultAdmin, undefined); + } catch (error) { + console.debug(`Error validating session data: ${(error as Error).message}`); + return failResp('Error validating auth header'); + } +}; diff --git a/core/modules/WebServer/ctxTypes.ts b/core/modules/WebServer/ctxTypes.ts new file mode 100644 index 0000000..df9dd3e --- /dev/null +++ b/core/modules/WebServer/ctxTypes.ts @@ -0,0 +1,40 @@ +import type { ParameterizedContext } from "koa"; +import type { CtxTxVars } from "./middlewares/ctxVarsMw"; +import type { CtxTxUtils } from "./middlewares/ctxUtilsMw"; +import type { AuthedAdminType } from "./authLogic"; +import type { SessToolsType } from "./middlewares/sessionMws"; +import { Socket } from "socket.io"; + + +//Right as it comes from Koa +export type RawKoaCtx = ParameterizedContext< + { [key: string]: unknown }, //state + { [key: string]: unknown }, //context + unknown //response +>; + +//After passing through the libs (session, serve, body parse, etc) +export type CtxWithSession = RawKoaCtx & { + sessTools: SessToolsType; + request: any; +} + +//After setupVarsMw +export type CtxWithVars = CtxWithSession & { + txVars: CtxTxVars; +} + +//After setupUtilsMw +export type InitializedCtx = CtxWithVars & CtxTxUtils; + +//After some auth middleware +export type AuthedCtx = InitializedCtx & { + admin: AuthedAdminType; + params: any; + request: any; +} + +//The socket.io version of "context" +export type SocketWithSession = Socket & { + sessTools: SessToolsType; +}; diff --git a/core/modules/WebServer/getReactIndex.ts b/core/modules/WebServer/getReactIndex.ts new file mode 100644 index 0000000..4962851 --- /dev/null +++ b/core/modules/WebServer/getReactIndex.ts @@ -0,0 +1,197 @@ +const modulename = 'WebCtxUtils'; +import fsp from "node:fs/promises"; +import path from "node:path"; +import type { InjectedTxConsts, ThemeType } from '@shared/otherTypes'; +import { txEnv, txDevEnv, txHostConfig } from "@core/globalData"; +import { AuthedCtx, CtxWithVars } from "./ctxTypes"; +import consts from "@shared/consts"; +import consoleFactory from '@lib/console'; +import { AuthedAdminType, checkRequestAuth } from "./authLogic"; +import { isString } from "@modules/CacheStore"; +const console = consoleFactory(modulename); + +// NOTE: it's not possible to remove the hardcoded import of the entry point in the index.html file +// even if you set the entry point manually in the vite config. +// Therefore, it was necessary to tag it with `data-prod-only` so it can be removed in dev mode. + +//Consts +const serverTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + +//Cache the index.html file unless in dev mode +let htmlFile: string; + +// NOTE: https://vitejs.dev/guide/backend-integration.html +const viteOrigin = txDevEnv.VITE_URL ?? 'doesnt-matter'; +const devModulesScript = ` + + `; + + +//Custom themes placeholder +export const tmpDefaultTheme = 'dark'; +export const tmpDefaultThemes = ['dark', 'light']; +export const tmpCustomThemes: ThemeType[] = [ + // { + // name: 'deep-purple', + // isDark: true, + // style: { + // "background": "274 93% 39%", + // "foreground": "269 9% 100%", + // "card": "274 79% 53%", + // "card-foreground": "270 48% 99%", + // "popover": "240 10% 3.9%", + // "popover-foreground": "270 48% 99%", + // "primary": "270 48% 99%", + // "primary-foreground": "240 5.9% 10%", + // "secondary": "240 3.7% 15.9%", + // "secondary-foreground": "270 48% 99%", + // "muted": "240 3.7% 15.9%", + // "muted-foreground": "240 5% 64.9%", + // "accent": "240 3.7% 15.9%", + // "accent-foreground": "270 48% 99%", + // "destructive": "0 62.8% 30.6%", + // "destructive-foreground": "270 48% 99%", + // "border": "273 79%, 53%", + // "input": "240 3.7% 15.9%", + // "ring": "240 4.9% 83.9%", + // } + // } +]; + + + +/** + * Returns the react index.html file with placeholders replaced + * FIXME: add favicon + */ +export default async function getReactIndex(ctx: CtxWithVars | AuthedCtx) { + //Read file if not cached + if (txDevEnv.ENABLED || !htmlFile) { + try { + const indexPath = txDevEnv.ENABLED + ? path.join(txDevEnv.SRC_PATH, '/panel/index.html') + : path.join(txEnv.txaPath, 'panel/index.html') + const rawHtmlFile = await fsp.readFile(indexPath, 'utf-8'); + + //Remove tagged lines (eg hardcoded entry point) depending on env + if (txDevEnv.ENABLED) { + htmlFile = rawHtmlFile.replaceAll(/.+data-prod-only.+\r?\n/gm, ''); + } else { + htmlFile = rawHtmlFile.replaceAll(/.+data-dev-only.+\r?\n/gm, ''); + } + } catch (error) { + if ((error as any).code == 'ENOENT') { + return `

⚠ index.html not found:

You probably deleted the 'citizen/system_resources/monitor/panel/index.html' file, or the folders above it.
`; + } else { + return `

⚠ index.html load error:

${(error as Error).message}
` + } + } + } + + //Checking if already logged in + const authResult = checkRequestAuth( + ctx.request.headers, + ctx.ip, + ctx.txVars.isLocalRequest, + ctx.sessTools + ); + let authedAdmin: AuthedAdminType | false = false; + if (authResult.success) { + authedAdmin = authResult.admin; + } + + //Preparing vars + const basePath = (ctx.txVars.isWebInterface) ? '/' : consts.nuiWebpipePath; + const injectedConsts: InjectedTxConsts = { + //env + fxsVersion: txEnv.fxsVersionTag, + fxsOutdated: txCore.updateChecker.fxsUpdateData, + txaVersion: txEnv.txaVersion, + txaOutdated: txCore.updateChecker.txaUpdateData, + serverTimezone, + isWindows: txEnv.isWindows, + isWebInterface: ctx.txVars.isWebInterface, + showAdvanced: (txDevEnv.ENABLED || console.isVerbose), + hasMasterAccount: txCore.adminStore.hasAdmins(true), + defaultTheme: tmpDefaultTheme, + customThemes: tmpCustomThemes.map(({ name, isDark }) => ({ name, isDark })), + adsData: txEnv.adsData, + providerLogo: txHostConfig.providerLogo, + providerName: txHostConfig.providerName, + hostConfigSource: txHostConfig.sourceName, + + //Login page info + server: { + name: txCore.cacheStore.getTyped('fxsRuntime:projectName', isString) ?? txConfig.general.serverName, + game: txCore.cacheStore.getTyped('fxsRuntime:gameName', isString), + icon: txCore.cacheStore.getTyped('fxsRuntime:iconFilename', isString), + }, + + //auth + preAuth: authedAdmin && authedAdmin.getAuthData(), + }; + + //Prepare placeholders + const replacers: { [key: string]: string } = {}; + replacers.basePath = ``; + replacers.ogTitle = `txAdmin - ${txConfig.general.serverName}`; + replacers.ogDescripttion = `Manage & Monitor your FiveM/RedM Server with txAdmin v${txEnv.txaVersion} atop FXServer ${txEnv.fxsVersion}`; + replacers.txConstsInjection = ``; + replacers.devModules = txDevEnv.ENABLED ? devModulesScript : ''; + + //Prepare custom themes style tag + if (tmpCustomThemes.length) { + const cssThemes = []; + for (const theme of tmpCustomThemes) { + const cssVars = []; + for (const [name, value] of Object.entries(theme.style)) { + cssVars.push(`--${name}: ${value};`); + } + cssThemes.push(`.theme-${theme.name} { ${cssVars.join(' ')} }`); + } + replacers.customThemesStyle = ``; + } else { + replacers.customThemesStyle = ''; + } + + //Setting the theme class from the cookie + const themeCookie = ctx.cookies.get('txAdmin-theme'); + if (themeCookie) { + if (tmpDefaultThemes.includes(themeCookie)) { + replacers.htmlClasses = themeCookie; + } else { + const selectedCustomTheme = tmpCustomThemes.find((theme) => theme.name === themeCookie); + if (!selectedCustomTheme) { + replacers.htmlClasses = tmpDefaultTheme; + } else { + const lightDarkSelector = selectedCustomTheme.isDark ? 'dark' : 'light'; + replacers.htmlClasses = `${lightDarkSelector} theme-${selectedCustomTheme.name}`; + } + } + } else { + replacers.htmlClasses = tmpDefaultTheme; + } + + //Replace + let htmlOut = htmlFile; + for (const [placeholder, value] of Object.entries(replacers)) { + const replacerRegex = new RegExp(`()?`, 'g'); + htmlOut = htmlOut.replaceAll(replacerRegex, value); + } + + //If in prod mode and NUI, replace the entry point with the local one + //This is required because of how badly the WebPipe handles "large" files + if (!txDevEnv.ENABLED) { + const base = ctx.txVars.isWebInterface ? `./` : `nui://monitor/panel/`; + htmlOut = htmlOut.replace(/src="\.\/index-(\w+(?:\.v\d+)?)\.js"/, `src="${base}index-$1.js"`); + htmlOut = htmlOut.replace(/href="\.\/index-(\w+(?:\.v\d+)?)\.css"/, `href="${base}index-$1.css"`); + } + + return htmlOut; +} diff --git a/core/modules/WebServer/index.ts b/core/modules/WebServer/index.ts new file mode 100644 index 0000000..df4d1f0 --- /dev/null +++ b/core/modules/WebServer/index.ts @@ -0,0 +1,270 @@ +const modulename = 'WebServer'; +import crypto from 'node:crypto'; +import path from 'node:path'; +import HttpClass from 'node:http'; + +import Koa from 'koa'; +import KoaBodyParser from 'koa-bodyparser'; +import KoaCors from '@koa/cors'; + +import { Server as SocketIO } from 'socket.io'; +import WebSocket from './webSocket'; + +import { customAlphabet } from 'nanoid'; +import dict49 from 'nanoid-dictionary/nolookalikes'; + +import { txDevEnv, txEnv, txHostConfig } from '@core/globalData'; +import router from './router'; +import consoleFactory from '@lib/console'; +import topLevelMw from './middlewares/topLevelMw'; +import ctxVarsMw from './middlewares/ctxVarsMw'; +import ctxUtilsMw from './middlewares/ctxUtilsMw'; +import { SessionMemoryStorage, koaSessMw, socketioSessMw } from './middlewares/sessionMws'; +import checkRateLimit from './middlewares/globalRateLimiter'; +import checkHttpLoad from './middlewares/httpLoadMonitor'; +import cacheControlMw from './middlewares/cacheControlMw'; +import fatalError from '@lib/fatalError'; +import { isProxy } from 'node:util/types'; +import serveStaticMw from './middlewares/serveStaticMw'; +import serveRuntimeMw from './middlewares/serveRuntimeMw'; +const console = consoleFactory(modulename); +const nanoid = customAlphabet(dict49, 32); + + +/** + * Module for the web server and socket.io. + * It defines behaviors through middlewares, and instantiates the Koa app and the SocketIO server. + */ +export default class WebServer { + public isListening = false; + public isServing = false; + private sessionCookieName: string; + public luaComToken: string; + //setupKoa + private app: Koa; + public sessionStore: SessionMemoryStorage; + private koaCallback: (req: any, res: any) => Promise; + //setupWebSocket + private io: SocketIO; + public webSocket: WebSocket; + //setupServerCallbacks + private httpServer?: HttpClass.Server; + + constructor() { + //Generate cookie key & luaComToken + const pathHash = crypto.createHash('shake256', { outputLength: 6 }) + .update(txEnv.profilePath) + .digest('hex'); + this.sessionCookieName = `tx:${pathHash}`; + this.luaComToken = nanoid(); + + + // =================== + // Setting up Koa + // =================== + this.app = new Koa(); + this.app.keys = ['txAdmin' + nanoid()]; + + // Some people might want to enable it, but we are not guaranteeing XFF security + // due to the many possible ways you can connect to koa. + // this.app.proxy = true; + + //Setting up app + //@ts-ignore: no clue what this error is, but i'd bet it's just bad koa types + this.app.on('error', (error, ctx) => { + if (!( + error.code?.startsWith('HPE_') + || error.code?.startsWith('ECONN') + || error.code === 'EPIPE' + || error.code === 'ECANCELED' + )) { + console.error(`Probably harmless error on ${ctx.path}`); + console.dir(error); + } + }); + + //Disable CORS on dev mode + if (txDevEnv.ENABLED) { + this.app.use(KoaCors()); + } + + //Setting up timeout/error/no-output/413 + this.app.use(topLevelMw); + + //Setting up additional middlewares: + this.app.use(serveRuntimeMw); + this.app.use(serveStaticMw({ + noCaching: txDevEnv.ENABLED, + cacheMaxAge: 30 * 60, //30 minutes + //Scan Limits: (v8-dev prod build: 56 files, 11.25MB) + limits: { + MAX_BYTES: 75 * 1024 * 1024, //75MB + MAX_FILES: 300, + MAX_DEPTH: 10, + MAX_TIME: 2 * 60 * 1000, //2 minutes + }, + roots: [ + txDevEnv.ENABLED + ? path.join(txDevEnv.SRC_PATH, 'panel/public') + : path.join(txEnv.txaPath, 'panel'), + path.join(txEnv.txaPath, 'web/public'), + ], + onReady: () => { + this.isServing = true; + }, + })); + + this.app.use(KoaBodyParser({ + // Heavy bodies can cause v8 mem exhaustion during a POST DDoS. + // The heaviest JSON is the /intercom/resources endpoint. + // Conservative estimate: 768kb/300b = 2621 resources + jsonLimit: '768kb', + })); + + //Custom stuff + this.sessionStore = new SessionMemoryStorage(); + this.app.use(cacheControlMw); + this.app.use(koaSessMw(this.sessionCookieName, this.sessionStore)); + this.app.use(ctxVarsMw); + this.app.use(ctxUtilsMw); + + //Setting up routes + const txRouter = router(); + this.app.use(txRouter.routes()); + this.app.use(txRouter.allowedMethods()); + this.app.use(async (ctx) => { + if (typeof ctx._matchedRoute === 'undefined') { + if (ctx.path.startsWith('/legacy')) { + ctx.status = 404; + console.verbose.warn(`Request 404 error: ${ctx.path}`); + return ctx.send('Not found.'); + } else if (ctx.path.endsWith('.map')) { + ctx.status = 404; + return ctx.send('Not found.'); + } else { + return ctx.utils.serveReactIndex(); + } + } + }); + this.koaCallback = this.app.callback(); + + + // =================== + // Setting up SocketIO + // =================== + this.io = new SocketIO(HttpClass.createServer(), { serveClient: false }); + this.io.use(socketioSessMw(this.sessionCookieName, this.sessionStore)); + this.webSocket = new WebSocket(this.io); + //@ts-ignore + this.io.on('connection', this.webSocket.handleConnection.bind(this.webSocket)); + + + // =================== + // Setting up Callbacks + // =================== + this.setupServerCallbacks(); + } + + + /** + * Handler for all HTTP requests + * Note: i gave up on typing these + */ + httpCallbackHandler(req: any, res: any) { + //Calls the appropriate callback + try { + // console.debug(`HTTP ${req.method} ${req.url}`); + if (!checkHttpLoad()) return; + if (!checkRateLimit(req?.socket?.remoteAddress)) return; + if (req.url.startsWith('/socket.io')) { + (this.io.engine as any).handleRequest(req, res); + } else { + this.koaCallback(req, res); + } + } catch (error) { } + } + + + /** + * Setup the HTTP server callbacks + */ + setupServerCallbacks() { + //Just in case i want to re-execute this function + this.isListening = false; + + //HTTP Server + try { + const listenErrorHandler = (error: any) => { + if (error.code !== 'EADDRINUSE') return; + fatalError.WebServer(0, [ + `Failed to start HTTP server, port ${error.port} is already in use.`, + 'Maybe you already have another txAdmin running in this port.', + 'If you want to run multiple txAdmin instances, check the documentation for the port convar.', + 'You can also try restarting the host machine.', + ]); + }; + //@ts-ignore + this.httpServer = HttpClass.createServer(this.httpCallbackHandler.bind(this)); + // this.httpServer = HttpClass.createServer((req, res) => { + // // const reqSize = parseInt(req.headers['content-length'] || '0'); + // // if (req.method === 'POST' && reqSize > 0) { + // // console.debug(chalk.yellow(bytes(reqSize)), `HTTP ${req.method} ${req.url}`); + // // } + + // this.httpCallbackHandler(req, res); + // // if(checkRateLimit(req?.socket?.remoteAddress)){ + // // this.httpCallbackHandler(req, res); + // // }else { + // // req.destroy(); + // // } + // }); + this.httpServer.on('error', listenErrorHandler); + + const netInterface = txHostConfig.netInterface ?? '0.0.0.0'; + if (txHostConfig.netInterface) { + console.warn(`Starting with interface ${txHostConfig.netInterface}.`); + console.warn('If the HTTP server doesn\'t start, this is probably the reason.'); + } + + this.httpServer.listen(txHostConfig.txaPort, netInterface, async () => { + //Sanity check on globals, to _guarantee_ all routes will have access to them + if (!txCore || isProxy(txCore) || !txConfig || !txManager) { + console.dir({ + txCore: Boolean(txCore), + txCoreType: isProxy(txCore) ? 'proxy' : 'not proxy', + txConfig: Boolean(txConfig), + txManager: Boolean(txManager), + }); + fatalError.WebServer(2, [ + 'The HTTP server started before the globals were ready.', + 'This error should NEVER happen.', + 'Please report it to the developers.', + ]); + } + if (txHostConfig.netInterface) { + console.ok(`Listening on ${netInterface}.`); + } + this.isListening = true; + }); + } catch (error) { + fatalError.WebServer(1, 'Failed to start HTTP server.', error); + } + } + + + /** + * handler for the shutdown event + */ + public handleShutdown() { + return this.webSocket.handleShutdown(); + } + + + /** + * Resetting lua comms token - called by fxRunner on spawnServer() + */ + resetToken() { + this.luaComToken = nanoid(); + console.verbose.debug('Resetting luaComToken.'); + } +}; diff --git a/core/modules/WebServer/middlewares/authMws.ts b/core/modules/WebServer/middlewares/authMws.ts new file mode 100644 index 0000000..7ebf6ab --- /dev/null +++ b/core/modules/WebServer/middlewares/authMws.ts @@ -0,0 +1,185 @@ +const modulename = 'WebServer:AuthMws'; +import consoleFactory from '@lib/console'; +import { checkRequestAuth } from "../authLogic"; +import { ApiAuthErrorResp, ApiToastResp, GenericApiErrorResp } from "@shared/genericApiTypes"; +import { InitializedCtx } from '../ctxTypes'; +import { txHostConfig } from '@core/globalData'; +const console = consoleFactory(modulename); + +const webLogoutPage = ` +

+ User logged out.
+ Redirecting to login page... +

+`; + + +/** + * For the hosting provider routes + */ +export const hostAuthMw = async (ctx: InitializedCtx, next: Function) => { + const docs = 'https://aka.cfx.re/txadmin-env-config'; + + //Token disabled + if (txHostConfig.hostApiToken === 'disabled') { + return await next(); + } + + //Token undefined + if (!txHostConfig.hostApiToken) { + return ctx.send({ + error: 'token not configured', + desc: 'need to configure the TXHOST_API_TOKEN environment variable to be able to use the status endpoint', + docs, + }); + } + + //Token available + let tokenProvided: string | undefined; + const headerToken = ctx.headers['x-txadmin-envtoken']; + if (typeof headerToken === 'string' && headerToken) { + tokenProvided = headerToken; + } + const paramsToken = ctx.query.envtoken; + if (typeof paramsToken === 'string' && paramsToken) { + tokenProvided = paramsToken; + } + if (headerToken && paramsToken) { + return ctx.send({ + error: 'token conflict', + desc: 'cannot use both header and query token', + docs, + }); + } + if (!tokenProvided) { + return ctx.send({ + error: 'token missing', + desc: 'a token needs to be provided in the header or query string', + docs, + }); + } + if (tokenProvided !== txHostConfig.hostApiToken) { + return ctx.send({ + error: 'invalid token', + desc: 'the token provided does not match the TXHOST_API_TOKEN environment variable', + docs, + }); + } + + return await next(); +}; + + +/** + * Intercom auth middleware + * This does not set ctx.admin and does not use session/cookies whatsoever. + * FIXME: add isLocalAddress check? + */ +export const intercomAuthMw = async (ctx: InitializedCtx, next: Function) => { + if ( + typeof ctx.request.body?.txAdminToken !== 'string' + || ctx.request.body.txAdminToken !== txCore.webServer.luaComToken + ) { + return ctx.send({ error: 'invalid token' }); + } + + await next(); +}; + +/** + * Used for the legacy web interface. + */ +export const webAuthMw = async (ctx: InitializedCtx, next: Function) => { + //Check auth + const authResult = checkRequestAuth( + ctx.request.headers, + ctx.ip, + ctx.txVars.isLocalRequest, + ctx.sessTools + ); + if (!authResult.success) { + ctx.sessTools.destroy(); + if (authResult.rejectReason) { + console.verbose.warn(`Invalid session auth: ${authResult.rejectReason}`); + } + return ctx.send(webLogoutPage); + } + + //Adding the admin to the context + ctx.admin = authResult.admin; + await next(); +}; + +/** + * API Authentication Middleware + */ +export const apiAuthMw = async (ctx: InitializedCtx, next: Function) => { + const sendTypedResp = (data: ApiAuthErrorResp | (ApiToastResp & GenericApiErrorResp)) => ctx.send(data); + + //Check auth + const authResult = checkRequestAuth( + ctx.request.headers, + ctx.ip, + ctx.txVars.isLocalRequest, + ctx.sessTools + ); + if (!authResult.success) { + ctx.sessTools.destroy(); + if (authResult.rejectReason && (authResult.rejectReason !== 'nui_admin_not_found' || console.isVerbose)) { + console.verbose.warn(`Invalid session auth: ${authResult.rejectReason}`); + } + return sendTypedResp({ + logout: true, + reason: authResult.rejectReason ?? 'no session' + }); + } + + //For web routes, we need to check the CSRF token + //For nui routes, we need to check the luaComToken, which is already done in nuiAuthLogic above + if (ctx.txVars.isWebInterface) { + const sessToken = authResult.admin?.csrfToken; //it should exist for nui because of authLogic + const headerToken = ctx.headers['x-txadmin-csrftoken']; + if (!sessToken || !headerToken || sessToken !== headerToken) { + console.verbose.warn(`Invalid CSRF token: ${ctx.path}`); + const msg = (headerToken) + ? 'Error: Invalid CSRF token, please refresh the page or try to login again.' + : 'Error: Missing HTTP header \'x-txadmin-csrftoken\'. This likely means your files are not updated or you are using some reverse proxy that is removing this header from the HTTP request.'; + + //Doing ApiAuthErrorResp & GenericApiErrorResp to maintain compatibility with all routes + //"error" is used by diagnostic, masterActions, playerlist, whitelist and possibly more + return sendTypedResp({ + type: 'error', + msg: msg, + error: msg + }); + } + } + + //Adding the admin to the context + ctx.admin = authResult.admin; + await next(); +}; diff --git a/core/modules/WebServer/middlewares/cacheControlMw.ts b/core/modules/WebServer/middlewares/cacheControlMw.ts new file mode 100644 index 0000000..6b26d5f --- /dev/null +++ b/core/modules/WebServer/middlewares/cacheControlMw.ts @@ -0,0 +1,16 @@ +import type { CtxWithVars } from "../ctxTypes"; +import type { Next } from 'koa'; + +/** + * Middleware responsible for setting the cache control headers (disabling it entirely) + * Since this comes after the koa-static middleware, it will only apply to the web routes + * This is important because even our react index.html is actually SSR with auth context + */ +export default async function cacheControlMw(ctx: CtxWithVars, next: Next) { + ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate'); + ctx.set('Surrogate-Control', 'no-store'); + ctx.set('Expires', '0'); + ctx.set('Pragma', 'no-cache'); + + return next(); +}; diff --git a/core/modules/WebServer/middlewares/ctxUtilsMw.ts b/core/modules/WebServer/middlewares/ctxUtilsMw.ts new file mode 100644 index 0000000..ab97cf1 --- /dev/null +++ b/core/modules/WebServer/middlewares/ctxUtilsMw.ts @@ -0,0 +1,229 @@ +const modulename = 'WebCtxUtils'; +import path from 'node:path'; +import fsp from 'node:fs/promises'; +import ejs from 'ejs'; +import xssInstancer from '@lib/xss.js'; +import { txDevEnv, txEnv, txHostConfig } from '@core/globalData'; +import consoleFactory from '@lib/console'; +import getReactIndex, { tmpCustomThemes } from '../getReactIndex'; +import { CtxTxVars } from './ctxVarsMw'; +import { Next } from 'koa'; +import { CtxWithVars } from '../ctxTypes'; +import consts from '@shared/consts'; +import { AuthedAdminType } from '../authLogic'; +const console = consoleFactory(modulename); + +//Types +export type CtxTxUtils = { + send: (data: T) => void; + utils: { + render: (view: string, data?: { headerTitle?: string, [key: string]: any }) => Promise; + error: (httpStatus?: number, message?: string) => void; + serveReactIndex: () => Promise; + legacyNavigateToPage: (href: string) => void; + }; +} + +//Helper functions +const xss = xssInstancer(); +const getRenderErrorText = (view: string, error: Error, data: any) => { + console.error(`Error rendering ${view}.`); + console.verbose.dir(error); + if (data?.discord?.token) data.discord.token = '[redacted]'; + return [ + '
',
+        `Error rendering '${view}'.`,
+        `Message: ${xss(error.message)}`,
+        'The data provided was:',
+        '================',
+        xss(JSON.stringify(data, null, 2)),
+        '
', + ].join('\n'); +}; +const getWebViewPath = (view: string) => { + if (view.includes('..')) throw new Error('Path Traversal?'); + return path.join(txEnv.txaPath, 'web', view + '.ejs'); +}; +const getJavascriptConsts = (allConsts: NonNullable = {}) => { + return Object.entries(allConsts) + .map(([name, val]) => `const ${name} = ${JSON.stringify(val)};`) + .join(' '); +}; +function getEjsOptions(filePath: string) { + const webTemplateRoot = path.resolve(txEnv.txaPath, 'web'); + const webCacheDir = path.resolve(txEnv.txaPath, 'web-cache', filePath); + return { + cache: true, + filename: webCacheDir, + root: webTemplateRoot, + views: [webTemplateRoot], + rmWhitespace: true, + async: true, + }; +} + +//Consts +const templateCache = new Map(); +const RESOURCE_PATH = 'nui://monitor/web/public/'; + +const legacyNavigateHtmlTemplate = ` +

+ Redirecting to {{href}}... +

+` + + +/** + * Loads re-usable base templates + */ +async function loadWebTemplate(name: string) { + if (txDevEnv.ENABLED || !templateCache.has(name)) { + try { + const rawTemplate = await fsp.readFile(getWebViewPath(name), 'utf-8'); + const compiled = ejs.compile(rawTemplate, getEjsOptions(name + '.ejs')); + templateCache.set(name, compiled); + } catch (error) { + if ((error as any).code == 'ENOENT') { + throw new Error([ + `The '${name}' template was not found:`, + `You probably deleted the 'citizen/system_resources/monitor/web/${name}.ejs' file, or the folders above it.` + ].join('\n')); + } else { + throw error; + } + } + } + + return templateCache.get(name); +} + + +/** + * Renders normal views. + * Footer and header are configured inside the view template itself. + */ +async function renderView( + view: string, + possiblyAuthedAdmin: AuthedAdminType | undefined, + data: any, + txVars: CtxTxVars, +) { + data.adminUsername = possiblyAuthedAdmin?.name ?? 'unknown user'; + data.adminIsMaster = possiblyAuthedAdmin && possiblyAuthedAdmin.isMaster; + data.profilePicture = possiblyAuthedAdmin?.profilePicture ?? 'img/default_avatar.png'; + data.isTempPassword = possiblyAuthedAdmin && possiblyAuthedAdmin.isTempPassword; + data.isLinux = !txEnv.isWindows; + data.showAdvanced = (txDevEnv.ENABLED || console.isVerbose); + + try { + return await loadWebTemplate(view).then(template => template(data)); + } catch (error) { + return getRenderErrorText(view, error as Error, data); + } +} + + +/** + * Middleware that adds some helper functions and data to the koa ctx object + */ +export default async function ctxUtilsMw(ctx: CtxWithVars, next: Next) { + //Shortcuts + const isWebInterface = ctx.txVars.isWebInterface; + + //Functions + const renderUtil = async (view: string, data?: { headerTitle?: string, [key: string]: any }) => { + //Typescript is very annoying + const possiblyAuthedAdmin = ctx.admin as AuthedAdminType | undefined; + + //Setting up legacy theme + let legacyTheme = ''; + const themeCookie = ctx.cookies.get('txAdmin-theme'); + if (!themeCookie || themeCookie === 'dark' || !isWebInterface) { + legacyTheme = 'theme--dark'; + } else { + const selectorTheme = tmpCustomThemes.find((theme) => theme.name === themeCookie); + if (selectorTheme?.isDark) { + legacyTheme = 'theme--dark'; + } + } + + // Setting up default render data: + const baseViewData = { + isWebInterface, + basePath: (isWebInterface) ? '/' : consts.nuiWebpipePath, + resourcePath: (isWebInterface) ? '' : RESOURCE_PATH, + serverName: txConfig.general.serverName, + uiTheme: legacyTheme, + fxServerVersion: txEnv.fxsVersionTag, + txAdminVersion: txEnv.txaVersion, + hostConfigSource: txHostConfig.sourceName, + jsInjection: getJavascriptConsts({ + isWebInterface: isWebInterface, + csrfToken: possiblyAuthedAdmin?.csrfToken ?? 'not_set', + TX_BASE_PATH: (isWebInterface) ? '' : consts.nuiWebpipePath, + PAGE_TITLE: data?.headerTitle ?? 'txAdmin', + }), + }; + + const renderData = Object.assign(baseViewData, data); + ctx.body = await renderView(view, possiblyAuthedAdmin, renderData, ctx.txVars); + ctx.type = 'text/html'; + }; + + const errorUtil = (httpStatus = 500, message = 'unknown error') => { + ctx.status = httpStatus; + ctx.body = { + status: 'error', + code: httpStatus, + message, + }; + }; + + //Legacy page util to navigate parent (react) to some page + //NOTE: in use by deployer/stepper.js and setup/get.js + const legacyNavigateToPage = (href: string) => { + ctx.body = legacyNavigateHtmlTemplate.replace(/{{href}}/g, href); + ctx.type = 'text/html'; + } + + const serveReactIndex = async () => { + ctx.body = await getReactIndex(ctx); + ctx.type = 'text/html'; + }; + + //Injecting utils and forwarding + ctx.utils = { + render: renderUtil, + error: errorUtil, + serveReactIndex, + legacyNavigateToPage, + }; + ctx.send = (data: T) => { + ctx.body = data; + }; + return next(); +}; diff --git a/core/modules/WebServer/middlewares/ctxVarsMw.ts b/core/modules/WebServer/middlewares/ctxVarsMw.ts new file mode 100644 index 0000000..b104122 --- /dev/null +++ b/core/modules/WebServer/middlewares/ctxVarsMw.ts @@ -0,0 +1,62 @@ +const modulename = 'WebServer:SetupVarsMw'; +import consoleFactory from '@lib/console'; +import consts from '@shared/consts'; +const console = consoleFactory(modulename); +import { Next } from "koa"; +import { CtxWithSession } from '../ctxTypes'; +import { isIpAddressLocal } from '@lib/host/isIpAddressLocal'; + +//The custom tx-related vars set to the ctx +export type CtxTxVars = { + isWebInterface: boolean; + realIP: string; + isLocalRequest: boolean; + hostType: 'localhost' | 'ip' | 'other'; +}; + + +/** + * Middleware responsible for setting up the ctx.txVars + */ +const ctxVarsMw = (ctx: CtxWithSession, next: Next) => { + //Prepare variables + const txVars: CtxTxVars = { + isWebInterface: typeof ctx.headers['x-txadmin-token'] !== 'string', + realIP: ctx.ip, + isLocalRequest: isIpAddressLocal(ctx.ip), + hostType: 'other', + }; + + //Setting up the user's host type + const host = ctx.request.host ?? 'none'; + if (host.startsWith('localhost') || host.startsWith('127.')) { + txVars.hostType = 'localhost'; + } else if (/^\d+\.\d+\.\d+\.\d+(?::\d+)?$/.test(host)) { + txVars.hostType = 'ip'; + } + + //Setting up the user's real ip from the webpipe + //NOTE: not used anywhere except rate limiter, and login logs. + if ( + typeof ctx.headers['x-txadmin-identifiers'] === 'string' + && typeof ctx.headers['x-txadmin-token'] === 'string' + && ctx.headers['x-txadmin-token'] === txCore.webServer.luaComToken + && txVars.isLocalRequest + ) { + const ipIdentifier = ctx.headers['x-txadmin-identifiers'] + .split(',') + .find((i) => i.substring(0, 3) === 'ip:'); + if (ipIdentifier) { + const srcIP = ipIdentifier.substring(3); + if (consts.regexValidIP.test(srcIP)) { + txVars.realIP = srcIP; + } + } + } + + //Injecting vars and continuing + ctx.txVars = txVars; + return next(); +} + +export default ctxVarsMw; diff --git a/core/modules/WebServer/middlewares/globalRateLimiter.ts b/core/modules/WebServer/middlewares/globalRateLimiter.ts new file mode 100644 index 0000000..05d8f95 --- /dev/null +++ b/core/modules/WebServer/middlewares/globalRateLimiter.ts @@ -0,0 +1,97 @@ +const modulename = 'WebServer:RateLimiter'; +import consoleFactory from '@lib/console'; +import { isIpAddressLocal } from '@lib/host/isIpAddressLocal'; +const console = consoleFactory(modulename); + + +/* + Expected requests per user per minute: + 50 usual + 800 live console with ingame + 2 web pages + 2300 very very very heavy behavior +*/ + + +//Config +const DDOS_THRESHOLD = 20_000; +const MAX_RPM_DEFAULT = 5000; +const MAX_RPM_UNDER_ATTACK = 2500; +const DDOS_COOLDOWN_MINUTES = 15; + +//Vars +const bannedIps = new Set(); +const reqsPerIp = new Map(); +let httpRequestsCounter = 0; +let bansPendingWarn: string[] = []; +let minutesSinceLastAttack = Number.MAX_SAFE_INTEGER; + + +/** + * Process the counts and declares a DDoS or not, as well as warns of new banned IPs. + * Note if the requests are under the DDOS_THRESHOLD, banned ips will be immediately unbanned, so + * in this case the rate limiter will only serve to limit instead of banning these IPs. + */ +setInterval(() => { + if (httpRequestsCounter > DDOS_THRESHOLD) { + minutesSinceLastAttack = 0; + const numberFormatter = new Intl.NumberFormat('en-US'); + console.majorMultilineError([ + 'You might be under a DDoS attack!', + `txAdmin got ${numberFormatter.format(httpRequestsCounter)} HTTP requests in the last minute.`, + `The attacker IP addresses have been blocked until ${DDOS_COOLDOWN_MINUTES} mins after the attack stops.`, + 'Make sure you have a proper firewall setup and/or a reverse proxy with rate limiting.', + 'You can join https://discord.gg/txAdmin for support.' + ]); + } else { + minutesSinceLastAttack++; + if (minutesSinceLastAttack > DDOS_COOLDOWN_MINUTES) { + bannedIps.clear(); + } + } + httpRequestsCounter = 0; + reqsPerIp.clear(); + if (bansPendingWarn.length) { + console.warn('IPs blocked:', bansPendingWarn.join(', ')); + bansPendingWarn = []; + } +}, 60_000); + + +/** + * Checks if an IP is allowed to make a request based on the rate limit per IP. + * The rate limit ignores local IPs. + * The limits are calculated based on requests per minute, which varies if under attack or not. + * All bans are cleared 15 minutes after the attack stops. + */ +const checkRateLimit = (remoteAddress: string) => { + // Sanity check on the ip + if (typeof remoteAddress !== 'string' || !remoteAddress.length) return false; + + // Counting requests per minute + httpRequestsCounter++; + + // Whitelist all local addresses + if (isIpAddressLocal(remoteAddress)) return true; + + // Checking if the IP is banned + if (bannedIps.has(remoteAddress)) return false; + + // Check rate and count request + const reqsCount = reqsPerIp.get(remoteAddress); + if (reqsCount !== undefined) { + const limit = minutesSinceLastAttack < DDOS_COOLDOWN_MINUTES + ? MAX_RPM_UNDER_ATTACK + : MAX_RPM_DEFAULT; + if (reqsCount > limit) { + bannedIps.add(remoteAddress); + bansPendingWarn.push(remoteAddress); + return false; + } + reqsPerIp.set(remoteAddress, reqsCount + 1); + } else { + reqsPerIp.set(remoteAddress, 1); + } + return true; +} + +export default checkRateLimit; diff --git a/core/modules/WebServer/middlewares/httpLoadMonitor.ts b/core/modules/WebServer/middlewares/httpLoadMonitor.ts new file mode 100644 index 0000000..4d1b2c0 --- /dev/null +++ b/core/modules/WebServer/middlewares/httpLoadMonitor.ts @@ -0,0 +1,80 @@ +const modulename = 'WebServer:PacketDropper'; +import v8 from 'node:v8'; +import bytes from 'bytes'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + +//Config +const RPM_CHECK_INTERVAL = 800; //multiples of 100 only +const HIGH_LOAD_RPM = 15_000; +const HEAP_USED_LIMIT = 0.9; +const REQ_BLOCKER_DURATION = 60; //seconds + +//Vars +let reqCounter = 0; +let lastCheckTime = Date.now(); +let isUnderHighLoad = false; +let acceptRequests = true; + +//Helpers +const getHeapUsage = () => { + const heapStats = v8.getHeapStatistics(); + return { + heapSize: bytes(heapStats.used_heap_size), + heapLimit: bytes(heapStats.heap_size_limit), + heapUsed: (heapStats.used_heap_size / heapStats.heap_size_limit), + }; +} + +/** + * Protects txAdmin against a massive HTTP load, no matter the source of the requests. + * It will print warnings if the server is under high load and block requests if heap is almost full. + */ +const checkHttpLoad = () => { + if (!acceptRequests) return false; + + //Check RPM + reqCounter++; + if (reqCounter >= RPM_CHECK_INTERVAL) { + reqCounter = 0; + const now = Date.now(); + const elapsedMs = now - lastCheckTime; + lastCheckTime = now; + const requestsPerMinute = Math.ceil((RPM_CHECK_INTERVAL / elapsedMs) * 60_000); + + if (requestsPerMinute > HIGH_LOAD_RPM) { + isUnderHighLoad = true; + console.warn(`txAdmin is under high HTTP load: ~${Math.round(requestsPerMinute / 1000)}k reqs/min.`); + } else { + isUnderHighLoad = false; + // console.debug(`${requestsPerMinute.toLocaleString()} rpm`); + } + } + + //Every 100 requests if under high load + if (isUnderHighLoad && reqCounter % 100 === 0) { + const { heapSize, heapLimit, heapUsed } = getHeapUsage(); + // console.debug((heapUsed * 100).toFixed(2) + '% heap'); + if (heapUsed > HEAP_USED_LIMIT) { + console.majorMultilineError([ + `Node.js's V8 engine heap is almost full: ${(heapUsed * 100).toFixed(2)}% (${heapSize}/${heapLimit})`, + `All HTTP requests will be blocked for the next ${REQ_BLOCKER_DURATION} seconds to prevent a crash.`, + 'Make sure you have a proper firewall setup and/or a reverse proxy with rate limiting.', + 'You can join https://discord.gg/txAdmin for support.' + ]); + //Resetting counter + reqCounter = 0; + + //Blocking requests + setting a timeout to unblock + acceptRequests = false; + setTimeout(() => { + acceptRequests = true; + console.warn('HTTP requests are now allowed again.'); + }, REQ_BLOCKER_DURATION * 1000); + } + } + + return acceptRequests; +}; + +export default checkHttpLoad; diff --git a/core/modules/WebServer/middlewares/serveRuntimeMw.ts b/core/modules/WebServer/middlewares/serveRuntimeMw.ts new file mode 100644 index 0000000..c3190e2 --- /dev/null +++ b/core/modules/WebServer/middlewares/serveRuntimeMw.ts @@ -0,0 +1,116 @@ +const modulename = 'WebServer:ServeStaticMw'; +import path from 'path'; +import consoleFactory from '@lib/console'; +import type { Next } from "koa"; +import type { RawKoaCtx } from '../ctxTypes'; +import { txDevEnv, txEnv } from '@core/globalData'; +import { getCompressedFile, type CompressionResult } from './serveStaticMw'; +const console = consoleFactory(modulename); + + +type RuntimeFile = CompressionResult & { + mime: string; +}; +type RuntimeFileCached = RuntimeFile & { + name: string; + date: string; +}; + +class LimitedCacheArray extends Array { + constructor(public readonly limit: number) { + super(); + } + add(name: string, file: RuntimeFile) { + if (this.length >= this.limit) this.shift(); + const toCache: RuntimeFileCached = { + name, + date: new Date().toUTCString(), + ...file, + }; + super.push(toCache); + return toCache; + } +} +const runtimeCache = new LimitedCacheArray(50); + + +const getServerIcon = async (fileName: string): Promise => { + const fileRegex = /^icon-(?[a-f0-9]{16})\.png(?:\?.*|$)/; + const iconHash = fileName.match(fileRegex)?.groups?.hash; + if (!iconHash) return undefined; + const localPath = path.resolve(txEnv.txaPath, '.runtime', `icon-${iconHash}.png`); + const fileData = await getCompressedFile(localPath); + return { + ...fileData, + mime: 'image/png' + }; +} + + +const serveFile = async (ctx: RawKoaCtx, file: RuntimeFileCached) => { + ctx.type = file.mime; + if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip') { + ctx.set('Content-Encoding', 'gzip'); + ctx.body = file.gz; + } else { + ctx.body = file.raw; + } + if (txDevEnv.ENABLED) { + ctx.set('Cache-Control', `public, max-age=0`); + } else { + ctx.set('Cache-Control', `public, max-age=1800`); //30 minutes + ctx.set('Last-Modified', file.date); + } +} + + +/** + * Middleware responsible for serving all the /.runtime/ files + */ +export default async function serveRuntimeMw(ctx: RawKoaCtx, next: Next) { + //Middleware pre-condition + if (!ctx.path.startsWith('/.runtime/') || ctx.method !== 'GET') { + return await next(); + } + + const fileNameRegex = /^\/\.runtime\/(?[^\/#\?]{3,64})/; + const fileName = ctx.path.match(fileNameRegex)?.groups?.file; + if (!fileName) { + return await next(); + } + + //Try to serve from cache first + for (let i = 0; i < runtimeCache.length; i++) { + const currCachedFile = runtimeCache[i]; + if (currCachedFile.name === fileName) { + serveFile(ctx, currCachedFile); + return; + } + } + + const handleError = (error: any) => { + console.verbose.error(`Failed serve runtime file: ${fileName}`); + console.verbose.dir(error); + ctx.status = 500; + ctx.body = 'Internal Server Error'; + } + + //Server icon + let runtimeFile: RuntimeFile | undefined; + try { + runtimeFile = await getServerIcon(fileName); + } catch (error) { + if ((error as any).code !== 'ENOENT') { + return handleError(error); + } + } + + //If the file was not found + if (runtimeFile) { + const cached = runtimeCache.add(fileName, runtimeFile); + serveFile(ctx, cached); + } else { + ctx.status = 404; + ctx.body = 'File not found'; + } +} diff --git a/core/modules/WebServer/middlewares/serveStaticMw.ts b/core/modules/WebServer/middlewares/serveStaticMw.ts new file mode 100644 index 0000000..326105f --- /dev/null +++ b/core/modules/WebServer/middlewares/serveStaticMw.ts @@ -0,0 +1,348 @@ +const modulename = 'WebServer:ServeStaticMw'; +import path from 'path'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import consoleFactory from '@lib/console'; +import type { Next } from "koa"; +import type { RawKoaCtx } from '../ctxTypes'; +import zlib from 'node:zlib'; +import bytes from 'bytes'; +import { promisify } from 'util'; +const console = consoleFactory(modulename); + +const gzip = promisify(zlib.gzip); + +class ScanLimitError extends Error { + constructor( + code: string, + upperLimit: number, + current: number, + ) { + super(`${code}: ${current} > ${upperLimit}`); + } +} + + +/** + * MARK: Types + */ +type ScanLimits = { + MAX_BYTES?: number; + MAX_FILES?: number; + MAX_DEPTH?: number; + MAX_TIME?: number; +} + +type ServeStaticMwOpts = { + noCaching: boolean; + onReady: () => void; + cacheMaxAge: number; + roots: string[]; + limits: ScanLimits; +} + +type ScanFolderState = { + files: StaticFileCache[], + bytes: number, + tsStart: number, + elapsedMs: number, +} + +type ScanFolderOpts = { + rootPath: string, + state: ScanFolderState, + limits: ScanLimits, +} + +export type CompressionResult = { + raw: Buffer, + gz: Buffer, +} +type StaticFilePath = { + url: string, +} +type StaticFileCache = CompressionResult & StaticFilePath; + + +/** + * MARK: Caching Methods + */ +//Compression helper +const compressGzip = async (buffer: Buffer) => { + return gzip(buffer, { chunkSize: 64 * 1024, level: 4 }); +}; + + +//Reads and compresses a file +export const getCompressedFile = async (fullPath: string) => { + const raw = await fsp.readFile(fullPath); + const gz = await compressGzip(raw); + return { raw, gz }; +}; + + +//FIXME: This is a temporary function +const checkFileWhitelist = (rootPath: string, url: string) => { + if (!rootPath.endsWith('panel')) return true; + const nonHashedFiles = [ + '/favicon_default.svg', + '/favicon_offline.svg', + '/favicon_online.svg', + '/favicon_partial.svg', + '/index.html', + '/img/discord.png', + '/img/zap_login.png', + '/img/zap_main.png' + ]; + return nonHashedFiles.includes(url) || url.includes('.v800.'); +} + + +//Scans a folder and returns all files processed with size and count limits +export const scanStaticFolder = async ({ rootPath, state, limits }: ScanFolderOpts) => { + //100ms precision for elapsedMs + let timerId + if (limits.MAX_TIME) { + timerId = setInterval(() => { + state.elapsedMs = Date.now() - state.tsStart; + }, 100); + } + + let addedFiles = 0; + let addedBytes = 0; + const foldersToScan: string[][] = [[]]; + while (foldersToScan.length > 0) { + const currFolderPath = foldersToScan.pop()!; + const currentFolderUrl = path.posix.join(...currFolderPath); + const currentFolderAbs = path.join(rootPath, ...currFolderPath); + + //Ensure we don't go over the limits + if (limits.MAX_DEPTH && currFolderPath.length > limits.MAX_DEPTH) { + throw new ScanLimitError('MAX_DEPTH', limits.MAX_DEPTH, currFolderPath.length); + } + + const entries = await fsp.readdir(currentFolderAbs, { withFileTypes: true }); + for (const entry of entries) { + if (limits.MAX_FILES && state.files.length > limits.MAX_FILES) { + console.error('MAX_FILES ERROR', 'This likely means you did not erase the previous artifact files before adding new ones.'); + throw new ScanLimitError('MAX_FILES', limits.MAX_FILES, state.files.length); + } else if (limits.MAX_BYTES && state.bytes > limits.MAX_BYTES) { + console.error('MAX_BYTES ERROR', 'This likely means you did not erase the previous artifact files before adding new ones.'); + throw new ScanLimitError('MAX_BYTES', limits.MAX_BYTES, state.bytes); + } else if (limits.MAX_TIME && state.elapsedMs > limits.MAX_TIME) { + throw new ScanLimitError('MAX_TIME', limits.MAX_TIME, state.elapsedMs); + } + + if (entry.isDirectory()) { + //Queue the folder for scanning + if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) { + foldersToScan.push([...currFolderPath, entry.name]); + } + } else if (entry.isFile()) { + //Process the file + const entryPathUrl = '/' + path.posix.join(currentFolderUrl, entry.name); + if (!checkFileWhitelist(rootPath, entryPathUrl)) { + console.verbose.debug(`Skipping unknown file ${entryPathUrl}`); + continue; + } + + const entryPathAbs = path.join(currentFolderAbs, entry.name); + const fileData = await getCompressedFile(entryPathAbs); + state.bytes += fileData.raw.length; + state.files.push({ + url: entryPathUrl, + ...fileData, + }); + addedFiles++; + addedBytes += fileData.raw.length; + } + } + } + + if (timerId) { + clearInterval(timerId); + } + + return { addedFiles, addedBytes }; +}; + + +//Pre-processes all the static files +const preProcessFiles = async (opts: ServeStaticMwOpts) => { + const scanState: ScanFolderState = { + files: [], + bytes: 0, + tsStart: Date.now(), + elapsedMs: 0, + } + for (const rootPath of opts.roots) { + const res = await scanStaticFolder({ + rootPath, + state: scanState, + limits: opts.limits, + }); + console.verbose.debug(`Cached ${res.addedFiles} (${bytes(res.addedBytes)}) files from '${rootPath}'.`); + } + + const gzSize = scanState.files.reduce((acc, file) => acc + file.gz.length, 0); + // console.dir({ + // rawSize: bytes(rawSize), + // gzSize: bytes(gzSize), + // gzPct: (gzSize / rawSize * 100).toFixed(2) + '%', + // }); + return { + files: scanState.files, + memSize: scanState.bytes + gzSize, + }; +} + + +/** + * MARK: Cache Bootstrap + */ +const cacheDate = new Date().toUTCString(); //probably fine :P +let cachedFiles: StaticFileCache[] | undefined; +let bootstrapPromise: Promise | null = null; +let bootstrapLastRun: number | null = null; + +// Bootstraps the cache with state tracking +const bootstrapCache = async (opts: ServeStaticMwOpts): Promise => { + // Skip if already bootstrapped or running + if (bootstrapPromise) return bootstrapPromise; + if (cachedFiles) return; + + const now = Date.now(); + if (bootstrapLastRun && now - bootstrapLastRun < 15 * 1000) { + return console.warn('bootstrapCache recently failed, skipping new attempt for cooldown.'); + } + + bootstrapLastRun = now; + bootstrapPromise = (async () => { + const tsStart = now; + const { files, memSize } = await preProcessFiles(opts); + cachedFiles = files; + const elapsed = Date.now() - tsStart; + console.verbose.debug(`Cached ${files.length} static files (${bytes(memSize)} in memory) in ${elapsed}ms`); + opts.onReady(); + })(); + + try { + await bootstrapPromise; + } finally { + bootstrapPromise = null; // Clear the promise regardless of success or failure + } +}; + + +/** + * MARK: Prod Middleware + */ +const serveStaticMwProd = (opts: ServeStaticMwOpts) => async (ctx: RawKoaCtx, next: Next) => { + if (ctx.method !== 'HEAD' && ctx.method !== 'GET') { + return await next(); + } + + // Skip bootstrap if cache is ready + if (!cachedFiles) { + try { + await bootstrapCache(opts); + } catch (error) { + console.error(`Failed to bootstrap static files cache:`); + console.dir(error); + } + } + if (!cachedFiles) { + return ctx.throw(503, 'Service Unavailable: Static files cache not ready'); + } + + //Check if the file is in the cache + let staticFile: StaticFileCache | undefined; + for (let i = 0; i < cachedFiles.length; i++) { + const currCachedFile = cachedFiles[i]; + if (currCachedFile.url === ctx.path) { + staticFile = currCachedFile; + break; + } + } + if (!staticFile) return await next(); + + //Check if the client supports gzip + //NOTE: dropped brotli, it's not worth the hassle + if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip') { + ctx.set('Content-Encoding', 'gzip'); + ctx.body = staticFile.gz; + } else { + ctx.body = staticFile.raw; + } + + //Determine the MIME type based on the original file extension + ctx.type = path.extname(staticFile.url); // This sets the appropriate Content-Type header based on the extension + + //Set the client caching behavior (kinda conflicts with cacheControlMw) + //NOTE: The legacy URLs already contain the `txVer` param to bust the cache, so 30 minutes should be fine + ctx.set('Cache-Control', `public, max-age=${opts.cacheMaxAge}`); + ctx.set('Last-Modified', cacheDate); +}; + + +/** + * MARK: Dev Middleware + */ +const serveStaticMwDev = (opts: ServeStaticMwOpts) => async (ctx: RawKoaCtx, next: Next) => { + if (ctx.method !== 'HEAD' && ctx.method !== 'GET') { + return await next(); + } + + const isValidPath = (urlPath: string) => { + if (urlPath[0] !== '/') return false; + if (urlPath.indexOf('\0') !== -1) return false; + const traversalRegex = /(?:^|[\\/])\.\.(?:[\\/]|$)/; + if (traversalRegex.test(path.normalize('./' + urlPath))) return false; + if (!path.extname(urlPath)) return false; + return true; + } + if (!isValidPath(ctx.path)) return await next(); + + const tryAcquireFileStream = async (filePath: string) => { + try { + const stat = await fsp.stat(filePath); + if (stat.isFile()) { + return fs.createReadStream(filePath); + } + } catch (error) { + if ((error as any).code === 'ENOENT') return; + console.error(`Failed to create file read stream: ${filePath}`); + console.dir(error); + } + } + + //Look for it in the roots + let readStream: fs.ReadStream | undefined; + for (const rootPath of opts.roots) { + readStream = await tryAcquireFileStream(path.join(rootPath, ctx.path)); + if (readStream) break; + } + if (!readStream) return await next(); + + ctx.body = readStream; + ctx.type = path.extname(ctx.path); + ctx.set('Cache-Control', `public, max-age=0`); +} + + +/** + * Middleware responsible for serving all the static files. + * For prod environments, it will cache all the files in memory, pre-compressed. + */ +export default function serveStaticMw(opts: ServeStaticMwOpts) { + if (opts.noCaching) { + opts.onReady(); + return serveStaticMwDev(opts); + } else { + bootstrapCache(opts).catch((error) => { + console.error(`Failed to bootstrap static files cache:`); + console.dir(error); + }); + return serveStaticMwProd(opts); + } +} diff --git a/core/modules/WebServer/middlewares/sessionMws.ts b/core/modules/WebServer/middlewares/sessionMws.ts new file mode 100644 index 0000000..d9ce39c --- /dev/null +++ b/core/modules/WebServer/middlewares/sessionMws.ts @@ -0,0 +1,189 @@ +import { UserInfoType } from "@modules/AdminStore/providers/CitizenFX"; +import type { CfxreSessAuthType, PassSessAuthType } from "../authLogic"; +import { LRUCacheWithDelete } from "mnemonist"; +import { RawKoaCtx } from "../ctxTypes"; +import { Next } from "koa"; +import { randomUUID } from 'node:crypto'; +import { Socket } from "socket.io"; +import { parse as cookieParse } from 'cookie'; +import { SetOption as KoaCookieSetOption } from "cookies"; +import type { DeepReadonly } from 'utility-types'; + +//Types +export type ValidSessionType = { + auth?: PassSessAuthType | CfxreSessAuthType; + tmpOauthLoginStateKern?: string; //uuid v4 + tmpOauthLoginCallbackUri?: string; //the URI provided to the IDMS as a callback + tmpAddMasterUserInfo?: UserInfoType; +} +export type SessToolsType = { + get: () => DeepReadonly | undefined; + set: (sess: ValidSessionType) => void; + destroy: () => void; +} +type StoredSessionType = { + expires: number; + data: ValidSessionType; +} + +/** + * Storage for the sessions + */ +export class SessionMemoryStorage { + private readonly sessions = new LRUCacheWithDelete(5000); + public readonly maxAgeMs = 24 * 60 * 60 * 1000; + + constructor(maxAgeMs?: number) { + if (maxAgeMs) { + this.maxAgeMs = maxAgeMs; + } + + //Cleanup every 5 mins + setInterval(() => { + const now = Date.now(); + for (const [key, sess] of this.sessions) { + if (sess.expires < now) { + this.sessions.delete(key); + } + } + }, 5 * 60_000); + } + + get(key: string) { + const stored = this.sessions.get(key); + if (!stored) return; + if (stored.expires < Date.now()) { + this.sessions.delete(key); + return; + } + return stored.data as DeepReadonly; + } + + set(key: string, sess: ValidSessionType) { + this.sessions.set(key, { + expires: Date.now() + this.maxAgeMs, + data: sess, + }); + } + + refresh(key: string) { + const stored = this.sessions.get(key); + if (!stored) return; + this.sessions.set(key, { + expires: Date.now() + this.maxAgeMs, + data: stored.data, + }); + } + + destroy(key: string) { + return this.sessions.delete(key); + } + + get size() { + return this.sessions.size; + } +} + + +/** + * Helper to check if the session id is valid + */ +const isValidSessId = (sessId: string) => { + if (typeof sessId !== 'string') return false; + if (sessId.length !== 36) return false; + return true; +} + + +/** + * Middleware factory to add sessTools to the koa context. + */ +export const koaSessMw = (cookieName: string, store: SessionMemoryStorage) => { + const cookieOptions = { + path: '/', + maxAge: store.maxAgeMs, + httpOnly: true, + sameSite: 'lax', + secure: false, + overwrite: true, + signed: false, + + } as KoaCookieSetOption; + + //Middleware + return (ctx: RawKoaCtx, next: Next) => { + const sessGet = () => { + const sessId = ctx.cookies.get(cookieName); + if (!sessId || !isValidSessId(sessId)) return; + const stored = store.get(sessId); + if (!stored) return; + ctx._refreshSessionCookieId = sessId; + return stored; + } + + const sessSet = (sess: ValidSessionType) => { + const sessId = ctx.cookies.get(cookieName); + if (!sessId || !isValidSessId(sessId)) { + const newSessId = randomUUID(); + ctx.cookies.set(cookieName, newSessId, cookieOptions); + store.set(newSessId, sess); + } else { + store.set(sessId, sess); + } + } + + const sessDestroy = () => { + const sessId = ctx.cookies.get(cookieName); + if (!sessId || !isValidSessId(sessId)) return; + store.destroy(sessId); + ctx.cookies.set(cookieName, 'unset', cookieOptions); + } + + ctx.sessTools = { + get: sessGet, + set: sessSet, + destroy: sessDestroy, + } satisfies SessToolsType; + + try { + return next(); + } catch (error) { + throw error; + } finally { + if (typeof ctx._refreshSessionCookieId === 'string') { + ctx.cookies.set(cookieName, ctx._refreshSessionCookieId, cookieOptions); + store.refresh(ctx._refreshSessionCookieId); + } + } + } +} + + +/** + * Middleware factory to add sessTools to the socket context. + * + * NOTE: The set() and destroy() functions are NO-OPs because we cannot set cookies in socket.io, + * but that's fine since socket pages are always acompanied by a web page + * the authLogic only needs to get the cookie, and the webAuthMw only destroys it + * and webSocket.handleConnection() just drops if authLogic fails. + */ +export const socketioSessMw = (cookieName: string, store: SessionMemoryStorage) => { + return async (socket: Socket & { sessTools?: SessToolsType }, next: Function) => { + const sessGet = () => { + const cookiesString = socket?.handshake?.headers?.cookie; + if (typeof cookiesString !== 'string') return; + const cookies = cookieParse(cookiesString); + const sessId = cookies[cookieName]; + if (!sessId || !isValidSessId(sessId)) return; + return store.get(sessId); + } + + socket.sessTools = { + get: sessGet, + set: (sess: ValidSessionType) => { }, + destroy: () => { }, + } satisfies SessToolsType; + + return next(); + } +} diff --git a/core/modules/WebServer/middlewares/topLevelMw.ts b/core/modules/WebServer/middlewares/topLevelMw.ts new file mode 100644 index 0000000..6efa48f --- /dev/null +++ b/core/modules/WebServer/middlewares/topLevelMw.ts @@ -0,0 +1,108 @@ +const modulename = 'WebServer:TopLevelMw'; +import { txEnv } from '@core/globalData'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); +import { Next } from "koa"; +import { RawKoaCtx } from '../ctxTypes'; + +//Token Bucket (Rate Limiter) +const maxTokens = 20; +const tokensPerInterval = 5; +let availableTokens = maxTokens; +let suppressedErrors = 0; +setInterval(() => { + availableTokens = Math.min(availableTokens + tokensPerInterval, maxTokens); + if (suppressedErrors) { + console.warn(`Suppressed ${suppressedErrors} errors to prevent log spam.`); + suppressedErrors = 0; + } +}, 5_000); +const consumePrintToken = () => { + if (availableTokens > 0) { + availableTokens--; + return true; + } + suppressedErrors++; + return false; +} + + +//Consts +const timeoutLimit = 47 * 1000; //REQ_TIMEOUT_REALLY_REALLY_LONG is 45s + +/** + * Middleware responsible for timeout/error/no-output/413 + */ +const topLevelMw = async (ctx: RawKoaCtx, next: Next) => { + ctx.set('Server', `txAdmin v${txEnv.txaVersion}`); + let timerId; + const timeout = new Promise((_, reject) => { + timerId = setTimeout(() => { + reject(new Error('route_timed_out')); + }, timeoutLimit); + }); + try { + await Promise.race([timeout, next()]); + if (typeof ctx.body == 'undefined' || (typeof ctx.body == 'string' && !ctx.body.length)) { + console.verbose.warn(`Route without output: ${ctx.path}`); + return ctx.body = '[no output from route]'; + } + } catch (e) { + const error = e as any; //this has all been previously validated + const prefix = `[txAdmin v${txEnv.txaVersion}]`; + const reqPath = (ctx.path.length > 80) ? `${ctx.path.slice(0, 77)}...` : ctx.path; + const methodName = (error.stack && error.stack[0] && error.stack[0].name) ? error.stack[0].name : 'anonym'; + + //NOTE: I couldn't force xss on path message, but just in case I'm forcing it here + //but it is overwritten by koa when we set the body to an object, which is fine + ctx.type = 'text/plain'; + ctx.set('X-Content-Type-Options', 'nosniff'); + + //NOTE: not using HTTP logger endpoint anymore, FD3 only + if (error.type === 'entity.too.large') { + const desc = `Entity too large for: ${reqPath}`; + ctx.status = 413; + ctx.body = { error: desc }; + if (consumePrintToken()) console.verbose.error(desc, methodName); + } else if (error.type === 'stream.not.readable') { + const desc = `Stream Not Readable: ${reqPath}`; + ctx.status = 422; //"Unprocessable Entity" kinda matches + ctx.body = { error: desc }; + if (consumePrintToken()) console.verbose.warn(desc, methodName); + } else if (error.message === 'route_timed_out') { + const desc = `${prefix} Route timed out: ${reqPath}`; + ctx.status = 408; + ctx.body = desc; + if (consumePrintToken()) console.error(desc, methodName); + } else if (error.message === 'Malicious Path' || error.message === 'failed to decode') { + const desc = `${prefix} Malicious Path: ${reqPath}`; + ctx.status = 406; + ctx.body = desc; + if (consumePrintToken()) console.verbose.error(desc, methodName); + } else if (error.message.startsWith('Unexpected token')) { + const desc = `${prefix} Invalid JSON for: ${reqPath}`; + ctx.status = 400; + ctx.body = { error: desc }; + if (consumePrintToken()) console.verbose.error(desc, methodName); + } else { + const desc = [ + `${prefix} Internal Error.`, + `Route: ${reqPath}`, + `Message: ${error.message}`, + 'Make sure your txAdmin is updated.', + ].join('\n'); + ctx.status = 500; + ctx.body = desc; + if (consumePrintToken()) { + console.error(desc, methodName); + console.verbose.dir(error); + } + } + } finally { + //Cannot forget about this or the ctx will only be released from memory after the timeout, + //making it easier to crash the server in a DDoS attack + clearTimeout(timerId); + } +} + +export default topLevelMw; diff --git a/core/modules/WebServer/router.ts b/core/modules/WebServer/router.ts new file mode 100644 index 0000000..b5a0d8d --- /dev/null +++ b/core/modules/WebServer/router.ts @@ -0,0 +1,134 @@ +import { txDevEnv } from '@core/globalData'; +import Router from '@koa/router'; +import KoaRateLimit from 'koa-ratelimit'; + +import * as routes from '@routes/index'; +import { apiAuthMw, hostAuthMw, intercomAuthMw, webAuthMw } from './middlewares/authMws'; + + +/** + * Router factory + */ +export default () => { + const router = new Router(); + const authLimiter = KoaRateLimit({ + driver: 'memory', + db: new Map(), + duration: txConfig.webServer.limiterMinutes * 60 * 1000, // 15 minutes + errorMessage: JSON.stringify({ + //Duplicated to maintain compatibility with all auth api routes + error: `Too many attempts. Blocked for ${txConfig.webServer.limiterMinutes} minutes.`, + errorTitle: 'Too many attempts.', + errorMessage: `Blocked for ${txConfig.webServer.limiterMinutes} minutes.`, + }), + max: txConfig.webServer.limiterAttempts, + disableHeader: true, + id: (ctx: any) => ctx.txVars.realIP, + }); + + //Rendered Pages + router.get('/legacy/adminManager', webAuthMw, routes.adminManager_page); + router.get('/legacy/advanced', webAuthMw, routes.advanced_page); + router.get('/legacy/cfgEditor', webAuthMw, routes.cfgEditor_page); + router.get('/legacy/diagnostics', webAuthMw, routes.diagnostics_page); + router.get('/legacy/masterActions', webAuthMw, routes.masterActions_page); + router.get('/legacy/resources', webAuthMw, routes.resources); + router.get('/legacy/serverLog', webAuthMw, routes.serverLog); + router.get('/legacy/whitelist', webAuthMw, routes.whitelist_page); + router.get('/legacy/setup', webAuthMw, routes.setup_get); + router.get('/legacy/deployer', webAuthMw, routes.deployer_stepper); + + //Authentication + router.get('/auth/self', apiAuthMw, routes.auth_self); + router.post('/auth/password', authLimiter, routes.auth_verifyPassword); + router.post('/auth/logout', authLimiter, routes.auth_logout); + router.post('/auth/addMaster/pin', authLimiter, routes.auth_addMasterPin); + router.post('/auth/addMaster/callback', authLimiter, routes.auth_addMasterCallback); + router.post('/auth/addMaster/save', authLimiter, routes.auth_addMasterSave); + router.get('/auth/cfxre/redirect', authLimiter, routes.auth_providerRedirect); + router.post('/auth/cfxre/callback', authLimiter, routes.auth_providerCallback); + router.post('/auth/changePassword', apiAuthMw, routes.auth_changePassword); + router.get('/auth/getIdentifiers', apiAuthMw, routes.auth_getIdentifiers); + router.post('/auth/changeIdentifiers', apiAuthMw, routes.auth_changeIdentifiers); + + //Admin Manager + router.post('/adminManager/getModal/:modalType', webAuthMw, routes.adminManager_getModal); + router.post('/adminManager/:action', apiAuthMw, routes.adminManager_actions); + + //Settings + router.post('/setup/:action', apiAuthMw, routes.setup_post); + router.get('/deployer/status', apiAuthMw, routes.deployer_status); + router.post('/deployer/recipe/:action', apiAuthMw, routes.deployer_actions); + router.get('/settings/configs', apiAuthMw, routes.settings_getConfigs); + router.post('/settings/configs/:card', apiAuthMw, routes.settings_saveConfigs); + router.get('/settings/banTemplates', apiAuthMw, routes.settings_getBanTemplates); + router.post('/settings/banTemplates', apiAuthMw, routes.settings_saveBanTemplates); + router.post('/settings/resetServerDataPath', apiAuthMw, routes.settings_resetServerDataPath); + + //Master Actions + router.get('/masterActions/backupDatabase', webAuthMw, routes.masterActions_getBackup); + router.post('/masterActions/:action', apiAuthMw, routes.masterActions_actions); + + //FXServer + router.post('/fxserver/controls', apiAuthMw, routes.fxserver_controls); + router.post('/fxserver/commands', apiAuthMw, routes.fxserver_commands); + router.get('/fxserver/downloadLog', webAuthMw, routes.fxserver_downloadLog); + router.post('/fxserver/schedule', apiAuthMw, routes.fxserver_schedule); + + //CFG Editor + router.post('/cfgEditor/save', apiAuthMw, routes.cfgEditor_save); + + //Control routes + router.post('/intercom/:scope', intercomAuthMw, routes.intercom); + + //Diagnostic routes + router.post('/diagnostics/sendReport', apiAuthMw, routes.diagnostics_sendReport); + router.post('/advanced', apiAuthMw, routes.advanced_actions); + + //Data routes + router.get('/serverLog/partial', apiAuthMw, routes.serverLogPartial); + router.get('/systemLog/:scope', apiAuthMw, routes.systemLogs); + router.get('/perfChartData/:thread', apiAuthMw, routes.perfChart); + router.get('/playerDropsData', apiAuthMw, routes.playerDrops); + + /* + FIXME: reorganizar TODAS rotas de logs, incluindo listagem e download + /logs/:logpage - WEB + /logs/:log/list - API + /logs/:log/partial - API + /logs/:log/download - WEB + + */ + + //History routes + router.get('/history/stats', apiAuthMw, routes.history_stats); + router.get('/history/search', apiAuthMw, routes.history_search); + router.get('/history/action', apiAuthMw, routes.history_actionModal); + router.post('/history/:action', apiAuthMw, routes.history_actions); + + //Player routes + router.get('/player', apiAuthMw, routes.player_modal); + router.get('/player/stats', apiAuthMw, routes.player_stats); + router.get('/player/search', apiAuthMw, routes.player_search); + router.post('/player/checkJoin', intercomAuthMw, routes.player_checkJoin); + router.post('/player/:action', apiAuthMw, routes.player_actions); + router.get('/whitelist/:table', apiAuthMw, routes.whitelist_list); + router.post('/whitelist/:table/:action', apiAuthMw, routes.whitelist_actions); + + //Host routes + router.get('/host/status', hostAuthMw, routes.host_status); + + //DevDebug routes - no auth + if (txDevEnv.ENABLED) { + router.get('/dev/:scope', routes.dev_get); + router.post('/dev/:scope', routes.dev_post); + }; + + //Insights page mock + // router.get('/insights', (ctx) => { + // return ctx.utils.render('main/insights', { headerTitle: 'Insights' }); + // }); + + //Return router + return router; +}; diff --git a/core/modules/WebServer/webSocket.ts b/core/modules/WebServer/webSocket.ts new file mode 100644 index 0000000..1771cf8 --- /dev/null +++ b/core/modules/WebServer/webSocket.ts @@ -0,0 +1,293 @@ +const modulename = 'WebSocket'; +import { Server as SocketIO, Socket, RemoteSocket } from 'socket.io'; +import consoleFactory from '@lib/console'; +import statusRoom from './wsRooms/status'; +import dashboardRoom from './wsRooms/dashboard'; +import playerlistRoom from './wsRooms/playerlist'; +import liveconsoleRoom from './wsRooms/liveconsole'; +import serverlogRoom from './wsRooms/serverlog'; +import { AuthedAdminType, checkRequestAuth } from './authLogic'; +import { SocketWithSession } from './ctxTypes'; +import { isIpAddressLocal } from '@lib/host/isIpAddressLocal'; +import { txEnv } from '@core/globalData'; +const console = consoleFactory(modulename); + +//Types +export type RoomCommandHandlerType = { + permission: string | true; + handler: (admin: AuthedAdminType, ...args: any) => any +} + +export type RoomType = { + permission: string | true; + eventName: string; + cumulativeBuffer: boolean; + outBuffer: any; + commands?: Record; + initialData: () => any; +} + +//NOTE: quen adding multiserver, create dynamic rooms like playerlist# +const VALID_ROOMS = ['status', 'dashboard', 'liveconsole', 'serverlog', 'playerlist'] as const; +type RoomNames = typeof VALID_ROOMS[number]; + + +//Helpers +const getIP = (socket: SocketWithSession) => { + return socket?.request?.socket?.remoteAddress ?? 'unknown'; +}; +const terminateSession = (socket: SocketWithSession, reason: string, shouldLog = true) => { + try { + socket.emit('logout', reason); + socket.disconnect(); + if (shouldLog) { + console.verbose.warn('SocketIO', 'dropping new connection:', reason); + } + } catch (error) { } +}; +const forceUiReload = (socket: SocketWithSession) => { + try { + socket.emit('refreshToUpdate'); + socket.disconnect(); + } catch (error) { } +}; +const sendShutdown = (socket: SocketWithSession) => { + try { + socket.emit('txAdminShuttingDown'); + socket.disconnect(); + } catch (error) { } +}; + +export default class WebSocket { + readonly #io: SocketIO; + readonly #rooms: Record; + #eventBuffer: { name: string, data: any }[] = []; + + constructor(io: SocketIO) { + this.#io = io; + this.#rooms = { + status: statusRoom, + dashboard: dashboardRoom, + playerlist: playerlistRoom, + liveconsole: liveconsoleRoom, + serverlog: serverlogRoom, + }; + + setInterval(this.flushBuffers.bind(this), 250); + } + + + /** + * Sends a shutdown signal to all connected clients + */ + public async handleShutdown() { + const sockets = await this.#io.fetchSockets(); + for (const socket of sockets) { + //@ts-ignore + sendShutdown(socket); + } + } + + + /** + * Refreshes the auth data for all connected admins + * If an admin is not authed anymore, they will be disconnected + * If an admin lost permission to a room, they will be kicked out of it + * This is called from AdminStore.refreshOnlineAdmins() + */ + async reCheckAdminAuths() { + const sockets = await this.#io.fetchSockets(); + console.verbose.warn(`SocketIO`, `AdminStore changed, refreshing auth for ${sockets.length} sockets.`); + for (const socket of sockets) { + //@ts-ignore + const reqIp = getIP(socket); + const authResult = checkRequestAuth( + socket.handshake.headers, + reqIp, + isIpAddressLocal(reqIp), + //@ts-ignore + socket.sessTools + ); + if (!authResult.success) { + //@ts-ignore + return terminateSession(socket, 'session invalidated by websocket.reCheckAdminAuths()', true); + } + + //Sending auth data update - even if nothing changed + const { admin: authedAdmin } = authResult; + socket.emit('updateAuthData', authedAdmin.getAuthData()); + + //Checking permission of all joined rooms + for (const roomName of socket.rooms) { + if (roomName === socket.id) continue; + const roomData = this.#rooms[roomName as RoomNames]; + if (roomData.permission !== true && !authedAdmin.hasPermission(roomData.permission)) { + socket.leave(roomName); + } + } + } + } + + + /** + * Handles incoming connection requests, + */ + handleConnection(socket: SocketWithSession) { + //Check the UI version + if (socket.handshake.query.uiVersion && socket.handshake.query.uiVersion !== txEnv.txaVersion) { + return forceUiReload(socket); + } + + try { + //Checking for session auth + const reqIp = getIP(socket); + const authResult = checkRequestAuth( + socket.handshake.headers, + reqIp, + isIpAddressLocal(reqIp), + socket.sessTools + ); + if (!authResult.success) { + return terminateSession(socket, 'invalid session', false); + } + const { admin: authedAdmin } = authResult; + + + //Check if joining any room + if (typeof socket.handshake.query.rooms !== 'string') { + return terminateSession(socket, 'no query.rooms'); + } + + //Validating requested rooms + const requestedRooms = socket.handshake.query.rooms + .split(',') + .filter((v, i, arr) => arr.indexOf(v) === i) + .filter(r => VALID_ROOMS.includes(r as any)); + if (!requestedRooms.length) { + return terminateSession(socket, 'no valid room requested'); + } + + //To prevent user from receiving data duplicated in initial data and buffer data + //we need to flush the buffers first. This is a bit hacky, but performance shouldn't + //really be an issue since we are first validating the user auth. + this.flushBuffers(); + + //For each valid requested room + for (const requestedRoomName of requestedRooms) { + const room = this.#rooms[requestedRoomName as RoomNames]; + + //Checking Perms + if (room.permission !== true && !authedAdmin.hasPermission(room.permission)) { + continue; + } + + //Setting up event handlers + for (const [commandName, commandData] of Object.entries(room.commands ?? [])) { + if (commandData.permission === true || authedAdmin.hasPermission(commandData.permission)) { + socket.on(commandName, (...args) => { + //Checking if admin is still in the room - perms change can make them be kicked out of room + if (socket.rooms.has(requestedRoomName)) { + commandData.handler(authedAdmin, ...args); + } else { + console.verbose.debug('SocketIO', `Command '${requestedRoomName}#${commandName}' was ignored due to admin not being in the room.`); + } + }); + } + } + + //Sending initial data + socket.join(requestedRoomName); + socket.emit(room.eventName, room.initialData()); + } + + //General events + socket.on('disconnect', (reason) => { + // console.verbose.debug('SocketIO', `Client disconnected with reason: ${reason}`); + }); + socket.on('error', (error) => { + console.verbose.debug('SocketIO', `Socket error with message: ${error.message}`); + }); + + // console.verbose.log('SocketIO', `Connected: ${authedAdmin.name} from ${getIP(socket)}`); + } catch (error) { + console.error('SocketIO', `Error handling new connection: ${(error as Error).message}`); + socket.disconnect(); + } + } + + + /** + * Adds data to the a room buffer + */ + buffer(roomName: RoomNames, data: T) { + const room = this.#rooms[roomName]; + if (!room) throw new Error('Room not found'); + + if (room.cumulativeBuffer) { + if (Array.isArray(room.outBuffer)) { + room.outBuffer.push(data); + } else if (typeof room.outBuffer === 'string') { + room.outBuffer += data; + } else { + throw new Error(`cumulative buffers can only be arrays or strings`); + } + } else { + room.outBuffer = data; + } + } + + + /** + * Flushes the data buffers + * NOTE: this will also send data to users that no longer have permissions + */ + flushBuffers() { + //Sending room data + for (const [roomName, room] of Object.entries(this.#rooms)) { + if (room.cumulativeBuffer && room.outBuffer.length) { + this.#io.to(roomName).emit(room.eventName, room.outBuffer); + if (Array.isArray(room.outBuffer)) { + room.outBuffer = []; + } else if (typeof room.outBuffer === 'string') { + room.outBuffer = ''; + } else { + throw new Error(`cumulative buffers can only be arrays or strings`); + } + } else if (!room.cumulativeBuffer && room.outBuffer !== null) { + this.#io.to(roomName).emit(room.eventName, room.outBuffer); + room.outBuffer = null; + } + } + + //Sending events + for (const event of this.#eventBuffer) { + this.#io.emit(event.name, event.data); + } + this.#eventBuffer = []; + } + + + /** + * Pushes the initial data again for everyone in a room + * NOTE: we probably don't need to wait one tick, but since we are working with + * event handling, things might take a tick to update their status (maybe discord bot?) + */ + pushRefresh(roomName: RoomNames) { + if (!VALID_ROOMS.includes(roomName)) throw new Error(`Invalid room '${roomName}'.`); + const room = this.#rooms[roomName]; + if (room.cumulativeBuffer) throw new Error(`The room '${roomName}' has a cumulative buffer.`); + setImmediate(() => { + room.outBuffer = room.initialData(); + }); + } + + + /** + * Broadcasts an event to all connected clients + * This is used for data syncs that are not related to a specific room + * eg: update available + */ + pushEvent(name: string, data: T) { + this.#eventBuffer.push({ name, data }); + } +}; diff --git a/core/modules/WebServer/wsRooms/dashboard.ts b/core/modules/WebServer/wsRooms/dashboard.ts new file mode 100644 index 0000000..3a5e20f --- /dev/null +++ b/core/modules/WebServer/wsRooms/dashboard.ts @@ -0,0 +1,45 @@ +import type { RoomType } from "../webSocket"; +import { DashboardDataEventType } from "@shared/socketioTypes"; + + +/** + * Returns the dashboard stats data + */ +const getInitialData = (): DashboardDataEventType => { + const svRuntimeStats = txCore.metrics.svRuntime.getRecentStats(); + + return { + // joinLeaveTally30m: txCore.FxPlayerlist.joinLeaveTally, + playerDrop: { + summaryLast6h: txCore.metrics.playerDrop.getRecentDropTally(6), + }, + svRuntime: { + fxsMemory: svRuntimeStats.fxsMemory, + nodeMemory: svRuntimeStats.nodeMemory, + perfBoundaries: svRuntimeStats.perfBoundaries, + perfBucketCounts: svRuntimeStats.perfBucketCounts, + }, + } +} + + +/** + * The room for the dashboard page. + * It relays server performance stuff and drop reason categories. + * + * NOTE: + * - active push event for only from Metrics.svRuntime + * - Metrics.playerDrop does not push events, those are sent alongside the playerlist drop event + * which means that if accessing from NUI (ie not joining playerlist room), the chart will only be + * updated when the user refreshes the page. + * Same goes for "last 6h" not expiring old data if the server is not online pushing new perfs. + */ +export default { + permission: true, //everyone can see it + eventName: 'dashboard', + cumulativeBuffer: false, + outBuffer: null, + initialData: () => { + return getInitialData(); + }, +} satisfies RoomType; diff --git a/core/modules/WebServer/wsRooms/liveconsole.ts b/core/modules/WebServer/wsRooms/liveconsole.ts new file mode 100644 index 0000000..a295496 --- /dev/null +++ b/core/modules/WebServer/wsRooms/liveconsole.ts @@ -0,0 +1,30 @@ +import type { RoomType } from "../webSocket"; +import { AuthedAdminType } from "../authLogic"; + + +/** + * The console room is responsible for the server live console page + */ +export default { + permission: 'console.view', + eventName: 'consoleData', + cumulativeBuffer: true, + outBuffer: '', + initialData: () => txCore.logger.fxserver.getRecentBuffer(), + commands: { + consoleCommand: { + permission: 'console.write', + handler: (admin: AuthedAdminType, command: string) => { + if(typeof command !== 'string' || !command) return; + const sanitized = command.replaceAll(/\n/g, ' '); + admin.logCommand(sanitized); + txCore.fxRunner.sendRawCommand(sanitized, admin.name); + txCore.fxRunner.sendEvent('consoleCommand', { + channel: 'txAdmin', + command: sanitized, + author: admin.name, + }); + } + }, + }, +} satisfies RoomType; diff --git a/core/modules/WebServer/wsRooms/playerlist.ts b/core/modules/WebServer/wsRooms/playerlist.ts new file mode 100644 index 0000000..c1c21d5 --- /dev/null +++ b/core/modules/WebServer/wsRooms/playerlist.ts @@ -0,0 +1,20 @@ +import type { RoomType } from "../webSocket"; +import { FullPlayerlistEventType } from "@shared/socketioTypes"; + + +/** + * The the playerlist room is joined on all (except solo) pages when in web mode + */ +export default { + permission: true, //everyone can see it + eventName: 'playerlist', + cumulativeBuffer: true, + outBuffer: [], + initialData: () => { + return [{ + mutex: txCore.fxRunner.child?.mutex ?? null, + type: 'fullPlayerlist', + playerlist: txCore.fxPlayerlist.getPlayerList(), + } satisfies FullPlayerlistEventType]; + }, +} satisfies RoomType; diff --git a/core/modules/WebServer/wsRooms/serverlog.ts b/core/modules/WebServer/wsRooms/serverlog.ts new file mode 100644 index 0000000..bb1c73e --- /dev/null +++ b/core/modules/WebServer/wsRooms/serverlog.ts @@ -0,0 +1,13 @@ +import type { RoomType } from "../webSocket"; + +/** + * The console room is responsible for the server log page + */ +export default { + permission: true, //everyone can see it + eventName: 'logData', + cumulativeBuffer: true, + outBuffer: [], + initialData: () => txCore.logger.server.getRecentBuffer(500), + commands: {}, +} satisfies RoomType; diff --git a/core/modules/WebServer/wsRooms/status.ts b/core/modules/WebServer/wsRooms/status.ts new file mode 100644 index 0000000..44de676 --- /dev/null +++ b/core/modules/WebServer/wsRooms/status.ts @@ -0,0 +1,21 @@ +import type { RoomType } from "../webSocket"; + + +/** + * The main room is joined automatically in every txadmin page (except solo ones) + * It relays tx and server status data. + * + * NOTE: + * - active push event for FxMonitor, HostData, fxserver process + * - passive update for discord status, scheduler + * - the passive ones will be sent every 5 seconds anyways due to HostData updates + */ +export default { + permission: true, //everyone can see it + eventName: 'status', + cumulativeBuffer: false, + outBuffer: null, + initialData: () => { + return txManager.globalStatus; + }, +} satisfies RoomType; diff --git a/core/package.json b/core/package.json new file mode 100644 index 0000000..9b872b2 --- /dev/null +++ b/core/package.json @@ -0,0 +1,88 @@ +{ + "name": "txadmin-core", + "version": "1.0.0", + "description": "The core package is the backend of txAdmin and handles running fxserver and the web server and every other internal backend stuff.", + "type": "module", + "scripts": { + "build": "cd ../ && npx tsx scripts/build/publish.ts", + "dev": "cd ../ && npx tsx scripts/build/dev.ts", + "test": "vitest", + "typecheck": "tsc -p tsconfig.json --noEmit | node ../scripts/typecheck-formatter.js", + "typecheck:full": "tsc -p tsconfig.json --noEmit", + "lint": "eslint ./**", + "lint:count": "eslint ./** -f ../scripts/lint-formatter.js", + "lint:fix": "eslint ./** --fix", + "license:report": "npx license-report > ../.reports/license/core.html" + }, + "keywords": [], + "author": "André Tabarra", + "license": "MIT", + "dependencies": { + "@koa/cors": "^5.0.0", + "@koa/router": "^13.1.0", + "boxen": "^7.1.1", + "bytes": "^3.1.2", + "cookie": "^0.7.0", + "d3-array": "^3.2.4", + "dateformat": "^5.0.3", + "discord.js": "14.11.0", + "ejs": "^3.1.10", + "error-stack-parser": "^2.1.4", + "execa": "^5.1.1", + "fs-extra": "^9.1.0", + "fuse.js": "^7.0.0", + "got": "^13.0.0", + "is-localhost-ip": "^2.0.0", + "jose": "^4.15.4", + "js-yaml": "^4.1.0", + "koa": "^2.15.4", + "koa-bodyparser": "^4.4.1", + "koa-ratelimit": "^5.1.0", + "lodash": "^4.17.21", + "lowdb": "^6.1.0", + "mnemonist": "^0.39.8", + "mysql2": "^3.11.3", + "nanoid": "^4.0.2", + "nanoid-dictionary": "^4.3.0", + "node-polyglot": "^2.6.0", + "node-stream-zip": "^1.15.0", + "open": "7.1.0", + "openid-client": "^5.7.0", + "pidtree": "^0.6.0", + "pidusage": "^3.0.2", + "rotating-file-stream": "^3.2.5", + "slash": "^5.1.0", + "slug": "^8.2.3", + "socket.io": "^4.8.0", + "source-map-support": "^0.5.21", + "stream-json": "^1.9.1", + "string-argv": "^0.3.2", + "systeminformation": "^5.23.5", + "throttle-debounce": "^5.0.2", + "unicode-emoji-json": "^0.8.0", + "xss": "^1.0.15", + "zod": "^3.23.8", + "zod-validation-error": "^3.4.0" + }, + "devDependencies": { + "@types/bytes": "^3.1.4", + "@types/d3-array": "^3.2.1", + "@types/dateformat": "^5.0.2", + "@types/ejs": "^3.1.5", + "@types/fs-extra": "^11.0.4", + "@types/js-yaml": "^4.0.9", + "@types/koa": "^2.15.0", + "@types/koa__cors": "^5.0.0", + "@types/koa__router": "^12.0.4", + "@types/koa-bodyparser": "^4.3.12", + "@types/koa-ratelimit": "^5.0.5", + "@types/nanoid-dictionary": "^4.2.3", + "@types/node": "^16.9.1", + "@types/pidusage": "^2.0.5", + "@types/semver": "^7.5.8", + "@types/slug": "^5.0.9", + "@types/source-map-support": "^0.5.10", + "@types/stream-json": "^1.7.7", + "windows-release": "^4.0.0" + } +} diff --git a/core/routes/adminManager/actions.ts b/core/routes/adminManager/actions.ts new file mode 100644 index 0000000..155600b --- /dev/null +++ b/core/routes/adminManager/actions.ts @@ -0,0 +1,251 @@ +const modulename = 'WebServer:AdminManagerActions'; +import { customAlphabet } from 'nanoid'; +import dict49 from 'nanoid-dictionary/nolookalikes'; +import got from '@lib/got'; +import consts from '@shared/consts'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +const console = consoleFactory(modulename); + +//Helpers +const nanoid = customAlphabet(dict49, 20); +//NOTE: this desc misses that it should start and end with alphanum or _, and cannot have repeated -_. +const nameRegexDesc = 'up to 20 characters containing only letters, numbers and the characters \`_.-\`'; +const cfxHttpReqOptions = { + timeout: { request: 6000 }, +}; +type ProviderDataType = {id: string, identifier: string}; + +/** + * Returns the output page containing the admins. + */ +export default async function AdminManagerActions(ctx: AuthedCtx) { + //Sanity check + if (typeof ctx.params?.action !== 'string') { + return ctx.utils.error(400, 'Invalid Request'); + } + const action = ctx.params.action; + + //Check permissions + if (!ctx.admin.testPermission('manage.admins', modulename)) { + return ctx.send({ + type: 'danger', + message: 'You don\'t have permission to execute this action.', + }); + } + + //Delegate to the specific action handler + if (action == 'add') { + return await handleAdd(ctx); + } else if (action == 'edit') { + return await handleEdit(ctx); + } else if (action == 'delete') { + return await handleDelete(ctx); + } else { + return ctx.send({ + type: 'danger', + message: 'Unknown action.', + }); + } +}; + + +/** + * Handle Add + */ +async function handleAdd(ctx: AuthedCtx) { + //Sanity check + if ( + typeof ctx.request.body.name !== 'string' + || typeof ctx.request.body.citizenfxID !== 'string' + || typeof ctx.request.body.discordID !== 'string' + || ctx.request.body.permissions === undefined + ) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + + //Prepare and filter variables + const name = ctx.request.body.name.trim(); + const password = nanoid(); + const citizenfxID = ctx.request.body.citizenfxID.trim(); + const discordID = ctx.request.body.discordID.trim(); + let permissions = (Array.isArray(ctx.request.body.permissions)) ? ctx.request.body.permissions : []; + permissions = permissions.filter((x: unknown) => typeof x === 'string'); + if (permissions.includes('all_permissions')) permissions = ['all_permissions']; + + + //Validate name + if (!consts.regexValidFivemUsername.test(name)) { + return ctx.send({type: 'danger', markdown: true, message: `**Invalid username, it must follow the rule:**\n${nameRegexDesc}`}); + } + + //Validate & translate FiveM ID + let citizenfxData: ProviderDataType | undefined; + if (citizenfxID.length) { + try { + citizenfxData = { + id: citizenfxID, + identifier: citizenfxID, + }; + } catch (error) { + console.error(`Failed to resolve CitizenFX ID to game identifier with error: ${(error as Error).message}`); + } + } + + //Validate Discord ID + let discordData: ProviderDataType | undefined; + if (discordID.length) { + if (!consts.validIdentifierParts.discord.test(discordID)) { + return ctx.send({type: 'danger', message: 'Invalid Discord ID'}); + } + discordData = { + id: discordID, + identifier: `discord:${discordID}`, + }; + } + + //Check for privilege escalation + if (!ctx.admin.isMaster && !ctx.admin.permissions.includes('all_permissions')) { + const deniedPerms = permissions.filter((x: string) => !ctx.admin.permissions.includes(x)); + if (deniedPerms.length) { + return ctx.send({ + type: 'danger', + message: `You cannot give permissions you do not have:
${deniedPerms.join(', ')}`, + }); + } + } + + //Add admin and give output + try { + await txCore.adminStore.addAdmin(name, citizenfxData, discordData, password, permissions); + ctx.admin.logAction(`Adding user '${name}'.`); + return ctx.send({type: 'showPassword', password}); + } catch (error) { + return ctx.send({type: 'danger', message: (error as Error).message}); + } +} + + +/** + * Handle Edit + */ +async function handleEdit(ctx: AuthedCtx) { + //Sanity check + if ( + typeof ctx.request.body.name !== 'string' + || typeof ctx.request.body.citizenfxID !== 'string' + || typeof ctx.request.body.discordID !== 'string' + || ctx.request.body.permissions === undefined + ) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + + //Prepare and filter variables + const name = ctx.request.body.name.trim(); + const citizenfxID = ctx.request.body.citizenfxID.trim(); + const discordID = ctx.request.body.discordID.trim(); + + //Check if editing himself + if (ctx.admin.name.toLowerCase() === name.toLowerCase()) { + return ctx.send({type: 'danger', message: '(ERR0) You cannot edit yourself.'}); + } + + //Validate & translate permissions + let permissions; + if (Array.isArray(ctx.request.body.permissions)) { + permissions = ctx.request.body.permissions.filter((x: unknown) => typeof x === 'string'); + if (permissions.includes('all_permissions')) permissions = ['all_permissions']; + } else { + permissions = []; + } + + //Validate & translate FiveM ID + let citizenfxData: ProviderDataType | undefined; + if (citizenfxID.length) { + try { + citizenfxData = { + id: citizenfxID, + identifier: citizenfxID, + }; + } catch (error) { + console.error(`Failed to resolve CitizenFX ID to game identifier with error: ${(error as Error).message}`); + } + } + + //Validate Discord ID + //FIXME: you cannot remove a discord id by erasing from the field + let discordData: ProviderDataType | undefined; + if (discordID.length) { + if (!consts.validIdentifierParts.discord.test(discordID)) { + return ctx.send({type: 'danger', message: 'Invalid Discord ID'}); + } + discordData = { + id: discordID, + identifier: `discord:${discordID}`, + }; + } + + //Check if admin exists + const admin = txCore.adminStore.getAdminByName(name); + if (!admin) return ctx.send({type: 'danger', message: 'Admin not found.'}); + + //Check if editing an master admin + if (!ctx.admin.isMaster && admin.master) { + return ctx.send({type: 'danger', message: 'You cannot edit an admin master.'}); + } + + //Check for privilege escalation + if (permissions && !ctx.admin.isMaster && !ctx.admin.permissions.includes('all_permissions')) { + const deniedPerms = permissions.filter((x: string) => !ctx.admin.permissions.includes(x)); + if (deniedPerms.length) { + return ctx.send({ + type: 'danger', + message: `You cannot give permissions you do not have:
${deniedPerms.join(', ')}`, + }); + } + } + + //Add admin and give output + try { + await txCore.adminStore.editAdmin(name, null, citizenfxData, discordData, permissions); + ctx.admin.logAction(`Editing user '${name}'.`); + return ctx.send({type: 'success', refresh: true}); + } catch (error) { + return ctx.send({type: 'danger', message: (error as Error).message}); + } +} + + +/** + * Handle Delete + */ +async function handleDelete(ctx: AuthedCtx) { + //Sanity check + if (typeof ctx.request.body.name !== 'string') { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + const name = ctx.request.body.name.trim(); + + //Check if deleting himself + if (ctx.admin.name.toLowerCase() === name.toLowerCase()) { + return ctx.send({type: 'danger', message: "You can't delete yourself."}); + } + + //Check if admin exists + const admin = txCore.adminStore.getAdminByName(name); + if (!admin) return ctx.send({type: 'danger', message: 'Admin not found.'}); + + //Check if editing an master admin + if (admin.master) { + return ctx.send({type: 'danger', message: 'You cannot delete an admin master.'}); + } + + //Delete admin and give output + try { + await txCore.adminStore.deleteAdmin(name); + ctx.admin.logAction(`Deleting user '${name}'.`); + return ctx.send({type: 'success', refresh: true}); + } catch (error) { + return ctx.send({type: 'danger', message: (error as Error).message}); + } +} diff --git a/core/routes/adminManager/getModal.ts b/core/routes/adminManager/getModal.ts new file mode 100644 index 0000000..24989ff --- /dev/null +++ b/core/routes/adminManager/getModal.ts @@ -0,0 +1,106 @@ +const modulename = 'WebServer:AdminManagerGetModal'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + +//Separate permissions in general perms and menu perms, and mark the dangerous ones +const dangerousPerms = ['all_permissions', 'manage.admins', 'console.write', 'settings.write']; +const getPerms = (checkPerms: string[], allPermissions: [string, string][]) => { + type PermType = { + id: string; + desc: string; + checked: string; + dangerous: boolean; + }; + const permsGeneral: PermType[] = []; + const permsMenu: PermType[] = []; + for (const [id, desc] of allPermissions) { + const bucket = (id.startsWith('players.') || id.startsWith('menu.')) ? permsGeneral : permsMenu; + bucket.push({ + id, + desc, + checked: (checkPerms.includes(id)) ? 'checked' : '', + dangerous: dangerousPerms.includes(id), + }); + } + return [permsGeneral, permsMenu]; +}; + + +/** + * Returns the output page containing the admins. + */ +export default async function AdminManagerGetModal(ctx: AuthedCtx) { + //Sanity check + if (typeof ctx.params.modalType !== 'string') { + return ctx.utils.error(400, 'Invalid Request'); + } + const modalType = ctx.params.modalType; + + //Check permissions + if (!ctx.admin.testPermission('manage.admins', modulename)) { + return ctx.send({ + type: 'danger', + message: 'You don\'t have permission to execute this action.', + }); + } + + //Check which modal type to show + let isNewAdmin; + if (modalType == 'add') { + isNewAdmin = true; + } else if (modalType == 'edit') { + isNewAdmin = false; + } else { + return ctx.send({ + type: 'danger', + message: 'Unknown modalType.', + }); + } + + //If it's a modal for new admin, all fields will be empty + const allPermissions = Object.entries(txCore.adminStore.getPermissionsList()); + if (isNewAdmin) { + const [permsGeneral, permsMenu] = getPerms([], allPermissions); + const renderData = { + isNewAdmin: true, + username: '', + citizenfx_id: '', + discord_id: '', + permsGeneral, + permsMenu, + }; + return ctx.utils.render('parts/adminModal', renderData); + } + + //Sanity check + if (typeof ctx.request.body.name !== 'string') { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + const name = ctx.request.body.name.trim(); + + //Get admin data + const admin = txCore.adminStore.getAdminByName(name); + if (!admin) return ctx.send('Admin not found'); + + //Check if editing an master admin + if (!ctx.admin.isMaster && admin.master) { + return ctx.send('You cannot edit an admin master.'); + } + + //Prepare permissions + const [permsGeneral, permsMenu] = getPerms(admin.permissions, allPermissions); + + //Set render data + const renderData = { + isNewAdmin: false, + username: admin.name, + citizenfx_id: (admin.providers.citizenfx) ? admin.providers.citizenfx.id : '', + discord_id: (admin.providers.discord) ? admin.providers.discord.id : '', + permsGeneral, + permsMenu, + }; + + //Give output + return ctx.utils.render('parts/adminModal', renderData); +}; diff --git a/core/routes/adminManager/page.ts b/core/routes/adminManager/page.ts new file mode 100644 index 0000000..3648d64 --- /dev/null +++ b/core/routes/adminManager/page.ts @@ -0,0 +1,49 @@ +const modulename = 'WebServer:AdminManagerPage'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + + +/** + * Returns the output page containing the admins. + */ +export default async function AdminManagerPage(ctx: AuthedCtx) { + //Check permission + if (!ctx.admin.hasPermission('manage.admins')) { + return ctx.utils.render('main/message', {message: 'You don\'t have permission to view this page.'}); + } + + //Prepare admin array + const admins = txCore.adminStore.getAdminsList().map((admin) => { + let perms; + if (admin.master == true) { + perms = 'master account'; + } else if (admin.permissions.includes('all_permissions')) { + perms = 'all permissions'; + } else if (admin.permissions.length !== 1) { + perms = `${admin.permissions.length} permissions`; + } else { + perms = '1 permission'; + } + const isSelf = ctx.admin.name.toLowerCase() === admin.name.toLowerCase(); + + return { + hasCitizenFX: (admin.providers.includes('citizenfx')), + hasDiscord: (admin.providers.includes('discord')), + name: admin.name, + perms: perms, + isSelf, + disableEdit: !ctx.admin.isMaster && admin.master, + disableDelete: (admin.master || isSelf), + }; + }); + + //Set render data + const renderData = { + headerTitle: 'Admin Manager', + admins, + }; + + //Give output + return ctx.utils.render('main/adminManager', renderData); +}; diff --git a/core/routes/advanced/actions.js b/core/routes/advanced/actions.js new file mode 100644 index 0000000..8d3e9fc --- /dev/null +++ b/core/routes/advanced/actions.js @@ -0,0 +1,166 @@ +const modulename = 'WebServer:AdvancedActions'; +import v8 from 'node:v8'; +import bytes from 'bytes'; +import got from '@lib/got'; +import consoleFactory from '@lib/console'; +import { SYM_SYSTEM_AUTHOR } from '@lib/symbols'; +const console = consoleFactory(modulename); + +//Helper functions +const isUndefined = (x) => (x === undefined); + + +/** + * Endpoint for running advanced commands - basically, should not ever be used + */ +export default async function AdvancedActions(ctx) { + //Sanity check + if ( + isUndefined(ctx.request.body.action) + || isUndefined(ctx.request.body.parameter) + ) { + console.warn('Invalid request!'); + return ctx.send({ type: 'danger', message: 'Invalid request :(' }); + } + const action = ctx.request.body.action; + const parameter = ctx.request.body.parameter; + + + //Check permissions + if (!ctx.admin.testPermission('all_permissions', modulename)) { + return ctx.send({ + type: 'danger', + message: 'You don\'t have permission to execute this action.', + }); + } + + //Action: Change Verbosity + if (action == 'change_verbosity') { + console.setVerbose(parameter == 'true'); + //temp disabled because the verbosity convar is not being set by this method + return ctx.send({ refresh: true }); + } else if (action == 'perform_magic') { + const message = JSON.stringify(txCore.fxPlayerlist.getPlayerList(), null, 2); + return ctx.send({ type: 'success', message }); + } else if (action == 'show_db') { + const dbo = txCore.database.getDboRef(); + console.dir(dbo); + return ctx.send({ type: 'success', message: JSON.stringify(dbo, null, 2) }); + } else if (action == 'show_log') { + return ctx.send({ type: 'success', message: JSON.stringify(txCore.logger.server.getRecentBuffer(), null, 2) }); + } else if (action == 'memory') { + let memory; + try { + const usage = process.memoryUsage(); + Object.keys(usage).forEach((prop) => { + usage[prop] = bytes(usage[prop]); + }); + memory = JSON.stringify(usage, null, 2); + } catch (error) { + memory = 'error'; + } + return ctx.send({ type: 'success', message: memory }); + } else if (action == 'freeze') { + console.warn('Freezing process for 50 seconds.'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * 1000); + } else if (action == 'updateMutableConvars') { + txCore.fxRunner.updateMutableConvars(); + return ctx.send({ refresh: true }); + } else if (action == 'reauthLast10Players') { + // force refresh the admin status of the last 10 players to join + const lastPlayers = txCore.fxPlayerlist.getPlayerList().map((p) => p.netid).slice(-10); + txCore.fxRunner.sendEvent('adminsUpdated', lastPlayers); + return ctx.send({ type: 'success', message: `refreshed: ${JSON.stringify(lastPlayers)}` }); + } else if (action == 'getLoggerErrors') { + const outData = { + admin: txCore.logger.admin.lrLastError, + fxserver: txCore.logger.fxserver.lrLastError, + server: txCore.logger.server.lrLastError, + }; + return ctx.send({ type: 'success', message: JSON.stringify(outData, null, 2) }); + } else if (action == 'testSrcAddress') { + const url = 'https://api.myip.com'; + const respDefault = await got(url).json(); + const respReset = await got(url, { localAddress: undefined }).json(); + const outData = { + url, + respDefault, + respReset, + }; + return ctx.send({ type: 'success', message: JSON.stringify(outData, null, 2) }); + } else if (action == 'getProcessEnv') { + return ctx.send({ type: 'success', message: JSON.stringify(process.env, null, 2) }); + } else if (action == 'snap') { + setTimeout(() => { + // if (Citizen && Citizen.snap) Citizen.snap(); + const snapFile = v8.writeHeapSnapshot(); + console.warn(`Heap snapshot written to: ${snapFile}`); + }, 50); + return ctx.send({ type: 'success', message: 'terminal' }); + } else if (action === 'gc') { + if (typeof global.gc === 'function') { + global.gc(); + return ctx.send({ type: 'success', message: 'done' }); + } else { + return ctx.send({ type: 'danger', message: 'GC is not exposed' }); + } + } else if (action === 'safeEnsureMonitor') { + const setCmdResult = txCore.fxRunner.sendCommand( + 'set', + [ + 'txAdmin-luaComToken', + txCore.webServer.luaComToken, + ], + SYM_SYSTEM_AUTHOR + ); + if (!setCmdResult) { + return ctx.send({ type: 'danger', message: 'Failed to reset luaComToken.' }); + } + const ensureCmdResult = txCore.fxRunner.sendCommand( + 'ensure', + ['monitor'], + SYM_SYSTEM_AUTHOR + ); + if (ensureCmdResult) { + return ctx.send({ type: 'success', message: 'done' }); + } else { + return ctx.send({ type: 'danger', message: 'Failed to ensure monitor.' }); + } + } else if (action.startsWith('playerDrop')) { + const reason = action.split(' ', 2)[1]; + const category = txCore.metrics.playerDrop.handlePlayerDrop(reason); + return ctx.send({ type: 'success', message: category }); + + } else if (action.startsWith('set')) { + // set general.language "pt" + // set general.language "en" + // set server.onesync "on" + // set server.onesync "legacy" + try { + const [_, scopeKey, valueJson] = action.split(' ', 3); + if (!scopeKey || !valueJson) throw new Error(`Invalid set command: ${action}`); + const [scope, key] = scopeKey.split('.'); + if (!scope || !key) throw new Error(`Invalid set command: ${action}`); + const configUpdate = { [scope]: { [key]: JSON.parse(valueJson) } }; + const storedKeysChanges = txCore.configStore.saveConfigs(configUpdate, ctx.admin.name); + const outParts = [ + 'Keys Updated: ' + JSON.stringify(storedKeysChanges ?? 'not set', null, 2), + '-'.repeat(16), + 'Stored:' + JSON.stringify(txCore.configStore.getStoredConfig(), null, 2), + ]; + return ctx.send({ type: 'success', message: outParts.join('\n') }); + } catch (error) { + return ctx.send({ type: 'danger', message: error.message }); + } + + } else if (action == 'printFxRunnerChildHistory') { + const message = JSON.stringify(txCore.fxRunner.history, null, 2) + return ctx.send({ type: 'success', message }); + + } else if (action == 'xxxxxx') { + return ctx.send({ type: 'success', message: '😀👍' }); + } + + //Catch all + return ctx.send({ type: 'danger', message: 'Unknown action :(' }); +}; diff --git a/core/routes/advanced/get.js b/core/routes/advanced/get.js new file mode 100644 index 0000000..98c53b6 --- /dev/null +++ b/core/routes/advanced/get.js @@ -0,0 +1,20 @@ +const modulename = 'WebServer:AdvancedPage'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + + +/** + * Returns the output page containing the server.cfg + * @param {object} ctx + */ +export default async function AdvancedPage(ctx) { + //Check permissions + if (!ctx.admin.hasPermission('all_permisisons')) { + return ctx.utils.render('main/message', {message: 'You don\'t have permission to view this page.'}); + } + + return ctx.utils.render('main/advanced', { + headerTitle: 'Advanced', + verbosityEnabled: console.isVerbose, + }); +}; diff --git a/core/routes/authentication/addMasterCallback.ts b/core/routes/authentication/addMasterCallback.ts new file mode 100644 index 0000000..22087aa --- /dev/null +++ b/core/routes/authentication/addMasterCallback.ts @@ -0,0 +1,63 @@ +const modulename = 'WebServer:AuthAddMasterCallback'; +import { InitializedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { getIdFromOauthNameid } from '@lib/player/idUtils'; +import { ApiAddMasterCallbackResp } from '@shared/authApiTypes'; +import { z } from 'zod'; +import { handleOauthCallback } from './oauthMethods'; +const console = consoleFactory(modulename); + +//Helper functions +const bodySchema = z.object({ + redirectUri: z.string(), +}); +export type ApiAddMasterCallbackReqSchema = z.infer; + +/** + * Handles the Add Master flow + */ +export default async function AuthAddMasterCallback(ctx: InitializedCtx) { + const schemaRes = bodySchema.safeParse(ctx.request.body); + if (!schemaRes.success) { + return ctx.send({ + errorTitle: 'Invalid request body', + errorMessage: schemaRes.error.message, + }); + } + const { redirectUri } = schemaRes.data; + + //Check if there are already admins set up + if (txCore.adminStore.hasAdmins()) { + return ctx.send({ + errorTitle: `Master account already set.`, + errorMessage: `Please return to the login page.`, + }); + } + + //Handling the callback + const callbackResp = await handleOauthCallback(ctx, redirectUri); + if('errorCode' in callbackResp || 'errorTitle' in callbackResp){ + return ctx.send(callbackResp); + } + const userInfo = callbackResp; + + //Getting identifier + const fivemIdentifier = getIdFromOauthNameid(userInfo.nameid); + if(!fivemIdentifier){ + return ctx.send({ + errorTitle: 'Invalid nameid identifier.', + errorMessage: `Could not extract the user identifier from the URL below. Please report this to the txAdmin dev team.\n${userInfo.nameid.toString()}`, + }); + } + + //Setting session + ctx.sessTools.set({ + tmpAddMasterUserInfo: userInfo, + }); + + return ctx.send({ + fivemName: userInfo.name, + fivemId: fivemIdentifier, + profilePicture: userInfo.picture, + }); +}; diff --git a/core/routes/authentication/addMasterPin.ts b/core/routes/authentication/addMasterPin.ts new file mode 100644 index 0000000..2d62cbb --- /dev/null +++ b/core/routes/authentication/addMasterPin.ts @@ -0,0 +1,45 @@ +const modulename = 'WebServer:AuthAddMasterPin'; +import { InitializedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { ApiOauthRedirectResp } from '@shared/authApiTypes'; +import { z } from 'zod'; +import { getOauthRedirectUrl } from './oauthMethods'; +const console = consoleFactory(modulename); + +//Helper functions +const bodySchema = z.object({ + pin: z.string().trim(), + origin: z.string(), +}); +export type ApiAddMasterPinReqSchema = z.infer; + +/** + * Handles the Add Master flow + */ +export default async function AuthAddMasterPin(ctx: InitializedCtx) { + const schemaRes = bodySchema.safeParse(ctx.request.body); + if (!schemaRes.success) { + return ctx.send({ + error: `Invalid request body: ${schemaRes.error.message}`, + }); + } + const { pin, origin } = schemaRes.data; + + //Check if there are already admins set up + if (txCore.adminStore.hasAdmins()) { + return ctx.send({ + error: `master_already_set`, + }); + } + + //Checking the PIN + if (!pin.length || pin !== txCore.adminStore.addMasterPin) { + return ctx.send({ + error: `Wrong PIN.`, + }); + } + + return ctx.send({ + authUrl: getOauthRedirectUrl(ctx, 'addMaster', origin), + }); +}; diff --git a/core/routes/authentication/addMasterSave.ts b/core/routes/authentication/addMasterSave.ts new file mode 100644 index 0000000..0a3574b --- /dev/null +++ b/core/routes/authentication/addMasterSave.ts @@ -0,0 +1,96 @@ +const modulename = 'WebServer:AuthAddMasterSave'; +import { AuthedAdmin, CfxreSessAuthType } from '@modules/WebServer/authLogic'; +import { InitializedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { getIdFromOauthNameid } from '@lib/player/idUtils'; +import { ApiAddMasterSaveResp } from '@shared/authApiTypes'; +import { z } from 'zod'; +import consts from '@shared/consts'; +const console = consoleFactory(modulename); + +//Helper functions +const bodySchema = z.object({ + password: z.string().min(consts.adminPasswordMinLength).max(consts.adminPasswordMaxLength), + discordId: z.string().optional(), +}); +export type ApiAddMasterSaveReqSchema = z.infer; + +/** + * Handles the Add Master flow + */ +export default async function AuthAddMasterSave(ctx: InitializedCtx) { + const schemaRes = bodySchema.safeParse(ctx.request.body); + if (!schemaRes.success) { + return ctx.send({ + error: `Invalid request body: ${schemaRes.error.message}`, + }); + } + const { password, discordId } = schemaRes.data; + + //Check if there are already admins set up + if (txCore.adminStore.hasAdmins()) { + return ctx.send({ + error: `master_already_set`, + }); + } + + //Checking the discordId + if (typeof discordId === 'string' && !consts.validIdentifierParts.discord.test(discordId)) { + return ctx.send({ + error: `Invalid Discord ID.`, + }); + } + + //Checking if session is still present + const inboundSession = ctx.sessTools.get(); + if (!inboundSession || !inboundSession?.tmpAddMasterUserInfo) { + return ctx.send({ + error: `invalid_session`, + }); + } + const userInfo = inboundSession.tmpAddMasterUserInfo; + + //Getting identifier + const fivemIdentifier = getIdFromOauthNameid(userInfo.nameid); + if (!fivemIdentifier) { + return ctx.send({ + error: `Could not extract the user identifier from userInfo.nameid.\nPlease report this to the txAdmin dev team.\n${userInfo.nameid.toString()}`, + }); + } + + //Create admins file and log in admin + try { + const vaultAdmin = txCore.adminStore.createAdminsFile( + userInfo.name, + fivemIdentifier, + discordId, + password, + true, + ); + + //If the user has a picture, save it to the cache + if (userInfo.picture) { + txCore.cacheStore.set(`admin:picture:${userInfo.name}`, userInfo.picture); + } + + //Setting session + const sessData = { + type: 'cfxre', + username: userInfo.name, + csrfToken: txCore.adminStore.genCsrfToken(), + expiresAt: Date.now() + 86_400_000, //24h, + identifier: fivemIdentifier, + } satisfies CfxreSessAuthType; + ctx.sessTools.set({ auth: sessData }); + + const authedAdmin = new AuthedAdmin(vaultAdmin, sessData.csrfToken); + authedAdmin.logAction(`created admins file`); + return ctx.send(authedAdmin.getAuthData()); + } catch (error) { + ctx.sessTools.destroy(); + console.error(`Failed to create session: ${(error as Error).message}`); + return ctx.send({ + error: `Failed to create session: ${(error as Error).message}`, + }); + } +}; diff --git a/core/routes/authentication/changeIdentifiers.ts b/core/routes/authentication/changeIdentifiers.ts new file mode 100644 index 0000000..78133a7 --- /dev/null +++ b/core/routes/authentication/changeIdentifiers.ts @@ -0,0 +1,77 @@ +const modulename = 'WebServer:AuthChangeIdentifiers'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import consts from '@shared/consts'; +import { GenericApiResp } from '@shared/genericApiTypes'; +import { z } from 'zod'; +import got from '@lib/got'; +const console = consoleFactory(modulename); + +//Helpers +const cfxHttpReqOptions = { + timeout: { request: 6000 }, +}; +type ProviderDataType = {id: string, identifier: string}; + +const bodySchema = z.object({ + cfxreId: z.string().trim(), + discordId: z.string().trim(), +}); +export type ApiChangeIdentifiersReqSchema = z.infer; + +/** + * Route to change your own identifiers + */ +export default async function AuthChangeIdentifiers(ctx: AuthedCtx) { + //Sanity check + const schemaRes = bodySchema.safeParse(ctx.request.body); + if (!schemaRes.success) { + return ctx.send({ + error: `Invalid request body: ${schemaRes.error.message}`, + }); + } + const { cfxreId, discordId } = schemaRes.data; + + //Validate & translate FiveM ID + let citizenfxData: ProviderDataType | false = false; + if (cfxreId.length) { + try { + citizenfxData = { + id: cfxreId, + identifier: cfxreId, + }; + } catch (error) { + return ctx.send({ + error: `Failed to resolve CitizenFX ID to game identifier with error: ${(error as Error).message}`, + }); + } + } + + //Validate Discord ID + let discordData: ProviderDataType | false = false; + if (discordId.length) { + if (!consts.validIdentifiers.discord.test(discordId)) { + return ctx.send({ + error: `The Discord ID needs to be the numeric "User ID" instead of the username.\n You can also leave it blank.`, + }); + } + discordData = { + id: discordId.substring(8), + identifier: discordId, + }; + } + + //Get vault admin + const vaultAdmin = txCore.adminStore.getAdminByName(ctx.admin.name); + if (!vaultAdmin) throw new Error('Wait, what? Where is that admin?'); + + //Edit admin and give output + try { + await txCore.adminStore.editAdmin(ctx.admin.name, null, citizenfxData, discordData); + + ctx.admin.logAction('Changing own identifiers.'); + return ctx.send({ success: true }); + } catch (error) { + return ctx.send({ error: (error as Error).message }); + } +}; diff --git a/core/routes/authentication/changePassword.ts b/core/routes/authentication/changePassword.ts new file mode 100644 index 0000000..350a40a --- /dev/null +++ b/core/routes/authentication/changePassword.ts @@ -0,0 +1,69 @@ +const modulename = 'WebServer:AuthChangePassword'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import consts from '@shared/consts'; +import { GenericApiResp } from '@shared/genericApiTypes'; +import { z } from 'zod'; +const console = consoleFactory(modulename); + +//Helper functions +const bodySchema = z.object({ + oldPassword: z.string().optional(), + newPassword: z.string(), +}); +export type ApiChangePasswordReqSchema = z.infer; + + +/** + * Route to change your own password + */ +export default async function AuthChangePassword(ctx: AuthedCtx) { + //Sanity check + const schemaRes = bodySchema.safeParse(ctx.request.body); + if (!schemaRes.success) { + return ctx.send({ + error: `Invalid request body: ${schemaRes.error.message}`, + }); + } + const { newPassword, oldPassword } = schemaRes.data; + + //Validate new password + if (newPassword.trim() !== newPassword) { + return ctx.send({ + error: 'Your password either starts or ends with a space, which was likely an accident. Please remove it and try again.', + }); + } + if (newPassword.length < consts.adminPasswordMinLength || newPassword.length > consts.adminPasswordMaxLength) { + return ctx.send({ error: 'Invalid new password length.' }); + } + + //Get vault admin + const vaultAdmin = txCore.adminStore.getAdminByName(ctx.admin.name); + if (!vaultAdmin) throw new Error('Wait, what? Where is that admin?'); + if (!ctx.admin.isTempPassword) { + if (!oldPassword || !VerifyPasswordHash(oldPassword, vaultAdmin.password_hash)) { + return ctx.send({ error: 'Wrong current password.' }); + } + } + + //Edit admin and give output + try { + const newHash = await txCore.adminStore.editAdmin(ctx.admin.name, newPassword); + + //Update session hash if logged in via password + const currSess = ctx.sessTools.get(); + if (currSess?.auth?.type === 'password') { + ctx.sessTools.set({ + auth: { + ...currSess.auth, + password_hash: newHash, + } + }); + } + + ctx.admin.logAction('Changing own password.'); + return ctx.send({ success: true }); + } catch (error) { + return ctx.send({ error: (error as Error).message }); + } +}; diff --git a/core/routes/authentication/getIdentifiers.ts b/core/routes/authentication/getIdentifiers.ts new file mode 100644 index 0000000..7dc6aa6 --- /dev/null +++ b/core/routes/authentication/getIdentifiers.ts @@ -0,0 +1,27 @@ +const modulename = 'WebServer:AuthGetIdentifiers'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { z } from 'zod'; +const console = consoleFactory(modulename); + +//Helper functions +const bodySchema = z.object({ + oldPassword: z.string().optional(), + newPassword: z.string(), +}); +export type ApiChangePasswordReqSchema = z.infer; + + +/** + * Returns the identifiers of the current admin + */ +export default async function AuthGetIdentifiers(ctx: AuthedCtx) { + //Get vault admin + const vaultAdmin = txCore.adminStore.getAdminByName(ctx.admin.name); + if (!vaultAdmin) throw new Error('Wait, what? Where is that admin?'); + + return ctx.send({ + cfxreId: (vaultAdmin.providers.citizenfx) ? vaultAdmin.providers.citizenfx.identifier : '', + discordId: (vaultAdmin.providers.discord) ? vaultAdmin.providers.discord.identifier : '', + }); +}; diff --git a/core/routes/authentication/logout.ts b/core/routes/authentication/logout.ts new file mode 100644 index 0000000..cc9ba8d --- /dev/null +++ b/core/routes/authentication/logout.ts @@ -0,0 +1,22 @@ +const modulename = 'WebServer:AuthLogout'; +import { InitializedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { ApiLogoutResp } from '@shared/authApiTypes'; +const console = consoleFactory(modulename); + + +/** + * Once upon a cyber-time, in the land of API wonder, there was a humble route called 'AuthLogout'. + * It was the epitome of simplicity, with just a single line of code. In a project brimming with + * complexity, this little route stood as a beacon of uncomplicated grace. It dutifully ensured + * that users could bid farewell to txAdmin with ease, never overstaying its welcome. + * And so, with a single request, users embarked on their journeys, leaving behind the virtual + * realm, 😄👋 #ByeFelicia + */ +export default async function AuthLogout(ctx: InitializedCtx) { + ctx.sessTools.destroy(); + + return ctx.send({ + logout: true, + }); +}; diff --git a/core/routes/authentication/oauthMethods.ts b/core/routes/authentication/oauthMethods.ts new file mode 100644 index 0000000..7050708 --- /dev/null +++ b/core/routes/authentication/oauthMethods.ts @@ -0,0 +1,90 @@ + +const modulename = 'WebServer:OauthMethods'; +import { InitializedCtx } from "@modules/WebServer/ctxTypes"; +import { ValidSessionType } from "@modules/WebServer/middlewares/sessionMws"; +import { ApiOauthCallbackErrorResp, ApiOauthCallbackResp } from "@shared/authApiTypes"; +import { randomUUID } from "node:crypto"; +import consoleFactory from '@lib/console'; +import { UserInfoType } from "@modules/AdminStore/providers/CitizenFX"; +const console = consoleFactory(modulename); + + +/** + * Sets the user session and generates the provider redirect url + */ +export const getOauthRedirectUrl = (ctx: InitializedCtx, purpose: 'login' | 'addMaster', origin: string) => { + const callbackUrl = origin + `/${purpose}/callback`; + + //Setting up session + const sessData = { + tmpOauthLoginStateKern: randomUUID(), + tmpOauthLoginCallbackUri: callbackUrl, + } satisfies ValidSessionType; + ctx.sessTools.set(sessData); + + //Generate CitizenFX provider Auth URL + const idmsAuthUrl = txCore.adminStore.providers.citizenfx.getAuthURL( + callbackUrl, + sessData.tmpOauthLoginStateKern, + ); + + return idmsAuthUrl; +} + + +/** + * Handles the provider login callbacks by doing the code exchange, validations and returning the userInfo + */ +export const handleOauthCallback = async (ctx: InitializedCtx, redirectUri: string): Promise => { + //Checking session + const inboundSession = ctx.sessTools.get(); + if (!inboundSession || !inboundSession?.tmpOauthLoginStateKern || !inboundSession?.tmpOauthLoginCallbackUri) { + return { + errorCode: 'invalid_session', + }; + } + + //Exchange code for access token + let tokenSet; + try { + tokenSet = await txCore.adminStore.providers.citizenfx.processCallback( + inboundSession.tmpOauthLoginCallbackUri, + inboundSession.tmpOauthLoginStateKern, + redirectUri, + ); + if (!tokenSet) throw new Error('tokenSet is undefined'); + if (!tokenSet.access_token) throw new Error('tokenSet.access_token is undefined'); + } catch (e) { + const error = e as any; + console.warn(`Code Exchange error: ${error.message}`); + if (error.tolerance !== undefined) { + return { + errorCode: 'clock_desync', + }; + } else if (error.code === 'ETIMEDOUT') { + return { + errorCode: 'timeout', + }; + } else if (error.message.startsWith('state mismatch')) { + return { + errorCode: 'invalid_state', //same as invalid_session? + }; + } else { + return { + errorTitle: 'Code Exchange error:', + errorMessage: error.message, + }; + } + } + + //Get userinfo + try { + return await txCore.adminStore.providers.citizenfx.getUserInfo(tokenSet.access_token); + } catch (error) { + console.verbose.error(`Get UserInfo error: ${(error as Error).message}`); + return { + errorTitle: 'Get UserInfo error:', + errorMessage: (error as Error).message, + }; + } +} diff --git a/core/routes/authentication/providerCallback.ts b/core/routes/authentication/providerCallback.ts new file mode 100644 index 0000000..4fdf0af --- /dev/null +++ b/core/routes/authentication/providerCallback.ts @@ -0,0 +1,89 @@ +const modulename = 'WebServer:AuthProviderCallback'; +import consoleFactory from '@lib/console'; +import { InitializedCtx } from '@modules/WebServer/ctxTypes'; +import { AuthedAdmin, CfxreSessAuthType } from '@modules/WebServer/authLogic'; +import { z } from 'zod'; +import { ApiOauthCallbackErrorResp, ApiOauthCallbackResp, ReactAuthDataType } from '@shared/authApiTypes'; +import { handleOauthCallback } from './oauthMethods'; +import { getIdFromOauthNameid } from '@lib/player/idUtils'; +const console = consoleFactory(modulename); + +//Helper functions +const bodySchema = z.object({ + redirectUri: z.string(), +}); +export type ApiOauthCallbackReqSchema = z.infer; + +/** + * Handles the provider login callbacks + */ +export default async function AuthProviderCallback(ctx: InitializedCtx) { + const schemaRes = bodySchema.safeParse(ctx.request.body); + if (!schemaRes.success) { + return ctx.send({ + errorTitle: 'Invalid request body', + errorMessage: schemaRes.error.message, + }); + } + const { redirectUri } = schemaRes.data; + + //Handling the callback + const callbackResp = await handleOauthCallback(ctx, redirectUri); + if('errorCode' in callbackResp || 'errorTitle' in callbackResp){ + return ctx.send(callbackResp); + } + const userInfo = callbackResp; + + //Getting identifier + const fivemIdentifier = getIdFromOauthNameid(userInfo.nameid); + if(!fivemIdentifier){ + return ctx.send({ + errorTitle: 'Invalid nameid identifier.', + errorMessage: `Could not extract the user identifier from the URL below. Please report this to the txAdmin dev team.\n${userInfo.nameid.toString()}`, + }); + } + + //Check & Login user + try { + const vaultAdmin = txCore.adminStore.getAdminByIdentifiers([fivemIdentifier]); + if (!vaultAdmin) { + ctx.sessTools.destroy(); + return ctx.send({ + errorCode: 'not_admin', + errorContext: { + identifier: fivemIdentifier, + name: userInfo.name, + profile: userInfo.profile + } + }); + } + + //Setting session + const sessData = { + type: 'cfxre', + username: vaultAdmin.name, + csrfToken: txCore.adminStore.genCsrfToken(), + expiresAt: Date.now() + 86_400_000, //24h, + identifier: fivemIdentifier, + } satisfies CfxreSessAuthType; + ctx.sessTools.set({ auth: sessData }); + + //If the user has a picture, save it to the cache + if (userInfo.picture) { + txCore.cacheStore.set(`admin:picture:${vaultAdmin.name}`, userInfo.picture); + } + + const authedAdmin = new AuthedAdmin(vaultAdmin, sessData.csrfToken); + authedAdmin.logAction(`logged in from ${ctx.ip} via cfxre`); + txCore.metrics.txRuntime.loginOrigins.count(ctx.txVars.hostType); + txCore.metrics.txRuntime.loginMethods.count('citizenfx'); + return ctx.send(authedAdmin.getAuthData()); + } catch (error) { + ctx.sessTools.destroy(); + console.verbose.error(`Failed to login: ${(error as Error).message}`); + return ctx.send({ + errorTitle: 'Failed to login:', + errorMessage: (error as Error).message, + }); + } +}; diff --git a/core/routes/authentication/providerRedirect.ts b/core/routes/authentication/providerRedirect.ts new file mode 100644 index 0000000..11edead --- /dev/null +++ b/core/routes/authentication/providerRedirect.ts @@ -0,0 +1,36 @@ +const modulename = 'WebServer:AuthProviderRedirect'; +import { InitializedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { ApiOauthRedirectResp } from '@shared/authApiTypes'; +import { z } from 'zod'; +import { getOauthRedirectUrl } from './oauthMethods'; +const console = consoleFactory(modulename); + +const querySchema = z.object({ + origin: z.string(), +}); + + +/** + * Generates the provider auth url and redirects the user + */ +export default async function AuthProviderRedirect(ctx: InitializedCtx) { + const schemaRes = querySchema.safeParse(ctx.request.query); + if (!schemaRes.success) { + return ctx.send({ + error: `Invalid request query: ${schemaRes.error.message}`, + }); + } + const { origin } = schemaRes.data; + + //Check if there are already admins set up + if (!txCore.adminStore.hasAdmins()) { + return ctx.send({ + error: `no_admins_setup`, + }); + } + + return ctx.send({ + authUrl: getOauthRedirectUrl(ctx, 'login', origin), + }); +}; diff --git a/core/routes/authentication/self.ts b/core/routes/authentication/self.ts new file mode 100644 index 0000000..5fb6fdd --- /dev/null +++ b/core/routes/authentication/self.ts @@ -0,0 +1,13 @@ +const modulename = 'WebServer:AuthSelf'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { ReactAuthDataType } from '@shared/authApiTypes'; +const console = consoleFactory(modulename); + +/** + * Method to check for the authentication, returning the admin object if it's valid. + * This is used in the NUI auth and in the sv_admins.lua, as well as in the react web ui. + */ +export default async function AuthSelf(ctx: AuthedCtx) { + ctx.send(ctx.admin.getAuthData()); +}; diff --git a/core/routes/authentication/verifyPassword.ts b/core/routes/authentication/verifyPassword.ts new file mode 100644 index 0000000..3a8fd3c --- /dev/null +++ b/core/routes/authentication/verifyPassword.ts @@ -0,0 +1,85 @@ +const modulename = 'WebServer:AuthVerifyPassword'; +import { AuthedAdmin, PassSessAuthType } from '@modules/WebServer/authLogic'; +import { InitializedCtx } from '@modules/WebServer/ctxTypes'; +import { txEnv } from '@core/globalData'; +import consoleFactory from '@lib/console'; +import { ApiVerifyPasswordResp, ReactAuthDataType } from '@shared/authApiTypes'; +import { z } from 'zod'; +const console = consoleFactory(modulename); + +//Helper functions +const bodySchema = z.object({ + username: z.string().trim(), + password: z.string().trim(), +}); +export type ApiVerifyPasswordReqSchema = z.infer; + +/** + * Verify login + */ +export default async function AuthVerifyPassword(ctx: InitializedCtx) { + //Check UI version + const { uiVersion } = ctx.request.query; + if(uiVersion && uiVersion !== txEnv.txaVersion){ + return ctx.send({ + error: `refreshToUpdate`, + }); + } + + //Checking body + const schemaRes = bodySchema.safeParse(ctx.request.body); + if (!schemaRes.success) { + return ctx.send({ + error: `Invalid request body: ${schemaRes.error.message}`, + }); + } + const postBody = schemaRes.data; + + //Check if there are already admins set up + if (!txCore.adminStore.hasAdmins()) { + return ctx.send({ + error: `no_admins_setup`, + }); + } + + try { + //Checking admin + const vaultAdmin = txCore.adminStore.getAdminByName(postBody.username); + if (!vaultAdmin) { + console.warn(`Wrong username from: ${ctx.ip}`); + return ctx.send({ + error: 'Wrong username or password!', + }); + } + if (!VerifyPasswordHash(postBody.password, vaultAdmin.password_hash)) { + console.warn(`Wrong password from: ${ctx.ip}`); + return ctx.send({ + error: 'Wrong username or password!', + }); + } + + //Setting up session + const sessData = { + type: 'password', + username: vaultAdmin.name, + password_hash: vaultAdmin.password_hash, + expiresAt: false, + csrfToken: txCore.adminStore.genCsrfToken(), + } satisfies PassSessAuthType; + ctx.sessTools.set({ auth: sessData }); + + txCore.logger.admin.write(vaultAdmin.name, `logged in from ${ctx.ip} via password`); + txCore.metrics.txRuntime.loginOrigins.count(ctx.txVars.hostType); + txCore.metrics.txRuntime.loginMethods.count('password'); + + const authedAdmin = new AuthedAdmin(vaultAdmin, sessData.csrfToken) + return ctx.send(authedAdmin.getAuthData()); + + } catch (error) { + console.warn(`Failed to authenticate ${postBody.username} with error: ${(error as Error).message}`); + console.verbose.dir(error); + return ctx.send({ + error: 'Error autenticating admin.', + }); + } +}; diff --git a/core/routes/banTemplates/getBanTemplates.ts b/core/routes/banTemplates/getBanTemplates.ts new file mode 100644 index 0000000..9cef6fd --- /dev/null +++ b/core/routes/banTemplates/getBanTemplates.ts @@ -0,0 +1,19 @@ +const modulename = 'WebServer:GetBanTemplates'; +import consoleFactory from '@lib/console'; +import { BanTemplatesDataType } from '@modules/ConfigStore/schema/banlist'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { GenericApiErrorResp } from '@shared/genericApiTypes'; +const console = consoleFactory(modulename); + + +//Response type +export type GetBanTemplatesSuccessResp = BanTemplatesDataType[]; + + +/** + * Retrieves the ban templates from the config file + */ +export default async function GetBanTemplates(ctx: AuthedCtx) { + const sendTypedResp = (data: GetBanTemplatesSuccessResp | GenericApiErrorResp) => ctx.send(data); + return sendTypedResp(txConfig.banlist.templates); +}; diff --git a/core/routes/banTemplates/saveBanTemplates.ts b/core/routes/banTemplates/saveBanTemplates.ts new file mode 100644 index 0000000..84fdc8c --- /dev/null +++ b/core/routes/banTemplates/saveBanTemplates.ts @@ -0,0 +1,53 @@ +const modulename = 'WebServer:SaveBanTemplates'; +import consoleFactory from '@lib/console'; +import { BanTemplatesDataType } from '@modules/ConfigStore/schema/banlist'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { GenericApiOkResp } from '@shared/genericApiTypes'; +import { z } from 'zod'; +const console = consoleFactory(modulename); + + +//Req validation & types +const bodySchema = z.any().array(); +export type SaveBanTemplatesReq = BanTemplatesDataType[]; +export type SaveBanTemplatesResp = GenericApiOkResp; + + +/** + * Saves the ban templates to the config file + */ +export default async function SaveBanTemplates(ctx: AuthedCtx) { + const sendTypedResp = (data: SaveBanTemplatesResp) => ctx.send(data); + + //Check permissions + if (!ctx.admin.testPermission('settings.write', modulename)) { + return sendTypedResp({ + error: 'You do not have permission to change the settings.' + }); + } + + //Validating input + const schemaRes = bodySchema.safeParse(ctx.request.body); + if (!schemaRes.success) { + return sendTypedResp({ + error: `Invalid request body: ${schemaRes.error.message}`, + }); + } + const banTemplates = schemaRes.data; + + //Preparing & saving config + try { + txCore.configStore.saveConfigs({ + banlist: { templates: banTemplates }, + }, ctx.admin.name); + } catch (error) { + console.warn(`[${ctx.admin.name}] Error changing banTemplates settings.`); + console.verbose.dir(error); + return sendTypedResp({ + error: `Error saving the configuration file: ${(error as Error).message}` + }); + } + + //Sending output + return sendTypedResp({ success: true }); +}; diff --git a/core/routes/cfgEditor/get.js b/core/routes/cfgEditor/get.js new file mode 100644 index 0000000..845d8ce --- /dev/null +++ b/core/routes/cfgEditor/get.js @@ -0,0 +1,38 @@ +const modulename = 'WebServer:CFGEditorPage'; +import { resolveCFGFilePath, readRawCFGFile } from '@lib/fxserver/fxsConfigHelper'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + + +/** + * Returns the output page containing the server.cfg + * @param {object} ctx + */ +export default async function CFGEditorPage(ctx) { + //Check permissions + if (!ctx.admin.hasPermission('server.cfg.editor')) { + return ctx.utils.render('main/message', {message: 'You don\'t have permission to view this page.'}); + } + + //Check if file is set + if (!txCore.fxRunner.isConfigured) { + let message = 'You need to configure your server data path before being able to edit the CFG file.'; + return ctx.utils.render('main/message', {message}); + } + + //Read cfg file + let rawFile; + try { + let cfgFilePath = resolveCFGFilePath(txConfig.server.cfgPath, txConfig.server.dataPath); + rawFile = await readRawCFGFile(cfgFilePath); + } catch (error) { + let message = `Failed to read CFG File with error: ${error.message}`; + return ctx.utils.render('main/message', {message}); + } + + return ctx.utils.render('main/cfgEditor', { + headerTitle: 'CFG Editor', + rawFile, + disableRestart: (ctx.admin.hasPermission('control.server')) ? '' : 'disabled', + }); +}; diff --git a/core/routes/cfgEditor/save.js b/core/routes/cfgEditor/save.js new file mode 100644 index 0000000..6e60a2c --- /dev/null +++ b/core/routes/cfgEditor/save.js @@ -0,0 +1,74 @@ +const modulename = 'WebServer:CFGEditorSave'; +import { validateModifyServerConfig } from '@lib/fxserver/fxsConfigHelper'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + + +const isUndefined = (x) => (x === undefined); + + +/** + * Saves the server.cfg + * @param {object} ctx + */ +export default async function CFGEditorSave(ctx) { + //Sanity check + if ( + isUndefined(ctx.request.body.cfgData) + || typeof ctx.request.body.cfgData !== 'string' + ) { + return ctx.utils.error(400, 'Invalid Request'); + } + + //Check permissions + if (!ctx.admin.testPermission('server.cfg.editor', modulename)) { + return ctx.send({ + type: 'danger', + message: 'You don\'t have permission to execute this action.', + }); + } + + //Check if file is set + if (!txCore.fxRunner.isConfigured) { + const message = 'CFG or Server Data Path not defined. Configure it in the settings page first.'; + return ctx.send({type: 'danger', message}); + } + + + //Validating config contents + saving file and backup + let result; + try { + result = await validateModifyServerConfig( + ctx.request.body.cfgData, + txConfig.server.cfgPath, + txConfig.server.dataPath, + ); + } catch (error) { + return ctx.send({ + type: 'danger', + markdown: true, + message: `**Failed to save \`server.cfg\` with error:**\n${error.message}`, + }); + } + + //Handle result + if (result.errors) { + return ctx.send({ + type: 'danger', + markdown: true, + message: `**Cannot save \`server.cfg\` due to error(s) in your config file(s):**\n${result.errors}`, + }); + } + if (result.warnings) { + return ctx.send({ + type: 'warning', + markdown: true, + message: `**File saved, but there are warnings you should pay attention to:**\n${result.warnings}`, + }); + } + return ctx.send({ + type: 'success', + markdown: true, + message: '**File saved.**', + }); +}; diff --git a/core/routes/deployer/actions.js b/core/routes/deployer/actions.js new file mode 100644 index 0000000..c84fa82 --- /dev/null +++ b/core/routes/deployer/actions.js @@ -0,0 +1,286 @@ +const modulename = 'WebServer:DeployerActions'; +import path from 'node:path'; +import { cloneDeep } from 'lodash-es'; +import slash from 'slash'; +import mysql from 'mysql2/promise'; +import consts from '@shared/consts'; +import { txEnv, txHostConfig } from '@core/globalData'; +import { validateModifyServerConfig } from '@lib/fxserver/fxsConfigHelper'; +import consoleFactory from '@lib/console'; +import { SYM_RESET_CONFIG } from '@lib/symbols'; +const console = consoleFactory(modulename); + +//Helper functions +const isUndefined = (x) => (x === undefined); + + +/** + * Handle all the server control actions + * @param {object} ctx + */ +export default async function DeployerActions(ctx) { + //Sanity check + if (isUndefined(ctx.params.action)) { + return ctx.utils.error(400, 'Invalid Request'); + } + const action = ctx.params.action; + + //Check permissions + if (!ctx.admin.testPermission('master', modulename)) { + return ctx.send({ success: false, refresh: true }); + } + + //Check if this is the correct state for the deployer + if (txManager.deployer == null) { + return ctx.send({ success: false, refresh: true }); + } + + //Delegate to the specific action functions + if (action == 'confirmRecipe') { + return await handleConfirmRecipe(ctx); + } else if (action == 'setVariables') { + return await handleSetVariables(ctx); + } else if (action == 'commit') { + return await handleSaveConfig(ctx); + } else if (action == 'cancel') { + return await handleCancel(ctx); + } else { + return ctx.send({ + type: 'danger', + message: 'Unknown setup action.', + }); + } +}; + + +//================================================================ +/** + * Handle submition of user-edited recipe (record to deployer, starts the process) + * @param {object} ctx + */ +async function handleConfirmRecipe(ctx) { + //Sanity check + if (isUndefined(ctx.request.body.recipe)) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + const userEditedRecipe = ctx.request.body.recipe; + + try { + ctx.admin.logAction('Setting recipe.'); + await txManager.deployer.confirmRecipe(userEditedRecipe); + } catch (error) { + return ctx.send({ type: 'danger', message: error.message }); + } + + return ctx.send({ success: true }); +} + + +//================================================================ +/** + * Handle submition of the input variables/parameters + * @param {object} ctx + */ +async function handleSetVariables(ctx) { + //Sanity check + if (isUndefined(ctx.request.body.svLicense)) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + const userVars = cloneDeep(ctx.request.body); + + //Validating sv_licenseKey + if ( + !consts.regexSvLicenseNew.test(userVars.svLicense) + && !consts.regexSvLicenseOld.test(userVars.svLicense) + ) { + return ctx.send({ type: 'danger', message: 'The Server License does not appear to be valid.' }); + } + + //Validating steam api key requirement + if ( + txManager.deployer.recipe.steamRequired + && (typeof userVars.steam_webApiKey !== 'string' || userVars.steam_webApiKey.length < 24) + ) { + return ctx.send({ + type: 'danger', + message: 'This recipe requires steam_webApiKey to be set and valid.', + }); + } + + //DB Stuff + if (typeof userVars.dbDelete !== 'undefined') { + //Testing the db config + try { + userVars.dbPort = parseInt(userVars.dbPort); + if (isNaN(userVars.dbPort)) { + return ctx.send({ type: 'danger', message: 'The database port is invalid (non-integer). The default is 3306.' }); + } + + const mysqlOptions = { + host: userVars.dbHost, + port: userVars.dbPort, + user: userVars.dbUsername, + password: userVars.dbPassword, + connectTimeout: 5000, + }; + await mysql.createConnection(mysqlOptions); + } catch (error) { + let outMessage = error?.message ?? 'Unknown error occurred.'; + if (error?.code === 'ECONNREFUSED') { + let specificError = (txEnv.isWindows) + ? 'If you do not have a database installed, you can download and run XAMPP.' + : 'If you do not have a database installed, you must download and run MySQL or MariaDB.'; + if (userVars.dbPort !== 3306) { + specificError += '
\nYou are not using the default DB port 3306, make sure it is correct!'; + } + outMessage = `${error?.message}
\n${specificError}`; + } else if (error.message?.includes('auth_gssapi_client')) { + outMessage = `Your database does not accept the required authentication method. Please update your MySQL/MariaDB server and try again.`; + } + + return ctx.send({ type: 'danger', message: `Database connection failed: ${outMessage}` }); + } + + //Setting connection string + userVars.dbDelete = (userVars.dbDelete === 'true'); + const dbFullHost = (userVars.dbPort === 3306) + ? userVars.dbHost + : `${userVars.dbHost}:${userVars.dbPort}`; + userVars.dbConnectionString = (userVars.dbPassword.length) + ? `mysql://${userVars.dbUsername}:${userVars.dbPassword}@${dbFullHost}/${userVars.dbName}?charset=utf8mb4` + : `mysql://${userVars.dbUsername}@${dbFullHost}/${userVars.dbName}?charset=utf8mb4`; + } + + //Max Clients & Server Endpoints + userVars.maxClients = (txHostConfig.forceMaxClients) ? txHostConfig.forceMaxClients : 48; + if (txHostConfig.netInterface || txHostConfig.fxsPort) { + const comment = `# ${txHostConfig.sourceName}: do not modify!`; + const endpointIface = txHostConfig.netInterface ?? '0.0.0.0'; + const endpointPort = txHostConfig.fxsPort ?? 30120; + userVars.serverEndpoints = [ + `endpoint_add_tcp "${endpointIface}:${endpointPort}" ${comment}`, + `endpoint_add_udp "${endpointIface}:${endpointPort}" ${comment}`, + ].join('\n'); + } else { + userVars.serverEndpoints = [ + 'endpoint_add_tcp "0.0.0.0:30120"', + 'endpoint_add_udp "0.0.0.0:30120"', + ].join('\n'); + } + + //Setting identifiers array + const admin = txCore.adminStore.getAdminByName(ctx.admin.name); + if (!admin) return ctx.send({ type: 'danger', message: 'Admin not found.' }); + const addPrincipalLines = []; + Object.keys(admin.providers).forEach((providerName) => { + if (admin.providers[providerName].identifier) { + addPrincipalLines.push(`add_principal identifier.${admin.providers[providerName].identifier} group.admin #${ctx.admin.name}`); + } + }); + userVars.addPrincipalsMaster = (addPrincipalLines.length) + ? addPrincipalLines.join('\n') + : '# Deployer Note: this admin master has no identifiers to be automatically added.\n# add_principal identifier.discord:111111111111111111 group.admin #example'; + + //Start deployer + try { + ctx.admin.logAction('Running recipe.'); + txManager.deployer.start(userVars); + } catch (error) { + return ctx.send({ type: 'danger', message: error.message }); + } + + return ctx.send({ success: true }); +} + + +//================================================================ +/** + * Handle the commit of a Recipe by receiving the user edited server.cfg + * @param {object} ctx + */ +async function handleSaveConfig(ctx) { + //Sanity check + if (isUndefined(ctx.request.body.serverCFG)) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + const serverCFG = ctx.request.body.serverCFG; + const cfgFilePath = path.join(txManager.deployer.deployPath, 'server.cfg'); + txCore.cacheStore.set('deployer:recipe', txManager.deployer?.recipe?.name ?? 'unknown'); + + //Validating config contents + saving file and backup + try { + const result = await validateModifyServerConfig(serverCFG, cfgFilePath, txManager.deployer.deployPath); + if (result.errors) { + return ctx.send({ + type: 'danger', + success: false, + markdown: true, + message: `**Cannot save \`server.cfg\` due to error(s) in your config file(s):**\n${result.errors}`, + }); + } + } catch (error) { + return ctx.send({ + type: 'danger', + success: false, + markdown: true, + message: `**Failed to save \`server.cfg\` with error:**\n${error.message}`, + }); + } + + //Preparing & saving config + let onesync = SYM_RESET_CONFIG; + if (typeof txManager.deployer?.recipe?.onesync === 'string' && txManager.deployer.recipe.onesync.length) { + onesync = txManager.deployer.recipe.onesync; + } + try { + txCore.configStore.saveConfigs({ + server: { + dataPath: slash(path.normalize(txManager.deployer.deployPath)), + cfgPath: 'server.cfg', + onesync, + } + }, ctx.admin.name); + } catch (error) { + console.warn(`[${ctx.admin.name}] Error changing fxserver settings via deployer.`); + console.verbose.dir(error); + return ctx.send({ + type: 'danger', + markdown: true, + message: `**Error saving the configuration file:** ${error.message}` + }); + } + + ctx.admin.logAction('Completed and committed server deploy.'); + + //If running (for some reason), kill it first + if (!txCore.fxRunner.isIdle) { + ctx.admin.logCommand('STOP SERVER'); + await txCore.fxRunner.killServer('new server deployed', ctx.admin.name, true); + } + + //Starting server + const spawnError = await txCore.fxRunner.spawnServer(false); + if (spawnError !== null) { + return ctx.send({ + type: 'danger', + markdown: true, + message: `Config file saved, but failed to start server with error:\n${spawnError}`, + }); + } else { + txManager.deployer = null; + txCore.webServer.webSocket.pushRefresh('status'); + return ctx.send({ success: true }); + } +} + + +//================================================================ +/** + * Handle the cancellation of the deployer proguess + * @param {object} ctx + */ +async function handleCancel(ctx) { + txManager.deployer = null; + txCore.webServer.webSocket.pushRefresh('status'); + return ctx.send({ success: true }); +} diff --git a/core/routes/deployer/status.js b/core/routes/deployer/status.js new file mode 100644 index 0000000..74a8327 --- /dev/null +++ b/core/routes/deployer/status.js @@ -0,0 +1,35 @@ +const modulename = 'WebServer:DeployerStatus'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + + +/** + * Returns the output page containing the live console + * @param {object} ctx + */ +export default async function DeployerStatus(ctx) { + //Check permissions + if (!ctx.admin.hasPermission('all_permissions')) { + return ctx.send({success: false, refresh: true}); + } + + //Check if this is the correct state for the deployer + if (txManager.deployer == null) { + return ctx.send({success: false, refresh: true}); + } + + //Prepare data + const outData = { + progress: txManager.deployer.progress, + log: txManager.deployer.getDeployerLog(), + }; + if (txManager.deployer.step == 'configure') { + outData.status = 'done'; + } else if (txManager.deployer.deployFailed) { + outData.status = 'failed'; + } else { + outData.status = 'running'; + } + + return ctx.send(outData); +}; diff --git a/core/routes/deployer/stepper.js b/core/routes/deployer/stepper.js new file mode 100644 index 0000000..2e0414b --- /dev/null +++ b/core/routes/deployer/stepper.js @@ -0,0 +1,95 @@ +const modulename = 'WebServer:DeployerStepper'; +import fse from 'fs-extra'; +import { txHostConfig } from '@core/globalData'; +import consoleFactory from '@lib/console'; +import { TxConfigState } from '@shared/enums'; +const console = consoleFactory(modulename); + + +/** + * Returns the output page containing the deployer stepper page (all 3 stages) + * @param {object} ctx + */ +export default async function DeployerStepper(ctx) { + //Check permissions + if (!ctx.admin.hasPermission('master')) { + return ctx.utils.render('main/message', { message: 'You need to be the admin master to use the deployer.' }); + } + + //Ensure the correct state for the deployer + if(txManager.configState === TxConfigState.Setup) { + return ctx.utils.legacyNavigateToPage('/server/setup'); + } else if(txManager.configState !== TxConfigState.Deployer) { + return ctx.utils.legacyNavigateToPage('/'); + } else if(!txManager.deployer?.step){ + throw new Error(`txManager.configState is Deployer but txManager.deployer is not defined`); + } + + //Prepare Output + const renderData = { + step: txManager.deployer.step, + deploymentID: txManager.deployer.deploymentID, + requireDBConfig: false, + defaultLicenseKey: '', + recipe: undefined, + defaults: {}, + }; + + if (txManager.deployer.step === 'review') { + renderData.recipe = { + isTrustedSource: txManager.deployer.isTrustedSource, + name: txManager.deployer.recipe.name, + author: txManager.deployer.recipe.author, + description: txManager.deployer.recipe.description, + raw: txManager.deployer.recipe.raw, + }; + } else if (txManager.deployer.step === 'input') { + renderData.defaultLicenseKey = txHostConfig.defaults.cfxKey ?? ''; + renderData.requireDBConfig = txManager.deployer.recipe.requireDBConfig; + renderData.defaults = { + autofilled: Object.values(txHostConfig.defaults).some(Boolean), + license: txHostConfig.defaults.cfxKey ?? '', + mysqlHost: txHostConfig.defaults.dbHost ?? 'localhost', + mysqlPort: txHostConfig.defaults.dbPort ?? '3306', + mysqlUser: txHostConfig.defaults.dbUser ?? 'root', + mysqlPassword: txHostConfig.defaults.dbPass ?? '', + mysqlDatabase: txHostConfig.defaults.dbName ?? txManager.deployer.deploymentID, + }; + + const knownVarDescriptions = { + steam_webApiKey: 'The Steam Web API Key is used to authenticate players when they join.
\nYou can get one at https://steamcommunity.com/dev/apikey.', + } + const recipeVars = txManager.deployer.getRecipeVars(); + renderData.inputVars = Object.keys(recipeVars).map((name) => { + return { + name: name, + value: recipeVars[name], + description: knownVarDescriptions[name] || '', + }; + }); + } else if (txManager.deployer.step === 'run') { + renderData.deployPath = txManager.deployer.deployPath; + } else if (txManager.deployer.step === 'configure') { + const errorMessage = `# server.cfg Not Found! +# This probably means you deleted it before pressing "Next". +# Press cancel and start the deployer again, +# or insert here the server.cfg contents. +# (╯°□°)╯︵ ┻━┻`; + try { + renderData.serverCFG = await fse.readFile(`${txManager.deployer.deployPath}/server.cfg`, 'utf8'); + if (renderData.serverCFG == '#save_attempt_please_ignore' || !renderData.serverCFG.length) { + renderData.serverCFG = errorMessage; + } else if (renderData.serverCFG.length > 10240) { //10kb + renderData.serverCFG = `# This recipe created a ./server.cfg above 10kb, meaning its probably the wrong data. +Make sure everything is correct in the recipe and try again.`; + } + } catch (error) { + console.verbose.dir(error); + renderData.serverCFG = errorMessage; + } + } else { + return ctx.utils.render('main/message', { message: 'Unknown Deployer step, please report this bug and restart txAdmin.' }); + } + + return ctx.utils.render('standalone/deployer', renderData); +}; diff --git a/core/routes/devDebug.ts b/core/routes/devDebug.ts new file mode 100644 index 0000000..c2aaa1a --- /dev/null +++ b/core/routes/devDebug.ts @@ -0,0 +1,74 @@ +const modulename = 'WebServer:DevDebug'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { txDevEnv } from '@core/globalData'; +import consoleFactory from '@lib/console'; +import { z } from 'zod'; +const console = consoleFactory(modulename); + + +//Helpers +const devWarningMessage = 'The unsafe privileged /dev webroute was called and should only be used in developer mode.'; +const paramsSchema = z.object({ + scope: z.string(), +}); +let playerJoinCounter = 0; + + +/** + * Handler for the GET calls + */ +export const get = async (ctx: AuthedCtx) => { + //Sanity check + if (!txDevEnv.ENABLED) return ctx.send({ error: 'this route is dev mode only' }); + const schemaRes = paramsSchema.safeParse(ctx.params); + if (!schemaRes.success) return ctx.utils.error(400, 'Invalid Request'); + console.warn(devWarningMessage); + const { scope } = schemaRes.data; + + return ctx.send({ + rand: Math.random() + }); +}; + + +/** + * Handler for the POST calls + */ +export const post = async (ctx: AuthedCtx) => { + //Sanity check + if (!txDevEnv.ENABLED) return ctx.send({ error: 'this route is dev mode only' }); + const schemaRes = paramsSchema.safeParse(ctx.params); + if (!schemaRes.success) return ctx.utils.error(400, 'Invalid Request'); + console.warn(devWarningMessage); + const { scope } = schemaRes.data; + + if (scope === 'event') { + try { + if(!txCore.fxRunner.child?.isAlive){ + return ctx.send({ error: 'server not ready' }); + } + if (ctx.request.body.id === null) { + if (ctx.request.body.event === 'playerDropped') { + const onlinePlayers = txCore.fxPlayerlist.getPlayerList(); + if (onlinePlayers.length){ + ctx.request.body.id = onlinePlayers[0].netid; + } + } else if (ctx.request.body.event === 'playerJoining') { + ctx.request.body.id = playerJoinCounter + 101; + } + } + if (ctx.request.body.event === 'playerJoining') { + playerJoinCounter++; + } + txCore.fxPlayerlist.handleServerEvents(ctx.request.body, txCore.fxRunner.child.mutex); + return ctx.send({ success: true }); + } catch (error) { + console.dir(error); + } + + } else { + console.dir(scope); + console.dir(ctx.request.body); + return ctx.send({ error: 'unknown scope' }); + } +}; diff --git a/core/routes/diagnostics/page.ts b/core/routes/diagnostics/page.ts new file mode 100644 index 0000000..b35ac26 --- /dev/null +++ b/core/routes/diagnostics/page.ts @@ -0,0 +1,37 @@ +const modulename = 'WebServer:Diagnostics'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import MemCache from '@lib/MemCache'; +import * as diagnosticsFuncs from '@lib/diagnostics'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); +const cache = new MemCache(5); + + +/** + * Returns the output page containing the full report + */ +export default async function Diagnostics(ctx: AuthedCtx) { + const cachedData = cache.get(); + if (cachedData) { + cachedData.message = 'This page was cached in the last 5 seconds'; + return ctx.utils.render('main/diagnostics', cachedData); + } + + const timeStart = Date.now(); + const data: any = { + headerTitle: 'Diagnostics', + message: '', + }; + [data.host, data.txadmin, data.fxserver, data.proccesses] = await Promise.all([ + diagnosticsFuncs.getHostData(), + diagnosticsFuncs.getTxAdminData(), + diagnosticsFuncs.getFXServerData(), + diagnosticsFuncs.getProcessesData(), + ]); + + const timeElapsed = Date.now() - timeStart; + data.message = `Executed in ${timeElapsed} ms`; + + cache.set(data); + return ctx.utils.render('main/diagnostics', data); +}; diff --git a/core/routes/diagnostics/sendReport.ts b/core/routes/diagnostics/sendReport.ts new file mode 100644 index 0000000..770d076 --- /dev/null +++ b/core/routes/diagnostics/sendReport.ts @@ -0,0 +1,172 @@ +const modulename = 'WebServer:SendDiagnosticsReport'; +import got from '@lib/got'; +import { txEnv, txHostConfig } from '@core/globalData'; +import { GenericApiErrorResp } from '@shared/genericApiTypes'; +import * as diagnosticsFuncs from '@lib/diagnostics'; +import { redactApiKeys, redactStartupSecrets } from '@lib/misc'; +import { type ServerDataContentType, type ServerDataConfigsType, getServerDataContent, getServerDataConfigs } from '@lib/fxserver/serverData'; +import MemCache from '@lib/MemCache'; +import consoleFactory, { getLogBuffer } from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import scanMonitorFiles from '@lib/fxserver/scanMonitorFiles'; +const console = consoleFactory(modulename); + +//Consts & Helpers +const reportIdCache = new MemCache(60); +const maskedKeywords = ['key', 'license', 'pass', 'private', 'secret', 'token', 'webhook']; +const maskString = (input: string) => input.replace(/\w/gi, 'x'); +const maskIps = (input: string) => input.replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/gi, 'x.x.x.x'); +type ServerLogType = { + ts: number; + type: string; + src: { + id: string | false; + name: string; + }; + msg: string; +} + +/** + * Prepares and sends the diagnostics report to txAPI + */ +export default async function SendDiagnosticsReport(ctx: AuthedCtx) { + type SuccessResp = { + reportId: string; + }; + const sendTypedResp = (data: SuccessResp | GenericApiErrorResp) => ctx.send(data); + + //Rate limit (and cache) report submissions + const cachedReportId = reportIdCache.get(); + if (cachedReportId) { + return sendTypedResp({ + error: `You can send at most one report per minute. Your last report ID was ${cachedReportId}.` + }); + } + + //Diagnostics + let diagnostics; + try { + const [host, txadmin, fxserver, proccesses] = await Promise.all([ + diagnosticsFuncs.getHostData(), + diagnosticsFuncs.getTxAdminData(), + diagnosticsFuncs.getFXServerData(), + diagnosticsFuncs.getProcessesData(), + ]); + diagnostics = { host, txadmin, fxserver, proccesses }; + } catch (error) { } + + //Admins + const adminList = (txCore.adminStore.getRawAdminsList() as any[]) + .map(a => ({ ...a, password_hash: '[REDACTED]' })); + + //Settings + const storedConfigs = txCore.configStore.getStoredConfig() as any; + if (storedConfigs?.discordBot?.token) { + storedConfigs.discordBot.token = '[REDACTED]'; + } + if (storedConfigs?.server?.startupArgs) { + storedConfigs.server.startupArgs = redactStartupSecrets(storedConfigs.server.startupArgs); + } + + //Env vars + const envVars: Record = {}; + for (const [envKey, envValue] of Object.entries(process.env)) { + if (!envValue) continue; + + if (maskedKeywords.some((kw) => envKey.toLowerCase().includes(kw))) { + envVars[envKey] = maskString(envValue); + } else { + envVars[envKey] = envValue; + } + } + + //Remove IP from logs + const txSystemLog = maskIps(getLogBuffer()); + + const rawTxActionLog = await txCore.logger.admin.getRecentBuffer(); + const txActionLog = (typeof rawTxActionLog !== 'string') + ? 'error reading log file' + : maskIps(rawTxActionLog).split('\n').slice(-500).join('\n'); + + const serverLog = (txCore.logger.server.getRecentBuffer(500) as ServerLogType[]) + .map((l) => ({ ...l, msg: maskIps(l.msg) })); + const fxserverLog = maskIps(txCore.logger.fxserver.getRecentBuffer()); + + //Getting server data content + let serverDataContent: ServerDataContentType = []; + let cfgFiles: ServerDataConfigsType = []; + //FIXME: use txCore.fxRunner.serverPaths + if (storedConfigs.server?.dataPath) { + serverDataContent = await getServerDataContent(storedConfigs.server.dataPath); + const rawCfgFiles = await getServerDataConfigs(storedConfigs.server.dataPath, serverDataContent); + cfgFiles = rawCfgFiles.map(([fName, fData]) => [fName, redactApiKeys(fData)]); + } + + //Database & perf stats + let dbStats = {}; + try { + dbStats = txCore.database.stats.getDatabaseStats(); + } catch (error) { } + + let perfSvMain: ReturnType = null; + try { + perfSvMain = txCore.metrics.svRuntime.getServerPerfSummary(); + } catch (error) { } + + //Monitor integrity check + let monitorContent = null; + try { + monitorContent = await scanMonitorFiles(); + } catch (error) { } + + //Prepare report object + const reportData = { + $schemaVersion: 2, + $txVersion: txEnv.txaVersion, + $fxVersion: txEnv.fxsVersion, + $provider: String(txHostConfig.providerName), //we want an 'undefined' + diagnostics, + txSystemLog, + txActionLog, + serverLog, + fxserverLog, + envVars, + perfSvMain, + dbStats, + settings: storedConfigs, + adminList, + serverDataContent, + cfgFiles, + monitorContent, + }; + + // //Preparing request + const requestOptions = { + url: `https://txapi.cfx-services.net/public/submit`, + // url: `http://127.0.0.1:8121/public/submit`, + retry: { limit: 1 }, + json: reportData, + }; + + //Making HTTP Request + try { + type ResponseType = { reportId: string } | { error: string, message?: string }; + const apiResp = await got.post(requestOptions).json() as ResponseType; + if ('reportId' in apiResp) { + reportIdCache.set(apiResp.reportId); + console.warn(`Diagnostics data report ID ${apiResp.reportId} sent by ${ctx.admin.name}`); + return sendTypedResp({ reportId: apiResp.reportId }); + } else { + console.verbose.dir(apiResp); + return sendTypedResp({ error: `Report failed: ${apiResp.message ?? apiResp.error}` }); + } + } catch (error) { + try { + const apiErrorResp = JSON.parse((error as any)?.response?.body); + const reason = apiErrorResp.message ?? apiErrorResp.error ?? (error as Error).message; + return sendTypedResp({ error: `Report failed: ${reason}` }); + } catch (error2) { + return sendTypedResp({ error: `Report failed: ${(error as Error).message}` }); + } + } +}; diff --git a/core/routes/fxserver/commands.ts b/core/routes/fxserver/commands.ts new file mode 100644 index 0000000..445f347 --- /dev/null +++ b/core/routes/fxserver/commands.ts @@ -0,0 +1,194 @@ +const modulename = 'WebServer:FXServerCommands'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { ApiToastResp } from '@shared/genericApiTypes'; +import { txEnv } from '@core/globalData'; +const console = consoleFactory(modulename); + +//Helper functions +const delay = async (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; + + +/** + * Handle all the server commands + * @param {object} ctx + */ +export default async function FXServerCommands(ctx: AuthedCtx) { + if ( + typeof ctx.request.body.action === 'undefined' + || typeof ctx.request.body.parameter === 'undefined' + ) { + return ctx.send({ + type: 'error', + msg: 'Invalid request.', + }); + } + const action = ctx.request.body.action; + const parameter = ctx.request.body.parameter; + + //Ignore commands when the server is offline + if (!txCore.fxRunner.child?.isAlive) { + return ctx.send({ + type: 'error', + msg: 'The server is not running.', + }); + } + + //Block starting/restarting the 'runcode' resource + const unsafeActions = ['restart_res', 'start_res', 'ensure_res']; + if (unsafeActions.includes(action) && parameter.includes('runcode')) { + return ctx.send({ + type: 'error', + msg: 'The resource "runcode" might be unsafe.
If you know what you are doing, run it via the Live Console.', + }); + } + + + //============================================== + //DEBUG: Only available in the /advanced page + //FIXME: move to the advanced route, give button for profiling, saving mem snapshot, verbose, etc. + if (action == 'profile_monitor') { + if (!ensurePermission(ctx, 'all_permissions')) return false; + ctx.admin.logAction('Profiling txAdmin instance.'); + + const profileDuration = 5; + const savePath = `${txEnv.profilePath}/data/txProfile.bin`; + ExecuteCommand('profiler record start'); + await delay(profileDuration * 1000); + ExecuteCommand('profiler record stop'); + await delay(150); + ExecuteCommand(`profiler save "${savePath}"`); + await delay(150); + console.ok(`Profile saved to: ${savePath}`); + txCore.fxRunner.sendCommand('profiler', ['view', savePath], ctx.admin.name); + return ctx.send({ + type: 'success', + msg: 'Check your live console in a few seconds.', + }); + + //============================================== + } else if (action == 'admin_broadcast') { + if (!ensurePermission(ctx, 'announcement')) return false; + const message = (parameter ?? '').trim(); + + // Dispatch `txAdmin:events:announcement` + txCore.fxRunner.sendEvent('announcement', { + message, + author: ctx.admin.name, + }); + ctx.admin.logAction(`Sending announcement: ${parameter}`); + + // Sending discord announcement + const publicAuthor = txCore.adminStore.getAdminPublicName(ctx.admin.name, 'message'); + txCore.discordBot.sendAnnouncement({ + type: 'info', + title: { + key: 'nui_menu.misc.announcement_title', + data: { author: publicAuthor } + }, + description: message + }); + + return ctx.send({ + type: 'success', + msg: 'Announcement command sent.', + }); + + //============================================== + } else if (action == 'kick_all') { + if (!ensurePermission(ctx, 'control.server')) return false; + const kickReason = (parameter ?? '').trim() || txCore.translator.t('kick_messages.unknown_reason'); + const dropMessage = txCore.translator.t( + 'kick_messages.everyone', + { reason: kickReason } + ); + ctx.admin.logAction(`Kicking all players: ${kickReason}`); + // Dispatch `txAdmin:events:playerKicked` + txCore.fxRunner.sendEvent('playerKicked', { + target: -1, + author: ctx.admin.name, + reason: kickReason, + dropMessage, + }); + return ctx.send({ + type: 'success', + msg: 'Kick All command sent.', + }); + + //============================================== + } else if (action == 'restart_res') { + if (!ensurePermission(ctx, 'commands.resources')) return false; + ctx.admin.logAction(`Restarted resource "${parameter}"`); + txCore.fxRunner.sendCommand('restart', [parameter], ctx.admin.name); + return ctx.send({ + type: 'warning', + msg: 'Resource restart command sent.', + }); + + //============================================== + } else if (action == 'start_res') { + if (!ensurePermission(ctx, 'commands.resources')) return false; + ctx.admin.logAction(`Started resource "${parameter}"`); + txCore.fxRunner.sendCommand('start', [parameter], ctx.admin.name); + return ctx.send({ + type: 'warning', + msg: 'Resource start command sent.', + }); + + //============================================== + } else if (action == 'ensure_res') { + if (!ensurePermission(ctx, 'commands.resources')) return false; + ctx.admin.logAction(`Ensured resource "${parameter}"`); + txCore.fxRunner.sendCommand('ensure', [parameter], ctx.admin.name); + return ctx.send({ + type: 'warning', + msg: 'Resource ensure command sent.', + }); + + //============================================== + } else if (action == 'stop_res') { + if (!ensurePermission(ctx, 'commands.resources')) return false; + ctx.admin.logAction(`Stopped resource "${parameter}"`); + txCore.fxRunner.sendCommand('stop', [parameter], ctx.admin.name); + return ctx.send({ + type: 'warning', + msg: 'Resource stop command sent.', + }); + + //============================================== + } else if (action == 'refresh_res') { + if (!ensurePermission(ctx, 'commands.resources')) return false; + ctx.admin.logAction(`Refreshed resources`); + txCore.fxRunner.sendCommand('refresh', [], ctx.admin.name); + return ctx.send({ + type: 'warning', + msg: 'Refresh command sent.', + }); + + //============================================== + } else { + return ctx.send({ + type: 'error', + msg: 'Unknown Action.', + }); + } +}; + + +//================================================================ +/** + * Wrapper function to check permission and give output if denied + */ +function ensurePermission(ctx: AuthedCtx, perm: string) { + if (ctx.admin.testPermission(perm, modulename)) { + return true; + } else { + ctx.send({ + type: 'error', + msg: 'You don\'t have permission to execute this action.', + }); + return false; + } +} diff --git a/core/routes/fxserver/controls.ts b/core/routes/fxserver/controls.ts new file mode 100644 index 0000000..93ab82d --- /dev/null +++ b/core/routes/fxserver/controls.ts @@ -0,0 +1,81 @@ +const modulename = 'WebServer:FXServerControls'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { ApiToastResp } from '@shared/genericApiTypes'; +import { msToShortishDuration } from '@lib/misc'; +import ConfigStore from '@modules/ConfigStore'; +const console = consoleFactory(modulename); + + +/** + * Handle all the server control actions + */ +export default async function FXServerControls(ctx: AuthedCtx) { + //Sanity check + if (typeof ctx.request.body?.action !== 'string') { + return ctx.utils.error(400, 'Invalid Request'); + } + const { action } = ctx.request.body; + + //Check permissions + if (!ctx.admin.testPermission('control.server', modulename)) { + return ctx.send({ + type: 'error', + msg: 'You don\'t have permission to execute this action.', + }); + } + + if (action === 'restart') { + ctx.admin.logCommand('RESTART SERVER'); + + //If too much of a delay, do it async + const respawnDelay = txCore.fxRunner.restartSpawnDelay; + if (respawnDelay.ms > 10_000) { + txCore.fxRunner.restartServer('admin request', ctx.admin.name).catch((e) => { }); + const durationStr = msToShortishDuration( + respawnDelay.ms, + { units: ['m', 's', 'ms'] } + ); + return ctx.send({ + type: 'warning', + msg: `The server is will restart with delay of ${durationStr}.` + }); + } else { + const restartError = await txCore.fxRunner.restartServer('admin request', ctx.admin.name); + if (restartError !== null) { + return ctx.send({ type: 'error', md: true, msg: restartError }); + } else { + return ctx.send({ type: 'success', msg: 'The server is now restarting.' }); + } + } + + } else if (action === 'stop') { + if (txCore.fxRunner.isIdle) { + return ctx.send({ type: 'success', msg: 'The server is already stopped.' }); + } + ctx.admin.logCommand('STOP SERVER'); + await txCore.fxRunner.killServer('admin request', ctx.admin.name, false); + return ctx.send({ type: 'success', msg: 'Server stopped.' }); + + } else if (action === 'start') { + if (!txCore.fxRunner.isIdle) { + return ctx.send({ + type: 'error', + msg: 'The server is already running. If it\'s not working, press RESTART.' + }); + } + ctx.admin.logCommand('START SERVER'); + const spawnError = await txCore.fxRunner.spawnServer(true); + if (spawnError !== null) { + return ctx.send({ type: 'error', md: true, msg: spawnError }); + } else { + return ctx.send({ type: 'success', msg: 'The server is now starting.' }); + } + + } else { + return ctx.send({ + type: 'error', + msg: `Unknown control action '${action}'.`, + }); + } +}; diff --git a/core/routes/fxserver/downloadLog.js b/core/routes/fxserver/downloadLog.js new file mode 100644 index 0000000..10db657 --- /dev/null +++ b/core/routes/fxserver/downloadLog.js @@ -0,0 +1,29 @@ +const modulename = 'WebServer:FXServerDownloadLog'; +import fs from 'node:fs'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + + +/** + * Returns the console log file + * @param {object} ctx + */ +export default async function FXServerDownloadLog(ctx) { + //Check permissions + if (!ctx.admin.testPermission('console.view', modulename)) { + return ctx.utils.render('main/message', {message: 'You don\'t have permission to download this log.'}); + } + + let readFile; + try { + //NOTE: why the fuck are errors from `createReadStream` not being caught? Well, using readFileSync for now... + // readFile = fs.createReadStream(txCore.logger.fxserver.activeFilePath); + readFile = fs.readFileSync(txCore.logger.fxserver.activeFilePath); + } catch (error) { + console.error(`Could not read log file ${txCore.logger.fxserver.activeFilePath}.`); + } + const now = (new Date() / 1000).toFixed(); + ctx.attachment(`fxserver_${now}.log`); + ctx.body = readFile; + console.log(`[${ctx.admin.name}] Downloading console log file.`); +}; diff --git a/core/routes/fxserver/schedule.ts b/core/routes/fxserver/schedule.ts new file mode 100644 index 0000000..dd93ea9 --- /dev/null +++ b/core/routes/fxserver/schedule.ts @@ -0,0 +1,69 @@ +const modulename = 'WebServer:FXServerSchedule'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { ApiToastResp } from '@shared/genericApiTypes'; +const console = consoleFactory(modulename); + + +/** + * Handle all the server scheduler commands + * @param {object} ctx + */ +export default async function FXServerSchedule(ctx: AuthedCtx) { + if ( + typeof ctx.request.body.action === 'undefined' + || typeof ctx.request.body.parameter === 'undefined' + ) { + return ctx.send({ + type: 'error', + msg: `invalid request`, + }); + } + const {action, parameter} = ctx.request.body; + + //Check permissions + if (!ctx.admin.testPermission('control.server', modulename)) { + return ctx.send({ + type: 'error', + msg: 'You don\'t have permission to execute this action.', + }); + } + + if (action === 'setNextTempSchedule') { + try { + txCore.fxScheduler.setNextTempSchedule(parameter); + ctx.admin.logAction(`Scheduling server restart at ${parameter}`); + return ctx.send({ + type: 'success', + msg: 'Restart scheduled.', + }); + } catch (error) { + return ctx.send({ + type: 'error', + msg: (error as Error).message, + }); + } + + } else if (action === 'setNextSkip') { + try { + txCore.fxScheduler.setNextSkip(parameter, ctx.admin.name); + const logAct = parameter ? 'Cancelling' : 'Re-enabling'; + ctx.admin.logAction(`${logAct} next scheduled restart.`); + return ctx.send({ + type: 'success', + msg: 'Schedule changed.', + }); + } catch (error) { + return ctx.send({ + type: 'error', + msg: (error as Error).message, + }); + } + + } else { + return ctx.send({ + type: 'error', + msg: 'Unknown Action.', + }); + } +}; diff --git a/core/routes/history/actionModal.ts b/core/routes/history/actionModal.ts new file mode 100644 index 0000000..8a72fe1 --- /dev/null +++ b/core/routes/history/actionModal.ts @@ -0,0 +1,39 @@ +const modulename = 'WebServer:HistoryActionModal'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { HistoryActionModalResp } from '@shared/historyApiTypes'; +import { now } from '@lib/misc'; +const console = consoleFactory(modulename); + + +/** + * Returns the history action modal data + */ +export default async function HistoryActionModal(ctx: AuthedCtx) { + //Sanity check + if (typeof ctx.query === 'undefined') { + return ctx.utils.error(400, 'Invalid Request'); + } + const { id: actionId } = ctx.query; + const sendTypedResp = (data: HistoryActionModalResp) => ctx.send(data); + + //Checking action id + if (typeof actionId !== 'string' || !actionId.length) { + return sendTypedResp({ error: 'Invalid action ID.' }); + } + + //Getting the action data + let actionData; + try { + actionData = txCore.database.actions.findOne(actionId) + if (!actionData) return sendTypedResp({ error: 'Action not found' }); + } catch (error) { + return sendTypedResp({ error: `Getting history action failed with error: ${(error as Error).message}` }); + } + + //Sending the data + return sendTypedResp({ + serverTime: now(), + action: actionData, + }); +}; diff --git a/core/routes/history/actions.ts b/core/routes/history/actions.ts new file mode 100644 index 0000000..8756bd3 --- /dev/null +++ b/core/routes/history/actions.ts @@ -0,0 +1,184 @@ +const modulename = 'WebServer:HistoryActions'; +import { GenericApiOkResp } from '@shared/genericApiTypes'; +import { DatabaseActionType } from '@modules/Database/databaseTypes'; +import { calcExpirationFromDuration } from '@lib/misc'; +import consts from '@shared/consts'; +import humanizeDuration, { Unit } from 'humanize-duration'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { z } from 'zod'; +const console = consoleFactory(modulename); + +//Schema +const addLegacyBanBodySchema = z.object({ + identifiers: z.string().array(), + reason: z.string().trim().min(3).max(2048), + duration: z.string(), +}); +export type ApiAddLegacyBanReqSchema = z.infer; + +const revokeActionBodySchema = z.object({ + actionId: z.string(), +}); +export type ApiRevokeActionReqSchema = z.infer; + + +/** + * Endpoint to interact with the actions database. + */ +export default async function HistoryActions(ctx: AuthedCtx & { params: any }) { + //Sanity check + if (!ctx.params.action) { + return ctx.utils.error(400, 'Invalid Request'); + } + const action = ctx.params.action; + const sendTypedResp = (data: GenericApiOkResp) => ctx.send(data); + + //Delegate to the specific action handler + if (action === 'addLegacyBan') { + return sendTypedResp(await handleBandIds(ctx)); + } else if (action === 'revokeAction') { + return sendTypedResp(await handleRevokeAction(ctx)); + } else { + return sendTypedResp({ error: 'unknown action' }); + } +}; + + +/** + * Handle Ban Player IDs (legacy ban!) + * This is only called from the players page, where you ban an ID array instead of a PlayerClass + * Doesn't support HWIDs, only banning player does + */ +async function handleBandIds(ctx: AuthedCtx): Promise { + //Checking request + const schemaRes = addLegacyBanBodySchema.safeParse(ctx.request.body); + if (!schemaRes.success) { + return { error: 'Invalid request body.' }; + } + const { + reason, + identifiers: identifiersInput, + duration: durationInput + } = schemaRes.data; + + //Filtering identifiers + if (!identifiersInput.length) { + return { error: 'You must send at least one identifier' }; + } + const invalids = identifiersInput.filter((id) => { + return (typeof id !== 'string') || !Object.values(consts.validIdentifiers).some((vf) => vf.test(id)); + }); + if (invalids.length) { + return { error: 'Invalid IDs: ' + invalids.join(', ') }; + } + const identifiers = [...new Set(identifiersInput)]; + + + //Calculating expiration/duration + let calcResults; + try { + calcResults = calcExpirationFromDuration(durationInput); + } catch (error) { + return { error: (error as Error).message }; + } + const { expiration, duration } = calcResults; + + //Check permissions + if (!ctx.admin.testPermission('players.ban', modulename)) { + return { error: 'You don\'t have permission to execute this action.' } + } + + //Register action + let actionId; + try { + actionId = txCore.database.actions.registerBan( + identifiers, + ctx.admin.name, + reason, + expiration, + false + ); + } catch (error) { + return { error: `Failed to ban identifiers: ${(error as Error).message}` }; + } + ctx.admin.logAction(`Banned <${identifiers.join(';')}>: ${reason}`); + + // Dispatch `txAdmin:events:playerBanned` + try { + let kickMessage, durationTranslated; + const tOptions: any = { + author: ctx.admin.name, + reason: reason, + }; + if (expiration !== false && duration) { + durationTranslated = txCore.translator.tDuration( + duration * 1000, + { units: ['d', 'h'] }, + ); + tOptions.expiration = durationTranslated; + kickMessage = txCore.translator.t('ban_messages.kick_temporary', tOptions); + } else { + durationTranslated = null; + kickMessage = txCore.translator.t('ban_messages.kick_permanent', tOptions); + } + txCore.fxRunner.sendEvent('playerBanned', { + author: ctx.admin.name, + reason, + actionId, + expiration, + durationInput, + durationTranslated, + targetNetId: null, + targetIds: identifiers, + targetHwids: [], + targetName: 'identifiers', + kickMessage, + }); + } catch (error) { } + + return { success: true }; +} + + +/** + * Handle revoke database action. + * This is called from the player modal or the players page. + */ +async function handleRevokeAction(ctx: AuthedCtx): Promise { + //Checking request + const schemaRes = revokeActionBodySchema.safeParse(ctx.request.body); + if (!schemaRes.success) { + return { error: 'Invalid request body.' }; + } + const { actionId } = schemaRes.data; + + //Check permissions + const perms = []; + if (ctx.admin.hasPermission('players.ban')) perms.push('ban'); + if (ctx.admin.hasPermission('players.warn')) perms.push('warn'); + + let action; + try { + action = txCore.database.actions.revoke(actionId, ctx.admin.name, perms) as DatabaseActionType; + ctx.admin.logAction(`Revoked ${action.type} id ${actionId} from ${action.playerName ?? 'identifiers'}`); + } catch (error) { + return { error: `Failed to revoke action: ${(error as Error).message}` }; + } + + // Dispatch `txAdmin:events:actionRevoked` + try { + txCore.fxRunner.sendEvent('actionRevoked', { + actionId: action.id, + actionType: action.type, + actionReason: action.reason, + actionAuthor: action.author, + playerName: action.playerName, + playerIds: action.ids, + playerHwids: 'hwids' in action ? action.hwids : [], + revokedBy: ctx.admin.name, + }); + } catch (error) { } + + return { success: true }; +} diff --git a/core/routes/history/search.ts b/core/routes/history/search.ts new file mode 100644 index 0000000..6c37a37 --- /dev/null +++ b/core/routes/history/search.ts @@ -0,0 +1,167 @@ +const modulename = 'WebServer:HistorySearch'; +import { DatabaseActionType } from '@modules/Database/databaseTypes'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { chain as createChain } from 'lodash-es'; +import Fuse from 'fuse.js'; +import { now } from '@lib/misc'; +import { parseLaxIdsArrayInput } from '@lib/player/idUtils'; +import { HistoryTableActionType, HistoryTableSearchResp } from '@shared/historyApiTypes'; +import { TimeCounter } from '@modules/Metrics/statsUtils'; +const console = consoleFactory(modulename); + +//Helpers +const DEFAULT_LIMIT = 100; //cant override it for now +const ALLOWED_SORTINGS = ['timestamp']; + + +/** + * Returns the players stats for the Players page table + */ +export default async function HistorySearch(ctx: AuthedCtx) { + //Sanity check + if (typeof ctx.query === 'undefined') { + return ctx.utils.error(400, 'Invalid Request'); + } + const { + searchValue, + searchType, + filterbyType, + filterbyAdmin, + sortingKey, + sortingDesc, + offsetParam, + offsetActionId + } = ctx.query; + const sendTypedResp = (data: HistoryTableSearchResp) => ctx.send(data); + const searchTime = new TimeCounter(); + const dbo = txCore.database.getDboRef(); + let chain = dbo.chain.get('actions').clone(); //shallow clone to avoid sorting the original + + //sort the actions by the sortingKey/sortingDesc + const parsedSortingDesc = sortingDesc === 'true'; + if (typeof sortingKey !== 'string' || !ALLOWED_SORTINGS.includes(sortingKey)) { + return sendTypedResp({ error: 'Invalid sorting key' }); + } + chain = chain.sort((a, b) => { + // @ts-ignore + return parsedSortingDesc ? b[sortingKey] - a[sortingKey] : a[sortingKey] - b[sortingKey]; + }); + + //offset the actions by the offsetParam/offsetActionId + if (offsetParam !== undefined && offsetActionId !== undefined) { + const parsedOffsetParam = parseInt(offsetParam as string); + if (isNaN(parsedOffsetParam) || typeof offsetActionId !== 'string' || !offsetActionId.length) { + return sendTypedResp({ error: 'Invalid offsetParam or offsetActionId' }); + } + chain = chain.takeRightWhile((a) => { + return a.id !== offsetActionId && parsedSortingDesc + ? a[sortingKey as keyof DatabaseActionType] as number <= parsedOffsetParam + : a[sortingKey as keyof DatabaseActionType] as number >= parsedOffsetParam + }); + } + + //filter the actions by the simple filters (lightweight) + const effectiveTypeFilter = typeof filterbyType === 'string' && filterbyType.length ? filterbyType : undefined; + const effectiveAdminFilter = typeof filterbyAdmin === 'string' && filterbyAdmin.length ? filterbyAdmin : undefined; + if (effectiveTypeFilter || effectiveAdminFilter) { + chain = chain.filter((a) => { + if (effectiveTypeFilter && a.type !== effectiveTypeFilter) { + return false; + } + if (effectiveAdminFilter && a.author !== effectiveAdminFilter) { + return false; + } + return true; + }); + } + + // filter the actions by the searchValue/searchType (VERY HEAVY!) + if (typeof searchType === 'string') { + if (typeof searchValue !== 'string' || !searchValue.length) { + return sendTypedResp({ error: 'Invalid searchValue' }); + } + + if (searchType === 'actionId') { + //Searching by action ID + const cleanId = searchValue.toUpperCase().trim(); + if (!cleanId.length) { + return sendTypedResp({ error: 'This action ID is unsearchable (empty?).' }); + } + const actions = chain.value(); + const fuse = new Fuse(actions, { + isCaseSensitive: true, //maybe that's an optimization?! + keys: ['id'], + threshold: 0.3 + }); + const filtered = fuse.search(cleanId).map(x => x.item); + chain = createChain(filtered); + } else if (searchType === 'reason') { + //Searching by player notes + const actions = chain.value(); + const fuse = new Fuse(actions, { + keys: ['reason'], + threshold: 0.3 + }); + const filtered = fuse.search(searchValue).map(x => x.item); + chain = createChain(filtered); + } else if (searchType === 'identifiers') { + //Searching by target identifiers + const { validIds, validHwids, invalids } = parseLaxIdsArrayInput(searchValue); + if (invalids.length) { + return sendTypedResp({ error: `Invalid identifiers (${invalids.join(',')}). Prefix any identifier with their type, like 'fivem:123456' instead of just '123456'.` }); + } + if (!validIds.length && !validHwids.length) { + return sendTypedResp({ error: `No valid identifiers found.` }); + } + chain = chain.filter((a) => { + if (validIds.length && !validIds.some((id) => a.ids.includes(id))) { + return false; + } + if (validHwids.length && 'hwids' in a && !validHwids.some((hwid) => a.hwids!.includes(hwid))) { + return false; + } + return true; + }); + } else { + return sendTypedResp({ error: 'Unknown searchType' }); + } + } + + //filter players by the limit - taking 1 more to check if we reached the end + chain = chain.take(DEFAULT_LIMIT + 1); + const actions = chain.value(); + const hasReachedEnd = actions.length <= DEFAULT_LIMIT; + const currTs = now(); + const processedActions = actions.slice(0, DEFAULT_LIMIT).map((a) => { + let banExpiration, warnAcked; + if (a.type === 'ban') { + if (a.expiration === false) { + banExpiration = 'permanent' as const; + } else if (typeof a.expiration === 'number' && a.expiration < currTs) { + banExpiration = 'expired' as const; + } else { + banExpiration = 'active' as const; + } + } else if (a.type === 'warn') { + warnAcked = a.acked; + } + return { + id: a.id, + type: a.type, + playerName: a.playerName, + author: a.author, + reason: a.reason, + timestamp: a.timestamp, + isRevoked: !!a.revocation.timestamp, + banExpiration, + warnAcked, + } satisfies HistoryTableActionType; + }); + + txCore.metrics.txRuntime.historyTableSearchTime.count(searchTime.stop().milliseconds); + return sendTypedResp({ + history: processedActions, + hasReachedEnd, + }); +}; diff --git a/core/routes/history/stats.ts b/core/routes/history/stats.ts new file mode 100644 index 0000000..8cd59f2 --- /dev/null +++ b/core/routes/history/stats.ts @@ -0,0 +1,34 @@ +const modulename = 'WebServer:HistoryStats'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { HistoryStatsResp } from '@shared/historyApiTypes'; +import { union } from 'lodash-es'; +const console = consoleFactory(modulename); + + +/** + * Returns the players stats for the Players page callouts + */ +export default async function HistoryStats(ctx: AuthedCtx) { + const sendTypedResp = (data: HistoryStatsResp) => ctx.send(data); + try { + const dbStats = txCore.database.stats.getActionStats(); + const dbAdmins = Object.keys(dbStats.groupedByAdmins); + // @ts-ignore i don't wanna type this + const vaultAdmins = txCore.adminStore.getAdminsList().map(a => a.name); + const adminStats = union(dbAdmins, vaultAdmins) + .sort((a, b) => a.localeCompare(b)) + .map(admin => ({ + name: admin, + actions: dbStats.groupedByAdmins[admin] ?? 0 + })); + return sendTypedResp({ + ...dbStats, + groupedByAdmins: adminStats, + }); + } catch (error) { + const msg = `getStats failed with error: ${(error as Error).message}`; + console.verbose.error(msg); + return sendTypedResp({ error: msg }); + } +}; diff --git a/core/routes/hostStatus.ts b/core/routes/hostStatus.ts new file mode 100644 index 0000000..b541b11 --- /dev/null +++ b/core/routes/hostStatus.ts @@ -0,0 +1,9 @@ +import type { InitializedCtx } from '@modules/WebServer/ctxTypes'; + + +/** + * Returns host status information + */ +export default async function HostStatus(ctx: InitializedCtx) { + return ctx.send(txManager.hostStatus); +}; diff --git a/core/routes/index.ts b/core/routes/index.ts new file mode 100644 index 0000000..e43244c --- /dev/null +++ b/core/routes/index.ts @@ -0,0 +1,78 @@ +export { default as diagnostics_page } from './diagnostics/page'; +export { default as diagnostics_sendReport } from './diagnostics/sendReport'; +export { default as intercom } from './intercom.js'; +export { default as resources } from './resources.js'; +export { default as perfChart } from './perfChart'; +export { default as playerDrops } from './playerDrops'; +export { default as systemLogs } from './systemLogs'; + +export { default as auth_addMasterPin } from './authentication/addMasterPin.js'; +export { default as auth_addMasterCallback } from './authentication/addMasterCallback.js'; +export { default as auth_addMasterSave } from './authentication/addMasterSave.js'; +export { default as auth_providerRedirect } from './authentication/providerRedirect'; +export { default as auth_providerCallback } from './authentication/providerCallback'; +export { default as auth_verifyPassword } from './authentication/verifyPassword'; +export { default as auth_changePassword } from './authentication/changePassword'; +export { default as auth_self } from './authentication/self'; +export { default as auth_logout } from './authentication/logout'; +export { default as auth_getIdentifiers } from './authentication/getIdentifiers'; +export { default as auth_changeIdentifiers } from './authentication/changeIdentifiers'; + +export { default as adminManager_page } from './adminManager/page.js'; +export { default as adminManager_getModal } from './adminManager/getModal'; +export { default as adminManager_actions } from './adminManager/actions'; + +export { default as cfgEditor_page } from './cfgEditor/get'; +export { default as cfgEditor_save } from './cfgEditor/save'; + +export { default as deployer_stepper } from './deployer/stepper'; +export { default as deployer_status } from './deployer/status'; +export { default as deployer_actions } from './deployer/actions'; + +//FIXME join bantemplates with settings +export { default as settings_getConfigs } from './settings/getConfigs'; +export { default as settings_saveConfigs } from './settings/saveConfigs'; +export { default as settings_getBanTemplates } from './banTemplates/getBanTemplates'; +export { default as settings_saveBanTemplates } from './banTemplates/saveBanTemplates'; +export { default as settings_resetServerDataPath } from './settings/resetServerDataPath'; + +export { default as masterActions_page } from './masterActions/page'; +export { default as masterActions_getBackup } from './masterActions/getBackup'; +export { default as masterActions_actions } from './masterActions/actions'; + +export { default as setup_get } from './setup/get'; +export { default as setup_post } from './setup/post'; + +export { default as fxserver_commands } from './fxserver/commands'; +export { default as fxserver_controls } from './fxserver/controls'; +export { default as fxserver_downloadLog } from './fxserver/downloadLog'; +export { default as fxserver_schedule } from './fxserver/schedule'; + +export { default as history_stats } from './history/stats'; +export { default as history_search } from './history/search'; +export { default as history_actionModal } from './history/actionModal'; +export { default as history_actions } from './history/actions.js'; + +export { default as player_stats } from './player/stats'; +export { default as player_search } from './player/search'; +export { default as player_modal } from './player/modal'; +export { default as player_actions } from './player/actions'; +export { default as player_checkJoin } from './player/checkJoin'; + +export { default as whitelist_page } from './whitelist/page'; +export { default as whitelist_list } from './whitelist/list'; +export { default as whitelist_actions } from './whitelist/actions'; + +export { default as advanced_page } from './advanced/get'; +export { default as advanced_actions } from './advanced/actions'; + +//FIXME: reorganizar TODAS rotas de logs, incluindo listagem e download +export { default as serverLog } from './serverLog.js'; +export { default as serverLogPartial } from './serverLogPartial.js'; + +export { default as host_status } from './hostStatus'; + +export { + get as dev_get, + post as dev_post, +} from './devDebug.js'; diff --git a/core/routes/intercom.ts b/core/routes/intercom.ts new file mode 100644 index 0000000..ff11415 --- /dev/null +++ b/core/routes/intercom.ts @@ -0,0 +1,50 @@ +const modulename = 'WebServer:Intercom'; +import { cloneDeep } from 'lodash-es'; +import { txEnv } from '@core/globalData'; +import consoleFactory from '@lib/console'; +import { InitializedCtx } from '@modules/WebServer/ctxTypes'; +const console = consoleFactory(modulename); + + +/** + * Intercommunications endpoint + * @param {object} ctx + */ +export default async function Intercom(ctx: InitializedCtx) { + //Sanity check + if ((typeof (ctx as any).params.scope !== 'string') || (ctx as any).request.body === undefined) { + return ctx.utils.error(400, 'Invalid Request'); + } + const scope = (ctx as any).params.scope as string; + + const postData = cloneDeep(ctx.request.body); + postData.txAdminToken = true; + + //Delegate to the specific scope functions + if (scope == 'monitor') { + try { + txCore.fxMonitor.handleHeartBeat('http'); + return ctx.send(txCore.metrics.txRuntime.currHbData); + } catch (error) { + return ctx.send({ + txAdminVersion: txEnv.txaVersion, + success: false, + }); + } + } else if (scope == 'resources') { + if (!Array.isArray(postData.resources)) { + return ctx.utils.error(400, 'Invalid Request'); + } + txCore.fxResources.tmpUpdateResourceList(postData.resources); + } else { + return ctx.send({ + type: 'danger', + message: 'Unknown intercom scope.', + }); + } + + return ctx.send({ + txAdminVersion: txEnv.txaVersion, + success: false, + }); +}; diff --git a/core/routes/masterActions/actions.ts b/core/routes/masterActions/actions.ts new file mode 100644 index 0000000..41bbbf3 --- /dev/null +++ b/core/routes/masterActions/actions.ts @@ -0,0 +1,202 @@ +/* eslint-disable no-unused-vars */ +const modulename = 'WebServer:MasterActions:Action'; +import { DatabaseActionBanType, DatabaseActionType, DatabaseActionWarnType, DatabasePlayerType } from '@modules/Database/databaseTypes'; +import { now } from '@lib/misc'; +import { GenericApiErrorResp } from '@shared/genericApiTypes'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { SYM_RESET_CONFIG } from '@lib/symbols'; +const console = consoleFactory(modulename); + + +/** + * Handle all the master actions... actions + */ +export default async function MasterActionsAction(ctx: AuthedCtx) { + //Sanity check + if (typeof ctx.params.action !== 'string') { + return ctx.send({ error: 'Invalid Request' }); + } + const action = ctx.params.action; + + //Check permissions + if (!ctx.admin.testPermission('master', modulename)) { + return ctx.send({ error: 'Only the master account has permission to view/use this page.' }); + } + if (!ctx.txVars.isWebInterface) { + return ctx.send({ error: 'This functionality cannot be used by the in-game menu, please use the web version of txAdmin.' }); + } + + //Delegate to the specific action functions + if (action == 'cleanDatabase') { + return handleCleanDatabase(ctx); + } else if (action == 'revokeWhitelists') { + return handleRevokeWhitelists(ctx); + } else { + return ctx.send({ error: 'Unknown settings action.' }); + } +}; + + +/** + * Handle clean database request + */ +async function handleCleanDatabase(ctx: AuthedCtx) { + //Typescript stuff + type successResp = { + msElapsed: number; + playersRemoved: number; + actionsRemoved: number; + hwidsRemoved: number; + } + const sendTypedResp = (data: successResp | GenericApiErrorResp) => ctx.send(data); + + //Sanity check + if ( + typeof ctx.request.body.players !== 'string' + || typeof ctx.request.body.bans !== 'string' + || typeof ctx.request.body.warns !== 'string' + || typeof ctx.request.body.hwids !== 'string' + ) { + return sendTypedResp({ error: 'Invalid Request' }); + } + const { players, bans, warns, hwids } = ctx.request.body; + const daySecs = 86400; + const currTs = now(); + + //Prepare filters + let playersFilter: Function; + if (players === 'none') { + playersFilter = (x: DatabasePlayerType) => false; + } else if (players === '60d') { + playersFilter = (x: DatabasePlayerType) => x.tsLastConnection < (currTs - 60 * daySecs) && !x.notes; + } else if (players === '30d') { + playersFilter = (x: DatabasePlayerType) => x.tsLastConnection < (currTs - 30 * daySecs) && !x.notes; + } else if (players === '15d') { + playersFilter = (x: DatabasePlayerType) => x.tsLastConnection < (currTs - 15 * daySecs) && !x.notes; + } else { + return sendTypedResp({ error: 'Invalid players filter type.' }); + } + + let bansFilter: Function; + if (bans === 'none') { + bansFilter = (x: DatabaseActionBanType) => false; + } else if (bans === 'revoked') { + bansFilter = (x: DatabaseActionBanType) => x.type === 'ban' && x.revocation.timestamp; + } else if (bans === 'revokedExpired') { + bansFilter = (x: DatabaseActionBanType) => x.type === 'ban' && (x.revocation.timestamp || (x.expiration && x.expiration < currTs)); + } else if (bans === 'all') { + bansFilter = (x: DatabaseActionBanType) => x.type === 'ban'; + } else { + return sendTypedResp({ error: 'Invalid bans filter type.' }); + } + + let warnsFilter: Function; + if (warns === 'none') { + warnsFilter = (x: DatabaseActionWarnType) => false; + } else if (warns === 'revoked') { + warnsFilter = (x: DatabaseActionWarnType) => x.type === 'warn' && x.revocation.timestamp; + } else if (warns === '30d') { + warnsFilter = (x: DatabaseActionWarnType) => x.type === 'warn' && x.timestamp < (currTs - 30 * daySecs); + } else if (warns === '15d') { + warnsFilter = (x: DatabaseActionWarnType) => x.type === 'warn' && x.timestamp < (currTs - 15 * daySecs); + } else if (warns === '7d') { + warnsFilter = (x: DatabaseActionWarnType) => x.type === 'warn' && x.timestamp < (currTs - 7 * daySecs); + } else if (warns === 'all') { + warnsFilter = (x: DatabaseActionWarnType) => x.type === 'warn'; + } else { + return sendTypedResp({ error: 'Invalid warns filter type.' }); + } + + const actionsFilter = (x: DatabaseActionType) => { + return bansFilter(x) || warnsFilter(x); + }; + + let hwidsWipePlayers: boolean; + let hwidsWipeBans: boolean; + if (hwids === 'none') { + hwidsWipePlayers = false; + hwidsWipeBans = false; + } else if (hwids === 'players') { + hwidsWipePlayers = true; + hwidsWipeBans = false; + } else if (hwids === 'bans') { + hwidsWipePlayers = false; + hwidsWipeBans = true; + } else if (hwids === 'all') { + hwidsWipePlayers = true; + hwidsWipeBans = true; + } else { + return sendTypedResp({ error: 'Invalid HWIDs filter type.' }); + } + + //Run db cleaner + const tsStart = Date.now(); + let playersRemoved = 0; + try { + playersRemoved = txCore.database.cleanup.bulkRemove('players', playersFilter); + } catch (error) { + return sendTypedResp({ error: `Failed to clean players with error:
${(error as Error).message}` }); + } + + let actionsRemoved = 0; + try { + actionsRemoved = txCore.database.cleanup.bulkRemove('actions', actionsFilter); + } catch (error) { + return sendTypedResp({ error: `Failed to clean actions with error:
${(error as Error).message}` }); + } + + let hwidsRemoved = 0; + try { + hwidsRemoved = txCore.database.cleanup.wipeHwids(hwidsWipePlayers, hwidsWipeBans); + } catch (error) { + return sendTypedResp({ error: `Failed to clean HWIDs with error:
${(error as Error).message}` }); + } + + //Return results + const msElapsed = Date.now() - tsStart; + return sendTypedResp({ msElapsed, playersRemoved, actionsRemoved, hwidsRemoved }); +} + + +/** + * Handle clean database request + */ +async function handleRevokeWhitelists(ctx: AuthedCtx) { + //Typescript stuff + type successResp = { + msElapsed: number; + cntRemoved: number; + } + const sendTypedResp = (data: successResp | GenericApiErrorResp) => ctx.send(data); + + //Sanity check + if (typeof ctx.request.body.filter !== 'string') { + return sendTypedResp({ error: 'Invalid Request' }); + } + const filterInput = ctx.request.body.filter; + const daySecs = 86400; + const currTs = now(); + + let filterFunc: Function; + if (filterInput === 'all') { + filterFunc = (p: DatabasePlayerType) => true; + } else if (filterInput === '30d') { + filterFunc = (p: DatabasePlayerType) => p.tsLastConnection < (currTs - 30 * daySecs); + } else if (filterInput === '15d') { + filterFunc = (p: DatabasePlayerType) => p.tsLastConnection < (currTs - 15 * daySecs); + } else if (filterInput === '7d') { + filterFunc = (p: DatabasePlayerType) => p.tsLastConnection < (currTs - 7 * daySecs); + } else { + return sendTypedResp({ error: 'Invalid whitelists filter type.' }); + } + + try { + const tsStart = Date.now(); + const cntRemoved = txCore.database.players.bulkRevokeWhitelist(filterFunc); + const msElapsed = Date.now() - tsStart; + return sendTypedResp({ msElapsed, cntRemoved }); + } catch (error) { + return sendTypedResp({ error: `Failed to clean players with error:
${(error as Error).message}` }); + } +} diff --git a/core/routes/masterActions/getBackup.ts b/core/routes/masterActions/getBackup.ts new file mode 100644 index 0000000..8760e73 --- /dev/null +++ b/core/routes/masterActions/getBackup.ts @@ -0,0 +1,34 @@ +const modulename = 'WebServer:MasterActions:GetBackup'; +import fsp from 'node:fs/promises'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { getTimeFilename } from '@lib/misc'; +import { txEnv } from '@core/globalData'; +const console = consoleFactory(modulename); + + +/** + * Handles the rendering or delivery of master action resources + */ +export default async function MasterActionsGet(ctx: AuthedCtx) { + //Check permissions + if (!ctx.admin.testPermission('master', modulename)) { + return ctx.utils.render('main/message', { message: 'Only the master account has permission to view/use this page.' }); + } + if (!ctx.txVars.isWebInterface) { + return ctx.utils.render('main/message', { message: 'This functionality cannot be used by the in-game menu, please use the web version of txAdmin.' }); + } + + const dbPath = `${txEnv.profilePath}/data/playersDB.json`; + let readFile; + try { + readFile = await fsp.readFile(dbPath); + } catch (error) { + console.error(`Could not read database file ${dbPath}.`); + return ctx.utils.render('main/message', { message: `Failed to generate backup file with error: ${(error as Error).message}` }); + } + //getTimeFilename + ctx.attachment(`playersDB_${getTimeFilename()}.json`); + ctx.body = readFile; + console.log(`[${ctx.admin.name}] Downloading player database.`); +}; diff --git a/core/routes/masterActions/page.ts b/core/routes/masterActions/page.ts new file mode 100644 index 0000000..bb64def --- /dev/null +++ b/core/routes/masterActions/page.ts @@ -0,0 +1,16 @@ +const modulename = 'WebServer:MasterActions:Page'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + +/** + * Handles the rendering or delivery of master action resources + */ +export default async function MasterActionsPage(ctx: AuthedCtx) { + const isMasterAdmin = (ctx.admin.hasPermission('master')); + return ctx.utils.render('main/masterActions', { + headerTitle: 'Master Actions', + isMasterAdmin, + disableActions: (isMasterAdmin && ctx.txVars.isWebInterface) ? '' : 'disabled', + }); +}; diff --git a/core/routes/perfChart.ts b/core/routes/perfChart.ts new file mode 100644 index 0000000..f1687e0 --- /dev/null +++ b/core/routes/perfChart.ts @@ -0,0 +1,61 @@ +const modulename = 'WebServer:PerfChart'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { SvRtLogFilteredType, SvRtPerfBoundariesType } from '@modules/Metrics/svRuntime/perfSchemas'; +import { z } from 'zod'; +import { DeepReadonly } from 'utility-types'; +const console = consoleFactory(modulename); + + +//Types +export type PerfChartApiErrorResp = { + fail_reason: string; +}; +export type PerfChartApiSuccessResp = { + boundaries: SvRtPerfBoundariesType; + threadPerfLog: SvRtLogFilteredType; +} +export type PerfChartApiResp = DeepReadonly; + +//Schema +const paramsSchema = z.object({ thread: z.string() }); +const requiredMinDataAge = 30 * 60 * 1000; //30 mins +const chartWindow30h = 30 * 60 * 60 * 1000; //30 hours + +/** + * Returns the data required to build the dashboard performance chart of a specific thread + */ +export default async function perfChart(ctx: AuthedCtx) { + const sendTypedResp = (data: PerfChartApiResp) => ctx.send(data); + + //Validating input + const schemaRes = paramsSchema.safeParse(ctx.request.params); + if (!schemaRes.success) { + return sendTypedResp({ fail_reason: 'bad_request' }); + } + + const chartData = txCore.metrics.svRuntime.getChartData(schemaRes.data.thread); + if ('fail_reason' in chartData) { + return sendTypedResp(chartData); + } + const { threadPerfLog, boundaries } = chartData; + + //FIXME: temporary while I work on the chart optimizations + const windowCutoffTs = Date.now() - chartWindow30h; + const filteredThreadPerfLog = threadPerfLog.filter((log) => log.ts >= windowCutoffTs); + + const oldestDataLogged = filteredThreadPerfLog.find((log) => log.type === 'data'); + if (!oldestDataLogged) { + return sendTypedResp({ + fail_reason: 'not_enough_data', + }); + } else if (oldestDataLogged.ts > Date.now() - requiredMinDataAge) { + return sendTypedResp({ + fail_reason: 'not_enough_data', + }); + } + return sendTypedResp({ + boundaries, + threadPerfLog: filteredThreadPerfLog, + }); +}; diff --git a/core/routes/player/actions.ts b/core/routes/player/actions.ts new file mode 100644 index 0000000..6208426 --- /dev/null +++ b/core/routes/player/actions.ts @@ -0,0 +1,364 @@ +const modulename = 'WebServer:PlayerActions'; +import humanizeDuration, { Unit } from 'humanize-duration'; +import playerResolver from '@lib/player/playerResolver'; +import { GenericApiResp } from '@shared/genericApiTypes'; +import { PlayerClass, ServerPlayer } from '@lib/player/playerClasses'; +import { anyUndefined, calcExpirationFromDuration } from '@lib/misc'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { SYM_CURRENT_MUTEX } from '@lib/symbols'; +const console = consoleFactory(modulename); + + +/** + * Actions route for the player modal + */ +export default async function PlayerActions(ctx: AuthedCtx) { + //Sanity check + if (anyUndefined(ctx.params.action)) { + return ctx.utils.error(400, 'Invalid Request'); + } + const action = ctx.params.action; + const { mutex, netid, license } = ctx.query; + const sendTypedResp = (data: GenericApiResp) => ctx.send(data); + + //Finding the player + let player; + try { + const refMutex = mutex === 'current' ? SYM_CURRENT_MUTEX : mutex; + player = playerResolver(refMutex, parseInt((netid as string)), license); + } catch (error) { + return sendTypedResp({ error: (error as Error).message }); + } + + //Delegate to the specific action handler + if (action === 'save_note') { + return sendTypedResp(await handleSaveNote(ctx, player)); + } else if (action === 'warn') { + return sendTypedResp(await handleWarning(ctx, player)); + } else if (action === 'ban') { + return sendTypedResp(await handleBan(ctx, player)); + } else if (action === 'whitelist') { + return sendTypedResp(await handleSetWhitelist(ctx, player)); + } else if (action === 'message') { + return sendTypedResp(await handleDirectMessage(ctx, player)); + } else if (action === 'kick') { + return sendTypedResp(await handleKick(ctx, player)); + } else { + return sendTypedResp({ error: 'unknown action' }); + } +}; + + +/** + * Handle Save Note (open to all admins) + */ +async function handleSaveNote(ctx: AuthedCtx, player: PlayerClass): Promise { + //Checking request + if (anyUndefined( + ctx.request.body, + ctx.request.body.note, + )) { + return { error: 'Invalid request.' }; + } + const note = ctx.request.body.note.trim(); + + try { + player.setNote(note, ctx.admin.name); + ctx.admin.logAction(`Set notes for ${player.license}`); + return { success: true }; + } catch (error) { + return { error: `Failed to save note: ${(error as Error).message}` }; + } +} + + +/** + * Handle Send Warning + */ +async function handleWarning(ctx: AuthedCtx, player: PlayerClass): Promise { + //Checking request + if (anyUndefined( + ctx.request.body, + ctx.request.body.reason, + )) { + return { error: 'Invalid request.' }; + } + const reason = ctx.request.body.reason.trim() || 'no reason provided'; + + //Check permissions + if (!ctx.admin.testPermission('players.warn', modulename)) { + return { error: 'You don\'t have permission to execute this action.' }; + } + + //Validating server & player + const allIds = player.getAllIdentifiers(); + if (!allIds.length) { + return { error: 'Cannot warn a player with no identifiers.' }; + } + + //Register action + let actionId; + try { + actionId = txCore.database.actions.registerWarn( + allIds, + ctx.admin.name, + reason, + player.displayName, + ); + } catch (error) { + return { error: `Failed to warn player: ${(error as Error).message}` }; + } + ctx.admin.logAction(`Warned player "${player.displayName}": ${reason}`); + + // Dispatch `txAdmin:events:playerWarned` + const eventSent = txCore.fxRunner.sendEvent('playerWarned', { + author: ctx.admin.name, + reason, + actionId, + targetNetId: (player instanceof ServerPlayer && player.isConnected) ? player.netid : null, + targetIds: allIds, + targetName: player.displayName, + }); + + if (eventSent) { + return { success: true }; + } else { + return { error: `Warn saved, but likely failed to send the warn in game (stdin error).` }; + } +} + + +/** + * Handle Banning command + */ +async function handleBan(ctx: AuthedCtx, player: PlayerClass): Promise { + //Checking request + if ( + anyUndefined( + ctx.request.body, + ctx.request.body.duration, + ctx.request.body.reason, + ) + ) { + return { error: 'Invalid request.' }; + } + const durationInput = ctx.request.body.duration.trim(); + const reason = (ctx.request.body.reason as string).trim() || 'no reason provided'; + + //Calculating expiration/duration + let calcResults; + try { + calcResults = calcExpirationFromDuration(durationInput); + } catch (error) { + return { error: (error as Error).message }; + } + const { expiration, duration } = calcResults; + + //Check permissions + if (!ctx.admin.testPermission('players.ban', modulename)) { + return { error: 'You don\'t have permission to execute this action.' } + } + + //Validating player - hwids.length can be zero + const allIds = player.getAllIdentifiers(); + const allHwids = player.getAllHardwareIdentifiers(); + if (!allIds.length) { + return { error: 'Cannot ban a player with no identifiers.' } + } + + //Register action + let actionId; + try { + actionId = txCore.database.actions.registerBan( + allIds, + ctx.admin.name, + reason, + expiration, + player.displayName, + allHwids + ); + } catch (error) { + return { error: `Failed to ban player: ${(error as Error).message}` }; + } + ctx.admin.logAction(`Banned player "${player.displayName}": ${reason}`); + + //No need to dispatch events if server is not online + if (txCore.fxRunner.isIdle) { + return { success: true }; + } + + //Prepare and send command + let kickMessage, durationTranslated; + const tOptions: any = { + author: txCore.adminStore.getAdminPublicName(ctx.admin.name, 'punishment'), + reason: reason, + }; + if (expiration !== false && duration) { + durationTranslated = txCore.translator.tDuration( + duration * 1000, + { units: ['d', 'h'] }, + ); + tOptions.expiration = durationTranslated; + kickMessage = txCore.translator.t('ban_messages.kick_temporary', tOptions); + } else { + durationTranslated = null; + kickMessage = txCore.translator.t('ban_messages.kick_permanent', tOptions); + } + + // Dispatch `txAdmin:events:playerBanned` + const eventSent = txCore.fxRunner.sendEvent('playerBanned', { + author: ctx.admin.name, + reason, + actionId, + expiration, + durationInput, + durationTranslated, + targetNetId: (player instanceof ServerPlayer) ? player.netid : null, + targetIds: player.ids, + targetHwids: player.hwids, + targetName: player.displayName, + kickMessage, + }); + + if (eventSent) { + return { success: true }; + } else { + return { error: `Player banned, but likely failed to kick player (stdin error).` }; + } +} + + +/** + * Handle Player Whitelist Action + */ +async function handleSetWhitelist(ctx: AuthedCtx, player: PlayerClass): Promise { + //Checking request + if (anyUndefined( + ctx.request.body, + ctx.request.body.status, + )) { + return { error: 'Invalid request.' }; + } + const status = (ctx.request.body.status === 'true' || ctx.request.body.status === true); + + //Check permissions + if (!ctx.admin.testPermission('players.whitelist', modulename)) { + return { error: 'You don\'t have permission to execute this action.' } + } + + try { + player.setWhitelist(status); + if (status) { + ctx.admin.logAction(`Added ${player.license} to the whitelist.`); + } else { + ctx.admin.logAction(`Removed ${player.license} from the whitelist.`); + } + + // Dispatch `txAdmin:events:whitelistPlayer` + txCore.fxRunner.sendEvent('whitelistPlayer', { + action: status ? 'added' : 'removed', + license: player.license, + playerName: player.displayName, + adminName: ctx.admin.name, + }); + + return { success: true }; + } catch (error) { + return { error: `Failed to save whitelist status: ${(error as Error).message}` }; + } +} + + +/** + * Handle Direct Message Action + */ +async function handleDirectMessage(ctx: AuthedCtx, player: PlayerClass): Promise { + //Checking request + if (anyUndefined( + ctx.request.body, + ctx.request.body.message, + )) { + return { error: 'Invalid request.' }; + } + const message = ctx.request.body.message.trim(); + if (!message.length) { + return { error: 'Cannot send a DM with empty message.' }; + } + + //Check permissions + if (!ctx.admin.testPermission('players.direct_message', modulename)) { + return { error: 'You don\'t have permission to execute this action.' }; + } + + //Validating server & player + if (!txCore.fxRunner.child?.isAlive) { + return { error: 'The server is not running.' }; + } + if (!(player instanceof ServerPlayer) || !player.isConnected) { + return { error: 'This player is not connected to the server.' }; + } + + try { + ctx.admin.logAction(`DM to "${player.displayName}": ${message}`); + + // Dispatch `txAdmin:events:playerDirectMessage` + txCore.fxRunner.sendEvent('playerDirectMessage', { + target: player.netid, + author: ctx.admin.name, + message, + }); + + return { success: true }; + } catch (error) { + return { error: `Failed to save dm player: ${(error as Error).message}` }; + } +} + + +/** + * Handle Kick Action + */ +async function handleKick(ctx: AuthedCtx, player: PlayerClass): Promise { + //Checking request + if (anyUndefined( + ctx.request.body, + ctx.request.body.reason, + )) { + return { error: 'Invalid request.' }; + } + const kickReason = ctx.request.body.reason.trim() || txCore.translator.t('kick_messages.unknown_reason'); + + //Check permissions + if (!ctx.admin.testPermission('players.kick', modulename)) { + return { error: 'You don\'t have permission to execute this action.' }; + } + + //Validating server & player + if (!txCore.fxRunner.child?.isAlive) { + return { error: 'The server is not running.' }; + } + if (!(player instanceof ServerPlayer) || !player.isConnected) { + return { error: 'This player is not connected to the server.' }; + } + + try { + ctx.admin.logAction(`Kicked "${player.displayName}": ${kickReason}`); + const dropMessage = txCore.translator.t( + 'kick_messages.player', + { reason: kickReason } + ); + + // Dispatch `txAdmin:events:playerKicked` + txCore.fxRunner.sendEvent('playerKicked', { + target: player.netid, + author: ctx.admin.name, + reason: kickReason, + dropMessage, + }); + + return { success: true }; + } catch (error) { + return { error: `Failed to save kick player: ${(error as Error).message}` }; + } +} diff --git a/core/routes/player/checkJoin.ts b/core/routes/player/checkJoin.ts new file mode 100644 index 0000000..c513742 --- /dev/null +++ b/core/routes/player/checkJoin.ts @@ -0,0 +1,531 @@ +const modulename = 'WebServer:PlayerCheckJoin'; +import cleanPlayerName from '@shared/cleanPlayerName'; +import { GenericApiErrorResp } from '@shared/genericApiTypes'; +import { DatabaseActionBanType, DatabaseActionType, DatabaseWhitelistApprovalsType } from '@modules/Database/databaseTypes'; +import { anyUndefined, now } from '@lib/misc'; +import { filterPlayerHwids, parsePlayerIds, shortenId, summarizeIdsArray } from '@lib/player/idUtils'; +import type { PlayerIdsObjectType } from "@shared/otherTypes"; +import xssInstancer from '@lib/xss'; +import playerResolver from '@lib/player/playerResolver'; +import humanizeDuration, { Unit } from 'humanize-duration'; +import consoleFactory from '@lib/console'; +import { TimeCounter } from '@modules/Metrics/statsUtils'; +import { InitializedCtx } from '@modules/WebServer/ctxTypes'; +const console = consoleFactory(modulename); +const xss = xssInstancer(); + +//Helper +const htmlCodeTag = ''; +const htmlCodeIdTag = ''; +const htmlGuildNameTag = ''; +const rejectMessageTemplate = (title: string, content: string) => { + content = content.replaceAll('', htmlCodeTag); + content = content.replaceAll('', htmlCodeIdTag).replaceAll('', ''); + content = content.replaceAll('', htmlGuildNameTag).replaceAll('', ''); + return ` +
+

${title}

+
+

+ ${content} +

+ +
`.replaceAll(/[\r\n]/g, ''); +} + +const prepCustomMessage = (msg: string) => { + if (!msg) return ''; + return '
' + msg.trim().replaceAll(/\n/g, '
'); +} + +//Resp Type +type AllowRespType = { + allow: true; +} +type DenyRespType = { + allow: false; + reason: string; +} +type PlayerCheckJoinApiRespType = AllowRespType | DenyRespType | GenericApiErrorResp; + + +/** + * Endpoint for checking a player join, which checks whitelist and bans. + */ +export default async function PlayerCheckJoin(ctx: InitializedCtx) { + const sendTypedResp = (data: PlayerCheckJoinApiRespType) => ctx.send(data); + + //If checking not required at all + if ( + !txConfig.banlist.enabled + && txConfig.whitelist.mode === 'disabled' + ) { + return sendTypedResp({ allow: true }); + } + + //Checking request + if (anyUndefined( + ctx.request.body, + ctx.request.body.playerName, + ctx.request.body.playerIds, + ctx.request.body.playerHwids, + )) { + return sendTypedResp({ error: 'Invalid request.' }); + } + const { playerName, playerIds, playerHwids } = ctx.request.body; + + //Validating body data + if (typeof playerName !== 'string') return sendTypedResp({ error: 'playerName should be an string.' }); + if (!Array.isArray(playerIds)) return sendTypedResp({ error: 'playerIds should be an array.' }); + const { validIdsArray, validIdsObject } = parsePlayerIds(playerIds); + if (validIdsArray.length < 1) return sendTypedResp({ error: 'Identifiers array must contain at least 1 valid identifier.' }); + if (!Array.isArray(playerHwids)) return sendTypedResp({ error: 'playerHwids should be an array.' }); + const { validHwidsArray } = filterPlayerHwids(playerHwids); + + + try { + // If ban checking enabled + if (txConfig.banlist.enabled) { + const checkTime = new TimeCounter(); + const result = checkBan(validIdsArray, validIdsObject, validHwidsArray); + txCore.metrics.txRuntime.banCheckTime.count(checkTime.stop().milliseconds); + if (!result.allow) return sendTypedResp(result); + } + + //Checking whitelist + if (txConfig.whitelist.mode === 'adminOnly') { + const checkTime = new TimeCounter(); + const result = await checkAdminOnlyMode(validIdsArray, validIdsObject, playerName); + txCore.metrics.txRuntime.whitelistCheckTime.count(checkTime.stop().milliseconds); + if (!result.allow) return sendTypedResp(result); + + } else if (txConfig.whitelist.mode === 'approvedLicense') { + const checkTime = new TimeCounter(); + const result = await checkApprovedLicense(validIdsArray, validIdsObject, validHwidsArray, playerName); + txCore.metrics.txRuntime.whitelistCheckTime.count(checkTime.stop().milliseconds); + if (!result.allow) return sendTypedResp(result); + + } else if (txConfig.whitelist.mode === 'discordMember') { + const checkTime = new TimeCounter(); + const result = await checkDiscordMember(validIdsArray, validIdsObject, playerName); + txCore.metrics.txRuntime.whitelistCheckTime.count(checkTime.stop().milliseconds); + if (!result.allow) return sendTypedResp(result); + + } else if (txConfig.whitelist.mode === 'discordRoles') { + const checkTime = new TimeCounter(); + const result = await checkDiscordRoles(validIdsArray, validIdsObject, playerName); + txCore.metrics.txRuntime.whitelistCheckTime.count(checkTime.stop().milliseconds); + if (!result.allow) return sendTypedResp(result); + } + + //If not blocked by ban/wl, allow join + // return sendTypedResp({ allow: false, reason: 'APPROVED, BUT TEMP BLOCKED (DEBUG)' }); + return sendTypedResp({ allow: true }); + } catch (error) { + const msg = `Failed to check ban/whitelist status: ${(error as Error).message}`; + console.error(msg); + console.verbose.dir(error); + return sendTypedResp({ error: msg }); + } +}; + + +/** + * Checks if the player is banned + */ +function checkBan( + validIdsArray: string[], + validIdsObject: PlayerIdsObjectType, + validHwidsArray: string[] +): AllowRespType | DenyRespType { + // Check active bans on matching identifiers + const ts = now(); + const filter = (action: DatabaseActionType): action is DatabaseActionBanType => { + return ( + action.type === 'ban' + && (!action.expiration || action.expiration > ts) + && (!action.revocation.timestamp) + ); + }; + const activeBans = txCore.database.actions.findMany(validIdsArray, validHwidsArray, filter); + + if (activeBans.length) { + const ban = activeBans[0]; + + // Count matching IDs + const matchingIdsCount = ban.ids.filter(id => validIdsArray.includes(id)).length; + + // Only ban if at least 2 IDs match + if (matchingIdsCount >= 2) { + //Translation keys + const textKeys = { + title_permanent: txCore.translator.t('ban_messages.reject.title_permanent'), + title_temporary: txCore.translator.t('ban_messages.reject.title_temporary'), + label_expiration: txCore.translator.t('ban_messages.reject.label_expiration'), + label_date: txCore.translator.t('ban_messages.reject.label_date'), + label_author: txCore.translator.t('ban_messages.reject.label_author'), + label_reason: txCore.translator.t('ban_messages.reject.label_reason'), + label_id: txCore.translator.t('ban_messages.reject.label_id'), + note_multiple_bans: txCore.translator.t('ban_messages.reject.note_multiple_bans'), + note_diff_license: txCore.translator.t('ban_messages.reject.note_diff_license'), + }; + + //Ban data + let title; + let expLine = ''; + if (ban.expiration) { + const duration = txCore.translator.tDuration( + (ban.expiration - ts) * 1000, + { + largest: 2, + units: ['d', 'h', 'm'] as Unit[], + }, + ); + expLine = `${textKeys.label_expiration}: ${duration}
`; + title = textKeys.title_temporary; + } else { + title = textKeys.title_permanent; + } + const banDate = new Date(ban.timestamp * 1000).toLocaleString( + txCore.translator.canonical, + { dateStyle: 'medium', timeStyle: 'medium' } + ); + + //Ban author + let authorLine = ''; + if (!txConfig.gameFeatures.hideAdminInPunishments) { + authorLine = `${textKeys.label_author}: ${xss(ban.author)}
`; + } + + //Informational notes + let note = ''; + if (activeBans.length > 1) { + note += `
${textKeys.note_multiple_bans}`; + } + const bannedLicense = ban.ids.find(id => id.startsWith('license:')); + if (bannedLicense && validIdsObject.license && bannedLicense.substring(8) !== validIdsObject.license) { + note += `
${textKeys.note_diff_license}`; + } + + //Prepare rejection message + const reason = rejectMessageTemplate( + title, + `${expLine} + ${textKeys.label_date}: ${banDate}
+ ${textKeys.label_reason}: ${xss(ban.reason)}
+ ${textKeys.label_id}: ${ban.id}
+ ${authorLine} + ${prepCustomMessage(txConfig.banlist.rejectionMessage)} + ${note}` + ); + + //Send serverlog message + const matchingIds = ban.ids.filter(id => validIdsArray.includes(id)); + const matchingHwids = ('hwids' in ban && ban.hwids) + ? ban.hwids.filter(hw => validHwidsArray.includes(hw)) + : []; + const combined = [...matchingIds, ...matchingHwids]; + const summarizedIds = summarizeIdsArray(combined); + const loggerReason = `active ban (${ban.id}) for identifiers ${summarizedIds}`; + txCore.logger.server.write([{ + src: 'tx', + type: 'playerJoinDenied', + ts, + data: { reason: loggerReason } + }]); + + return { allow: false, reason }; + } + } + + return { allow: true }; +} + + +/** + * Checks if the player is an admin + */ +async function checkAdminOnlyMode( + validIdsArray: string[], + validIdsObject: PlayerIdsObjectType, + playerName: string +): Promise { + const textKeys = { + mode_title: txCore.translator.t('whitelist_messages.admin_only.mode_title'), + insufficient_ids: txCore.translator.t('whitelist_messages.admin_only.insufficient_ids'), + deny_message: txCore.translator.t('whitelist_messages.admin_only.deny_message'), + }; + + //Check if fivem/discord ids are available + if (!validIdsObject.license && !validIdsObject.discord) { + return { + allow: false, + reason: rejectMessageTemplate( + textKeys.mode_title, + textKeys.insufficient_ids + ), + } + } + + //Looking for admin + const admin = txCore.adminStore.getAdminByIdentifiers(validIdsArray); + if (admin) return { allow: true }; + + //Prepare rejection message + const reason = rejectMessageTemplate( + textKeys.mode_title, + `${textKeys.deny_message}
+ ${prepCustomMessage(txConfig.whitelist.rejectionMessage)}` + ); + return { allow: false, reason }; +} + + +/** + * Checks if the player is a discord guild member + */ +async function checkDiscordMember( + validIdsArray: string[], + validIdsObject: PlayerIdsObjectType, + playerName: string +): Promise { + const guildname = `${txCore.discordBot.guildName}`; + const textKeys = { + mode_title: txCore.translator.t('whitelist_messages.guild_member.mode_title'), + insufficient_ids: txCore.translator.t('whitelist_messages.guild_member.insufficient_ids'), + deny_title: txCore.translator.t('whitelist_messages.guild_member.deny_title'), + deny_message: txCore.translator.t('whitelist_messages.guild_member.deny_message', { guildname }), + }; + + //Check if discord id is available + if (!validIdsObject.discord) { + return { + allow: false, + reason: rejectMessageTemplate( + textKeys.mode_title, + textKeys.insufficient_ids + ), + } + } + + //Resolving member + let errorTitle, errorMessage; + try { + const { isMember, memberRoles } = await txCore.discordBot.resolveMemberRoles(validIdsObject.discord); + if (isMember) { + return { allow: true }; + } else { + errorTitle = textKeys.deny_title; + errorMessage = textKeys.deny_message; + } + } catch (error) { + errorTitle = `Error validating Discord Server Member Whitelist:`; + errorMessage = `${(error as Error).message}`; + } + + //Prepare rejection message + const reason = rejectMessageTemplate( + errorTitle, + `${errorMessage}
+ ${prepCustomMessage(txConfig.whitelist.rejectionMessage)}` + ); + return { allow: false, reason }; +} + + +/** + * Checks if the player has specific discord guild roles + */ +async function checkDiscordRoles( + validIdsArray: string[], + validIdsObject: PlayerIdsObjectType, + playerName: string +): Promise { + const guildname = `${txCore.discordBot.guildName}`; + const textKeys = { + mode_title: txCore.translator.t('whitelist_messages.guild_roles.mode_title'), + insufficient_ids: txCore.translator.t('whitelist_messages.guild_roles.insufficient_ids'), + deny_notmember_title: txCore.translator.t('whitelist_messages.guild_roles.deny_notmember_title'), + deny_notmember_message: txCore.translator.t('whitelist_messages.guild_roles.deny_notmember_message', { guildname }), + deny_noroles_title: txCore.translator.t('whitelist_messages.guild_roles.deny_noroles_title'), + deny_noroles_message: txCore.translator.t('whitelist_messages.guild_roles.deny_noroles_message', { guildname }), + }; + + //Check if discord id is available + if (!validIdsObject.discord) { + return { + allow: false, + reason: rejectMessageTemplate( + textKeys.mode_title, + textKeys.insufficient_ids + ), + } + } + + //Resolving member + let errorTitle, errorMessage; + try { + const { isMember, memberRoles } = await txCore.discordBot.resolveMemberRoles(validIdsObject.discord); + if (isMember) { + const matchingRole = txConfig.whitelist.discordRoles + .find((requiredRole) => memberRoles?.includes(requiredRole)); + if (matchingRole) { + return { allow: true }; + } else { + errorTitle = textKeys.deny_noroles_title; + errorMessage = textKeys.deny_noroles_message; + } + } else { + errorTitle = textKeys.deny_notmember_title; + errorMessage = textKeys.deny_notmember_message; + } + } catch (error) { + errorTitle = `Error validating Discord Role Whitelist:`; + errorMessage = `${(error as Error).message}`; + } + + //Prepare rejection message + const reason = rejectMessageTemplate( + errorTitle, + `${errorMessage}
+ ${prepCustomMessage(txConfig.whitelist.rejectionMessage)}` + ); + return { allow: false, reason }; +} + + +/** + * Checks if the player has a whitelisted license + */ +async function checkApprovedLicense( + validIdsArray: string[], + validIdsObject: PlayerIdsObjectType, + validHwidsArray: string[], + playerName: string +): Promise { + const textKeys = { + mode_title: txCore.translator.t('whitelist_messages.approved_license.mode_title'), + insufficient_ids: txCore.translator.t('whitelist_messages.approved_license.insufficient_ids'), + deny_title: txCore.translator.t('whitelist_messages.approved_license.deny_title'), + request_id_label: txCore.translator.t('whitelist_messages.approved_license.request_id_label'), + }; + + //Check if license is available + if (!validIdsObject.license) { + return { + allow: false, + reason: rejectMessageTemplate( + textKeys.mode_title, + textKeys.insufficient_ids + ), + } + } + + //Finding the player and checking if already whitelisted + let player; + try { + player = playerResolver(null, null, validIdsObject.license); + const dbData = player.getDbData(); + if (dbData && dbData.tsWhitelisted) { + return { allow: true }; + } + } catch (error) { } + + //Common vars + const { displayName, pureName } = cleanPlayerName(playerName); + const ts = now(); + + //Searching for the license/discord on whitelistApprovals + const allIdsFilter = (x: DatabaseWhitelistApprovalsType) => { + return validIdsArray.includes(x.identifier); + } + const approvals = txCore.database.whitelist.findManyApprovals(allIdsFilter); + if (approvals.length) { + //update or register player + if (typeof player !== 'undefined' && player.license) { + player.setWhitelist(true); + } else { + txCore.database.players.register({ + license: validIdsObject.license, + ids: validIdsArray, + hwids: validHwidsArray, + displayName, + pureName, + playTime: 0, + tsLastConnection: ts, + tsJoined: ts, + tsWhitelisted: ts, + }); + } + + //Remove entries from whitelistApprovals & whitelistRequests + txCore.database.whitelist.removeManyApprovals(allIdsFilter); + txCore.database.whitelist.removeManyRequests({ license: validIdsObject.license }); + + //return allow join + return { allow: true }; + } + + + //Player is not whitelisted + //Resolve player discord + let discordTag, discordAvatar; + if (validIdsObject.discord && txCore.discordBot.isClientReady) { + try { + const { tag, avatar } = await txCore.discordBot.resolveMemberProfile(validIdsObject.discord); + discordTag = tag; + discordAvatar = avatar; + } catch (error) { } + } + + //Check if this player has an active wl request + //NOTE: it could return multiple, but we are not dealing with it + let wlRequestId: string; + const requests = txCore.database.whitelist.findManyRequests({ license: validIdsObject.license }); + if (requests.length) { + wlRequestId = requests[0].id; //just getting the first + txCore.database.whitelist.updateRequest(validIdsObject.license, { + playerDisplayName: displayName, + playerPureName: pureName, + discordTag, + discordAvatar, + tsLastAttempt: ts, + }); + } else { + wlRequestId = txCore.database.whitelist.registerRequest({ + license: validIdsObject.license, + playerDisplayName: displayName, + playerPureName: pureName, + discordTag, + discordAvatar, + tsLastAttempt: ts, + }); + txCore.fxRunner.sendEvent('whitelistRequest', { + action: 'requested', + playerName: displayName, + requestId: wlRequestId, + license: validIdsObject.license, + }); + } + + //Prepare rejection message + const reason = rejectMessageTemplate( + textKeys.deny_title, + `${textKeys.request_id_label}: + ${wlRequestId}
+ ${prepCustomMessage(txConfig.whitelist.rejectionMessage)}` + ); + return { allow: false, reason } +} diff --git a/core/routes/player/modal.ts b/core/routes/player/modal.ts new file mode 100644 index 0000000..967dbf4 --- /dev/null +++ b/core/routes/player/modal.ts @@ -0,0 +1,97 @@ +const modulename = 'WebServer:PlayerModal'; +import dateFormat from 'dateformat'; +import playerResolver from '@lib/player/playerResolver'; +import { PlayerHistoryItem, PlayerModalResp, PlayerModalPlayerData } from '@shared/playerApiTypes'; +import { DatabaseActionType } from '@modules/Database/databaseTypes'; +import { ServerPlayer } from '@lib/player/playerClasses'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { now } from '@lib/misc'; +import { SYM_CURRENT_MUTEX } from '@lib/symbols'; +const console = consoleFactory(modulename); + +//Helpers +const processHistoryLog = (hist: DatabaseActionType[]) => { + try { + return hist.map((log): PlayerHistoryItem => { + return { + id: log.id, + type: log.type, + reason: log.reason, + author: log.author, + ts: log.timestamp, + exp: log.expiration ? log.expiration : undefined, + revokedBy: log.revocation.author ? log.revocation.author : undefined, + revokedAt: log.revocation.timestamp ? log.revocation.timestamp : undefined, + }; + }); + } catch (error) { + console.error(`Error processing player history: ${(error as Error).message}`); + return []; + } +}; + + +/** + * Returns the data for the player's modal + * NOTE: sending license instead of id to be able to show data even for offline players + */ +export default async function PlayerModal(ctx: AuthedCtx) { + //Sanity check + if (typeof ctx.query === 'undefined') { + return ctx.utils.error(400, 'Invalid Request'); + } + const { mutex, netid, license } = ctx.query; + const sendTypedResp = (data: PlayerModalResp) => ctx.send(data); + + //Finding the player + let player; + try { + const refMutex = mutex === 'current' ? SYM_CURRENT_MUTEX : mutex; + player = playerResolver(refMutex, parseInt((netid as string)), license); + } catch (error) { + return sendTypedResp({ error: (error as Error).message }); + } + + //Prepping player data + const playerData: PlayerModalPlayerData = { + displayName: player.displayName, + pureName: player.pureName, + isRegistered: player.isRegistered, + isConnected: player.isConnected, + license: player.license, + ids: player.ids, + hwids: player.hwids, + actionHistory: processHistoryLog(player.getHistory()), + } + + if (player instanceof ServerPlayer) { + playerData.netid = player.netid; + playerData.sessionTime = Math.ceil((now() - player.tsConnected) / 60); + } + + const playerDbData = player.getDbData(); + if (playerDbData) { + playerData.tsJoined = playerDbData.tsJoined; + playerData.playTime = playerDbData.playTime; + playerData.tsWhitelisted = playerDbData.tsWhitelisted ? playerDbData.tsWhitelisted : undefined; + playerData.oldIds = playerDbData.ids; + playerData.oldHwids = playerDbData.hwids; + playerData.tsLastConnection = playerDbData.tsLastConnection; + + if (playerDbData.notes?.lastAdmin && playerDbData.notes?.tsLastEdit) { + playerData.notes = playerDbData.notes.text; + const lastEditObj = new Date(playerDbData.notes.tsLastEdit * 1000); + const lastEditString = dateFormat(lastEditObj, 'longDate'); + playerData.notesLog = `Last modified by ${playerDbData.notes.lastAdmin} at ${lastEditString}`; + } + } + + // console.dir(metaFields); + // console.dir(playerData); + return sendTypedResp({ + serverTime: now(), + banTemplates: txConfig.banlist.templates, //TODO: move this to websocket push + player: playerData + }); +}; diff --git a/core/routes/player/search.ts b/core/routes/player/search.ts new file mode 100644 index 0000000..730fccf --- /dev/null +++ b/core/routes/player/search.ts @@ -0,0 +1,184 @@ +const modulename = 'WebServer:PlayersTableSearch'; +import { PlayersTablePlayerType, PlayersTableSearchResp } from '@shared/playerApiTypes'; +import { DatabasePlayerType } from '@modules/Database/databaseTypes'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import cleanPlayerName from '@shared/cleanPlayerName'; +import { chain as createChain } from 'lodash-es'; +import Fuse from 'fuse.js'; +import { parseLaxIdsArrayInput } from '@lib/player/idUtils'; +import { TimeCounter } from '@modules/Metrics/statsUtils'; +const console = consoleFactory(modulename); + +//Helpers +const DEFAULT_LIMIT = 100; //cant override it for now +const ALLOWED_SORTINGS = ['playTime', 'tsJoined', 'tsLastConnection']; +const SIMPLE_FILTERS = ['isAdmin', 'isOnline', 'isWhitelisted', 'hasNote']; +//'isBanned', 'hasPreviousBan' + + +/** + * Returns the players stats for the Players page table + */ +export default async function PlayerSearch(ctx: AuthedCtx) { + //Sanity check + if (typeof ctx.query === 'undefined') { + return ctx.utils.error(400, 'Invalid Request'); + } + const { + searchValue, + searchType, + filters, + sortingKey, + sortingDesc, + offsetParam, + offsetLicense + } = ctx.query; + const sendTypedResp = (data: PlayersTableSearchResp) => ctx.send(data); + const searchTime = new TimeCounter(); + const adminsIdentifiers = txCore.adminStore.getAdminsIdentifiers(); + const onlinePlayersLicenses = txCore.fxPlayerlist.getOnlinePlayersLicenses(); + const dbo = txCore.database.getDboRef(); + let chain = dbo.chain.get('players').clone(); //shallow clone to avoid sorting the original + /* + In order: + - [X] sort the players by the sortingKey/sortingDesc + - [x] filter the players by the simple filters (lightweight) + - [x] offset the players by the offsetParam/offsetLicense + - [x] filter the players by the searchValue/searchType (VERY HEAVY!) + - [ ] reduce actions table to get info on currently filtered players + - [ ] filter players by isBanned & hasPreviousBan + - [x] filter players by the limit + - [x] process the result and (no {isBanned, warnCount, banCount} for now) + - [x] return the result + */ + + + //sort the players by the sortingKey/sortingDesc + const parsedSortingDesc = sortingDesc === 'true'; + if (typeof sortingKey !== 'string' || !ALLOWED_SORTINGS.includes(sortingKey)) { + return sendTypedResp({ error: 'Invalid sorting key' }); + } + chain = chain.sort((a, b) => { + // @ts-ignore + return parsedSortingDesc ? b[sortingKey] - a[sortingKey] : a[sortingKey] - b[sortingKey]; + }); + + + //filter the players by the simple filters (lightweight) + if (typeof filters === 'string' && filters.length) { + const validRequestedFilters = new Set(filters.split(',').filter((x) => SIMPLE_FILTERS.includes(x))); + if (validRequestedFilters.size) { + const playerFilterFunctions = { + 'isAdmin': (p: DatabasePlayerType) => p.ids.some((id) => adminsIdentifiers.includes(id)), + 'isOnline': (p: DatabasePlayerType) => onlinePlayersLicenses.has(p.license), + 'isWhitelisted': (p: DatabasePlayerType) => p.tsWhitelisted, + 'hasNote': (p: DatabasePlayerType) => p.notes, + }; + chain = chain.filter((p) => { + for (const filterName of validRequestedFilters) { + if (!playerFilterFunctions[filterName as keyof typeof playerFilterFunctions](p)) { + return false; + } + } + return true; + }); + } + } + + + //offset the players by the offsetParam/offsetLicense + if (offsetParam !== undefined && offsetLicense !== undefined) { + const parsedOffsetParam = parseInt(offsetParam as string); + if (isNaN(parsedOffsetParam) || typeof offsetLicense !== 'string' || !offsetLicense.length) { + return sendTypedResp({ error: 'Invalid offsetParam or offsetLicense' }); + } + chain = chain.takeRightWhile((p) => { + return p.license !== offsetLicense && parsedSortingDesc + ? p[sortingKey as keyof DatabasePlayerType] as number <= parsedOffsetParam + : p[sortingKey as keyof DatabasePlayerType] as number >= parsedOffsetParam + }); + } + + + // filter the players by the searchValue/searchType (VERY HEAVY!) + if (typeof searchType === 'string') { + if (typeof searchValue !== 'string' || !searchValue.length) { + return sendTypedResp({ error: 'Invalid searchValue' }); + } + + if (searchType === 'playerName') { + //Searching by player name + const { pureName } = cleanPlayerName(searchValue); + if (pureName === 'emptyname') { + return sendTypedResp({ error: 'This player name is unsearchable (pureName is empty).' }); + } + const players = chain.value(); + const fuse = new Fuse(players, { + isCaseSensitive: true, //maybe that's an optimization?! + keys: ['pureName'], + threshold: 0.3 + }); + const filtered = fuse.search(pureName).map(x => x.item); + chain = createChain(filtered); + } else if (searchType === 'playerNotes') { + //Searching by player notes + const players = chain.value(); + const fuse = new Fuse(players, { + keys: ['notes.text'], + threshold: 0.3 + }); + const filtered = fuse.search(searchValue).map(x => x.item); + chain = createChain(filtered); + } else if (searchType === 'playerIds') { + //Searching by player identifiers + const { validIds, validHwids, invalids } = parseLaxIdsArrayInput(searchValue); + if (invalids.length) { + return sendTypedResp({ error: `Invalid identifiers (${invalids.join(',')}). Prefix any identifier with their type, like 'fivem:123456' instead of just '123456'.` }); + } + if (!validIds.length && !validHwids.length) { + return sendTypedResp({ error: `No valid identifiers found.` }); + } + chain = chain.filter((p) => { + if (validIds.length && !validIds.some((id) => p.ids.includes(id))) { + return false; + } + if (validHwids.length && !validHwids.some((hwid) => p.hwids.includes(hwid))) { + return false; + } + return true; + }); + } else { + return sendTypedResp({ error: 'Unknown searchType' }); + } + } + + + //filter players by the limit - taking 1 more to check if we reached the end + chain = chain.take(DEFAULT_LIMIT + 1); + const players = chain.value(); + const hasReachedEnd = players.length <= DEFAULT_LIMIT; + const processedPlayers: PlayersTablePlayerType[] = players.slice(0, DEFAULT_LIMIT).map((p) => { + return { + license: p.license, + displayName: p.displayName, + playTime: p.playTime, + tsJoined: p.tsJoined, + tsLastConnection: p.tsLastConnection, + notes: p.notes ? p.notes.text : undefined, + + isAdmin: p.ids.some((id) => adminsIdentifiers.includes(id)), + isOnline: onlinePlayersLicenses.has(p.license), + isWhitelisted: p.tsWhitelisted ? true : false, + // isBanned: boolean, + // warnCount: number, + // banCount: number, + }; + }); + + txCore.metrics.txRuntime.playersTableSearchTime.count(searchTime.stop().milliseconds); + return sendTypedResp({ + players: processedPlayers, + hasReachedEnd, + }); +}; diff --git a/core/routes/player/stats.ts b/core/routes/player/stats.ts new file mode 100644 index 0000000..485c149 --- /dev/null +++ b/core/routes/player/stats.ts @@ -0,0 +1,21 @@ +const modulename = 'WebServer:PlayersStats'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { PlayersStatsResp } from '@shared/playerApiTypes'; +const console = consoleFactory(modulename); + + +/** + * Returns the players stats for the Players page callouts + */ +export default async function PlayersStats(ctx: AuthedCtx) { + const sendTypedResp = (data: PlayersStatsResp) => ctx.send(data); + try { + const stats = txCore.database.stats.getPlayersStats(); + return sendTypedResp(stats); + } catch (error) { + const msg = `getStats failed with error: ${(error as Error).message}`; + console.verbose.error(msg); + return sendTypedResp({ error: msg }); + } +}; diff --git a/core/routes/playerDrops.ts b/core/routes/playerDrops.ts new file mode 100644 index 0000000..30c9443 --- /dev/null +++ b/core/routes/playerDrops.ts @@ -0,0 +1,100 @@ +const modulename = 'WebServer:PlayerDrops'; +import { PDLHourlyRawType } from '@modules/Metrics/playerDrop/playerDropSchemas'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { DeepReadonly } from 'utility-types'; +import { z } from 'zod'; +const console = consoleFactory(modulename); + + +//Types & validation +const querySchema = z.object({ + detailedWindow: z.string().optional(), + detailedDaysAgo: z.string().optional(), +}); + +const SUMMARY_DEFAULT_HOURS = 14 * 24; +const DETAILED_DEFAULT_HOURS = 7 * 24; + +export type PlayerDropsSummaryHour = { + hour: string; + changes: number; + dropTypes: [reason: string, count: number][]; +} + +//NOTE: cumulative, not hourly +export type PlayerDropsDetailedWindow = Omit; + +export type PlayerDropsApiSuccessResp = { + ts: number; + summary: PlayerDropsSummaryHour[]; + detailed: { + windowStart: string; + windowEnd: string; + windowData: PlayerDropsDetailedWindow; + }; +}; +export type PlayerDropsApiErrorResp = { + fail_reason: string; +}; + +export type PlayerDropsApiResp = DeepReadonly; + + +/** + * Returns the data required to build the player drops page, both summary timeline and detailed drilldown + */ +export default async function playerDrops(ctx: AuthedCtx) { + const sendTypedResp = (data: PlayerDropsApiResp) => ctx.send(data); + const schemaRes = querySchema.safeParse(ctx.request.query); + if (!schemaRes.success) { + return sendTypedResp({ + fail_reason: `Invalid request query: ${schemaRes.error.message}`, + }); + } + const { detailedWindow, detailedDaysAgo } = schemaRes.data; + const lookupTs = Date.now(); + + //Get the summary for the last 2 weeks + const summary = txCore.metrics.playerDrop.getRecentSummary(SUMMARY_DEFAULT_HOURS); + + //Get the detailed data for the requested window or 1d by default + let detailedWindowStart, detailedWindowEnd; + if (detailedWindow) { + try { + const [windowStartStr, windowEndStr] = detailedWindow.split(','); + detailedWindowStart = new Date(windowStartStr).getTime(); + detailedWindowEnd = Math.min( + lookupTs, + new Date(windowEndStr).getTime(), + ); + } catch (error) { + return sendTypedResp({ + fail_reason: `Invalid date format: ${(error as Error).message}`, + }) + } + } else { + let windowHours = DETAILED_DEFAULT_HOURS; + if (detailedDaysAgo) { + const daysAgo = parseInt(detailedDaysAgo); + if (!isNaN(daysAgo) && daysAgo >= 1 && daysAgo <= 14) { + windowHours = daysAgo * 24; + } + } + + const startDate = new Date(); + detailedWindowStart = startDate.setHours(startDate.getHours() - (windowHours), 0, 0, 0); + detailedWindowEnd = lookupTs; + } + const detailed = txCore.metrics.playerDrop.getWindowData(detailedWindowStart, detailedWindowEnd); + + return sendTypedResp({ + ts: lookupTs, + summary, + detailed: { + windowStart: new Date(detailedWindowStart).toISOString(), + windowEnd: new Date(detailedWindowEnd).toISOString(), + windowData: detailed, + } + }); +}; diff --git a/core/routes/resources.js b/core/routes/resources.js new file mode 100644 index 0000000..97afee3 --- /dev/null +++ b/core/routes/resources.js @@ -0,0 +1,152 @@ +const modulename = 'WebServer:Resources'; +import path from 'node:path'; +import slash from 'slash'; +import slug from 'slug'; +import consoleFactory from '@lib/console'; +import { SYM_SYSTEM_AUTHOR } from '@lib/symbols'; +const console = consoleFactory(modulename); + +//Helper functions +const isUndefined = (x) => (x === undefined); +const breakPath = (inPath) => slash(path.normalize(inPath)).split('/').filter(String); +const dynamicSort = (prop) => { + let sortOrder = 1; + if (prop[0] === '-') { + sortOrder = -1; + prop = prop.substr(1); + } + return function (a, b) { + const result = (a[prop] < b[prop]) ? -1 : (a[prop] > b[prop]) ? 1 : 0; + return result * sortOrder; + }; +}; +const getResourceSubPath = (resPath) => { + if (resPath.indexOf('system_resources') >= 0) return 'system_resources'; + if (!path.isAbsolute(resPath)) return resPath; + + let serverDataPathArr = breakPath(`${txConfig.server.dataPath}/resources`); + let resPathArr = breakPath(resPath); + for (let i = 0; i < serverDataPathArr.length; i++) { + if (isUndefined(resPathArr[i])) break; + if (serverDataPathArr[i].toLowerCase() == resPathArr[i].toLowerCase()) { + delete resPathArr[i]; + } + } + resPathArr.pop(); + resPathArr = resPathArr.filter(String); + + if (resPathArr.length) { + return resPathArr.join('/'); + } else { + return 'root'; + } +}; + +/** + * Returns the resources list + * @param {object} ctx + */ +export default async function Resources(ctx) { + if (!txCore.fxRunner.child?.isAlive) { + return ctx.utils.render('main/message', { + message: 'The resources list is only available when the server is running.', + }); + } + + const timeoutMessage = `Couldn't load the resources list.
+ - Make sure the server is online (try to join it).
+ - Make sure you don't have more than 1000 resources.
+ - Make sure you are not running the FXServer outside of txAdmin.
+ - Check the Live Console for any errors which may indicate that some resource has a malformed fxmanifest.lua file.`; + + //Send command request + const cmdSuccess = txCore.fxRunner.sendCommand('txaReportResources', [], SYM_SYSTEM_AUTHOR); + if (!cmdSuccess) { + return ctx.utils.render('main/message', {message: timeoutMessage}); + } + + //Timer fot list delivery + let tListTimer; + let tErrorTimer; + const tList = new Promise((resolve, reject) => { + tListTimer = setInterval(() => { + if ( + txCore.fxResources.resourceReport + && (new Date() - txCore.fxResources.resourceReport.ts) <= 1000 + && Array.isArray(txCore.fxResources.resourceReport.resources) + ) { + clearTimeout(tListTimer); + clearTimeout(tErrorTimer); + const resGroups = processResources(txCore.fxResources.resourceReport.resources); + const renderData = { + headerTitle: 'Resources', + resGroupsJS: JSON.stringify(resGroups), + resGroups, + disableActions: (ctx.admin.hasPermission('commands.resources')) ? '' : 'disabled', + }; + resolve(['main/resources', renderData]); + } + }, 100); + }); + + //Timer for timing out + const tError = new Promise((resolve, reject) => { + tErrorTimer = setTimeout(() => { + clearTimeout(tListTimer); + resolve(['main/message', {message: timeoutMessage}]); + }, 1000); + }); + + //Start race and give output + const [view, renderData] = await Promise.race([tList, tError]); + return ctx.utils.render(view, renderData); +}; + + +//================================================================ +/** + * Returns the Processed Resource list. + * @param {array} resList + */ +function processResources(resList) { + //Clean resource data and add it so an object separated by subpaths + const resGroupList = {}; + resList.forEach((resource) => { + if (isUndefined(resource.name) || isUndefined(resource.status) || isUndefined(resource.path) || resource.path === '') { + return; + } + const subPath = getResourceSubPath(resource.path); + const resData = { + name: resource.name, + divName: slug(resource.name), + status: resource.status, + statusClass: (resource.status === 'started') ? 'success' : 'danger', + // path: slash(path.normalize(resource.path)), + version: (resource.version) ? `(${resource.version.trim()})` : '', + author: (resource.author) ? `${resource.author.trim()}` : '', + description: (resource.description) ? resource.description.trim() : '', + }; + + if (resGroupList.hasOwnProperty(subPath)) { + resGroupList[subPath].push(resData); + } else { + resGroupList[subPath] = [resData]; + } + }); + + //Generate final array with subpaths and div ids + const finalList = []; + Object.keys(resGroupList).forEach((subPath) => { + const subPathData = { + subPath: subPath, + divName: slug(subPath), + resources: resGroupList[subPath].sort(dynamicSort('name')), + }; + finalList.push(subPathData); + }); + + // finalList = JSON.stringify(finalList, null, 2) + // console.log(finalList) + // return [] + return finalList; +} diff --git a/core/routes/serverLog.js b/core/routes/serverLog.js new file mode 100644 index 0000000..0e68c62 --- /dev/null +++ b/core/routes/serverLog.js @@ -0,0 +1,20 @@ +const modulename = 'WebServer:ServerLog'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + + +/** + * Returns the server log page + * @param {object} ctx + */ +export default async function ServerLog(ctx) { + //Check permissions + if (!ctx.admin.hasPermission('server.log.view')) { + return ctx.utils.render('main/message', { message: 'You don\'t have permission to view this page.' }); + } + + const renderData = { + headerTitle: 'Server Log', + }; + return ctx.utils.render('main/serverLog', renderData); +}; diff --git a/core/routes/serverLogPartial.js b/core/routes/serverLogPartial.js new file mode 100644 index 0000000..92b91ee --- /dev/null +++ b/core/routes/serverLogPartial.js @@ -0,0 +1,37 @@ +const modulename = 'WebServer:ServerLogPartial'; +import consoleFactory from '@lib/console'; +const console = consoleFactory(modulename); + + +/** + * Returns the output page containing the admin log. + * @param {object} ctx + */ +export default async function ServerLogPartial(ctx) { + //Check permissions + if (!ctx.admin.hasPermission('server.log.view')) { + return sendTypedResp({ error: 'You don\'t have permission to call this endpoint.' }); + } + + const isDigit = /^\d{13}$/; + const sliceSize = 500; + + if (ctx.request.query.dir === 'older' && isDigit.test(ctx.request.query.ref)) { + const log = txCore.logger.server.readPartialOlder(ctx.request.query.ref, sliceSize); + return ctx.send({ + boundry: log.length < sliceSize, + log, + }); + } else if (ctx.request.query.dir === 'newer' && isDigit.test(ctx.request.query.ref)) { + const log = txCore.logger.server.readPartialNewer(ctx.request.query.ref, sliceSize); + return ctx.send({ + boundry: log.length < sliceSize, + log, + }); + } else { + return ctx.send({ + boundry: true, + log: txCore.logger.server.getRecentBuffer(), + }); + } +}; diff --git a/core/routes/settings/getConfigs.ts b/core/routes/settings/getConfigs.ts new file mode 100644 index 0000000..4afeafb --- /dev/null +++ b/core/routes/settings/getConfigs.ts @@ -0,0 +1,67 @@ +const modulename = 'WebServer:GetSettingsConfigs'; +import localeMap from '@shared/localeMap'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import { GenericApiErrorResp } from '@shared/genericApiTypes'; +import ConfigStore from '@modules/ConfigStore'; +import { PartialTxConfigs, TxConfigs } from '@modules/ConfigStore/schema'; +import { ConfigChangelogEntry } from '@modules/ConfigStore/changelog'; +import { redactApiKeys, redactStartupSecrets } from '@lib/misc'; +import { txHostConfig } from '@core/globalData'; +const console = consoleFactory(modulename); + + +export type GetConfigsResp = { + locales: { code: string, label: string }[], + dataPath: string, + hasCustomDataPath: boolean, + changelog: ConfigChangelogEntry[], + storedConfigs: PartialTxConfigs, + defaultConfigs: TxConfigs, + forceQuietMode: boolean, +} + + +/** + * Returns the output page containing the live console + */ +export default async function GetSettingsConfigs(ctx: AuthedCtx) { + const sendTypedResp = (data: GetConfigsResp | GenericApiErrorResp) => ctx.send(data); + + //Check permissions + if (!ctx.admin.testPermission('settings.view', modulename)) { + return sendTypedResp({ + error: 'You do not have permission to view the settings.' + }); + } + + //Prepare data + const locales = Object.keys(localeMap).map(code => ({ + code, + label: localeMap[code].$meta.label, + })); + locales.sort((a, b) => a.label.localeCompare(b.label)); + + const outData: GetConfigsResp = { + locales, + dataPath: txHostConfig.dataPath, + hasCustomDataPath: txHostConfig.hasCustomDataPath, + changelog: txCore.configStore.getChangelog(), + storedConfigs: txCore.configStore.getStoredConfig(), + defaultConfigs: ConfigStore.SchemaDefaults, + forceQuietMode: txHostConfig.forceQuietMode, + }; + + //Redact sensitive data if the user doesn't have the write permission + if (!ctx.admin.hasPermission('settings.write')) { + const toRedact = outData.storedConfigs as any; //dont want to type this + if(outData.storedConfigs.server?.startupArgs) { + toRedact.server.startupArgs = redactStartupSecrets(outData.storedConfigs.server.startupArgs); + } + if(outData.storedConfigs.discordBot?.token) { + toRedact.discordBot.token = '[redacted by txAdmin]'; + } + } + + return sendTypedResp(outData); +}; diff --git a/core/routes/settings/resetServerDataPath.ts b/core/routes/settings/resetServerDataPath.ts new file mode 100644 index 0000000..5d2a2e5 --- /dev/null +++ b/core/routes/settings/resetServerDataPath.ts @@ -0,0 +1,61 @@ +const modulename = 'WebServer:SettingsPage'; +import consoleFactory from '@lib/console'; +import type { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import type { ApiToastResp } from '@shared/genericApiTypes'; +import { SYM_RESET_CONFIG } from '@lib/symbols'; +const console = consoleFactory(modulename); + +export type ResetServerDataPathResp = ApiToastResp; + +/** + * Resets the server settings (was a master action) + */ +export default async function ResetServerDataPath(ctx: AuthedCtx) { + const sendTypedResp = (data: ResetServerDataPathResp) => ctx.send(data); + + //Check permissions + if (!ctx.admin.testPermission('all_permissions', modulename)) { + return sendTypedResp({ + type: 'error', + msg: 'You don\'t have permission to execute this action.', + }); + } + + //Kill the server async + if (!txCore.fxRunner.isIdle) { + ctx.admin.logCommand('STOP SERVER'); + txCore.fxRunner.killServer('new server set up', ctx.admin.name, false).catch((e) => { }); + } + + //Making sure the deployer is not running + txManager.deployer = null; + + //Preparing & saving config + try { + txCore.configStore.saveConfigs({ + server: { + dataPath: SYM_RESET_CONFIG, + cfgPath: SYM_RESET_CONFIG, + } + }, ctx.admin.name); + } catch (error) { + console.warn(`[${ctx.admin.name}] Error resetting server data settings.`); + console.verbose.dir(error); + return sendTypedResp({ + type: 'error', + md: true, + title: `Error resetting the server data path.`, + msg: (error as any).message, + }); + } + + //technically not required, but faster than fxRunner.killServer() + txCore.webServer.webSocket.pushRefresh('status'); + + //Sending output + ctx.admin.logAction('Resetting server data settings.'); + return sendTypedResp({ + type: 'success', + msg: 'Server data path reset.' + }); +}; diff --git a/core/routes/settings/saveConfigs.ts b/core/routes/settings/saveConfigs.ts new file mode 100644 index 0000000..56da9de --- /dev/null +++ b/core/routes/settings/saveConfigs.ts @@ -0,0 +1,405 @@ +const modulename = 'WebServer:SettingsPage'; +import consoleFactory from '@lib/console'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import slash from 'slash'; +import type { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import type { ApiToastResp } from '@shared/genericApiTypes'; +import type { PartialTxConfigs, PartialTxConfigsToSave } from '@modules/ConfigStore/schema'; +import type { ConfigChangelogEntry } from '@shared/otherTypes'; +import { z } from 'zod'; +import { fromError } from 'zod-validation-error'; +import Translator, { localeFileSchema } from '@modules/Translator'; +import ConfigStore from '@modules/ConfigStore'; +import { resolveCFGFilePath } from '@lib/fxserver/fxsConfigHelper'; +import { findPotentialServerDataPaths, isValidServerDataPath } from '@lib/fxserver/serverData'; +import { getFsErrorMdMessage } from '@lib/fs'; +import { generateStatusMessage } from '@modules/DiscordBot/commands/status'; +import { getSchemaChainError } from '@modules/ConfigStore/schema/utils'; +import { confx } from '@modules/ConfigStore/utils'; +import { SYM_RESET_CONFIG } from '@lib/symbols'; +const console = consoleFactory(modulename); + + +//Types +export type SaveConfigsReq = { + resetKeys: string[]; + changes: PartialTxConfigs, +}; +export type SaveConfigsResp = ApiToastResp & { + stored?: PartialTxConfigs; + changelog?: ConfigChangelogEntry[]; +}; + +type SendTypedResp = (data: SaveConfigsResp) => void; +type CardHandlerSuccessResp = { + processedConfig: PartialTxConfigsToSave; + successToast?: ApiToastResp; +} +type CardHandler = ( + inputConfig: PartialTxConfigsToSave, + sendTypedResp: SendTypedResp +) => Promise; + +//Known cards +const cardNamesMap = { + general: 'General', + fxserver: 'FXServer', + bans: 'Bans', + whitelist: 'Whitelist', + discord: 'Discord', + 'game-menu': 'Game Menu', + 'game-notifications': 'Game Notifications', +} as const; +const validCardIds = Object.keys(cardNamesMap) as [keyof typeof cardNamesMap]; + +//Req validation +const paramsSchema = z.object({ card: z.enum(validCardIds) }); +const bodySchema = z.object({ + resetKeys: z.array(z.string()), + changes: z.object({}).passthrough(), +}); + +//Helper to clean paths +const cleanPath = (x: string) => slash(path.normalize(x)); + + +/** + * Processes a settings save request + * NOTE: the UI trims all strings + */ +export default async function SaveSettingsConfigs(ctx: AuthedCtx) { + const sendTypedResp = (data: SaveConfigsResp) => ctx.send(data); + + //Check permissions + if (!ctx.admin.testPermission('settings.write', modulename)) { + return sendTypedResp({ + type: 'error', + msg: 'You don\'t have permission to execute this action.', + }); + } + + //Validating input + const paramsSchemaRes = paramsSchema.safeParse(ctx.params); + const bodySchemaRes = bodySchema.safeParse(ctx.request.body); + if (!paramsSchemaRes.success || !bodySchemaRes.success) { + return sendTypedResp({ + type: 'error', + md: true, + title: 'Invalid Request', + msg: fromError( + paramsSchemaRes.error ?? bodySchemaRes.error, + { prefix: null } + ).message, + }); + } + const cardId = paramsSchemaRes.data.card; + const { resetKeys, changes: inputConfig } = bodySchemaRes.data; + const cardName = cardNamesMap[ctx.params.card as keyof typeof cardNamesMap] ?? 'UNKNOWN'; + + //Delegate to the specific card handlers - if required + let handlerResp: CardHandlerSuccessResp | void = { processedConfig: inputConfig }; + try { + if (cardId === 'general') { + handlerResp = await handleGeneralCard(inputConfig, sendTypedResp); + } else if (cardId === 'fxserver') { + handlerResp = await handleFxserverCard(inputConfig, sendTypedResp); + } else if (cardId === 'discord') { + handlerResp = await handleDiscordCard(inputConfig, sendTypedResp); + } + } catch (error) { + return sendTypedResp({ + type: 'error', + md: true, + title: `Error processing the ${cardName} changes.`, + msg: (error as any).message, + }); + } + if (!handlerResp) return; //resp already sent + + //Apply reset keys + const configChanges = handlerResp.processedConfig; + try { + for (const config of resetKeys) { + const [scope, key] = config.split('.'); + if (!scope || !key) throw new Error(`Invalid reset key: \`${config}\``); + confx(configChanges).set(scope, key, SYM_RESET_CONFIG); + } + } catch (error) { + return sendTypedResp({ + type: 'error', + md: true, + title: `Error processing the ${cardName} changes.`, + msg: (error as any).message, + }); + } + + //Save the changes + try { + const changes = txCore.configStore.saveConfigs(configChanges, ctx.admin.name); + if (changes.hasMatch(['server.dataPath', 'server.cfgPath'])) { + txCore.webServer.webSocket.pushRefresh('status'); + } + return sendTypedResp({ + type: 'success', + msg: `${cardName} Settings saved!`, + ...(handlerResp?.successToast ?? {}), + stored: txCore.configStore.getStoredConfig(), + changelog: txCore.configStore.getChangelog(), + }); + } catch (error) { + const cardName = cardNamesMap[ctx.params.card as keyof typeof cardNamesMap] ?? 'UNKNOWN'; + return sendTypedResp({ + type: 'error', + md: true, + title: `Error saving the ${cardName} changes.`, + msg: (error as any).message, + }); + } +}; + + +/** + * General card handler + */ +const handleGeneralCard: CardHandler = async (inputConfig, sendTypedResp) => { + //Validates custom language file + if (inputConfig.general?.language === undefined) throw new Error(`Unexpected data for the 'general' card.`); + if (inputConfig.general.language === 'custom') { + try { + const raw = await fsp.readFile(txCore.translator.customLocalePath, 'utf8'); + if (!raw.length) throw new Error('The \`locale.json\` file is empty.'); + const parsed = JSON.parse(raw); + const locale = localeFileSchema.parse(parsed); + if (!Translator.humanizerLanguages.includes(locale.$meta.humanizer_language)) { + throw new Error(`Invalid humanizer language: \`${locale.$meta.humanizer_language}\`.`); + } + } catch (error) { + let msg = (error as Error).message; + if (error instanceof Error) { + if (error.message.includes('ENOENT')) { + msg = `Could not find the custom language file:\n\`${txCore.translator.customLocalePath}\``; + } else if (error.message.includes('JSON')) { + msg = 'The custom language file contains invalid JSON.'; + } else if (error instanceof z.ZodError) { + msg = fromError(error, { prefix: 'Invalid Locale Metadata' }).message; + } + } + return sendTypedResp({ + type: 'error', + title: 'Custom Language Error', + md: true, + msg, + }); + } + } + + return { processedConfig: inputConfig }; +} + + +/** + * FXServer card handler + */ +const handleFxserverCard: CardHandler = async (inputConfig, sendTypedResp) => { + // if (typeof inputConfig.server?.dataPath === 'string' && inputConfig.server.dataPath.length) { + // inputConfig.server.dataPath = cleanPath(inputConfig.server.dataPath + '/'); + // } + if (typeof inputConfig.server?.dataPath !== 'string' || !inputConfig.server?.dataPath.length) { + throw new Error(`Unexpected data for the 'fxserver' card.`); + } + + //Validating Server Data Path + const dataPath = inputConfig.server.dataPath; + try { + const isValid = await isValidServerDataPath(dataPath); + if (!isValid) throw new Error(`unexpected isValidServerDataPath response`); + } catch (error) { + try { + const potentialFix = await findPotentialServerDataPaths(dataPath); + if (potentialFix) { + return sendTypedResp({ + type: 'error', + title: 'Server Data Folder Error', + md: true, + msg: `The path provided is not valid.\n\nDid you mean this path?\n\`${cleanPath(potentialFix)}\``, + }); + } + } catch (error2) { } + return sendTypedResp({ + type: 'error', + title: 'Server Data Folder Error', + md: true, + msg: (error as any).message, + }); + } + + + //Validating CFG Path + let cfgPath = txConfig.server.cfgPath; + if (inputConfig.server?.cfgPath !== undefined) { + const res = ConfigStore.Schema.server.cfgPath.validator.safeParse(inputConfig.server.cfgPath); + if (!res.success) { + return sendTypedResp({ + type: 'error', + title: 'Invalid CFG Path', + md: true, + msg: fromError(res.error, { prefix: null }).message, + }); + } + cfgPath = res.data; + } + + try { + cfgPath = resolveCFGFilePath(cfgPath, dataPath); + const cfgFileStat = await fsp.stat(cfgPath); + if (!cfgFileStat.isFile()) { + throw new Error('The path provided is not a file'); + } + } catch (error) { + return sendTypedResp({ + type: 'error', + title: 'CFG Path Error', + md: true, + msg: getFsErrorMdMessage(error, cleanPath(cfgPath)), + }); + } + + //FIXME: implement findLikelyCFGPath in here + + + //Final cleanup + if (typeof inputConfig.server?.dataPath === 'string') { + inputConfig.server.dataPath = cleanPath(inputConfig.server.dataPath); + } + if (typeof inputConfig.server?.cfgPath === 'string') { + inputConfig.server.cfgPath = cleanPath(inputConfig.server.cfgPath); + } + + return { + processedConfig: inputConfig, + successToast: { + type: 'success', + title: 'FXServer Settings Saved!', + msg: 'You need to restart the server for the changes to take effect.', + } + }; +} + + +/** + * Discord card handler + */ +const handleDiscordCard: CardHandler = async (inputConfig, sendTypedResp) => { + if (!inputConfig.discordBot) throw new Error(`Unexpected data for the 'discord' card.`); + + //Validating embed JSONs + //NOTE: need this before checking if enabled, or while disabled one could save invalid JSON + if (typeof inputConfig.discordBot.embedJson === 'string' || typeof inputConfig.discordBot.embedConfigJson === 'string') { + try { + generateStatusMessage( + inputConfig.discordBot.embedJson as string | undefined ?? txConfig.discordBot.embedJson, + inputConfig.discordBot.embedConfigJson as string | undefined ?? txConfig.discordBot.embedConfigJson, + ); + } catch (error) { + return sendTypedResp({ + type: 'error', + title: 'Embed validation failed:', + md: true, + msg: (error as Error).message, + }); + } + } + + //If bot disabled, kill the bot and don't validate anything + if (!inputConfig.discordBot?.enabled) { + await txCore.discordBot.attemptBotReset(false); + return { processedConfig: inputConfig }; + } + + //Validating fields manually before trying to start the bot + const baseError = { + type: 'error', + title: 'Discord Bot Error', + md: true, + } as const; + const schemas = ConfigStore.Schema.discordBot; + const validationError = getSchemaChainError([ + [schemas.enabled, inputConfig.discordBot.enabled], + [schemas.token, inputConfig.discordBot.token], + [schemas.guild, inputConfig.discordBot.guild], + [schemas.warningsChannel, inputConfig.discordBot.warningsChannel], + ]); + if (validationError) { + return sendTypedResp({ + ...baseError, + msg: validationError, + }); + } + + //Checking if required fields are present (frontend should have done this already) + if ( + !inputConfig.discordBot.token + || !inputConfig.discordBot.guild + ) { + return sendTypedResp({ + ...baseError, + msg: 'Missing required fields to enable the bot.', + }); + } + + //Restarting discord bot + let successMsg; + try { + successMsg = await txCore.discordBot.attemptBotReset({ + enabled: true, + //They have been validated, so this is fine + token: inputConfig.discordBot.token as any, + guild: inputConfig.discordBot.guild as any, + warningsChannel: inputConfig.discordBot.warningsChannel as any, + }); + } catch (error) { + const errorCode = (error as any).code; + let extraContext = ''; + if (errorCode === 'DisallowedIntents' || errorCode === 4014) { + extraContext = `**The bot requires the \`GUILD_MEMBERS\` intent.** + - Go to the [Discord Dev Portal](https://discord.com/developers/applications) + - Navigate to \`Bot > Privileged Gateway Intents\`. + - Enable the \`GUILD_MEMBERS\` intent. + - Press save on the developer portal. + - Go to the \`txAdmin > Settings > Discord Bot\` and press save.`; + } else if (errorCode === 'CustomNoGuild') { + const inviteUrl = ('clientId' in (error as any)) + ? `https://discord.com/oauth2/authorize?client_id=${(error as any).clientId}&scope=bot&permissions=0` + : `https://discordapi.com/permissions.html#0` + extraContext = `**This usually mean one of the issues below:** + - **Wrong server ID:** read the description of the server ID setting for more information. + - **Bot is not in the server:** you need to [INVITE THE BOT](${inviteUrl}) to join the server. + - **Wrong bot:** you may be using the token of another discord bot.`; + } else if (errorCode === 'DangerousPermission') { + extraContext = `You need to remove the permissions listed above to be able to enable this bot. + This should be done in the Discord Server role configuration page and not in the Dev Portal. + Check every single role that the bot has in the server. + + Please keep in mind that: + - These permissions are dangerous because if the bot token leaks, an attacker can cause permanent damage to your server. + - No bot should have more permissions than strictly needed, especially \`Administrator\`. + - You should never have multiple bots using the same token, create a new one for each bot.`; + } + return sendTypedResp({ + ...baseError, + title: 'Error starting the bot:', + msg: `${(error as Error).message}\n${extraContext}`.trim(), + }); + } + + return { + processedConfig: inputConfig, + successToast: { + type: 'success', + md: true, + title: 'FXServer Settings Saved!', + msg: `${successMsg}\nIf _(and only if)_ the status embed is not being updated, check the \`System > Console Log\` page to look for embed errors.`, + } + }; +} diff --git a/core/routes/setup/get.ts b/core/routes/setup/get.ts new file mode 100644 index 0000000..2aa7351 --- /dev/null +++ b/core/routes/setup/get.ts @@ -0,0 +1,38 @@ +const modulename = 'WebServer:SetupGet'; +import path from 'node:path'; +import { txEnv, txHostConfig } from '@core/globalData'; +import { RECIPE_DEPLOYER_VERSION } from '@core/deployer/index'; +import consoleFactory from '@lib/console'; +import { TxConfigState } from '@shared/enums'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +const console = consoleFactory(modulename); + + +/** + * Returns the output page containing the live console + */ +export default async function SetupGet(ctx: AuthedCtx) { + //Check permissions + if (!ctx.admin.hasPermission('master')) { + return ctx.utils.render('main/message', {message: 'You need to be the admin master to use the setup page.'}); + } + + // Ensure correct state for the setup page + if(txManager.configState === TxConfigState.Deployer) { + return ctx.utils.legacyNavigateToPage('/server/deployer'); + } else if(txManager.configState !== TxConfigState.Setup) { + return ctx.utils.legacyNavigateToPage('/'); + } + + //Output + const storedConfig = txCore.configStore.getStoredConfig(); + const renderData = { + headerTitle: 'Setup', + skipServerName: !!(storedConfig.general?.serverName), + deployerEngineVersion: RECIPE_DEPLOYER_VERSION, + forceGameName: txHostConfig.forceGameName ?? '', //ejs injection works better with strings + dataPath: txHostConfig.dataPath, + hasCustomDataPath: txHostConfig.hasCustomDataPath, + }; + return ctx.utils.render('standalone/setup', renderData); +}; diff --git a/core/routes/setup/post.js b/core/routes/setup/post.js new file mode 100644 index 0000000..e310370 --- /dev/null +++ b/core/routes/setup/post.js @@ -0,0 +1,429 @@ +const modulename = 'WebServer:SetupPost'; +import path from 'node:path'; +import fse from 'fs-extra'; +import fsp from 'node:fs/promises'; +import slash from 'slash'; +import { Deployer } from '@core/deployer/index'; +import { validateFixServerConfig, findLikelyCFGPath } from '@lib/fxserver/fxsConfigHelper'; +import got from '@lib/got'; +import consoleFactory from '@lib/console'; +import recipeParser from '@core/deployer/recipeParser'; +import { validateTargetPath } from '@core/deployer/utils'; +import { TxConfigState } from '@shared/enums'; +const console = consoleFactory(modulename); + +//Helper functions +const isUndefined = (x) => (x === undefined); + +const getDirectories = (source) => { + return fse.readdirSync(source, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); +}; + +const getPotentialServerDataFolders = (source) => { + try { + return getDirectories(source) + .filter((dirent) => getDirectories(path.join(source, dirent)).includes('resources')) + .map((dirent) => slash(path.join(source, dirent)) + '/'); + } catch (error) { + console.verbose.warn(`Failed to find server data folder with message: ${error.message}`); + return []; + } +}; + +/* + NOTE: How forgiving are we: + - Ignore trailing slashes, as well as fix backslashes + - Check if its the parent folder + - Check if its inside the parent folder + - Check if its inside current folder + - Check if it contains the string `/resources`, then if its the path up to that string + - Detect config as `server.cfg` or with wrong extensions inside the Server Data Folder + + FIXME: Also note that this entire file is a bit too messy, please clean it up a bit +*/ + +/** + * Handle all the server control actions + * FIXME: separate into validate.ts, saveDeployer.ts, and saveLocal.ts files + * FIXME: or maybe postDeployer.ts, and postLocal.ts files + * @param {object} ctx + */ +export default async function SetupPost(ctx) { + //Sanity check + if (isUndefined(ctx.params.action)) { + return ctx.utils.error(400, 'Invalid Request'); + } + const action = ctx.params.action; + + //Check permissions + if (!ctx.admin.testPermission('all_permissions', modulename)) { + return ctx.send({ + success: false, + message: 'You need to be the admin master or have all permissions to use the setup page.', + }); + } + + //Ensure the correct state for the setup page + if (txManager.configState !== TxConfigState.Setup) { + return ctx.send({ + success: false, + refresh: true, + }); + } + + //Delegate to the specific action functions + if (action == 'validateRecipeURL') { + return await handleValidateRecipeURL(ctx); + } else if (action == 'validateLocalDeployPath') { + return await handleValidateLocalDeployPath(ctx); + } else if (action == 'validateLocalDataFolder') { + return await handleValidateLocalDataFolder(ctx); + } else if (action == 'validateCFGFile') { + return await handleValidateCFGFile(ctx); + } else if (action == 'save' && ctx.request.body.type == 'popular') { + return await handleSaveDeployerImport(ctx); + } else if (action == 'save' && ctx.request.body.type == 'remote') { + return await handleSaveDeployerImport(ctx); + } else if (action == 'save' && ctx.request.body.type == 'custom') { + return await handleSaveDeployerCustom(ctx); + } else if (action == 'save' && ctx.request.body.type == 'local') { + return await handleSaveLocal(ctx); + } else { + return ctx.send({ + success: false, + message: 'Unknown setup action.', + }); + } +}; + + +/** + * Handle Validation of a remote recipe/template URL + * @param {object} ctx + */ +async function handleValidateRecipeURL(ctx) { + //Sanity check + if (isUndefined(ctx.request.body.recipeURL)) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + const recipeURL = ctx.request.body.recipeURL.trim(); + + //Make request & validate recipe + try { + const recipeText = await got.get({ + url: recipeURL, + timeout: { request: 4500 } + }).text(); + if (typeof recipeText !== 'string') throw new Error('This URL did not return a string.'); + const recipe = recipeParser(recipeText); + return ctx.send({success: true, name: recipe.name}); + } catch (error) { + return ctx.send({success: false, message: `Recipe error: ${error.message}`}); + } +} + + +/** + * Handle Validation of a remote recipe/template URL + * @param {object} ctx + */ +async function handleValidateLocalDeployPath(ctx) { + //Sanity check + if (isUndefined(ctx.request.body.deployPath)) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + const deployPath = slash(path.normalize(ctx.request.body.deployPath.trim())); + + //Perform path checking + try { + await validateTargetPath(deployPath); // will throw if invalid + return ctx.send({success: true, message: 'Path is valid.'}); + } catch (error) { + return ctx.send({success: false, message: error.message}); + } +} + + +/** + * Handle Validation of Local (existing) Server Data Folder + * @param {object} ctx + */ +async function handleValidateLocalDataFolder(ctx) { + //Sanity check + if (isUndefined(ctx.request.body.dataFolder)) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + const dataFolderPath = slash(path.normalize(ctx.request.body.dataFolder.trim() + '/')); + + //FIXME: replace with stuff in core/routes/settings/saveConfigs.ts > handleFxserverCard + try { + if (!fse.existsSync(path.join(dataFolderPath, 'resources'))) { + const recoveryTemplate = `The path provided is invalid.
+ But it looks like {{attempt}} is correct.
+ Do you want to use it instead?`; + + //Recovery if parent folder + const attemptIsParent = path.join(dataFolderPath, '..'); + if (fse.existsSync(path.join(attemptIsParent, 'resources'))) { + const message = recoveryTemplate.replace('{{attempt}}', attemptIsParent); + return ctx.send({success: false, message, suggestion: attemptIsParent}); + } + + //Recovery parent inside folder + const attemptOutside = getPotentialServerDataFolders(path.join(dataFolderPath, '..')); + if (attemptOutside.length >= 1) { + const message = recoveryTemplate.replace('{{attempt}}', attemptOutside[0]); + return ctx.send({success: false, message, suggestion: attemptOutside[0]}); + } + + //Recovery if resources + if (dataFolderPath.includes('/resources')) { + const attemptRes = dataFolderPath.split('/resources')[0]; + if (fse.existsSync(path.join(attemptRes, 'resources'))) { + const message = recoveryTemplate.replace('{{attempt}}', attemptRes); + return ctx.send({success: false, message, suggestion: attemptRes}); + } + } + + //Recovery subfolder + const attemptInside = getPotentialServerDataFolders(dataFolderPath); + if (attemptInside.length >= 1) { + const message = recoveryTemplate.replace('{{attempt}}', attemptInside[0]); + return ctx.send({success: false, message, suggestion: attemptInside[0]}); + } + + //really invalid :( + throw new Error("Couldn't locate or read a resources folder inside of the path provided."); + } else { + return ctx.send({ + success: true, + detectedConfig: findLikelyCFGPath(dataFolderPath), + }); + } + } catch (error) { + return ctx.send({success: false, message: error.message}); + } +} + + +/** + * Handle Validation of CFG File + * @param {object} ctx + */ +async function handleValidateCFGFile(ctx) { + //Sanity check + if ( + isUndefined(ctx.request.body.dataFolder) + || isUndefined(ctx.request.body.cfgFile) + ) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + + const dataFolderPath = slash(path.normalize(ctx.request.body.dataFolder.trim())); + const cfgFilePathNormalized = slash(path.normalize(ctx.request.body.cfgFile.trim())); + + //Validate file + try { + const result = await validateFixServerConfig(cfgFilePathNormalized, dataFolderPath); + if (result.errors) { + const message = `**The file path is correct, but there are error(s) in your config file(s):**\n${result.errors}`; + return ctx.send({success: false, markdown: true, message}); + } else { + return ctx.send({success: true}); + } + } catch (error) { + const message = `Error:\n ${error.message}.`; + return ctx.send({success: false, message}); + } +} + + +/** + * Handle Save settings for local server data imports + * Actions: sets serverDataPath/cfgPath, starts the server, redirect to live console + * @param {object} ctx + */ +async function handleSaveLocal(ctx) { + //Sanity check + if ( + isUndefined(ctx.request.body.name) + || isUndefined(ctx.request.body.dataFolder) + || isUndefined(ctx.request.body.cfgFile) + ) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + + //Prepare body input + const cfg = { + name: ctx.request.body.name.trim(), + dataFolder: slash(path.normalize(ctx.request.body.dataFolder + '/')), + cfgFile: slash(path.normalize(ctx.request.body.cfgFile)), + }; + + //Validating Server Data Path + try { + const stat = await fsp.stat(path.join(cfg.dataFolder, 'resources')) + if (!stat.isDirectory()) { + throw new Error('not a directory'); + } + } catch (error) { + let msg = error?.message ?? 'unknown error'; + if (error?.code === 'ENOENT') { + msg = 'The server data folder does not exist.'; + } + return ctx.send({success: false, message: `Server Data Folder error: ${msg}`}); + } + + //Preparing & saving config + try { + txCore.configStore.saveConfigs({ + general: { + serverName: cfg.name, + }, + server: { + dataPath: cfg.dataFolder, + cfgPath: cfg.cfgFile, + } + }, ctx.admin.name); + } catch (error) { + console.warn(`[${ctx.admin.name}] Error changing global/fxserver settings via setup stepper.`); + console.verbose.dir(error); + return ctx.send({ + type: 'danger', + markdown: true, + message: `**Error saving the configuration file:**\n${error.message}` + }); + } + + //Refreshing config + txCore.cacheStore.set('deployer:recipe', 'none'); + + //Logging + ctx.admin.logAction('Changing global/fxserver settings via setup stepper.'); + + //If running (for some reason), kill it first + if (!txCore.fxRunner.isIdle) { + ctx.admin.logCommand('STOP SERVER'); + await txCore.fxRunner.killServer('new server set up', ctx.admin.name, true); + } + + //Starting server + const spawnError = await txCore.fxRunner.spawnServer(false); + if (spawnError !== null) { + return ctx.send({success: false, markdown: true, message: spawnError}); + } else { + return ctx.send({success: true}); + } +} + + +/** + * Handle Save settings for remote recipe importing + * Actions: download recipe, starts deployer + * @param {object} ctx + */ +async function handleSaveDeployerImport(ctx) { + //Sanity check + if ( + isUndefined(ctx.request.body.name) + || isUndefined(ctx.request.body.isTrustedSource) + || isUndefined(ctx.request.body.recipeURL) + || isUndefined(ctx.request.body.targetPath) + || isUndefined(ctx.request.body.deploymentID) + ) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + const isTrustedSource = (ctx.request.body.isTrustedSource === 'true'); + const serverName = ctx.request.body.name.trim(); + const recipeURL = ctx.request.body.recipeURL.trim(); + const targetPath = slash(path.normalize(ctx.request.body.targetPath + '/')); + const deploymentID = ctx.request.body.deploymentID; + + //Get recipe + let recipeText; + try { + recipeText = await got.get({ + url: recipeURL, + timeout: { request: 4500 } + }).text(); + if (typeof recipeText !== 'string') throw new Error('This URL did not return a string.'); + } catch (error) { + return ctx.send({success: false, message: `Recipe download error: ${error.message}`}); + } + + //Preparing & saving config + try { + txCore.configStore.saveConfigs({ + general: { serverName }, + }, ctx.admin.name); + } catch (error) { + console.warn(`[${ctx.admin.name}] Error changing global settings via setup stepper.`); + console.verbose.dir(error); + return ctx.send({ + type: 'danger', + markdown: true, + message: `**Error saving the configuration file:** ${error.message}` + }); + } + ctx.admin.logAction('Changing global settings via setup stepper and started Deployer.'); + + //Start deployer (constructor will validate the recipe) + try { + txManager.startDeployer(recipeText, deploymentID, targetPath, isTrustedSource, {serverName}); + txCore.webServer.webSocket.pushRefresh('status'); + } catch (error) { + return ctx.send({success: false, message: error.message}); + } + return ctx.send({success: true}); +} + + +/** + * Handle Save settings for custom recipe + * Actions: download recipe, starts deployer + * @param {object} ctx + */ +async function handleSaveDeployerCustom(ctx) { + //Sanity check + if ( + isUndefined(ctx.request.body.name) + || isUndefined(ctx.request.body.targetPath) + || isUndefined(ctx.request.body.deploymentID) + ) { + return ctx.utils.error(400, 'Invalid Request - missing parameters'); + } + const serverName = ctx.request.body.name.trim(); + const targetPath = slash(path.normalize(ctx.request.body.targetPath + '/')); + const deploymentID = ctx.request.body.deploymentID; + + //Preparing & saving config + try { + txCore.configStore.saveConfigs({ + general: { serverName }, + }, ctx.admin.name); + } catch (error) { + console.warn(`[${ctx.admin.name}] Error changing global settings via setup stepper.`); + console.verbose.dir(error); + return ctx.send({ + type: 'danger', + markdown: true, + message: `**Error saving the configuration file:** ${error.message}` + }); + } + ctx.admin.logAction('Changing global settings via setup stepper and started Deployer.'); + + //Start deployer (constructor will create the recipe template) + const customMetaData = { + author: ctx.admin.name, + serverName, + }; + try { + txManager.startDeployer(false, deploymentID, targetPath, false, customMetaData); + txCore.webServer.webSocket.pushRefresh('status'); + } catch (error) { + return ctx.send({success: false, message: error.message}); + } + return ctx.send({success: true}); +} diff --git a/core/routes/systemLogs.ts b/core/routes/systemLogs.ts new file mode 100644 index 0000000..4472cfd --- /dev/null +++ b/core/routes/systemLogs.ts @@ -0,0 +1,33 @@ +import { getLogBuffer } from '@lib/console'; +import type { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import type { GenericApiErrorResp } from '@shared/genericApiTypes'; + + +/** + * Returns the data of for the system logs pages (console/actions) + * NOTE: would be more efficient to return the raw string, but the frontend fetcher expects JSON + */ +export default async function SystemLogs(ctx: AuthedCtx) { + const { scope } = ctx.params as any; + const sendTypedResp = (data: { data: string } | GenericApiErrorResp) => ctx.send(data); + + //Check permissions + if (!ctx.admin.hasPermission('txadmin.log.view')) { + return sendTypedResp({ error: 'You don\'t have permission to call this endpoint.' }); + } + + //Returning the data + if (scope === 'console') { + return sendTypedResp({ + data: getLogBuffer(), + }); + } else if (scope === 'action') { + const rawActions = await txCore.logger.admin.getRecentBuffer(); + if (!rawActions) return sendTypedResp({ error: 'Error fetching actions' }); + return sendTypedResp({ + data: rawActions, + }); + } else { + return sendTypedResp({ error: 'Invalid scope' }); + } +}; diff --git a/core/routes/whitelist/actions.ts b/core/routes/whitelist/actions.ts new file mode 100644 index 0000000..6c6f56b --- /dev/null +++ b/core/routes/whitelist/actions.ts @@ -0,0 +1,206 @@ +const modulename = 'WebServer:WhitelistActions'; +import { GenericApiResp } from '@shared/genericApiTypes'; +import { DuplicateKeyError } from '@modules/Database/dbUtils'; +import { now } from '@lib/misc'; +import { parsePlayerId } from '@lib/player/idUtils'; +import { DatabaseWhitelistRequestsType } from '@modules/Database/databaseTypes'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +const console = consoleFactory(modulename); + +//Helper functions +const anyUndefined = (...args: any) => [...args].some((x) => (typeof x === 'undefined')); + + + +/** + * Returns the output page containing the bans experiment + */ +export default async function WhitelistActions(ctx: AuthedCtx) { + //Sanity check + if (anyUndefined(ctx.params.action)) { + return ctx.utils.error(400, 'Invalid Request'); + } + const { table, action } = ctx.params; + const sendTypedResp = (data: GenericApiResp) => ctx.send(data); + + //Check permissions + if (!ctx.admin.testPermission('players.whitelist', modulename)) { + return sendTypedResp({ error: 'You don\'t have permission to execute this action.' }); + } + + //Delegate to the specific table handler + if (table === 'approvals') { + return sendTypedResp(await handleApprovals(ctx, action)); + } else if (table === 'requests') { + return sendTypedResp(await handleRequests(ctx, action)); + } else { + return sendTypedResp({ error: 'unknown table' }); + } +}; + + +/** + * Handle actions regarding the whitelist approvals table + */ +async function handleApprovals(ctx: AuthedCtx, action: any): Promise { + //Input validation + if (typeof ctx.request.body?.identifier !== 'string') { + return { error: 'identifier not specified' }; + } + const identifier = ctx.request.body.identifier; + const { isIdValid, idType, idValue, idlowerCased } = parsePlayerId(identifier); + if (!isIdValid || !idType || !idValue || !idlowerCased) { + return { error: 'Error: the provided identifier does not seem to be valid' }; + } + + if (action === 'add') { + //Preparing player name/avatar + let playerAvatar = null; + let playerName = (idValue.length > 8) + ? `${idType}...${idValue.slice(-8)}` + : `${idType}:${idValue}`; + if (idType === 'discord') { + try { + const { tag, avatar } = await txCore.discordBot.resolveMemberProfile(idValue); + playerName = tag; + playerAvatar = avatar; + } catch (error) { } + } + + //Registering approval + try { + txCore.database.whitelist.registerApproval({ + identifier: idlowerCased, + playerName, + playerAvatar, + tsApproved: now(), + approvedBy: ctx.admin.name, + }); + txCore.fxRunner.sendEvent('whitelistPreApproval', { + action: 'added', + identifier: idlowerCased, + playerName, + adminName: ctx.admin.name, + }); + } catch (error) { + return { error: `Failed to save wl approval: ${(error as Error).message}` }; + } + ctx.admin.logAction(`Added whitelist approval for ${playerName}.`); + return { success: true }; + + } else if (action === 'remove') { + try { + txCore.database.whitelist.removeManyApprovals({ identifier: idlowerCased }); + txCore.fxRunner.sendEvent('whitelistPreApproval', { + action: 'removed', + identifier: idlowerCased, + adminName: ctx.admin.name, + }); + } catch (error) { + return { error: `Failed to remove wl approval: ${(error as Error).message}` }; + } + ctx.admin.logAction(`Removed whitelist approval from ${idlowerCased}.`); + return { success: true }; + + } else { + return { error: 'unknown action' }; + } +} + + +/** + * Handle actions regarding the whitelist requests table + */ +async function handleRequests(ctx: AuthedCtx, action: any): Promise { + //Checkinf for the deny all action, the others need reqId + if (action === 'deny_all') { + const cutoff = parseInt(ctx.request.body?.newestVisible); + if (isNaN(cutoff)) { + return { error: 'newestVisible not specified' }; + } + + try { + const filter = (req: DatabaseWhitelistRequestsType) => req.tsLastAttempt <= cutoff; + txCore.database.whitelist.removeManyRequests(filter); + txCore.fxRunner.sendEvent('whitelistRequest', { + action: 'deniedAll', + adminName: ctx.admin.name, + }); + } catch (error) { + return { error: `Failed to remove all wl request: ${(error as Error).message}` }; + } + ctx.admin.logAction('Denied all whitelist requests.'); + return { success: true }; + } + + //Input validation + const reqId = ctx.request.body?.reqId; + if (typeof reqId !== 'string' || !reqId.length) { + return { error: 'reqId not specified' }; + } + + if (action === 'approve') { + //Find request + const requests = txCore.database.whitelist.findManyRequests({ id: reqId }); + if (!requests.length) { + return { error: `Whitelist request ID ${reqId} not found.` }; + } + const req = requests[0]; //just getting the first + + //Register whitelistApprovals + const playerName = req.discordTag ?? req.playerDisplayName; + const identifier = `license:${req.license}`; + try { + txCore.database.whitelist.registerApproval({ + identifier, + playerName, + playerAvatar: (req.discordAvatar) ? req.discordAvatar : null, + tsApproved: now(), + approvedBy: ctx.admin.name, + }); + txCore.fxRunner.sendEvent('whitelistRequest', { + action: 'approved', + playerName, + requestId: req.id, + license: req.license, + adminName: ctx.admin.name, + }); + } catch (error) { + if (!(error instanceof DuplicateKeyError)) { + return { error: `Failed to save wl approval: ${(error as Error).message}` }; + } + } + ctx.admin.logAction(`Approved whitelist request from ${playerName}.`); + + //Remove record from whitelistRequests + try { + txCore.database.whitelist.removeManyRequests({ id: reqId }); + } catch (error) { + return { error: `Failed to remove wl request: ${(error as Error).message}` }; + } + return { success: true }; + + } else if (action === 'deny') { + try { + const requests = txCore.database.whitelist.removeManyRequests({ id: reqId }); + if(requests.length){ + const req = requests[0]; //just getting the first + txCore.fxRunner.sendEvent('whitelistRequest', { + action: 'denied', + playerName: req.playerDisplayName, + requestId: req.id, + license: req.license, + adminName: ctx.admin.name, + }); + } + } catch (error) { + return { error: `Failed to remove wl request: ${(error as Error).message}` }; + } + ctx.admin.logAction(`Denied whitelist request ${reqId}.`); + return { success: true }; + + } else { + return { error: 'unknown action' }; + } +} diff --git a/core/routes/whitelist/list.ts b/core/routes/whitelist/list.ts new file mode 100644 index 0000000..e153a41 --- /dev/null +++ b/core/routes/whitelist/list.ts @@ -0,0 +1,93 @@ +const modulename = 'WebServer:WhitelistList'; +import Fuse from "fuse.js"; +import { DatabaseWhitelistApprovalsType, DatabaseWhitelistRequestsType } from '@modules/Database/databaseTypes'; +import cleanPlayerName from "@shared/cleanPlayerName"; +import { GenericApiErrorResp } from "@shared/genericApiTypes"; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from "@modules/WebServer/ctxTypes"; +const console = consoleFactory(modulename); + + +/** + * Returns the output page containing the action log, and the console log + */ +export default async function WhitelistList(ctx: AuthedCtx) { + const table = ctx.params.table; + + //Delegate to the specific handler + if (table === 'requests') { + return await handleRequests(ctx); + } else if (table === 'approvals') { + return await handleApprovals(ctx); + } else { + return ctx.send({ error: 'unknown table' }); + } +}; + + +/** + * Handles the search functionality. + */ +async function handleRequests(ctx: AuthedCtx) { + type resp = { + cntTotal: number; + cntFiltered: number; + newest: number; //for the ignore all button not remove any that hasn't been seeing by the admin + totalPages: number; + currPage: number; + requests: DatabaseWhitelistRequestsType[]; + } | GenericApiErrorResp; + const sendTypedResp = (data: resp) => ctx.send(data); + + const requests = txCore.database.whitelist.findManyRequests().reverse(); + + //Filter by player name, discord tag and req id + let filtered = requests; + const searchString = ctx.request.query?.searchString; + if (typeof searchString === 'string' && searchString.length) { + const fuse = new Fuse(requests, { + keys: ['id', 'playerPureName', 'discordTag'], + threshold: 0.3 + }); + const { pureName } = cleanPlayerName(searchString); + filtered = fuse.search(pureName).map(x => x.item); + } + + //Pagination + //NOTE: i think we can totally just send the whole list to the front end do pagination + const pageSize = 15; + const pageinput = ctx.request.query?.page; + let currPage = 1; + if (typeof pageinput === 'string') { + if (/^\d+$/.test(pageinput)) { + currPage = parseInt(pageinput); + if (currPage < 1) { + return sendTypedResp({error: 'page should be >= 1'}); + } + } else { + return sendTypedResp({error: 'page should be a number'}); + } + } + const skip = (currPage - 1) * pageSize; + const paginated = filtered.slice(skip, skip+pageSize); + + return sendTypedResp({ + cntTotal: requests.length, + cntFiltered: filtered.length, + newest: (requests.length) ? requests[0].tsLastAttempt : 0, + totalPages: Math.ceil(filtered.length/pageSize), + currPage, + requests: paginated, + }); +} + + +/** + * Handles the search functionality. + */ +async function handleApprovals(ctx: AuthedCtx) { + const sendTypedResp = (data: DatabaseWhitelistApprovalsType[]) => ctx.send(data); + + const approvals = txCore.database.whitelist.findManyApprovals().reverse(); + return sendTypedResp(approvals); +} diff --git a/core/routes/whitelist/page.ts b/core/routes/whitelist/page.ts new file mode 100644 index 0000000..fdd46d9 --- /dev/null +++ b/core/routes/whitelist/page.ts @@ -0,0 +1,17 @@ +const modulename = 'WebServer:WhitelistPage'; +import consoleFactory from '@lib/console'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +const console = consoleFactory(modulename); + + +/** + * Returns the output page containing the action log, and the console log + */ +export default async function WhitelistPage(ctx: AuthedCtx) { + const respData = { + headerTitle: 'Whitelist', + hasWhitelistPermission: ctx.admin.hasPermission('players.whitelist'), + currentWhitelistMode: txConfig.whitelist.mode, + }; + return ctx.utils.render('main/whitelist', respData); +}; diff --git a/core/testing/fileSetup.ts b/core/testing/fileSetup.ts new file mode 100644 index 0000000..ce3dbe4 --- /dev/null +++ b/core/testing/fileSetup.ts @@ -0,0 +1,61 @@ +import path from "node:path"; +import { vi, inject } from "vitest"; +import os from "node:os"; + +// Stubbing globals +vi.stubGlobal('ExecuteCommand', (commandString: string) => { + //noop +}); +vi.stubGlobal('GetConvar', (varName: string, defaultValue: string) => { + if (varName === 'version') { + if (os.platform() === 'win32') { + return 'FXServer-test/txadmin SERVER v1.0.0.55555 win32'; + } else { + return 'FXServer-test/txadmin v1.0.0.55555 linux'; + } + } else if (varName === 'citizen_root') { + return inject('fxsPath'); + } else if (varName === 'txAdminDevMode') { + return 'false'; + } else if (varName === 'txAdminVerbose') { + return 'false'; + } else { + return defaultValue; + } +}); +vi.stubGlobal('GetCurrentResourceName', () => { + return 'monitor'; +}); +vi.stubGlobal('GetPasswordHash', (password: string) => { + //bcrypt hash for 'teste123' + return '$2b$11$K3HwDzkoUfhU6.W.tScfhOLEtR5uNc9qpQ685emtERx3dZ7fmgXCy'; +}); +vi.stubGlobal('GetResourceMetadata', (resourceName: string, metadataKey: string, index: number) => { + if (resourceName === 'monitor' && metadataKey === 'version' && index === 0) { + return '9.9.9'; + } else { + throw new Error(`not implemented`); + } +}); +vi.stubGlobal('GetResourcePath', (resourceName: string) => { + if (resourceName === 'monitor') { + return path.join(__dirname, '..', '..'); + } else { + throw new Error(`not implemented`); + } +}); +vi.stubGlobal('IsDuplicityVersion', () => { + return true; +}); +vi.stubGlobal('PrintStructuredTrace', (payload: string) => { + //noop +}); +vi.stubGlobal('ScanResourceRoot', (rootPath: string, callback: (data: object) => void) => { + throw new Error(`not implemented`); +}); +vi.stubGlobal('VerifyPasswordHash', (password: string, hash: string) => { + return true; +}); +vi.stubGlobal('Intl.getCanonicalLocales', (locales?: string | readonly string[] | undefined) => { + return Array.isArray(locales) ? locales : [locales]; +}); diff --git a/core/testing/globalSetup.ts b/core/testing/globalSetup.ts new file mode 100644 index 0000000..65b5482 --- /dev/null +++ b/core/testing/globalSetup.ts @@ -0,0 +1,34 @@ +import type { GlobalSetupContext } from 'vitest/node' +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + + +export default function setup({ config, provide }: GlobalSetupContext) { + //Preparing fxspath + const relativePath = os.platform() === 'win32' + ? 'fxserver/' + : 'alpine/opt/cfx-server/'; + const runId = Math.random().toString(36).substring(2, 15); + const tempFolderPath = path.join(os.tmpdir(), `.txtest-${runId}`); + // const tempFolderPath = path.join(os.tmpdir(), `.txtest-aaaaaaa`); + const fxsPath = path.join(tempFolderPath, relativePath); + const txDataPath = path.join(tempFolderPath, 'txData'); + provide('fxsPath', fxsPath); + + // Setup & Cleanup + console.log('Setting temp folder:', tempFolderPath); + fs.mkdirSync(fxsPath, { recursive: true }); + fs.mkdirSync(txDataPath, { recursive: true }); + + return () => { + console.log('Erasing temp folder:', tempFolderPath); + fs.rmSync(tempFolderPath, { recursive: true }); + } +} + +declare module 'vitest' { + export interface ProvidedContext { + fxsPath: string + } +} diff --git a/core/tsconfig.json b/core/tsconfig.json new file mode 100644 index 0000000..0884394 --- /dev/null +++ b/core/tsconfig.json @@ -0,0 +1,66 @@ +{ + "compilerOptions": { + /* Enable constraints that allow a TypeScript project to be used with project references. */ + "composite": true, + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2021", + + /* Specify what module code is generated. */ + "module": "ES2020", + + /* Specify the root folder within your source files. */ + "rootDir": "../", + + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", + + /* Specify the base directory to resolve non-relative module names. */ + "baseUrl": "./", + + /* Specify a set of entries that re-map imports to additional lookup locations. */ + // WARNING: do not break the array into lines as it will break the parser in ./vitest.config.ts + "paths": { + "@shared/*": ["../shared/*"], + "@lib/*": ["./lib/*"], + "@modules/*": ["./modules/*"], + "@routes/*": ["./routes/*"], + "@core/*": ["./*"] + }, + + /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + "allowJs": true, + + /* Specify an output folder for all emitted files. */ + "outDir": "../.tsc/core", + + /* Disable emitting files if any type checking errors are reported. */ + "noEmitOnError": true, + + /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "esModuleInterop": true, + + /* Specify library files to be included in the compilation. */ + "lib": ["ES2021"], //@tsconfig/node16 + + /* Specify type declaration files to be included in compilation. */ + "types": ["node"], + + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true + }, + "include": [ + "**/*", + "../shared/", + "../dynamicAds2.json", + ], + "references": [ + { "path": "../shared" } + ] +} diff --git a/core/txAdmin.ts b/core/txAdmin.ts new file mode 100644 index 0000000..7105a4c --- /dev/null +++ b/core/txAdmin.ts @@ -0,0 +1,118 @@ +import consoleFactory from '@lib/console'; +import { getCoreProxy } from './boot/globalPlaceholder'; + +import TxManager from './txManager'; +import ConfigStore from '@modules/ConfigStore'; +import AdminStore from '@modules/AdminStore'; +import DiscordBot from '@modules/DiscordBot'; +import FxRunner from '@modules/FxRunner'; +import Logger from '@modules/Logger'; +import FxMonitor from '@modules/FxMonitor'; +import FxScheduler from '@modules/FxScheduler'; +import Metrics from '@modules/Metrics'; +import Translator from '@modules/Translator'; +import WebServer from '@modules/WebServer'; +import FxResources from '@modules/FxResources'; +import FxPlayerlist from '@modules/FxPlayerlist'; +import Database from '@modules/Database'; +import CacheStore from '@modules/CacheStore'; +import UpdateChecker from '@modules/UpdateChecker'; +const console = consoleFactory(); + + +export type TxCoreType = { + //Storage + adminStore: AdminStore; + cacheStore: CacheStore; + configStore: ConfigStore; + database: Database; + logger: Logger; + metrics: Metrics; + + //FXServer + fxMonitor: FxMonitor; + fxPlayerlist: FxPlayerlist; + fxResources: FxResources; + fxRunner: FxRunner; + fxScheduler: FxScheduler; + + //Other + discordBot: DiscordBot; + translator: Translator; + updateChecker: UpdateChecker; + webServer: WebServer; +} + +export default function bootTxAdmin() { + /** + * MARK: Setting up Globals + */ + //Initialize the global txCore object + const _txCore = { + configStore: new ConfigStore(), + } as TxCoreType; + + //Setting up the global txCore object as a Proxy + (globalThis as any).txCore = getCoreProxy(_txCore); + + //Setting up & Validating txConfig + if (!txConfig || typeof txConfig !== 'object' || txConfig === null) { + throw new Error('txConfig is not defined'); + } + + //Initialize the txManager + (globalThis as any).txManager = new TxManager(); + + + /** + * MARK: Booting Modules + */ + //Helper function to start the modules & register callbacks + const startModule = (Class: GenericTxModule): T => { + const instance = new Class(); + if(Array.isArray(Class.configKeysWatched) && Class.configKeysWatched.length > 0){ + if(!('handleConfigUpdate' in instance) || typeof instance?.handleConfigUpdate !== 'function'){ + throw new Error(`Module '${Class.name}' has configKeysWatched[] but no handleConfigUpdate()`); + } + _txCore.configStore.registerUpdateCallback( + Class.name, + Class.configKeysWatched, + instance.handleConfigUpdate.bind(instance), + ); + } + if(instance?.handleShutdown) { + txManager.addShutdownHandler(instance.handleShutdown.bind(instance)); + } + + return instance as T; + }; + + //High Priority (required for banner) + _txCore.adminStore = startModule(AdminStore); + _txCore.webServer = startModule(WebServer); + _txCore.database = startModule(Database); + + //Required for signalStartReady() + _txCore.fxMonitor = startModule(FxMonitor); + _txCore.discordBot = startModule(DiscordBot); + _txCore.logger = startModule(Logger); + _txCore.fxRunner = startModule(FxRunner); + + //Low Priority + _txCore.translator = startModule(Translator); + _txCore.fxScheduler = startModule(FxScheduler); + _txCore.metrics = startModule(Metrics); + _txCore.fxResources = startModule(FxResources); + _txCore.fxPlayerlist = startModule(FxPlayerlist); + _txCore.cacheStore = startModule(CacheStore); + + //Very Low Priority + _txCore.updateChecker = startModule(UpdateChecker); + + + /** + * MARK: Finalizing Boot + */ + delete (globalThis as any).txCore; + (globalThis as any).txCore = _txCore; +} diff --git a/core/txManager.ts b/core/txManager.ts new file mode 100644 index 0000000..d0ccff7 --- /dev/null +++ b/core/txManager.ts @@ -0,0 +1,210 @@ +import { getHostData } from "@lib/diagnostics"; +import { isProxy } from "util/types"; +import { startReadyWatcher } from "./boot/startReadyWatcher"; +import { Deployer } from "./deployer"; +import { TxConfigState, type FxMonitorHealth } from "@shared/enums"; +import type { GlobalStatusType } from "@shared/socketioTypes"; +import quitProcess from "@lib/quitProcess"; +import consoleFactory, { processStdioEnsureEol, setTTYTitle } from "@lib/console"; +import { isNumber, isString } from "@modules/CacheStore"; +const console = consoleFactory('Manager'); + +//Types +type gameNames = 'fivem' | 'redm'; +type HostStatusType = { + //txAdmin state + cfgPath: string | null; + dataPath: string | null; + isConfigured: boolean; + playerCount: number; + status: FxMonitorHealth; + + //Detected at runtime + cfxId: string | null; + gameName: gameNames | null; + joinLink: string | null; + joinDeepLink: string | null; + playerSlots: number | null; + projectName: string | null; + projectDesc: string | null; +} + + +/** + * This class is for "high order" logic and methods that shouldn't live inside any specific component. + */ +export default class TxManager { + public deployer: Deployer | null = null; //FIXME: implementar o deployer + private readonly moduleShutdownHandlers: (() => void)[] = []; + public isShuttingDown = false; + + //TODO: move txRuntime here?! + + constructor() { + //Listen for shutdown signals + process.on('SIGHUP', this.gracefulShutdown.bind(this)); //terminal closed + process.on('SIGINT', this.gracefulShutdown.bind(this)); //ctrl+c (mostly users) + process.on('SIGTERM', this.gracefulShutdown.bind(this)); //kill (docker, etc) + + //Sync start, boot fxserver when conditions are met + startReadyWatcher(() => { + txCore.fxRunner.signalStartReady(); + }); + + //FIXME: mover o cron do FxMonitor (getHostStats() + websocket push) para cá + //FIXME: if ever changing this, need to make sure the other data + //in the status event will be pushed, since right some of now it + //relies on this event every 5 seconds + //NOTE: probably txManager should be the one to decide if stuff like the host + //stats changed enough to merit a refresh push + setInterval(async () => { + txCore.webServer.webSocket.pushRefresh('status'); + }, 5000); + + //Updates the terminal title every 15 seconds + setInterval(() => { + setTTYTitle(`(${txCore.fxPlayerlist.onlineCount}) ${txConfig.general.serverName} - txAdmin`); + }, 15000); + + //Pre-calculate static data + setTimeout(() => { + getHostData().catch((e) => { }); + }, 10_000); + } + + + /** + * Gracefully shuts down the application by running all exit handlers. + * If the process takes more than 5 seconds to exit, it will force exit. + */ + public async gracefulShutdown(signal: NodeJS.Signals) { + //Prevent race conditions + if (this.isShuttingDown) { + processStdioEnsureEol(); + console.warn(`Got ${signal} while already shutting down.`); + return; + } + console.warn(`Got ${signal}, shutting down...`); + this.isShuttingDown = true; + + //Stop all module timers + for (const moduleName of Object.keys(txCore)) { + const module = txCore[moduleName as keyof typeof txCore] as GenericTxModuleInstance; + if (Array.isArray(module.timers)) { + for (const interval of module.timers) { + clearInterval(interval); + } + } + } + + //Sets a hard limit to the shutdown process + setTimeout(() => { + console.error(`Graceful shutdown timed out after 5s, forcing exit...`); + quitProcess(1); + }, 5000); + + //Run all exit handlers + await Promise.allSettled(this.moduleShutdownHandlers.map((handler) => handler())); + console.verbose.debug(`All exit handlers finished, shutting down...`); + quitProcess(0); + } + + + /** + * Adds a handler to be run when txAdmin gets a SIG* event + */ + public addShutdownHandler(handler: () => void) { + this.moduleShutdownHandlers.push(handler); + } + + + /** + * Starts the deployer (TODO: rewrite deployer) + */ + startDeployer( + recipeText: string | false, + deploymentID: string, + targetPath: string, + isTrustedSource: boolean, + customMetaData: Record = {}, + ) { + if (this.deployer) { + throw new Error('Deployer is already running'); + } + this.deployer = new Deployer(recipeText, deploymentID, targetPath, isTrustedSource, customMetaData); + } + + + // isDeployerRunning(): this is { deployer: Deployer } { + // return this.deployer !== null; + // } + + + /** + * Unknown, Deployer, Setup, Ready + */ + get configState() { + if (isProxy(txCore)) { + return TxConfigState.Unkown; + } else if (this.deployer) { + return TxConfigState.Deployer; + } else if (!txCore.fxRunner.isConfigured) { + return TxConfigState.Setup; + } else { + return TxConfigState.Ready; + } + } + + + /** + * Returns the status object that is sent to the host status endpoint + */ + get hostStatus(): HostStatusType { + const serverPaths = txCore.fxRunner.serverPaths; + const cfxId = txCore.cacheStore.getTyped('fxsRuntime:cfxId', isString) ?? null; + const isGameName = (val: any): val is gameNames => val === 'fivem' || val === 'redm'; + return { + //txAdmin state + isConfigured: this.configState === TxConfigState.Ready, + dataPath: serverPaths?.dataPath ?? null, + cfgPath: serverPaths?.cfgPath ?? null, + playerCount: txCore.fxPlayerlist.onlineCount, + status: txCore.fxMonitor.status.health, + + //Detected at runtime + cfxId, + gameName: txCore.cacheStore.getTyped('fxsRuntime:gameName', isGameName) ?? null, + joinDeepLink: cfxId ? `fivem://connect/cfx.re/join/${cfxId}` : null, + joinLink: cfxId ? `https://cfx.re/join/${cfxId}` : null, + playerSlots: txCore.cacheStore.getTyped('fxsRuntime:maxClients', isNumber) ?? null, + projectName: txCore.cacheStore.getTyped('fxsRuntime:projectName', isString) ?? null, + projectDesc: txCore.cacheStore.getTyped('fxsRuntime:projectDesc', isString) ?? null, + } + } + + + /** + * Returns the global status object that is sent to the clients + */ + get globalStatus(): GlobalStatusType { + const fxMonitorStatus = txCore.fxMonitor.status; + return { + configState: txManager.configState, + discord: txCore.discordBot.status, + runner: { + isIdle: txCore.fxRunner.isIdle, + isChildAlive: txCore.fxRunner.child?.isAlive ?? false, + }, + server: { + name: txConfig.general.serverName, + uptime: fxMonitorStatus.uptime, + health: fxMonitorStatus.health, + healthReason: fxMonitorStatus.healthReason, + whitelist: txConfig.whitelist.mode, + }, + scheduler: txCore.fxScheduler.getStatus(), //no push events, updated every Scheduler.checkSchedule() + } + } +} + +export type TxManagerType = InstanceType; diff --git a/core/vitest.config.ts b/core/vitest.config.ts new file mode 100644 index 0000000..6dea60f --- /dev/null +++ b/core/vitest.config.ts @@ -0,0 +1,26 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { defineConfig } from 'vitest/config'; +import type { InlineConfig } from 'vitest/node'; + + +//Detect the aliases from the tsconfig.json +//NOTE: this regex is obviously sensitive to the format of the tsconfig.json +// but I don't feel like using a jsonc parser in here +const tsconfig = fs.readFileSync(path.resolve(__dirname, './tsconfig.json'), 'utf-8'); +const aliasRegex = /"(?@\w+)\/\*":\s\["(?\.+\/[^*]*)\*"]/g; +const resolvedAliases: InlineConfig['alias'] = { + '@locale': path.resolve(__dirname, '../locale'), //from ./shared/tsconfig.json +}; +for (const match of tsconfig.matchAll(aliasRegex)) { + resolvedAliases[match.groups!.alias] = path.resolve(__dirname, match.groups!.path); +} + + +export default defineConfig({ + test: { + globalSetup: './testing/globalSetup.ts', + setupFiles: ['./testing/fileSetup.ts'], + alias: resolvedAliases, + }, +}); diff --git a/docs/banner.png b/docs/banner.png new file mode 100644 index 0000000..5d90ada Binary files /dev/null and b/docs/banner.png differ diff --git a/docs/custom-server-log.md b/docs/custom-server-log.md new file mode 100644 index 0000000..58a7bf3 --- /dev/null +++ b/docs/custom-server-log.md @@ -0,0 +1,30 @@ +# Logging Extra Data + +This feature allows you to add logging for custom commands like `/car` and `/tp`. +To do that, you will need to edit the scripts of those commands to trigger a `txaLogger:CommandExecuted` event. +> **Note: for now this only supports client commands!** + +## How to Enable + +In the client script, add the following event call inside the command function: + +```lua +TriggerServerEvent('txaLogger:CommandExecuted', rawCommand) +``` + +Where `rawCommand` is a variable containing the full command with parameters. +You don't NEED to pass `rawCommand`, you can edit this string or pass anything you want. + +## Example + +In this example, we will log data from the `/car` command from the `CarCommand` script. + +```lua +RegisterCommand('car', function(source, args, rawCommand) + TriggerServerEvent('txaLogger:CommandExecuted', rawCommand) -- txAdmin logging Callback + + local x,y,z = table.unpack(GetOffsetFromEntityInWorldCoords(PlayerPedId(), 0.0, 8.0, 0.5)) + + -- there is more code here, no need to edit +end) +``` diff --git a/docs/dev-notes.md b/docs/dev-notes.md new file mode 100644 index 0000000..d43378e --- /dev/null +++ b/docs/dev-notes.md @@ -0,0 +1,758 @@ +Legend: +- [ ] -> Not started +- [x] -> Completed +- [!] -> Release Blocker +- [?] -> Lower priority or pending investigation + +## Small feat +- [x] improve UX for debugging bans + - tweak: improved readability on player join/leave events on server log + - feat(core): added server log for blocked joins of banned players + - feat(panel): added button to compare player/action ids + - feat(panel): added copy IDs button to player and action modals +- [x] feat(core): implement custom serveStatic middleware +- [x] feat(panel/console): added hidden copy options + +## Fixes +- [x] fix double server boot message: + - happens when some page starts the server and redirects you to the live console + - you join the room and gets initial data (directly from logger) + - while the websocket out buffer still haven't sent the boot message +- [x] fix: crashes table overflowing (DrilldownCrashesSubcard.tsx) + - [x] reported cases of crash reason too big without word break causing page to scroll horizontal +- [!] radix select/dropdown inside dialog + - test the settings one as well as the ban form inside the player modal +- [ ] the console lines are shorter on first full render (ctrl+f5) and on f5 it fixes itself + - didn't happen in v7.2.2, not sure about v7.3.2 + - doesn't seem to be neither fontSize nor lineHeight + - NOTE: this might solve itself with the WebGL renderer update, so try that first + +## Refactor + DX +- [x] deprecate fxRunner.srvCmd + - deprecate liveConsoleCmdHandler + - turn srvCmd into sendRawCommand + - use sendRawCommand in sendCommand (leave the fxserver.log*Command in sendRawCommand) +- [x] setup txData+profile on `index.js` before instantiating TxAdmin +- [x] process.exit reorg into lib/fatalError +- [x] move `ConfigVault.setupFolderStructure();` to index +- [x] improve db downgrade message +- [x] txGlobal.database.[players/actions/whitelist/cleanup].* +- [x] txGlobal/globals +- [x] txManager should be managing the deployer, not the modules +- [x] txManager should be exposing methods to get the status +- [x] em vários módilos eu fiz `this.config = txConfig.xxxxx`, mas tem que checar se o módulo não exige que o config não mude sem o this.refreshConfig + - provavelmente melhor esperar o refactor das configs + - [x] discord bot + - [x] fxrunner + - [x] health monitor + - [x] logger(s) + - [x] player database + - [x] scheduler + - [x] REFERENCIAS EXTERNAS? +- [x] .env + - [x] convert builders to use txDevEnv + - [x] convert tx code use txDevEnv +- [x] Config migrations: + - [x] commit renaming wip + - [x] decide on the REVIEW_SAVE_EMPTY_STRING vars + - [x] write schemas + - [x] write parser + migration + - [x] migrate the scope `fxRunner` -> `server` + - [x] migrate txConfig.logger + - [x] implement config saving + - [x] migrate txConfig.banTemplates + - [x] migrate txConfig.gameFeatures.playerModePtfx + - [x] implement changelog + - [x] implement the refreshConfig() stuff + - [x] migrate the old uses of refreshConfig to new config watcher + - [x] update `./core/boot/setup.ts` to use `public static ConfigStore.getEmptyConfigFile()` + - [x] migrate setup webroute + - [x] migrate deployer webroute + - [x] migrate masterActions->reset webroute + - [x] New Settings Page: + - [x] hide onesync + - [x] new layout + - [x] move all options from old page to new page (no code just yet) + - [x] make route to get all settings + - [x] create template tab for easy copy paste + - [x] figure out the use of id/names in the pathParams, confirm modal, error msg + - [x] apply template to all tabs + - [ish] json input modals + - [x] write down the client-side validations + - [x] perms: message if no settings.write perms (no token) + - [x] write saveConfigs.ts + - [x] perms: message if no settings.view perms (page error) + - [x] double check: + - [x] FIXME:NC + - [x] check if all disabled={pageCtx.isReadOnly} were applied + - [x] check if all text fields and selects have the `htmlFor` + - [x] check if all textarea fields are auto-sized + - [x] If shutdownNoticeDelayMs & restartSpawnDelayMs are really ms, and being migrated from secs for the case of shutdownNoticeDelay + - [x] remove `settings.ejs` and `core/routes/settings/get-old.ts` + - [x] migrate discord bot `refreshConfig()` and settings save + - [x] remove `./core/configMapping.tmp.ts` (was committed) + - [x] test `txConfig.server.startupArgs` + - [x] test if `server.startupArgs = ['+set']`, breaks the next 2 args + - [x] check all ConfigStore methods (including txCore.configStore.getRawFile()) + - [x] remap configs in `core/routes/diagnostics/sendReport.ts` and test it + - [x] change setup & deployer page to suggest relative `server.cfg` + - [x] check all modules to remove their + - [x] config validation at constructor + - [x] type definitions + - [x] check all typescript errors in all files + - [x] test setting up new profile from scratch + - [x] disable the "view changelog" button, or write the modal code + - [x] write dev notes on the config system (README.md in the panel settings and core configstore?) +- [x] Full FXRunner rewrite +- [x] add `.yarn.installed` to the dist? even in dev + +## Other stuff +- [x] new env vars +- [x] remove dynamicAds from the modules +- [x] fix custom locale +- [!] add stats tracking for the framework team (ask them, idk) +- [!] package updates - test radix stuff +- [!] commit stashed stuff +- [!] check txAdmin-private +- [ ] implement `cleanFullPath.ts` in settings save ui & api for comparison consistency + - [ ] add it to `setup/save.js -> handleValidateLocalDataFolder()` as well + + + +======================================================================= + + + + +- [ ] Layout refactor: + - não ter espaço em branco abaixo do header + - `2xl:mx-8 min-w-96` for all pages? (change on MainShell) + - checar tudo com iframe + - checar live console (e layers) + - checar modais + - checar sheets + - checar warning bar + - tirar o servername do menu de server? + - tirar servername do mobile header? +- NOTE: resoluções mobile + - 360x510 menor razoável + - 390x670 mais comum + +- [ ] use os.networkInterfaces()? + + +## Chores + boring stuff +- [ ] fully deprecate the ConVars and `txAdminZapConfig.json` + - reorganize the globalData.ts exports after that + - might not even need the separated `getXxxVars.ts` files after that + - still detect and issue an warning about its deprecation +- [ ] rename "citizenfx" to "fivem" everywhere. Or maybe cfx.re? +- [ ] replace lodash's cloneDeep with one of: + - https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone (node 17+) + - https://www.npmjs.com/package/rfdc +- [ ] switch to `game 'common'` and remove `rdr3_warning` +- [ ] check netid uint16 overflow + - right now the `mutex#netid` is being calculated on [logger](/core/modules/Logger/handlers/server.js#L148) + - detect netid rollover and set some flag to add some identifiable prefix to the mutex? + - increase mutex to 6 digits? + - `/^(?\w{5})#(?\d{1,6})(?:r(?\d{1,3}))?$/` + - write parser, which will return the groups, defaulting rollover to 0 + - NOTE: semver major is good opportunity for this change +- [ ] check if it makes sense to allow the txAdmin thread to run more than every 50ms + - node 22 branch -> code/components/citizen-server-monitor/src/MonitorInstance.cpp:307 +- [ ] see if it's a good idea to replace `getHostStats.js` with si.osInfo() + - same for getting process load, instead of fixing the wmic issue +- [ ] xterm changes + - [ ] deprecate canvas renderer and use the webgl instead + - [ ] check compatibility with text scaling - `window.devicePixelRatio` + - [ ] maybe update xterm to v5.6 + - ref: https://github.com/xtermjs/xterm.js/issues/3864 + - ref: https://github.com/xtermjs/xterm.js/issues/4779 + - ref: https://github.com/xtermjs/xterm.js/milestone/78 + - [ ] FIXME: Updating to WebGL might fix the font loading race condition + - Check the comments on LiveConsolePage.tsx +- [ ] fix circular dependencies + - search for `circular_dependency` + - use `madge` (command at the bottom of file) + +## Previous bugs +- [ ] use `ScanResourceRoot()` + - `ScanResourceRoot('xxx/resources/', (data: object) => {...});` + - test if a `while true do end` on a resource manifest would cause tx to hang + - make headless scan mode, running fxs+txa and getting the results + +## Pending Improvements +- [ ] Settings Page: + - [ ] bake in the defaults, so so SwitchText's don't show fale initial value + - [ ] check for pending changes on the navigate-away buttons + - [ ] use jsonForgivingParse for embed jsons and custom locale + - [ ] use the standalone json editor page + - [ ] if you type `E:\FiveM\txData\default.base` in the fxserver settings it will save but show as unsaved because the saved was the `cleanPath()` version `E:/FiveM/txData/default.base` +- [ ] Player drops page + - [ ] fix: blurred chart lines + - `imageRendering: 'pixelated'` might fix it + - try messing with the canvas size +- 0.5px + - [ ] review page layout: + - [ ] make it less card-y + - [ ] fix crashes table is not responsive + - [ ] fix scroll popping in/out + - [ ] switch from `useSWRImmutable` to `useSWR` + - [ ] add drilldown interval buttons +- Dashboard stuff: + - [ ] add testing for getServerStatsData + - full perf chart: + - [ ] disable `<...>.curve(d3.curveNatural)` on `playerLineGenerator` if more than 20 players? + - [ ] buttons to show memory usage, maybe hide player count + - [ ] calculate initial zoom of 30h + - Initial zoom code: https://observablehq.com/@d3/zoomable-area-chart?intent=fork + - [ ] use semi-transparent arrows on the sides to indicate there is more to pan to when hovering + - [ ] show server close reason + - [ ] don't clear svg on render, use d3 joins + - Metrics.svRuntime: + - [ ] write log optimizer and remove the webroute 30h filter + - [ref](/core/modules/Metrics/svRuntime/config.ts#L33) + - maybe use rounded/aligned times? + - check how this code works `d3.timeHours(new Date(1715741829000), new Date())[0]` + - thread perf chart: + - [ ] add the good/bad markers? + - [ ] fix getMinTickIntervalMarker behavior when 0.2 + - maybe just check if it's the hardcoded or color everything blue + - [ref](/core/modules/WebServer/wsRooms/dashboard.ts#L26) + - [ ] color should change correctly at the min interval marker point + - [ ] change the bg color to the color of the average ticket with heavy transparency? +- [ ] being able to /goto, /tpm while on noclip +- [ ] add stats tracking for runtime usage + - fw team request, probably a new native `GetResourceRuntimes(resName)` + +## Database Changes +- [ ] migration to change "revocation" to optional + - [ ] test the `getRegisteredActions()` filter as object, doing `{revocation: undefined}` +- [ ] add player name history +- [ ] add player session time tracking + - [ref](/core/playerLogic/playerClasses.ts#L281) + - [ ] create simple page to list top 100 players by playtime in the last 30d, 14d, 7d, yesterday, today + - if storing in a linear UInt16Array, 100k players * 120d * 4bytes per date = 48mb + + + +## other stuff +- Consider using Blob + - https://developer.mozilla.org/en-US/docs/Web/API/Blob + - https://chatgpt.com/c/670bf1f6-8ee4-8001-a731-3a219266d4c1 + + +## Refactor: AdminVault +- Adminvault: + - migrar admins.json + - pra cada admin do admins.json + - const admin = new StoredAdmin(rawObj) +- Middleware: + storedAdmin.getAuthed(csrfToken): AuthedAdmin +- class AuthedAdmin extends StoredAdmin + - has métodos to edit the admin + + + +## Refactor: Web Route Validation +> validator, it will know if web or api to give the correct response type +> if invalid, it will send the response and return undefined +```ts +import { z, ZodSchema, infer as zodInfer } from "zod"; +const checkParse = >( + schema: T, + data: unknown +): zodInfer | undefined => { + const result = schema.safeParse(data); + return result.success ? result.data : undefined; //maybe return ZodError instead +}; +const userSchema = z.object({ + name: z.string(), + age: z.number(), +}); +const data = { name: "Alice", age: 30 }; +const result = checkParse(userSchema, data); +// /\ Type: { name: string; age: number } | undefined + +//Now, apply that to create something for the ctx +const params = ctx.getParams(schema: ZodInstance, errorMessage?: string | false) //false means no auto resp +const query = ctx.getQuery(/*...*/) +const body = ctx.getBody(/*...*/) +if (!params || !query || !body) return; //error resp already sent +``` +```ts +// NOTE: current code +const paramsSchemaRes = paramsSchema.safeParse(ctx.params); +const bodySchemaRes = bodySchema.safeParse(ctx.request.body); +if (!paramsSchemaRes.success || !bodySchemaRes.success) { + return sendTypedResp({ + type: 'error', + md: true, + title: 'Invalid Request', + msg: fromZodError( + paramsSchemaRes.error ?? bodySchemaRes.error, + { prefix: null } + ).message, + }); +} +``` + + + +## Other annoying stuff to do +- [ ] headless deployer, without instantiating TxAdmin +- [ ] remove `fs-extra` - right now only used in deployer and setup +- [ ] create a global (or console?) `emsg(e: unknown)` that gets the message from an Error, and returns its message + - replace all `(error as Error).message` and `(error as any).message` +- [ ] include `list-dependencies.js` as part of the test workflow + - https://bun.sh/docs/api/transpiler#scan + - improve to read the parent package deps + - exit 1 on error + - detect circular imports +- [ ] testing + - use playwright + - [ ] use https://mswjs.io/docs/getting-started + - [ ] write some automated tests for the auth logic and middlewares +- [ ] ctrl+f doesn't work in the player modal anymore, if on the player or history pages + - criar um estado "any modal open" pra desabilitar todos hotkeys das páginas? +- [ ] add support for `sv_prometheusBasicAuthUser` & `sv_prometheusBasicAuthPassword` +- [ ] update tailwind + +## Refactor: Formatting + Linting +- [ ] fix the eslint config + tailwind sort + - [alternative](https://biomejs.dev/linter/rules/use-sorted-classes/) + - search the notes below for "dprint" and "prettier" + - check how the typescript repo uses dprint + - use `.git-blame-ignore-revs` +- maybe biome? +- Maybe prettier for all files except ts/js which could be in dprint +- Use the tailwind sorter plugin +- When running prettier, add ignore to the imported external files +https://prettier.io/docs/en/integrating-with-linters.html +https://tailwindcss.com/blog/automatic-class-sorting-with-prettier +- [ ] lua file changes (after PR merges) + - 4 spaces + - Upper case for globals + - alt+shift+f + - `.git-blame-ignore-revs` + +======================================================================= + + +## Next Up +- Kick as punishment might be needed since minimum ban is 1 hour, possible solutions: + - Allow for ban minutes + - Add a "timeout" button that brings a prompt with 1/5/15/30 mins buttons + - Add a checkbox to the kick modal to mark it as a punishment + +- [ ] rethink the flow of opening the menu `/tx ` and teleporting to targets + - need to use mouse, would be better if keyboardo nly + - the buttons need to be bigger, and tab-selectable, or hotkeys + - 💡 E se na main window do tx tivesse um , então vc pode só `F1 > tp 123 > enter` e seria tão rápido quanto usar o chat? + - 💡 Se abrir o menu via /tx e não for redm, avisar que é melhor fazer bind + +- [ ] live console + - [ ] if socket connects but no data received, add a warning to the console and wipe it after first write + - [ ] persistent cls via ts offsets + - [ ] improve the bufferization to allow just loading most recent "block" and loading prev blocks via button + - [ ] options dropdown? + - [ ] console nav button to jump to server start or errors? + - Or maybe filter just error lines (with margin) + - Or maybe even detect all channels and allow you to filter them, show dropdown sorted by frequency + +- [ ] Create txCore.logger.system + - replaces the configChangelog.json + - implements server.cfg changelog + - maybe use jsonl, or maybe literally use SQLite + - kinda replaces txCore.logger.admin + - on txadmin.exe, maybe implement some type of file signature + - for sure create a logs page with filter by admin, but dont overcomplicate + +- [ ] add average session time tracking to Metrics.playerDrop +- [ ] track resource download times? + +- [ ] fazer validação dos dados do banco usando a versão compilada do zod + - acho que tem essa ferramenta no playground do https://github.com/sinclairzx81/typebox + +- [ ] locale file optimization - build 8201 and above +- [ ] easter egg??? + - some old music? https://www.youtube.com/watch?v=nNoaXej0Jeg + - Having the menu protest when someone fixes their car too much in a short time span? + - Zeus or crazy thunder effects when someone spams no clip? + - Increasingly exciting 'tada' sounds when someone bans multiple people in a short time span? (ban 1: Ooh.. / ban 2: OOooh.. / ban 3: OOOOOHHH!) + +- [ ] remove more pending DynamicNewBadge/DynamicNewItem (settings page as well) +- [ ] reevaluate globals?.tmpSetHbDataTracking +- [ ] fix socket.io multiple connections - start a single instance when page opens, commands to switch rooms +- [ ] switch tx to lua54 + +- [ ] build: generate fxmanifest files list dynamically + - node 22 use fs.glob +- [ ] fix remaining imgur links +- [ ] update docs on development? +- [ ] rename to de-capitalize components files that have multiple exports +- [ ] instead of showing cfg errors when trying to start server, just show "there are errors in your cfg file" and link the user to the cfg editor page +- [ ] break down the discord /info command in /info and /admininfo? +- [ ] enable nui strict mode + - check if the menu -> tx -> iframe -> legacy iframe is not working + - check both canary and prod builds +- [ ] Implement `GET_RESOURCE_COMMANDS` available in b12739 + - Ref: https://github.com/citizenfx/fivem/pull/3012 +- [ ] cfg parser: resource relative read errors shouldn't trigger warnings +- [ ] check again for the need of lazy loading +- [ ] put in server name in the login page, to help lost admins notice they are in the wrong txAdmin +- [ ] Try to replace all the host stats/data with stuff from the SI lib (eg `systeminformation.processLoad()`). + - They are already using GWMI: https://github.com/sebhildebrandt/systeminformation/issues/616 + - Pay attention to the boot and shutdown comments + - NOTE: keep in mind the processor time vs utility difference: + - https://github.com/citizenfx/fivem/commit/034acc7ed47ec12ca4cfb64a83570cad7dde8f0c + - https://learn.microsoft.com/en-us/troubleshoot/windows-client/performance/cpu-usage-exceeds-100 + - NOTE: Old ref: + - update stuff that requires WMIC to use PS command directly + - issue: https://github.com/tabarra/txAdmin/issues/970#issuecomment-2308462733 + - new lib, same dev: https://www.npmjs.com/package/pidusage-gwmi + - https://learn.microsoft.com/en-us/powershell/scripting/learn/ps101/07-working-with-wmi?view=powershell-7.2 + +- After Node 22: + - check all `.npm-upgrade.json` for packages that can now be updated + - Use `/^\p{RGI_Emoji}$/v` to detect emojis + - ref: https://v8.dev/features/regexp-v-flag + - remove `unicode-emoji-json` from dependencies + - update cleanPlayerNames + - it will support native bindings, so this might work: + - https://www.npmjs.com/package/fd-lock + - change deployer and some other path manipulations to use `path.matchesGlob` + - replace all `global.*` to `globalThis.*` + - use `@tsconfig/node22` + + +- [ ] Migrate all log routes +- [ ] Add download modal to log pages + +- [ ] Migrate freecam to use IsRawKeyPressed instead of the GTA references + +- [ ] Playerlist: implement basic tag system with filters, sorting and Fuse.js + - the filter dropdown is written already, check `panel/src/layout/playerlistSidebar/Playerlist.tsx` + - when filterString is present, disable the filter/sort drowdown, as it will show all results sorted by fuse.js + - might be worth to debounce the search + - add tags to the players page search box (separate dropdown?) + - maybe https://shadcnui-expansions.typeart.cc/docs/multiple-selector + +- [ ] create new "Remove Player Data" permission which would allow to delete bans/warns, players and player identifiers + - Ref: https://github.com/tabarra/txAdmin/issues/751 + +- [ ] maybe use [this lib](https://www.npmjs.com/package/ntp-time-sync) to check for clock skew so I can remove the complexity of dealing with possible desync between core and ui on player modal, scheduler, etc; + - even better: clients2.google.com/time/1/current - there are alternatives +- [ ] slide gesture to open/close the sidebars on mobile +- [ ] new restart schedule in status card +- [ ] ask framework owners to use `txAdmin-locale` + + + +======================================================================= + + +## Next Page Changes: +### CFG Editor: +- multiple cfg editors +- add backup file to txdata, with the last 100 changes, name of the admin and timestamp + +### Setup: +- don't ask for server data location, list txData subfolders and let the user pick or specify +- don't ask for cfg location, assume server.cfg and let the user change + +### Master Actions: +- reset fxserver - becomes server add/remove/edit, or just an option in settings -> fxserver +- clean database - "bulk changes" button at the players page +- revoke whitelists - button to whitelist pages + +### Admin manager: +- stats on admins + - total count of bans/warns + - counts of bans/warns in the last 7, 14, 28d + - revocation % + - bans/warns % + +### Resources: +- release v1: + - should be stateful, with websocket + - layout inspired in code editors + - left sidebar with resource folders, no resources, with buttons to start/stop/restart/etc + - search bar at the top, searches any folder, has filters + - filters by default, running, stopped + - main content will show the resources of the selected folder OR "recently added" +- release v2: + - by default show only "recently added" resources + - each resoruce need to have: + - warning for outdated, button to update + - warning for script errors + - performance stats + - option to add/remove from auto boot + - option to auto restart on change (dev mode) + - button to see related insights (http calls, events, etc?) + +### Whitelist: +- remove the wl pending join table +- add a "latest whitelists" showing both pending and members (query players + pending and join tables) +- don't forget to keep the "add approval" button +- bulk actions button + - bulk revoke whitelist + +### Action Modal: +- feat requests: + - be able to delete bans/warns with new permission (Issue #910) + - top server asked for the option to edit ban duration (expire now / change) + - Thought: need to add an edit log like the one we have for player notes + - Thought: maybe we could use some dedicated icons for Expired, Edited, Revoked + +### Server Insights page ideas: +- resource load times +- resource streamed assets +- biggest events, or resources kbps out? something to help see which resource is bottlenecking the network + - apparently this can be done in scheduler quite easily by modifying the definition of `TriggerClientEvent` +- http requests (grouped by resource, grouped by root domain or both?) + - https://publicsuffix.org/list/ + - https://www.npmjs.com/search?q=effective%20domain + - https://www.npmjs.com/package/parse-domain + - https://www.npmjs.com/package/tldts +- performance chart with ram usage +- player count (longer window, maybe with some other data) + - show the player count at the peaks +- histogram of session time +- chart of new players per day +- top players? +- map heatmap?! +- player disconnect reasons +- something with server log events like chat messages, kills, leave reasons, etc? +- we must find a way to show player turnover and retention, like % that come back, etc + +======================================================================= + + + +### Improved scheduler precision +Talvez mudar a abordagem pra ser uma array e toda vez que a distância até o primeiro item for zero, executar a ação e dar um shift nos valores? +Exemplo: +```js +[ + {time: "12:00", temp: false, skipped: false}, + {time: "18:00", temp: false, skipped: false}, + {time: "22:00", temp: false, skipped: false}, +] +``` +Se a distância pro [0] for <= 0, executar restart e jogar o 12:00 pro final da array + +```js +function scheduleNextExecution() { + const now = new Date(); + const delay = 60 * 1000 - (now.getSeconds() * 1000 + now.getMilliseconds()) + 1000; + + setTimeout(() => { + yourFunction(); // replace this with your function + scheduleNextExecution(); + }, delay); +} + +function yourFunction() { + console.log('Function fired at', new Date()); +} + +scheduleNextExecution(); + +``` +https://www.npmjs.com/search?q=timer +https://www.npmjs.com/search?ranking=popularity&q=scheduler +https://www.npmjs.com/package/node-schedule + +> user report +> canceled 18:00 for a 20:00 restart and it wont let me change to 20:00 +problema: as vezes querem adiar um restart das settings, mas não é possível + + + +======================================================================= + +### Adaptive cards system +- Does not require the new ace system or the API +- Resources can register their adaptive cards interface which will show in the tx nui main tab, or as a player card tab +- The resources add a `ui_cards` definition to their `fxmanifest.lua` which is scanned by txadmin +- When an admin clicks on the button added, it will send a event through stdin to the tx resource which will verify caller and then call the resource export with the required context (eg. player id, admin name, etc). The exported function returns an adaptive card which is sent to txAdmin through fd3. +- This allows for resources to add their own UI to txAdmin, which supports buttons, inputs, etc +- cfx reference: `ext/cfx-ui/src/cfx/apps/mpMenu/parts/LegacyConnectingModal/AdaptiveCardPresenter/AdaptiveCardPresenter.tsx` + +```lua +ui_cards 'list' { + ['playerInfo'] = { + title = 'RP Info', + type = 'player', --show in player card + }, + ['generalStatsNui'] = { + title = 'RP Stats', + type = 'mainmenu', --show in nui main menu + }, + ['generalStatsWeb'] = { + title = 'RP Stats', + type = 'web', --show in the web panel + }, +} +``` + + +### New database alternatives: +Databases that i didn't check yet: +https://github.com/indradb/indradb +https://github.com/erikgrinaker/toydb +https://github.com/skytable/skytable +https://github.com/meilisearch/meilisearch +https://github.com/redwood/redwood +https://github.com/arangodb/arangodb +https://github.com/duckdb/duckdb + + + + +### txAdmin API/integrations: +- ban/warn/whitelist + revoke action: probably exports with GetInvokingResource() for perms +- get player info (history, playtime, joindate, etc): state bags +- events: keep the way it is +> Note: confirm with bubble +> Don't forget to add a integrations doc page + to the readme +> for menu and internal stuff to use token-based rest api: ok, just make sure to use the webpipe proxy +> for resource permissions, use resource.* ace thing, which also works for exports + +ps.: need to also include the external events reporting thing + + +### Admin ACE sync: +NOTE: Dec/2023 - why even bother?! Current system works, and we can exports the player permissions via state bags or whatever + +On server start, or admins permission change: +- write a `txData//txAcePerms.cfg` with: + - remove_ace/remove_principal to wipe old permissions (would need something like `remove_ace identifier.xxx:xx txadmin.* any`) + - or just `remove_ace identifier.xxx:xx txadmin.*` which would remove all aces, for all subscopes + - add_ace/add_principal for each admin +- stdin> `exec xxx.cfg; txaBroadcast xxxxx` + +- We should be able to get rid of our menu state management, mainly the part that sends to lua what are the admin ids when something changes +To check of admin perm, just do `IsPlayerAceAllowed(src, 'txadmin.xxxxxx')` +> Don't use, but I'll leave it saved here: https://github.com/citizenfx/fivem/commit/fd3fae946163e8af472b7f739aed6f29eae8105f +- need to find a way to protect against scripts, cfg/console changing these aces +- would be cool to have a `SetProtectedMonitorAces(table)` native dedicated to txadmin to set every admin/resource ace perms + +### Easy way of doing on/off duty scripts: +- NOTE: oct 2024 - the idea below changed a bit because of the initial player data, which should have the player admin status +- the current ones out there exist by abusing the auth event: + - `TriggerEvent("txcl:setAdmin", false, false, "you are offduty")` +- provide an export to register a resource as a onduty validator +- when an auth sets place, reach out to the registered export to validate if someone should get the admin perms or not + - if not, return an error message displaying a `[resource] ` as the fail reason +- provide an export to trigger the admin auth of any player +- provide an export to trigger a setAdmin removing the perms + + +### Reporting system +- Definitely worth to do discord integration, with good embeds (with buttons?) +- Need to show both ingame and on web +- Automatically pull all logs from a player, and the world log from around that time +- Notify admins ingame + +### txBanana +- code prototype with ItsANoBrainer#1337 (https://github.com/tabarra/txBanana) +- keybind to toggle gun (grab or put away) +- when you point at player, show above head some info +- when you "shoot" it will open the player menu and hopefully fire a laser or something +- when you right click, slap player (ApplyDamageToPed 5 damage + small psysichs push up and x+y random) + +NOTE: better to use some effect in game, it will likely sync between players +https://freesound.org/search/?q=toy+gun&f=&s=score+desc&advanced=0&g=1 +https://freesound.org/browse/tags/laser/?page=5#sound + https://freesound.org/people/nsstudios/sounds/344276/ + https://freesound.org/people/HadaHector/sounds/446383/ + https://freesound.org/people/unfa/sounds/193427/ + + +======================================= + + +## References + +### Locale +https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes + +### RedM stuff +https://github.com/femga/rdr3_discoveries +https://vespura.com/doc/natives/ + +### Ptero stuff +https://github.com/pelican-eggs/games-standalone/blob/main/gta/fivem/egg-five-m.json +https://github.com/pelican-eggs/yolks/blob/master/oses/debian/Dockerfile +https://github.com/pelican-eggs/yolks/commit/57e3ef41ed05109f5e693d2e0d648cf4b161f72c + + +### New UI stuff +https://www.tremor.so/blocks/landing-zone <<< boa inspiração de componentes +https://stacksorted.com/ +https://auto-animate.formkit.com + +### Theming stuff: +https://palettte.app/ +https://uicolors.app/create +https://www.tailwindshades.com/ +https://contrast.tools/?tab=apca +https://atmos.style/contrast-checker +https://realtimecolors.com/ +https://www.learnui.design/blog/color-in-ui-design-a-practical-framework.html +https://www.refactoringui.com/previews/building-your-color-palette +https://www.smashingmagazine.com/2021/07/hsl-colors-css/ +Base for themes: https://daisyui.com/docs/themes/ +Custom theme creator stuff: +- https://labs.mapbox.com/react-colorpickr/ +- https://react-spectrum.adobe.com/react-spectrum/ColorSlider.html#creating-a-color-picker +- https://www.peko-step.com/en/tool/hslrgb_en.html +cfxui colors: +- ext/cfx-ui/src/cfx/apps/mpMenu/styles/themes/fivem-dark.scss +- ext/cfx-ui/src/cfx/styles/_ui.scss + + +======================================= + +## CLTR+C+V +```bash +# repo stuff +npx knip +npm-upgrade +bunx madge --warning --circular --ts-config="core/tsconfig.json" core/index.ts + +# react renderin visualizer + + +# other stuff +con_miniconChannels script:monitor* +con_miniconChannels script:runcode ++setr txAdmin-debugMode true +nui_devtools mpMenu +window.invokeNative('changeName', '\u{1160}\u{3164}'); + +# hang fxserver (runcode) +const duration = 60_000; +console.log(`hanging the thread for ${duration}ms`); +Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, duration); +console.log('done'); + +setInterval(() => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2); +}, 0); + +# stress http post +seq 50000 | parallel --max-args 0 --jobs 10000 "curl -s http://xxxxxxxxxxx:40120/ -d @braces768kb.json --header \"Content-Type: application/json\" > /dev/null" + +# check external chart +cdt +cd web/public/ +curl -o svMain.json http://localhost:40120/chartData/svMain + +# check changes +git diff --unified=0 --no-color | grep '^+' | grep --color 'NOTE' +git diff --unified=0 --no-color | grep '^+' | grep --color 'TODO' +git diff --unified=0 --no-color | grep '^+' | grep --color 'FIXME' +git diff --unified=0 --no-color | grep '^+' | grep --color '!NC' +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..9bf6ac7 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,126 @@ +# txAdmin Development +If you are interested in development of txAdmin, this short guide will help setup your environment. +Before starting, please make sure you are familiar with the basics of NodeJS & ecosystem. +> **Note:** This guide does not cover translations, [which are very easy to do!](./translation.md) + + +## Requirements +- Windows, as the builder doesn't work for other OSs; +- NodeJS v22.9 or newer; +- FXServer; + + +## Project Structure +- `core`: Node Backend & Modules. This part is transpiled by `tsc` and then bundled with `esbuild`; + - `boot`: Code used/triggered during the boot process. + - `deployer`: Responsible for deploying new servers. + - `lib`: Collection of stateles utils, helpers and business logic. + - `modules`: The classes that compose the txAdmin instance, they are stateful, provide specific functionalities and are interconnected with each other. + - `routes`: All the web routes, contain all the logic referenced in the HTTP router. + - `testing`: Contains top-level testing utilities. +- `resource`: The in-game resource that runs under the `monitor` name. These files will be synchronized with the deploy path when running the `dev:main` npm script; +- `menu`: React source code for txAdmin's NUI Menu. It is transpiled & built using Vite; +- `web`: Legacy SSR templates & static assets used for the txAdmin's web panel. It uses EJS as templating engine, and will soon be deprecated in favor of `panel`; +- `panel`: The new UI built with React and Vite; +- `scripts`: The scripts used for development only; +- `shared`: Stuff used across multiple workspaces like small functions and type definitions. + + +## Preparing the environment +1. First, clone the txAdmin repository into a folder outside the fxserver directory; +```sh +git clone https://github.com/tabarra/txAdmin +``` +2. Install dependencies & prepare commit hook; +```sh +# In your root folder run the following +npm install +npm run prepare +``` +3. At the root of the project, create a `.env` file with `TXDEV_FXSERVER_PATH` pointing to the path of your FXServer folder. +``` +TXDEV_FXSERVER_PATH='E:/FiveM/10309/' +``` + + +## Development Workflows + +### Core/Panel/Resource +This workflow is controlled by `scripts/build/*`, which is responsible for: +- Watching and copying static files (resource, docs, license, entry file, etc) to the deploy path; +- Watching and re-transpiling the core files, and then bundling and deploying it; +- Run FXServer (in the same terminal), and restarting it when the core is modified (like `nodemon`, but fancy). + +In dev mode, core will redirect the panel `index.html` to use Vite, so you first need to start it, and only then start the builder: +```sh +# run vite +cd panel +npm run dev + +# In a new terminal - run the builder +cd core +npm run dev +``` + +### NUI Menu +```sh +cd nui + +#To run Vite on game dev mode: +npm run dev + +#To run Vite on browser dev mode: +npm run browser +``` +Keep in mind that for every change you will need to restart the `monitor` resource, and unless you started the server with `+setr txAdmin-debugMode true` txAdmin will detect that as a crash and restart your server. +Also, when running in game mode, it takes between 10 and 30 seconds for the vite builder to finish for you to be able to restart the `monitor` resource ingame. + +### Resource event naming rules: +- The event prefix must be `tx:` indicating where it is registered. +- Events that request something (like permission) from the server starts with `txsv:req`. +- Events can have verbs like `txsv:checkAdminStatus` or `txcl:setServerCtx`. +- Since most events are menu related, scoping events to menu is not required. + +### Testing & Building +The building process is normally done in the GitHub Action workflow only, but if you _must_ build it locally, that can be done with the command below. The output will be on the `dist/` folder. +```sh +npm run test --workspaces +GITHUB_REF="refs/tags/v9.9.9" npm run build +``` +> FIXME: add linting & typechecking back into the workflow above + + +## Notes regarding the Settings system +- `config.json` now only contains the changed, non-default values. +- `DEFAULT_NULL` is only for values that cannot and should not have defaults, like `fxRunner.dataPath`, `discordBot.token`, etc. Note how `fxRunner.cfgPath` does have a default. +- All schemas must have a default, even if `null`. +- The objective of the `schema.fixer` is to fix invalid values, not apply defaults for missing values. +- The `schema.fixer` is only used during boot, not during any saves. +- Only use `SYM_FIXER_FATAL` for settings that are very important, so txAdmin rather not boot than to boot with an unexpected config. +- The objective of the schema is to guarantee that the values are of the correct type (shouldn't cause TypeErrors), but does not check anything dynamic like existence of files, or anything that goes beyond one schema (eg. if bot enabled, token is required). +- Validator transformers are only to "polish" the value, like removing duplicates and sorting values, not to fix invalid values. + + +## Note regarding the Legacy UI + +**⚠Warning: The /web/ ui is considered legacy and will be migrated to /panel/.** + +**DO NOT** Modify `css/coreui.css`. Either do a patch in the `custom.css` or modify the SCSS variables. +This doc is a reference if you are trying to re-build the `css/coreui.css` from the SCSS source. +The only thing I changed from CoreUI was the `aside-menu` size from 200px to 300px in `scss/_variables.scss : $aside-menu-width`. +You can find the other variable names in `node_modules/@coreui/coreui/scss/coreui`. + +```bash +git clone https://github.com/coreui/coreui-free-bootstrap-admin-template.git coreui +cd coreui +npm i + +# If you want to make sure you used the same version of CoreUI +git checkout 0cb1d81a8471ff4b6eb80c41b45c61a8e2ab3ef6 + +# Edit your stuff and then to compile: +npx node-sass --output-style expanded --source-map true --source-map-contents true --precision 6 src/scss/style.scss src/css/style.css +``` + +Then copy the `src/css/style.css` to txAdmin's folder. + diff --git a/docs/discord-status.md b/docs/discord-status.md new file mode 100644 index 0000000..390a56c --- /dev/null +++ b/docs/discord-status.md @@ -0,0 +1,101 @@ +# Custom Discord Status Embed + +![example discord embed](https://forum-cfx-re.akamaized.net/original/5X/b/c/e/b/bceb61b4b506f0bfcc7429695e3e4c1699845605.png) + +Starting in v5.1, **txAdmin** now has a Discord Persistent Status Embed feature. +This is a Discord embed that txAdmin will update every minute, and you can configure it to display server status, and any other random thing that you can normally do with a Discord embed. +To add the embed, type `/status add` on a channel that the txAdmin bot has Send Message permission. + +To modify the embed, navigate to `txAdmin > Settings > Discord Bot`, and click on the two JSON editor buttons. +> **Important:** If you are having issues with the JSON encoding, we recommend you use [jsoneditoronline.org](https://jsoneditoronline.org/) to modify your JSON. + +## Placeholders +To add dynamic data to the embed, you can use the built-in placeholders, which txAdmin will replace at runtime. + +- `{{serverCfxId}}`: The Cfx.re id of your server, this is tied to your `sv_licenseKey` and detected at runtime. +- `{{serverJoinUrl}}`: The direct join URL of your server. Example: `https://cfx.re/join/xxxxxx`. +- `{{serverBrowserUrl}}`: The FiveM Server browser URL of your server. Example: `https://servers.fivem.net/servers/detail/xxxxxx`. +- `{{serverClients}}`: The number of players online in your server. +- `{{serverMaxClients}}`: The `sv_maxclients` of your server, detected at runtime. +- `{{serverName}}`: This is the txAdmin-given name for this server. Can be changed in `txAdmin > Settings > Global`. +- `{{statusColor}}`: A hex-encoded color, from the Config JSON. +- `{{statusString}}`: A text to be displayed with the server status, from the Config JSON. +- `{{uptime}}`: For how long is the server online. Example: `1 hr, 50 mins`. +- `{{nextScheduledRestart}}`: String with when is the next scheduled restart. Example: `in 2 hrs, 48 mins`. + + +## Embed JSON: +This is the JSON of the Embed that will be sent to Discord. +This MUST be a valid Discord embed JSON, and we recommend you use a tool like [discohook.org](https://discohook.org/) to edit the embed. To do so, at the bottom click `JSON Data Editor` and paste the JSON inside the `embeds: [...]` array. +On the JSON, you don't need to set `color` or `footer` as txAdmin will replace those. You can modify the color in the config json, but the footer is generated by txAdmin. + +> **Important:** At save time, txAdmin cannot validate if the embed is correct or not without sending it to the Discord API, so if it does not work check the `System Logs` page in txAdmin and see if there are any errors related to it. + +```json +{ + "title": "{{serverName}}", + "url": "{{serverBrowserUrl}}", + "description": "You can configure this embed in `txAdmin > Settings > Discord Bot`, and edit everything from it (except footer).", + "fields": [ + { + "name": "> STATUS", + "value": "```\n{{statusString}}\n```", + "inline": true + }, + { + "name": "> PLAYERS", + "value": "```\n{{serverClients}}/{{serverMaxClients}}\n```", + "inline": true + }, + { + "name": "> F8 CONNECT COMMAND", + "value": "```\nconnect 123.123.123.123\n```" + }, + { + "name": "> NEXT RESTART", + "value": "```\n{{nextScheduledRestart}}\n```", + "inline": true + }, + { + "name": "> UPTIME", + "value": "```\n{{uptime}}\n```", + "inline": true + } + ], + "image": { + "url": "https://forum-cfx-re.akamaized.net/original/5X/e/e/c/b/eecb4664ee03d39e34fcd82a075a18c24add91ed.png" + }, + "thumbnail": { + "url": "https://forum-cfx-re.akamaized.net/original/5X/9/b/d/7/9bd744dc2b21804e18c3bb331e8902c930624e44.png" + } +} +``` + +## Embed Config JSON: +The configuration of the embed, where you can change the status texts, as well as the embed color. +You can set up to 5 buttons. +For emojis, you can use an actual unicode emoji character, or the emoji ID. +To get the emoji ID, insert it into discord, and add `\` before it then send the message to get the full name (eg `<:txicon:1062339910654246964>`). + +```json +{ + "onlineString": "🟢 Online", + "onlineColor": "#0BA70B", + "partialString": "🟡 Partial", + "partialColor": "#FFF100", + "offlineString": "🔴 Offline", + "offlineColor": "#A70B28", + "buttons": [ + { + "emoji": "1062338355909640233", + "label": "Connect", + "url": "{{serverJoinUrl}}" + }, + { + "emoji": "1062339910654246964", + "label": "txAdmin Discord", + "url": "https://discord.gg/txAdmin" + } + ] +} +``` diff --git a/docs/env-config.md b/docs/env-config.md new file mode 100644 index 0000000..0b33c83 --- /dev/null +++ b/docs/env-config.md @@ -0,0 +1,175 @@ +# Environment Configuration + +Starting from txAdmin v8.0.0, **you can now customize txAdmin through `TXHOST_*` environment variables** documented in this page. +Those configurations are usually required for Game Server Providers (GSPs) and advanced server owners, and allow them to force txAdmin and FXServer to use specific ports/interfaces, customize the location of the txData directory, force a max player slot count, etc. + +> [!WARNING] +> The `txAdminPort`, `txAdminInterface`, and `txDataPath` ConVars, as well as the `txAdminZapConfig.json` file are now considered **deprecated** and will cease to work in an upcoming update. +> If the new and old configs are present at the same time, the new one will take priority. +> Setting `TXHOST_IGNORE_DEPRECATED_CONFIGS` to `true` will disable the old config and silence the respective warnings. + +> [!IMPORTANT] +> All ConVars but `serverProfile` became `TXHOST_*` env vars, and you should avoid using it as the concept of txAdmin profiles will likely be deprecated soon. +> If setting multiple servers, **we strongly encourage you to set up separate txDatas for your servers.** As otherwise the `admins.json` file will conflict. + +> [!CAUTION] +> Although the configuration below is useful for Game Server Providers (GSPs), **this is in no way, shape, or form an endorsement or permission for anyone to become an unauthorized GSP**. +> Please refer to the FiveM [Creator Platform License Agreement](https://fivem.net/terms). + + +## Setup + +The specific way to set up those variables vary from system to system, and there are usually multiple ways even within the same system. But these should work for most people: +- Windows: + - Edit your existing `start__.bat` to add the `set VAR_NAME=VALUE` commands before the `./<...>/FXServer.exe` line. + - Alternatively, you can create a `env.bat` file with the `set` commands, then start your server with `call env.bat && FXServer.exe`, which will run the `env.bat` before running `FXServer.exe`. +- Linux: + - Edit your existing `run.sh` to add the `export VAR_NAME=VALUE` commands before the `exec $SCRIPTPATH/[...]` line. + - Alternatively, you can create a `env.sh` file with the `export` commands, then start your server with `source env.sh && ./run.sh` +- Docker: + - Create a `.env` file with the vars like: `VAR_NAME=VALUE` + - Load it using the `--env-file=.env` flag in your docker run command. +- Pterodactyl: You will likely need to contact your GSP or edit the "egg" being used. + +> [!TIP] +> For security reasons, those environment variables **should** be set specifically for the boot process and **must not** be widely available for other processes. +> If they are to be written to a file (such as `.env`), the file should only be readable for the txAdmin process and not its children processes. + + +## Variables Available + +### General +- **TXHOST_DATA_PATH** + - **Default value:** + - **Windows:** `/../txData` — sits in the folder parent of the folder containing `fxserver.exe` (aka "the artifact"). + - **Linux:** `/../../../txData` — sits in the folder that contains your `run.sh`. + - The path to the txData folder, which contains the txAdmin logs, configs, and data. This is also the default place suggested for deploying new servers (as a subfolder). It is usually set to `/home/container` when running on Pterodactyl. + - NOTE: This variable takes priority over the deprecated `txDataPath` ConVar. +- **TXHOST_GAME_NAME** + - **Default value:** _undefined_. + - **Options:** `['fivem','redm']`. + - Restricts to only running either FiveM or RedM servers. + - The setup page will only show recipes for the game specified +- **TXHOST_MAX_SLOTS** + - **Default value:** _undefined_. + - Enforces the server `sv_maxClients` is set to a number less than or equal to the variable value. +- **TXHOST_QUIET_MODE** + - **Default value:** `false`. + - If true, do not pipe the FXServer's stdout/stderr to txAdmin's stdout, meaning that you will only be able to see the server output by visiting the txAdmin Live Console page. + - If enabled, server owners won't be able to disable it in `txAdmin -> Settings -> FXServer` page. + - NOTE: We recommend that Game Server Providers enable this option. +- **TXHOST_API_TOKEN** + - **Default value:** _undefined_. + - **Options:** `disabled` or a string matching `/^[A-Za-z0-9_-]{16,48}$/`. + - The token to be able to access the `/host/status` endpoint via the `x-txadmin-envtoken` HTTP header, or the `?envtoken=` URL parameter. + - If token is _undefined_: endpoint disabled & unavailable. + - If token is string `disabled`: endpoint will be publicly available without any restrictions. + - If token is present: endpoint requires the token to be present. + +### Networking +- **TXHOST_TXA_URL:** + - **Default value:** _undefined_. + - If present, that is the URL that will show on txAdmin as its public URL on the boot message. + - This is useful for when running inside a container using `0.0.0.0:40120` as interface/port. +- **TXHOST_TXA_PORT** + - **Default value:** `40120`. + - Which TCP port txAdmin should bind & listen to. + - This variable cannot be `30120` to prevent user confusion. + - NOTE: This variable takes priority over the deprecated `txAdminPort` ConVar. +- **TXHOST_FXS_PORT** + - **Default value:** _undefined_. + - Forces the FXServer to bind to the specified port by enforcing or replacing the `endpoint_add_*` commands in `server.cfg`. + - This variable cannot be `40120` to prevent user confusion. +- **TXHOST_INTERFACE** + - **Default value:** `0.0.0.0`. + - Which interface txAdmin will bind and enforce FXServer to bind to. + - NOTE: This variable takes priority over the deprecated `txAdminInterface` ConVar. + +### Provider +- **TXHOST_PROVIDER_NAME** + - **Default value:** `Host Config`. + - A short name to identify this hosting provider. + - The value must be between 2 and 16 characters long and can only contain letters, numbers, underscores, periods, hyphens, and spaces. Must not start or end with special chars, and must not have two subsequent special chars. + - This will usually show in warnings regarding configuration or user actions that go against any `TXHOST_*` variable. +- **TXHOST_PROVIDER_LOGO** + - **Default value:** _undefined_. + - The URL for the hosting provider logo which will appear at the login page. + - The maximum image size is **224x96**. + - You can create a theme-aware URL by including a `{theme}` placeholder in the URL, which will be replaced by `light` or `dark` at runtime, depending on the theme being used, eg. `https://.../logo_{theme}.png`. + + +### Defaults +- **TXHOST_DEFAULT_DB** + - **Default value:** _undefined_. + - To be used only for auto-filling the config steps when deploying a new server and can be overwritten during manual deployment or after that by modifying their `server.cfg`. + - All the values will be considered strings, and no validation will be done to them. +- **TXHOST_DEFAULT_CFXKEY** + - **Default value:** _undefined_. + - To be used only for auto-filling the config steps when deploying a new server and can be overwritten during manual deployment or after that by modifying their `server.cfg`. + - The value should be a `cfxk_xxxxxxxxxxxxxxxxxxxxx_xxxxxx` key, which individuals can obtain in the [Cfx.re Portal](https://portal.cfx.re/). + - This is also very useful for developers who need to go through the txAdmin Setup & Deployer frequently. +- **TXHOST_DEFAULT_ACCOUNT** + - **Default value:** _undefined_. + - This variable is used by GSPs for setting up an `admins.json` automatically on first boot. + - It contains a username, FiveM ID, and password (as bcrypt hash) separated by colons: + - **Username:** If a FiveM ID is provided, this must match the username of the FiveM account used by the second parameter. Otherwise, it accepts any username valid for txAdmin accounts (same rule used by the [FiveM Forum](https://forum.cfx.re/)). + - **FiveM ID:** The numeric ID of a FiveM account, same as the one visible as in-game identifier. For instance, the value should be `271816` for someone with the in-game identifier `fivem:271816`. When set, the admin will be able to login using the Cfx.re button instead of requiring a password. + - **Password:** A bcrypt-hashed password to be used as the "backup password". + - The account must at least have either FiveM ID or password set. If an account has no password, on first access the owner will be queried to change their password. + - Examples: + - `tabarra:271816` + - `tabarra:271816:$2a$11$K3HwDzkoUfhU6.W.tScfhOLEtR5uNc9qpQ685emtERx3dZ7fmgXCy` + - `tabarra::$2a$11$K3HwDzkoUfhU6.W.tScfhOLEtR5uNc9qpQ685emtERx3dZ7fmgXCy` + +> [!NOTE] +> More variables are still being considered, like options to configure reverse proxy IPs, security locks, etc. +> Please feel free to provide feedback and suggestions. + + +## Examples + +Migrating a dev server using an old `start.bat`: +```diff + @echo off ++set TXHOST_DATA_PATH=E:\FiveM\txData-dev ++set TXHOST_TXA_PORT=40125 +-FXServer.exe +set serverProfile "server2" +set txAdminPort "40125" ++FXServer.exe + pause +``` + +Setting up a dev server on Windows with a `env.bat` file: +```batch +@REM Deployer defaults +set TXHOST_DEFAULT_CFXKEY=cfxk_11hIT156dX0F0ekFVsuda_fQ0ZYS +set TXHOST_DEFAULT_DBHOST=127.0.0.1 +set TXHOST_DEFAULT_DBPORT=3306 +set TXHOST_DEFAULT_DBUSER=root +set TXHOST_DEFAULT_DBPASS=4b6c3_1919_ab04df6 +set TXHOST_DEFAULT_DBNAME=coolrp_dev + +@REM Prevent conflicting with main server +set TXHOST_DATA_PATH=C:/test-server/txData +set TXHOST_FXS_PORT=30125 +set TXHOST_MAX_SLOTS=8 +``` + +Setting a GSP configuration on Docker with a `.env` file: +```dotenv +# So txAdmin suggests the right path during setup +TXHOST_DATA_PATH=/home/container + +# Deployer defaults +TXHOST_DEFAULT_DBHOST=123.123.123 +TXHOST_DEFAULT_DBPORT=3306 +TXHOST_DEFAULT_DBUSER=u538241 +TXHOST_DEFAULT_DBPASS=4b6c3_1919_ab04df6 +TXHOST_DEFAULT_DBNAME=db538241 + +# Customer FiveM-linked account +TXHOST_DEFAULT_ACCOUNT=tabarra:271816 + +# Provider details +TXHOST_PROVIDER_NAME=ExampleHosting +TXHOST_PROVIDER_LOGO=https://github.com/tabarra/txAdmin/raw/master/docs/banner.png +``` diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..2b110af --- /dev/null +++ b/docs/events.md @@ -0,0 +1,197 @@ +# Events Broadcasted + +txAdmin sends **server events** to allow for integration of some functionalities with other resources. +The event name will be `txAdmin:events:` and the first (and only) parameter will be a table that may contain relevant data. + +> [!IMPORTANT] +> Do not fully rely on events where consistency is key since they may be executed while the server is not online therefore your resource would not be notified about it. For instance, while the server is stopped one could whitelist or ban player identifiers. + + +## Server-related Events + +### txAdmin:events:announcement +Broadcasted when an announcement is made using txAdmin. +If you want to hide the default notification, you can do that in `txAdmin -> Settings -> Game -> Notifications`. +Event Data: +- `author`: The name of the admin or `txAdmin`. +- `message`: The message of the broadcast. + +### txAdmin:events:serverShuttingDown +Broadcasted when the server is about to shut down. +This can be triggered in a scheduled and unscheduled stop or restart, by an admin or by the system. +Event Data: +- `delay`: How many milliseconds txAdmin will wait before killing the server process. +- `author`: The name of the admin or `txAdmin`. +- `message`: The message of the broadcast. + + +### txAdmin:events:scheduledRestart +Broadcasted automatically `[30, 15, 10, 5, 4, 3, 2, 1]` minutes before a scheduled restart. +If you want to hide the default notification, you can do that in `txAdmin -> Settings -> Game -> Notifications`. +Event Data: +- `secondsRemaining`: The number of seconds before the scheduled restart. +- `translatedMessage`: The translated message to show on the announcement. + +Example usage on ESX v1.2: +```lua +ESX = nil +TriggerEvent('esx:getSharedObject', function(obj) ESX = obj end) + +AddEventHandler('txAdmin:events:scheduledRestart', function(eventData) + if eventData.secondsRemaining == 60 then + CreateThread(function() + Wait(45000) + print("15 seconds before restart... saving all players!") + ESX.SavePlayers(function() + -- do something + end) + end) + end +end) +``` + +### txAdmin:events:scheduledRestartSkipped +Broadcasted when an admin skips the next scheduled restart. +Event Data: +- `secondsRemaining`: The number of seconds before the previously scheduled restart. +- `temporary`: If it was a temporary scheduled restart or one configured in the settings page. +- `author`: The name of the admin that skipped the restart. + + +## Player-related Events + +### txAdmin:events:playerBanned +Broadcasted when a player is banned using txAdmin. +On update v5.0.0 the field `target` was replaced by `targetNetId` and `targetIds`. +Event Data: +- `author`: The name of the admin. +- `reason`: The reason of the ban. +- `actionId`: The ID of this action. +- `expiration`: The timestamp for this ban expiration, for `false` if permanent. Added in txAdmin v4.9. +- `durationInput`: xxx. Added in v5.0. +- `durationTranslated`: xxx or `null`. Added in v5.0. +- `targetNetId`: The netid of the player that was banned, or `null` if a ban was applied to identifiers only. Added in v5.0. +- `targetIds`: The identifiers that were banned. Added in v5.0. +- `targetHwids`: The hardware identifiers that were banned. Might be an empty array. Added in v6.0. +- `targetName`: The clean name of the banned player, or `identifiers` if ban was applied to ids only (legacy ban). Added in v5.0. +- `kickMessage`: The message to show the player as a kick reason. Added in v5.0. + +### txAdmin:events:playerDirectMessage +Broadcasted when an admin DMs a player. +If you want to hide the default notification, you can do that in `txAdmin -> Settings -> Game -> Notifications`. +Event Data: +- `target`: The id of the player to receive the DM. +- `author`: The name of the admin. +- `message`: The message content. + +### txAdmin:events:playerHealed +Broadcasted when a heal event is triggered for a player/whole server. +This is most useful for servers running "ambulance job" or other resources that keep a player unconscious even after the health being restored to 100%. +Event Data: +- `target`: The ID of the healed player, or `-1` if the entire server was healed. +- `author`: The name of the admin that triggered the heal. + +### txAdmin:events:playerKicked +Broadcasted when a player is kicked using txAdmin. +Note: starting on v8.0, the `target` parameter might be `-1`, and `dropMessage` was introduced. +Event Data: +- `target`: The ID of the player that was kicked, or `-1` if kicking everyone. +- `author`: The name of the admin. +- `reason`: The reason of the kick. +- `dropMessage`: The translated message the players will see when kicked. + +### txAdmin:events:playerWarned +Broadcasted when a player is warned using txAdmin. +If you want to hide the default notification, you can do that in `txAdmin -> Settings -> Game -> Notifications`. +Note: starting on v7.3, the `target` parameter was replaced by `targetNetId`, `targetIds`, and `targetName` to accomodate offline warns. +Event Data: +- `author`: The name of the admin. +- `reason`: The reason of the warn. +- `actionId`: The ID of this action. +- `targetNetId`: The netid of the player that was warned, or `null` if the target is not online. Added in v7.3. +- `targetIds`: The identifiers that were warned. Added in v7.3. +- `targetName`: The clean name of the player warned. Added in v7.3. + + +## Whitelist-related Events + +### txAdmin:events:whitelistPlayer +Broadcasted when a player is whitelisted, or has the whitelisted status revoked. +This event is only fired when the player is already registered, and is not related to whitelist requests or approved whitelists pending join. +Event Data: +- `action`: `added`/`removed`. +- `license`: The license of the player. +- `playerName`: The player display name. +- `adminName`: Name of the admin that performed the action. + +### txAdmin:events:whitelistPreApproval +Broadcasted when manually adding some identifier to the whitelist pre-approvals, meaning that as soon as a player with this identifier connects to the server, they will be saved to the database as a whitelisted player (without triggering `txAdmin:events:whitelistPlayer`). +This event is not gonna be broadcasted when a whitelist request is approved, for that use `txAdmin:events:whitelistRequest`. +This can be done in the Whitelist Page, or using the `/whitelist ` Discord bot slash command. +Event Data: +- `action`: `added`/`removed`. +- `identifier`: The identifier that was pre-approved (eg. `discord:xxxxxx`). +- `playerName?`: The player display name, except when action is `removed`. +- `adminName`: Name of the admin that performed the action. + +### txAdmin:events:whitelistRequest +Broadcasted whenever some event related to the whitelist requests happen. +Event Data: +- `action`: `requested`/`approved`/`denied`/`deniedAll`. +- `playerName?`: The player display name, except when action is `deniedAll`. +- `requestId?`: The request ID (eg. `Rxxxx`), except when action is `deniedAll`. +- `license?`: The license of the player/requester, except when action is `deniedAll`. +- `adminName?`: Name of the admin that performed the action, except when action is `requested`. + + +## Other Events + +### txAdmin:events:actionRevoked +Broadcasted when an admin revokes a database action (ex. ban, warn). +Event Data: +- `actionId`: The id of the player to receive the DM. +- `actionType`: The type of the action that was revoked. +- `actionReason`: The action reason. +- `actionAuthor`: The name of the admin that issued the action. +- `playerName`: name of the player that received the action, or `false` if doesn't apply. +- `playerIds`: Array containing all identifiers (ex. license, discord, etc.) this action applied to. +- `playerHwids`: Array containing all hardware ID tokens this action applied to. Might be an empty array. Added in v6.0. +- `revokedBy`: The name of the admin that revoked the action. + +### txAdmin:events:adminAuth +Broadcasted whenever an admin is authenticated in game, or loses the admin permissions. +This event is particularly useful for anti-cheats to be able to ignore txAdmin admins. +Event Data: +- `netid` (number): The ID of the player or `-1` when revoking the permission of all admins (forced reauth). +- `isAdmin` (boolean): If the player is an admin or not. +- `username?` (string): The txAdmin username of the admin that was just authenticated. + +### txAdmin:events:adminsUpdated +Broadcasted whenever a change happens to the list of admins (including their permissions and identifiers). +This event is used by the txAdmin resource to force admins to refresh their auth. +Event Data: array of NetIds of the admins online. + +### txAdmin:events:configChanged +Broadcasted when the txAdmin settings change in a way that could be relevant for the server. +Event Data: this event has no data. +At the moment, this is only used to signal the txAdmin in-game Menu if the configured language has changed, and can be used to easily test custom language files without requiring a server restart. + +### txAdmin:events:consoleCommand +Broadcasted whenever an admin sends a command through the Live Console. +Event Data: +- `author`: The txAdmin username of the admin that sent the command. +- `channel`: For now this will always be `txAdmin`, but in the future it might be `rcon` and `game` as well. +- `command`: The command that was executed. + +## Deprecated Events + +### txAdmin:events:playerWhitelisted +This event was deprecated on v5.0.0, and on v5.2.0 new events were added to replace this one. + +### txAdmin:events:healedPlayer +This event was deprecated on v8.0, and soon will stop being triggered. +Please use `txAdmin:events:playerHealed` instead. + +### txAdmin:events:skippedNextScheduledRestart +This event was deprecated on v8.0, and soon will stop being triggered. +Please use `txAdmin:events:scheduledRestartSkipped` instead. diff --git a/docs/feature-graveyard.md b/docs/feature-graveyard.md new file mode 100644 index 0000000..1ad6d6b --- /dev/null +++ b/docs/feature-graveyard.md @@ -0,0 +1,21 @@ +# Feature Graveyard +In txAdmin codebase, we try to keep things lean, this is one of the few reasons after one year of project, our code base is not *that* bad. +And as part of the process, we "retired" many features and parts of our code base, here is a relation of the majority of them and the reason why: + +- **Setup script:** Now everything is automatic when you start with a profile set in the convars; +- **Admin add script:** Now its done via the master account creation UI flow, and the Admin Manager page; +- **Config tester:** With the gained knowledge of the edge cases, it became way easier to implement better checks and actionable error messages on the settings page; +- **Resources injector:** With the integration with FiveM, our plans for it changed drastically. It may or may not come back, meanwhile it was removed to prevent issues; +- **Automatic cache cleaner:** This feature were created due to the vast number of requests, but in the end this "common knowledge" was based on misinformation, therefore it was removed since we don't actually need it [(more info)](https://forum.fivem.net/t/why-people-delete-the-server-cache-folder/573851); +- **SSL support:** With the rework of the entire web layer of txAdmin in preparation with the FiveM integration, we ended up removing this (tricky to implement) feature. As of 2024, we no longer believe that txAdmin is the correct place to deal with HTTPS encryption. If you want SSL, setup a self-hosted reverse proxy (nginx, caddy, etc), or preferably set up a [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/); +- **Experiments:** Well... not much to experience with right now; +- **Discord static commands:** I don't think anyone ever used it since they can do it with basically any other bot; +- **Set process priority:** Although it was quite requested in the beginning, people just don't seem to use it; +- **Menu Weed troll effect:** It was just too similar to the drunk effect one, not worth keeping; +- **Discord /status command:** Removed to give place for the persistent & auto-updated embed message; +- **Import bans from EasyAdmin, BanSQL, vMenu, vRP, el_bwh:** It was there for over a year, who wanted to migrate could have migrated already. Furthermore it is kinda easy write code to import it directly into the database JSON file; +- **Cfx.re proxy URL:** The `https://monitor-xxxxx.users.cfx.re/` reverse proxy URL has been deprecated due to the complexity it added to txAdmin while being used by only 1% of all txAdmin servers. +- **Host CPU/memory stats on sidebar:** That was not really that useful, and took precious sidebar space. + +Don't cry because they are gone. +Smile because they once existed :) diff --git a/docs/logs.md b/docs/logs.md new file mode 100644 index 0000000..3e9cf2c --- /dev/null +++ b/docs/logs.md @@ -0,0 +1,58 @@ +# Logging +In version v4.6.0, **txAdmin** added support for persistent logging with file rotate, meaning you will have an organized folder (`txData//logs/`) containing your log files up to a maximum size and number of days. + +> Note: player warn/ban/whitelist actions are not just stored in the Admin Logs, but also on the players database. + +## Admin Logs: +Contains log of administrative actions as well as some automated ones like server restarts, bans, warns, settings change, live console input and so on. It does not log the user IP unless if from an authentication endpoint. +- Recent Buffer: None. Methods will read the entire file. +- Interval: 7d +- maxFiles: false +- maxSize: false + +## FXServer Console Log: +Contains the log of everything that happens in the fxserver console (`stdin`, `stdout`, `stderr`). Any live console input is prefixed with `> `. +- Recent Buffer: 64~128kb +- Interval: 1d +- maxFiles: 7 +- maxSize: 5G + +## Server Logs: +Contains all actions that happen inside the server, for example player join/leave/die, chat messages, explosions, menu events, commands. Player sources are kept in the format `[mutex#id] name` where the mutex is an identifier of that server execution. If you search the file for a `[mutex#id]`, the first result will be the player join with all his identifiers available. +- Recent Buffer: 32k events +- Interval: 1d +- maxFiles: 7 +- maxSize: 10G + + +## Configuring Log Rotate +The log rotation can be configured, so you can choose to store more or less logs according to your needs. +To configure it, edit your `txData//config.json` and add an object inside `logger` with the key being one of `[admin, fxserver, server]`. Then add option keys according with the library reference: https://github.com/iccicci/rotating-file-stream#options + +Example: +```jsonc +{ + //... + "logger": { + "fxserver": { + "interval": "1d", + "maxSize": "2G", //max size of rotated files to keep + "maxFiles": 14 //max number of rotated files to keep + } + } + //... +} +``` + +To completely disable one of the log types, set its value to `false`. + +Example: +```jsonc +{ + //... + "logger": { + "server": false + } + //... +} +``` diff --git a/docs/menu.md b/docs/menu.md new file mode 100644 index 0000000..11f2339 --- /dev/null +++ b/docs/menu.md @@ -0,0 +1,117 @@ +# In-Game Menu + +txAdmin v4.0.0 introduced an in-game menu equipped with common admin functionality, +an online player browser, and a slightly trimmed down version of the web panel. + +You can find a short preview video [here](https://www.youtube.com/watch?v=jWKg0VQK0sc) + +## Accessing the Menu + +You can access the menu in-game by using the command `/tx` or `/txadmin`, alternatively +you can also use a keybind by going to `Game Settings > Key Bindings > FiveM` and +setting the `(txAdmin) Menu: Open Main Page` option. + +### Permissions +Anybody who you would like to give permissions to open the menu in-game, must have a txAdmin +account with either their Discord or Cfx.re identifiers tied to it. + +***If you do not have any of these identifiers attached, you will not be able to access the menu*** + +You can further control the menu options accessible to admins by changing their permissions +in the admin manager as shown below. + +![img](https://i.imgur.com/LP7Ij8M.png) + +## Convars +The txAdmin menu has a variety convars that can alter the default behavior of the menu. +Convars configured in the settings page should not be set manually. + +### Settings page only +**txAdmin-menuEnabled** +- Description: Whether the menu is enabled or not. Changing it requires server restart. +- Default: `true` + +**txAdmin-menuAlignRight** +- Description: Whether to align the menu to the right of the screen instead of the left. +- Default: `false` + +**txAdmin-menuPageKey** +- Description: Will change the key used for changing pages in the menu. This value must be the exact browser key code for your preferred key. You can use [this](https://keycode.info/) website and the `event.code` section to find it. +- Default: `Tab` + +**txAdmin-playerModePtfx** +- Description: Determine whether to play particles effects and sound whenever an admin's player mode is changed, such as when enabling god mode or noclip. +- Default: `true` + +**txAdmin-hideAdminInPunishments** +- Description: Never show to the players the admin name on Bans or Warns. +- Default: `true` + +**txAdmin-hideAdminInMessages** +- Description: Do not show the admin name on Announcements or DMs. +- Default: `false` + +**txAdmin-hideDefaultAnnouncement** +- Description: Suppresses the display of announcements, allowing you to implement your own announcement via the event `txAdmin:events:announcement`. +- Default: `false` + +**txAdmin-hideDefaultDirectMessage** +- Description: Suppresses the display of direct messages, allowing you to implement your own direct message notification via the event `txAdmin:events:playerDirectMessage`. +- Default: `false` + +**txAdmin-hideDefaultWarning** +- Description: Suppresses the display of warnings, allowing you to implement your own warning via the event `txAdmin:events:playerWarned`. +- Default: `false` + +**txAdmin-hideDefaultScheduledRestartWarning** +- Description: Suppresses the display of scheduled restart warnings, allowing you to implement your own warning via the event `txAdmin:events:scheduledRestart`. +- Default: `false` + +### Convar only (not in settings page) +**txAdmin-debugMode** +- Description: Will toggle debug printing on the server and client. +- Default: `false` +- Usage: `setr txAdmin-debugMode true` + +**txAdmin-menuPlayerIdDistance** +- Description: The distance in which Player IDs become visible, if toggled on. Note that the game engine limits to show tags that are only closer than ~300m, so increasing the number above that might be useless. +- Default: 150 +- Usage: `setr txAdmin-menuPlayerIdDistance 100` + +**txAdmin-menuDrunkDuration** +- Description: How many seconds the drunk effect (troll action) should last. +- Default: 30 +- Usage: `setr txAdmin-menuDrunkDuration 120` + +**txAdmin-menuAnnounceNotiPos** +- Description: Determines the location of the txAdmin announcement notification. This **must** use one of the following valid +positions, `top-center`, `top-left`, `top-right`, `bottom-center`, `bottom-left`, `bottom-right`. +- Default: `top-center` +- Usage: `set txAdmin-menuAnnounceNotiPos top-right` + + +## Commands +**tx | txadmin** +- Description: Will toggle the in-game menu. This command has an optional argument of a player id that will quickly open up the target player's info modal. +- Usage: `/tx (playerID)`, `/txadmin (playerID)` +- Required Perm: `Must be an admin registered in the Admin Manager` + +**txAdmin-reauth** +- Description: Will retrigger the reauthentication process. +- Usage: `/txAdmin-reauth` +- Required Perm: `none` + +## Troubleshooting menu access +- If you type `/tx` and nothing happens, your menu is probably disabled. +- If you see a red message like [this](https://i.imgur.com/G83uTNC.png) and you are registered on txAdmin, you can type `/txAdmin-reauth` in the chat to retry the authentication. +- If you can't authenticate and the reason id `Invalid Request: source`, this means the source IP of the HTTP request being made by fxserver to txAdmin is not a "localhost" one, which might occur if your host has multiple IPs. To disable this protection, edit your `config.json` file and add `webServer.disableNuiSourceCheck` with value `true` then restart txAdmin. + +## Development +You can find development instructions regarding the menu [here.](https://github.com/tabarra/txAdmin/blob/master/docs/development.md#menu-development) + +## FAQ +- **Q**: Why don't the 'Heal' options revive a player when using ESX/QBCore/etc? +- **A**: Many frameworks independently handle a "dead" state for a player, meaning + the menu is unable to reset this state in an resource agnostic form directly. To establish compatibility + with any framework, txAdmin will emit an [txAdmin:events:healedPlayer](https://github.com/tabarra/txAdmin/blob/master/docs/events.md#txadmineventshealedplayer-v48) + for developers to handle. diff --git a/docs/palettes.json b/docs/palettes.json new file mode 100644 index 0000000..2b5e9f6 --- /dev/null +++ b/docs/palettes.json @@ -0,0 +1,275 @@ +[ + { + "paletteName": "dark semantic", + "swatches": [ + { + "name": "accent", + "color": "F50551" + }, + { + "name": "danger", + "color": "F86565" + }, + { + "name": "warning", + "color": "E8C957" + }, + { + "name": "success", + "color": "51E47A" + }, + { + "name": "info", + "color": "5AC8E1" + } + ] + }, + { + "paletteName": "dark backgrounds", + "swatches": [ + { + "name": "new background", + "color": "171516" + }, + { + "name": "DELETAR", + "color": "171516" + }, + { + "name": "DELETAR2", + "color": "171516" + }, + { + "name": "card", + "color": "1F1C1E" + }, + { + "name": "border", + "color": "322D31" + }, + { + "name": "muted", + "color": "2A2629" + }, + { + "name": "secondary", + "color": "463F44" + }, + { + "name": "primary", + "color": "F9F3F8" + } + ] + }, + { + "paletteName": "light backgrounds", + "swatches": [ + { + "name": "new background", + "color": "F9F4F8" + }, + { + "name": "DELETAR", + "color": "F9F4F8" + }, + { + "name": "DELETAR2", + "color": "F9F4F8" + }, + { + "name": "card", + "color": "EFEAEE" + }, + { + "name": "border", + "color": "D6D2D5" + }, + { + "name": "muted", + "color": "DCD8DB" + }, + { + "name": "secondary", + "color": "B7B3B6" + }, + { + "name": "primary", + "color": "1B161A" + } + ] + }, + { + "paletteName": "fivem light", + "swatches": [ + { + "name": "bg", + "color": "EAE8E8" + }, + { + "name": "card", + "color": "E4E4E5" + }, + { + "name": "button", + "color": "F5F6F6" + }, + { + "name": "secondary", + "color": "969DA6" + }, + { + "name": "primary", + "color": "F50551" + } + ] + }, + { + "paletteName": "fivem dark", + "swatches": [ + { + "name": "bg", + "color": "484B53" + }, + { + "name": "card", + "color": "32343D" + }, + { + "name": "button", + "color": "252830" + }, + { + "name": "secondary", + "color": "43494F" + }, + { + "name": "primary", + "color": "C30440" + } + ] + }, + { + "paletteName": "dark v2", + "swatches": [ + { + "name": "og background", + "color": "171516" + }, + { + "name": "fivem dark", + "color": "484B53" + }, + { + "name": "new bg", + "color": "19191C" + }, + { + "name": "card", + "color": "222326" + }, + { + "name": "border", + "color": "333539" + }, + { + "name": "muted", + "color": "26272A" + }, + { + "name": "secondary", + "color": "3F4146" + }, + { + "name": "primary", + "color": "F3F5F9" + } + ] + }, + { + "paletteName": "light v2", + "swatches": [ + { + "name": "og background", + "color": "F9F4F8" + }, + { + "name": "fivem light", + "color": "EAE8E8" + }, + { + "name": "new background", + "color": "F5F6FA" + }, + { + "name": "card", + "color": "EDEFF2" + }, + { + "name": "border", + "color": "CECFD1" + }, + { + "name": "muted", + "color": "D5D6D9" + }, + { + "name": "secondary", + "color": "D9DADE" + }, + { + "name": "primary", + "color": "16171B" + } + ] + }, + { + "paletteName": "fivem semantic", + "swatches": [ + { + "name": "primary", + "color": "F50551" + }, + { + "name": "error", + "color": "FF2600" + }, + { + "name": "warning", + "color": "FFAE00" + }, + { + "name": "success", + "color": "01A370" + }, + { + "name": "info", + "color": "007892" + } + ] + }, + { + "paletteName": "light semantic", + "swatches": [ + { + "name": "accent", + "color": "F50551" + }, + { + "name": "danger", + "color": "EF4141" + }, + { + "name": "warning", + "color": "E6C13A" + }, + { + "name": "success", + "color": "39E669" + }, + { + "name": "info", + "color": "39C6E6" + } + ] + } +] diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..96c4a9a --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,32 @@ +## Permission System +The permission system allows you to control which admins can perform which actions. +For instance you can allow one admin to only view the console and kick players, but not restart the server and execute arbitrary commands. +The permissions are saved in the `txData/admins.json` file and can be edited through the *Admin Manager* page by the Master admin, or users with `all_permissions` or `manage.admins` permissions. + +### Available Permissions +- `all_permissions`: Root permission that allows the user to perform any action. When set, this will remove all other permissions. +- `manage.admins`: Permission to create, edit, and remove other admin accounts. +- `settings.view`: View Settings (no tokens). +- `settings.write`: Change Settings. +- `console.view`: View Console. +- `console.write`: Write Console commands. +- `control.server`: Start/Stop/Restart Server. +- `announcement`: Send announcements. +- `commands.resources`: Start/Stop Resources. +- `server.cfg.editor`: Read/Write server.cfg. +- `txadmin.log.view`: View txAdmin Log. +- `server.log.view`: View server logs. +- `menu.vehicle`: Spawn/Fix Vehicles. +- `menu.clear_area`: Reset world area. +- `menu.viewids`: View Player IDs in-game. +- `players.direct_message`: Send direct messages. +- `players.whitelist`: Whitelist a player. +- `players.warn`: Warn a player. +- `players.kick`: Kick a player. +- `players.ban`: Ban/Unban a player. +- `players.freeze`: Freeze a player's ped. +- `players.heal`: Heal self or everyone. +- `players.playermode`: Toggle NoClip, God Mode, or Superjump. +- `players.spectate`: Spectate a player. +- `players.teleport`: Teleport self or a player. +- `players.troll`: Use the Troll Menu. diff --git a/docs/recipe.md b/docs/recipe.md new file mode 100644 index 0000000..8f2e106 --- /dev/null +++ b/docs/recipe.md @@ -0,0 +1,262 @@ +# Recipe Files +A Recipe is a YAML document that describes how to deploy a server properly: from downloading resources, to configuring the `server.cfg` file. +You can run a recipe from txAdmin's built-in Server Deployer. +Recipes will be "jailed" to the target folder, so for example they won't be able to execute `write_file` to your `admins.json`. +At the end of the deployment process, your target folder will be checked for the presence of a `server.cfg` and a `resources` folder to make sure everything went correctly. +On the setup page you will be able to import a recipe via its URL or by selecting one of the recommended ones from the community. + +Example recipe: +```yaml +name: PlumeESX2 +version: v1.2.3 +author: Toybarra +description: A full featured (8 jobs) and highly configurable yet lightweight ESX v2 base that can be easily extendable. + +variables: + dbHost: localhost + dbUsername: root + dbPassword: "" + dbName: null + +tasks: + - action: download_file + url: https://github.com/citizenfx/cfx-server-data/archive/master.zip + path: ./tmp/cfx-server-data.zip + + - action: another_task + optionA: aaaa + optionB: bbbbbb +``` + + +## Meta Data +The recipe accepts the following default meta data: +- Engine specific meta data *(optional)*: + - `$engine`: The recipe's target engine version. + - `$minFxVersion`: The minimum required FXserver version for this recipe. + - `$onesync`: The required onesync value to be set after deployment. Supports only `off`, `legacy`, `on` - just as FXServer. + - `$steamRequired`: Boolean declaring that the `steam_webApiKey` context variable MUST be set. +- General tags *(strongly-recommended)*: + - `name`: The short name for your recipe. Recommended to be under 24 characters. + - `version`: The version of your recipe. + - `author`: The short name of the author. Recommended to be under 24 characters. + - `description`: A single or multiline description for your recipe. Recommended to be under 256 characters. On YAML you can use multiline strings in many ways, for more information check https://yaml-multiline.info. + + +## Context Variables +The deployer has a shared context between tasks, and they are initially populated by the `variables` and the deployer step 2 (user input) which is used by things like database configuration and string replacements. +Default variables: +- `deploymentID`: composed by the shortened recipe name with a hex timestamp which will look something like `PlumeESX_BBC957`. +- `serverName`: The name of the server specified in the setup page. +- `recipeName`, `recipeAuthor`, `recipeVersion`, `recipeDescription`: Populated from the recipe metadata, if available. +- `dbHost`, `dbPort`, `dbUsername`, `dbPassword`, `dbName`, `dbDelete`, `dbConnectionString`: Populated from the database configuration user input, if available. +- `svLicense`: Required variable, inputed in the deployer step 2. The deployer will automatically replace `{{svLicense}}` in the `server.cfg` at the end of the deployment. +- `serverEndpoints`: The `endpoint_add_xxxx` for the server. The deployer will set this with the defaults (`0.0.0.0:30120`) or using the `TXHOST_INTERFACE` & `TXHOST_FXS_PORT` variables. +- `maxClients`: The number of max clients for the server. The deployer will set this with the default 48 slots or using the `TXHOST_MAX_SLOTS` variable. + +In the second step of the deployer, the user will be asked to fill some information required to configure his server which will be loaded into context variables. +How to set custom variables: +```yaml +variables: + aaa: bbbb + ccc: dddd +``` + + +## Tasks +Tasks/actions are executed sequentially, and any failure in the chain stops the process. +Attention: careful with the number of spaces used in the indentation. +Every task can contain a `timeoutSeconds` option to increase it's defautl value. + +**Available Actions:** +- [download_github](#download_github) +- [download_file](#download_file) +- [unzip](#unzip) +- [move_path](#move_path) +- [copy_path](#copy_path) +- [remove_path](#remove_path) +- [ensure_dir](#ensure_dir) +- [write_file](#write_file) +- [replace_string](#replace_string) +- [connect_database](#connect_database) +- [query_database](#query_database) +- [load_vars](#load_vars) + +### `download_github` +Downloads a GitHub repository with an optional reference (branch, tag, commit hash) or subpath. +If the directory structure does not exist, it is created. +- `src`: The repository to be downloaded. This can be an URL or `repo_owner/repo_name`. +- `ref`: *(optional)* The git reference to be downloaded. This can be a branch, a tag, or a commit hash. If none is set, the recipe engine will query GitHub's API to get the default branch name (usually `master` or `main`). +- `subpath`: *(optional)* When specified, copies a subpath of the repository. +- `dest`: The destination path for the downloaded file. +> Note: If you have more than 30 of this action, it is recommended to set the ref, otherwise users will may end up getting download errors (401/403 instead of 429) due to the number of times txAdmin calls the GitHub API. +```yaml +# Example with subpath and reference +- action: download_github + src: https://github.com/citizenfx/cfx-server-data + ref: 6eaa3525a6858a83546dc9c4ce621e59eae7085c + subpath: resources + dest: ./resources + +# Simple example +- action: download_github + src: esx-framework/es_extended + dest: ./resources/[esx]/es_extended +``` + +### `download_file` +Downloads a file to a specific path. +- `url`: The URL of the file. +- `path`: The destination path for the downloaded file. This must be a file name and not a path. +```yaml +- action: download_file + url: https://github.com/citizenfx/cfx-server-data/archive/master.zip + path: ./tmp/cfx-server-data.zip +``` + +### `unzip` +Extracts a ZIP file to a target folder. This will not work for tar files. +- `src`: The source path. +- `dest`: The destination path. +```yaml +- action: unzip + src: ./tmp/cfx-server-data.zip + dest: ./tmp +``` + +### `move_path` +Moves a file or directory. The directory can have contents. +This is an implementation of [fs-extra.move()](https://github.com/jprichardson/node-fs-extra/blob/HEAD/docs/move.md). +- `src`: The source path. This can be either a file or a folder. Cannot be the root path (`./`). +- `dest`: The destination path. Cannot be the root path (`./`). +- `overwrite`: *(optional, boolean)* When set to true, it will replace the destination path if it already exists. +```yaml +- action: move_path + src: ./tmp/cfx-server-data-master/resources + dest: ./resources + overwrite: true +``` + +### `copy_path` +Copy a file or directory. The directory can have contents. +This is an implementation of [fs-extra.copy()](https://github.com/jprichardson/node-fs-extra/blob/HEAD/docs/copy.md). +- `src`: The source path. Note that if `src` is a directory it will copy everything inside of this directory, not the entire directory itself. +- `dest`: The destination path. Note that if `src` is a file, `dest` cannot be a directory. +- `overwrite`: *(optional, boolean)* When set to `true`, overwrite existing file or directory, default is `true`. Note that the copy operation will silently fail if you set this to `false` and the destination exists. Use the `errorOnExist` option to change this behavior. +- `errorOnExist`: *(optional, boolean)* When overwrite is `false` and the destination exists, throw an error. Default is `false`. +```yaml +- action: copy_path + src: ./tmp/cfx-server-data-master/resources/ + dest: ./resources +``` + +### `remove_path` +Removes a file or directory. The directory can have contents. If the path does not exist, silently does nothing. +This is an implementation of [fs-extra.remove()](https://github.com/jprichardson/node-fs-extra/blob/HEAD/docs/remove.md). +- `path`: The path to be removed. Cannot be the root path (`./`). +```yaml +- action: remove_path + path: ./tmp +``` + +### `ensure_dir` +Ensures that the directory exists. If the directory structure does not exist, it is created. +This is an implementation of [fs-extra.ensureDir()](https://github.com/jprichardson/node-fs-extra/blob/HEAD/docs/ensureDir.md). +- `path`: The path to be created. Cannot be the root path (`./`). +```yaml +- action: ensure_dir + path: ./resources +``` + +### `write_file` +Writes or appends data to a file. If not in the append mode, the file will be overwritten and the directory structure will be created if it doesn't exists. +This is an implementation of [fs-extra.outputFile()](https://github.com/jprichardson/node-fs-extra/blob/HEAD/docs/outputFile.md) and Node's default `fs.appendFile()`. +- `file`: The path of the file to be written to. +- `append`: *(optional, boolean)* When set to true, the data will be appended to the end of the target file instead of overwriting everything. +- `data`: The data to be written to the target path. +```yaml +# Append example +- action: write_file + file: ./server.cfg + append: true + data: | + ensure example1 + ensure example2 + +# Write file example +- action: write_file + file: ./doesntexist/config.json + data: | + { + "someVariable": true, + "heyLookAnArray": [123, 456] + } +``` + +### `replace_string` +Replaces a string in the target file or files array based on a search string and/or context variables. +- `file`: String or array containing the file(s) to be checked for the replacer string. +- `mode`: *(optional)* Specify the behavior of the replacer. + - `template`: *(default)* The `replace` string option processed for context variables in the `{{varName}}` format. + - `all_vars`: All variables (`{{varName}}`) will be replaced in the target file. The `search` and `replace` options will be ignored. + - `literal`: Normal string search/replace without any vars +- `search`: The String to be searched for. +- `replace`: The String that will replace the `search` one. +```yaml +# Single file - template mode is implicit +- action: replace_string + file: ./server.cfg + search: 'FXServer, but unconfigured' + replace: '{{serverName}} built with {{recipeName}} by {{recipeAuthor}}!' + +# Multiple files +- action: replace_string + mode: all_vars + file: + - ./resources/blah.cfg + - ./something/config.json + +# Replace all variables +- action: replace_string + file: ./configs.cfg + search: 'omg_replace_this' + replace: 'got_it!' +``` + +### `connect_database` +Connects to a MySQL/MariaDB server and creates a database if the dbName variable is null. +You need to execute this action before the `query_database` to prepare the deployer context. +This action does not have any direct attributes attached to it. Instead it uses Context Variables set in the deployer step 2 (user input). +```yaml +# Yes, that's just it +- action: connect_database +``` + +### `query_database` +Runs a SQL query in the previously connected database. +This query can be a file path **OR** a string, but not both at the same time! +You need to execute the `connect_database` before this action. +- `file`: The path of the SQL file to be executed. +- `query`: The query string to be executed. +```yaml +# Running a query from a file +- action: query_database + file: ./tmp/create_tables.sql + +# Running a query from a string +- action: query_database + query: | + CREATE TABLE IF NOT EXISTS `users` ( + `id` int(10) unsigned NOT NULL, + `name` tinytext NOT NULL + ); + INSERT INTO `users` (`name`) VALUES ('tabarra'); +``` + +### `load_vars` +Loads variables from a JSON file to the deployer context. +- `src`: The path of the JSON file to be loaded. +```yaml +- action: load_vars + src: ./toload.json +``` diff --git a/docs/translation.md b/docs/translation.md new file mode 100644 index 0000000..fcbe4cf --- /dev/null +++ b/docs/translation.md @@ -0,0 +1,28 @@ +# Translation Support +txAdmin supports translation for over 30 languages for the in-game interface (menu/warn) and chat messages, as well as discord warnings. + + +## Custom locales: +If your language is not available, or you want to customize the messages, create a `locale.json` file in inside the `txData` folder based on any language file found on [our repository](https://github.com/tabarra/txAdmin/tree/master/locale). Then go to the settings and select the "Custom" language option. + +The `$meta.humanizer_language` key must be compatible with the library [humanize-duration](https://www.npmjs.com/package/humanize-duration), check their page for a list of compatible languages. + + +## Contributing: +We need the community help to translate, and keep the translations updated and high-quality. +For that you will need to: +- Make a custom locale file with the instructions above; +- Name the file using the language code in [this page](https://www.science.co.il/language/Locale-codes.php); +- The `$meta.label` must be the language name in English (eg `Spanish` instead of `Español`); +- If you create a new translation, make sure to add it to `shared/localeMap.ts`, and maintain the alphabetical order; +- Do a [Pull Request](https://github.com/tabarra/txAdmin/pulls) posting a few screenshots of evidence that you tested what you changed in-game. +- An automatic check will run, make sure to read the output in case of any errors. + +> [!TIP] +> To quickly test your changes, you can edit the `locale.json` file and then in the settings page click "Save Global Settings" again to see the changes in the game menu without needing to restart txAdmin or the server. + +> [!TIP] +> To make sure you didn't miss anything in the locale file, you can download the txAdmin source code, execute `npm i`, move the `locale.json` to inside the `txAdmin/locale` folder and run `npm run locale:check`. This will tell you about missing or extra keys. + +> [!NOTE] +> The performance of custom locale for big servers may not be ideal due to the way we need to sync dynamic content to clients. So it is strongly encouraged that you contribute with translations in our GitHub so it gets packed with the rest of txAdmin. diff --git a/docs/zaphosting.png b/docs/zaphosting.png new file mode 100644 index 0000000..9bd9514 Binary files /dev/null and b/docs/zaphosting.png differ diff --git a/dynamicAds.json b/dynamicAds.json new file mode 100644 index 0000000..6e4ce81 --- /dev/null +++ b/dynamicAds.json @@ -0,0 +1,36 @@ +{ + "login": [ + { + "text": "DDoS protected & preconfigured FiveM txAdmin servers", + "logoLightMode": "img/zap256_black.png", + "logoDarkMode": "img/zap256_white.png", + "link": "http://zap-hosting.com/txAdmin3" + } + ], + "main": [ + { + "text": "DDoS protected & preconfigured
FiveM txAdmin servers", + "logoLightMode": "img/zap256_black.png", + "logoDarkMode": "img/zap256_white.png", + "linkDesktop": "http://zap-hosting.com/txAdmin5", + "linkMobile": "http://zap-hosting.com/txAdmin7" + } + ], + "loginZap": [ + { + "text": "DDoS protected & preconfigured FiveM txAdmin servers", + "logoLightMode": "img/zap256_black.png", + "logoDarkMode": "img/zap256_white.png", + "link": "http://zap-hosting.com/txAdmin3" + } + ], + "mainZap": [ + { + "text": "DDoS protected & preconfigured
FiveM txAdmin servers", + "logoLightMode": "img/zap256_black.png", + "logoDarkMode": "img/zap256_white.png", + "linkDesktop": "http://zap-hosting.com/txAdmin5", + "linkMobile": "http://zap-hosting.com/txAdmin7" + } + ] +} diff --git a/dynamicAds2.json b/dynamicAds2.json new file mode 100644 index 0000000..aafdbe2 --- /dev/null +++ b/dynamicAds2.json @@ -0,0 +1,18 @@ +{ + "login": { + "img": "img/zap_login.png", + "url": "http://zap-hosting.com/txAdmin3" + }, + "main": { + "img": "img/zap_main.png", + "url": "http://zap-hosting.com/txAdmin5" + }, + "loginZap": { + "img": "img/zap_login.png", + "url": "http://zap-hosting.com/txAdmin3" + }, + "mainZap": { + "img": "img/zap_main.png", + "url": "http://zap-hosting.com/txAdmin5" + } +} diff --git a/entrypoint.js b/entrypoint.js new file mode 100644 index 0000000..6ed7e34 --- /dev/null +++ b/entrypoint.js @@ -0,0 +1,29 @@ +//NOTE: Due to fxs's node, declaring ANY variable in this file will pollute +// the global scope, and it will NOT show in `Object.keys(global)`! +// Hence why I'm doing some data juggling and duplicated function calls. + +//Check if running inside FXServer +try { + if (!IsDuplicityVersion()) throw new Error(); +} catch (error) { + console.log('txAdmin must be run inside FXServer in monitor mode!'); + process.exit(999); +} + +//Checking monitor mode and starting +try { + if (GetConvar('monitorMode', 'false') == 'true') { + require('./core/index.js'); + } else if (GetConvar('txAdminServerMode', 'false') == 'true') { + require('./resource/sv_reportHeap.js'); + } +} catch (error) { + //Prevent any async console.log messing with the output + process.stdout.write([ + 'e'.repeat(80), + `Resource load error: ${error.message}`, + error.stack.toString(), + 'e'.repeat(80), + '' + ].join('\n')); +} diff --git a/fxmanifest.lua b/fxmanifest.lua new file mode 100644 index 0000000..b33bd46 --- /dev/null +++ b/fxmanifest.lua @@ -0,0 +1,74 @@ +-- Modifying or rewriting this resource for local use only is strongly discouraged. +-- Feel free to open an issue or pull request in our GitHub. +-- Official discord server: https://discord.gg/txAdmin + +author 'Tabarra' +description 'The official FiveM/RedM server web/in-game management platform.' +repository 'https://github.com/tabarra/txAdmin' +version 'REPLACE-VERSION' +ui_label 'txAdmin' + +fx_version 'cerulean' +game 'common' +-- nui_callback_strict_mode 'true' --FIXME: menu iframe doesn't work +-- lua54 'yes' --TODO: check if it works +-- node_version '22' + +-- NOTE: All server_scripts will be executed both on monitor and server mode +-- NOTE: Due to global package constraints, js scripts will be loaded from entrypoint.js +-- NOTE: Due to people drag-n-dropping their artifacts, we can't do globbing +shared_scripts { + 'resource/shared.lua' +} + +server_scripts { + 'entrypoint.js', + 'resource/sv_main.lua', --must run first + 'resource/sv_admins.lua', + 'resource/sv_logger.lua', + 'resource/sv_resources.lua', + 'resource/sv_playerlist.lua', + 'resource/sv_ctx.lua', + 'resource/sv_initialData.lua', + 'resource/menu/server/sv_webpipe.lua', + 'resource/menu/server/sv_functions.lua', + 'resource/menu/server/sv_main_page.lua', + 'resource/menu/server/sv_vehicle.lua', + 'resource/menu/server/sv_freeze_player.lua', + 'resource/menu/server/sv_trollactions.lua', + 'resource/menu/server/sv_player_modal.lua', + 'resource/menu/server/sv_spectate.lua', + 'resource/menu/server/sv_player_mode.lua' +} + +client_scripts { + 'resource/cl_main.lua', + 'resource/cl_logger.lua', + 'resource/cl_playerlist.lua', + 'resource/menu/client/cl_webpipe.lua', + 'resource/menu/client/cl_base.lua', + 'resource/menu/client/cl_functions.lua', + 'resource/menu/client/cl_instructional_ui.lua', + 'resource/menu/client/cl_main_page.lua', + 'resource/menu/client/cl_vehicle.lua', + 'resource/menu/client/cl_player_ids.lua', + 'resource/menu/client/cl_ptfx.lua', --must run before cl_player_mode + 'resource/menu/client/cl_player_mode.lua', + 'resource/menu/client/cl_spectate.lua', + 'resource/menu/client/cl_trollactions.lua', + 'resource/menu/client/cl_freeze.lua', + 'resource/menu/vendor/freecam/utils.lua', + 'resource/menu/vendor/freecam/config.lua', + 'resource/menu/vendor/freecam/main.lua', + 'resource/menu/vendor/freecam/camera.lua', +} + +ui_page 'nui/index.html' + +files { + 'nui/**/*', + + -- WebPipe optimization: + 'panel/**/*', + 'web/public/**/*', +} diff --git a/locale/ar.json b/locale/ar.json new file mode 100644 index 0000000..686428e --- /dev/null +++ b/locale/ar.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Arabic", + "humanizer_language": "ar" + }, + "restarter": { + "server_unhealthy_kick_reason": "الخادم يحتاج إلى إعادة التشغيل, يرجى إعادة الاتصال", + "partial_hang_warn": ".بسبب حدوث تعليق جزئي ، ستتم إعادة تشغيل هذا الخادم خلال دقيقة واحدة. الرجاء قطع الاتصال الآن", + "partial_hang_warn_discord": ".سيتم إعادة التشغيل خلال دقيقة واحدة **%{servername}** ،بسبب تعليق جزئي", + "schedule_reason": "%{time} أعادة تشغيل مجدول في", + "schedule_warn": ".دقائق %{smart_count} دقيقة. يرجى قطع الاتصال الآن. |||| تمت جدولة هذا الخادم لإعادة التشغيل في %{smart_count} تمت جدولة هذا الخادم لإعادة التشغيل في", + "schedule_warn_discord": ".دقائق %{smart_count} ومن المقرر إعادة التشغيل في **%{servername}** |||| .دقيقة %{smart_count} ومن المقرر إعادة التشغيل في **%{servername}**" + }, + "kick_messages": { + "everyone": "تم طرد جميع اللاعبين: %{reason}.", + "player": "تم طردك: %{reason}.", + "unknown_reason": "لسبب مجهول" + }, + "ban_messages": { + "kick_temporary": ".%{expiration} :ستنتهي صلاحية حظرك في .\"%{reason}\" لقد تم حظرك من هذا الخادم لسبب (%{author})", + "kick_permanent": ".\"%{reason}\" لقد تم حظرك بشكل دائم من هذا الخادم لـسبب (%{author})", + "reject": { + "title_permanent": ".لقد تم حظرك بشكل دائم من هذا الخادم", + "title_temporary": ".لقد تم حظرك مؤقتا من هذا الخادم", + "label_expiration": "ستنتهي صلاحية حظرك في", + "label_date": "تاريخ الحظر", + "label_author": "محظور من قبل", + "label_reason": "سبب الحظر", + "label_id": "معرف الحظر", + "note_multiple_bans": ".ملاحظة: لديك أكثر من حظر نشط على المعرفات الخاصة بك", + "note_diff_license": ".تتطابق مع تلك المرتبطة بهذا الحظر HWID مما يعني أن بعض معرفاتك license ملاحظة: تم تطبيق الحظر أعلاه على شخص آخر" + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": ".المسؤول فقط هذا الخادم في وضع", + "insufficient_ids": ".txAdmin المعرفات، ومطلوب واحد منهم على الأقل للتحقق من صحتك إذا كنت مسؤولاً في discord أو fivem لم يكن لديك", + "deny_message": ".txadmin لم يتم تعيين المعرفات الخاصة بك لأي مسؤول" + }, + "guild_member": { + "mode_title": "عضو في القائمة هذا الخادم في الوضع", + "insufficient_ids": ".(لن يعمل الديسكورد الموقع) والمحاولة مرة أخرى Discord Desktop يرجى فتح تطبيق .Discord server المعرف ، وهو مطلوب للتحقق مما إذا كنت قد انضممت إلى discord ليس لديك", + "deny_title": "للدخول إلى السيرفر، أنت مطالب بالانضمام لسيرفر الديسكورد", + "deny_message": ".ثم أعد المحاولة %{guildname} يرجى الانضمام إلى سيرفر" + }, + "guild_roles": { + "mode_title": "هذا السيرفر يتطلب دخولك سيرفر الديسكورد للانضمام", + "insufficient_ids": ".والمحاولة مرة أخرى (لن يعمل الديسكورد الموقع) Discord Desktop يرجى فتح تطبيق .Discord server المعرف ، وهو مطلوب للتحقق مما إذا كنت قد انضممت إلى discord ليس لديك", + "deny_notmember_title": "لدخول Discord server أنت مطالب بالانضمام إلى", + "deny_notmember_message": ".احصل على أحد الرول المطلوبة ، ثم حاول مرة أخرى ،%{guildname} يرجى الانضمام", + "deny_noroles_title": ".ليس لديك دور مدرج في القائمة البيضاء مطلوب للانضمام", + "deny_noroles_message": "%{guildname}. للانضمام إلى هذا الخادم ، يجب أن يكون لديك واحد على الأقل من الرول المدرجة في القائمة" + }, + "approved_license": { + "mode_title": "القائمة للترخيص هذا الخادم في الوضع", + "insufficient_ids": "server.cfg ممكن. إذا كنت مالك الخادم ، فيمكنك تعطيله في ملف sv_lan تحديد ، مما يعني أن الخادم لديه license ليس لديك", + "deny_title": ".أنت غير مدرج في القائمة للانضمام إلى هذا الخادم", + "request_id_label": "طلب معرف" + } + }, + "server_actions": { + "restarting": ".(%{reason}) يتم أعادة التشغيل", + "restarting_discord": ".(%{reason}) يتم أعادة التشغيل **%{servername}**", + "stopping": ".(%{reason}) يـتم أيقاف تشغيل الـسيرفر", + "stopping_discord": ".(%{reason}) تم أيقاف السيرفر **%{servername}**", + "spawning_discord": "**%{servername}** .جاري تشغيل السيرفر" + }, + "nui_warning": { + "title": "تحذير", + "warned_by": ":تحذير من", + "stale_message": ".تم إصدار هذا التحذير قبل أن تتصل بالخادم", + "dismiss_key": "SPACE", + "instruction": ".اضغط باستمرار على %{key} لمدة %{smart_count} ثانية لتجاهل هذه الرسالة |||| .اضغط باستمرار على %{key} لمدة %{smart_count} ثوانٍ لتجاهل هذه الرسالة" + }, + "nui_menu": { + "misc": { + "help_message": "[Game Settings> Key Bindings> FiveM> Menu: Open Main Page]. في keybind يمكنك أيضًا تكوين \n /tx لفتحه. اكتب ،txAdmin تم تمكين قائمة", + "menu_not_admin": ".وتأكد من حفظ المعرفات الخاصة بك Admin Manager فانتقل إلى ،txAdmin إذا كنت مسجلاً في \n .txAdmin المعرفات الخاصة بك لا تتطابق مع أي مسؤول مسجل في", + "menu_auth_failed": "%{reason} :بسبب txAdmin فشلت مصادقة قائمة", + "no_perms": ".ليس لديك هذا الإذن", + "unknown_error": ".حدث خطأ غير معروف", + "not_enabled": ".txAdmin يمكنك تمكينه في صفحة إعدادات !txAdmin لم يتم تمكين قائمة", + "announcement_title": ":%{author} تنويه من الإداري", + "dialog_empty_input": ".لا يمكن أن يكون لديك إدخال فارغ", + "directmessage_title": ":%{author} رسالة مباشرة من المشرف", + "onesync_error": "هذا الأمر يتطلب تشغيل الون سنك" + }, + "frozen": { + "froze_player": "!لقد قمت بتجميد اللاعب", + "unfroze_player": "!لقد قمت بإلغاء تجميد اللاعب", + "was_frozen": "!لقد تم تجميدك من قبل إداري" + }, + "common": { + "cancel": "الغاء", + "submit": "تأكيد", + "error": "حدث خطأ", + "copied": ".نسخ إلى الحافظة" + }, + "page_main": { + "tooltips": { + "tooltip_1": "للتبديل بين الصفحات ومفاتيح الأسهم للتنقل بين عناصر القائمة %{key} استخدم", + "tooltip_2": "تحتوي بعض عناصر القائمة على خيارات فرعية يمكن تحديدها باستخدام مفاتيح الأسهم اليمنى واليسرى" + }, + "player_mode": { + "title": "وضع اللاعب", + "noclip": { + "title": "الطيران", + "label": "قم بالتحليق بالأجواء", + "success": "تم تفعيل طيران" + }, + "godmode": { + "title": "خارق", + "label": "لن يتمكن اللاعبين الآخير بقتلك", + "success": "تم تفعيل وضع خارق" + }, + "superjump": { + "title": "القفزة الخارقة", + "label": "سوف تصبح القفزة الخاصة بك عالية جداً، كذلك سوف تصبح شخصيتك اسرع بقليل", + "success": "تم تفعيل القفزة الخارقة" + }, + "normal": { + "title": "طبيعي", + "label": "الوضع الافتراضي", + "success": "لقد عدت للوضع الطبيعي" + } + }, + "teleport": { + "title": "التنقل", + "generic_success": "تم الانتقال بنجاح", + "waypoint": { + "title": "الوجهة", + "label": "انتقل إلى الموقع المحدد بالخريطة", + "error": "قم بتحديد موقع بالخريطة أولاً" + }, + "coords": { + "title": "إحداثيات", + "label": "انتقل إلى الإحداثيات المحددة", + "dialog_title": "التنقل", + "dialog_desc": "لتصفح الثقب الدودي. (x, y, z) قم بتوفير إحداثيات بتنسيق", + "dialog_error": "111, 222, 33 :إحداثيات غير صحيحة. يجب أن يكون بتنسيق" + }, + "back": { + "title": "رجوع", + "label": "العودة إلى آخر موقع", + "error": "ليس لديك مكان آخر للعودة إليه!" + }, + "copy": { + "title": "نسخ الاحداثيات", + "label": ".تم نسخ الاحداثيات إلى الحافظة" + } + }, + "vehicle": { + "title": "المركبات", + "not_in_veh_error": "!أنت لست في مركبة حاليا", + "spawn": { + "title": "انشاء", + "label": "انشئ المركبة باسم الطراز", + "dialog_title": "نشر مركبة", + "dialog_desc": ".أدخل اسم طراز المركبة التي تريد انشائها", + "dialog_success": "تم انشاء المركبة", + "dialog_error": "!غير موجود '%{modelName}' اسم طراز المركبة", + "dialog_info": ".%{modelName} تحاول أن انشاء" + }, + "fix": { + "title": "اصلاح", + "label": "إصلاح المركبة الحالية", + "success": "تم اصلاح المركبة" + }, + "delete": { + "title": "حذف", + "label": "احذف المركبة الحالية", + "success": "!تم حذف المركبة" + }, + "boost": { + "title": "عزز", + "label": "عزز المركبة لتحقيق أقصى قدر من المتعة (وربما السرعة)", + "success": "!عززت المركبة", + "already_boosted": ".تم تعزيز هذه المركبة بالفعل", + "unsupported_class": ".فئة المركبة هذه غير مدعومة", + "redm_not_mounted": ".يمكنك التعزيز فقط عند ركوبه على حصان" + } + }, + "heal": { + "title": "شفاء", + "myself": { + "title": "نفسي", + "label": "يعيد صحتك", + "success_0": "!شفاء كل", + "success_1": "سوف تشعر بشعور جيد الآن", + "success_2": "أنت الآن بصحة ممتازة", + "success_3": "تم اصلاح الخدشات" + }, + "everyone": { + "title": "الجميع", + "label": "سيشفي وينعش كل اللاعبين", + "success": "تم شفاء وإحياء جميع اللاعبين" + } + }, + "announcement": { + "title": "إرسال إعلان", + "label": "إرسال إعلان لجميع اللاعبين", + "dialog_desc": "..إرسال إعلان لجميع اللاعبين", + "dialog_placeholder": "... اعلانك", + "dialog_success": "تم إرسال الإعلان بنجاح" + }, + "clear_area": { + "title": "إعادة تعيين منطقة العالم", + "label": "إعادة تعيين منطقة عالمية محددة إلى حالتها الافتراضية", + "dialog_desc": ".الرجاء إدخال النطاق الذي ترغب في إعادة تعيين الكيانات فيه (0-300). لن يؤدي هذا إلى مسح الكيانات الناتجة من جانب الخادم", + "dialog_success": "%{radius}m منطقة المقاصة بنصف قطر", + "dialog_error": ".إدخال نصف قطر غير صالح. حاول ثانية" + }, + "player_ids": { + "title": "اظهار المعروف الاعبين", + "label": "قم باظهار معرفات اللاعب (والمعلومات الأخرى) فوق رأس كل اللاعبين القريبين", + "alert_show": ".لاعب مجاور NetIDs يتم عرض", + "alert_hide": ".لاعب قريب NetIDs إخفاء" + } + }, + "page_players": { + "misc": { + "online_players": "اللاعبين المتصلين", + "players": "لاعب", + "search": "بحث", + "zero_players": "لم يتم العثور على لاعبين" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "ترتيب حسب", + "distance": "مسافة", + "id": "الرقم بالسيرفر", + "joined_first": "انضم أولا", + "joined_last": "انضم اخرا", + "closest": "الأقرب", + "farthest": "ابعد" + }, + "card": { + "health": "%{percentHealth}% الدم" + } + }, + "player_modal": { + "misc": { + "error": ":حدث خطأ أثناء جلب تفاصيل هؤلاء المستخدمين. الخطأ موضح أدناه", + "target_not_found": "%{target} تعذر العثور على لاعب بمعرف أو اسم مستخدم" + }, + "tabs": { + "actions": "أجراءات", + "info": "معلومات", + "ids": "IDs", + "history": "تاريخ", + "ban": "حظر" + }, + "actions": { + "title": "إجراءات اللاعب", + "command_sent": "!تم إرسال الأمر", + "moderation": { + "title": "Moderation", + "options": { + "dm": "خاص", + "warn": "حذر", + "kick": "طرد", + "set_admin": "أعط الادارية" + }, + "dm_dialog": { + "title": "رسالة مباشرة", + "description": "ما هو سبب المراسلة المباشرة لهذا اللاعب؟", + "placeholder": "...سبب", + "success": "!تم إرسال رسالة الخاص بك" + }, + "warn_dialog": { + "title": "تحذير", + "description": "ما سبب الإنذار المباشر لهذا اللاعب؟", + "placeholder": "...سبب", + "success": "!تم تحذير اللاعب" + }, + "kick_dialog": { + "title": "طرد", + "description": "ما سبب طرد هذا اللاعب؟", + "placeholder": "...سبب", + "success": "تم طرد اللاعب!" + } + }, + "interaction": { + "title": "تفاعل", + "options": { + "heal": "شفاء", + "go_to": "انتقل للاعب", + "bring": "احضر اللاعب", + "spectate": "مراقبة اللاعب", + "toggle_freeze": "تفعيل/تعطيل التجميد" + }, + "notifications": { + "heal_player": "تم الشفاء لاعب", + "tp_player": "ذهبت الى لاعب", + "bring_player": "احضرت لاعب", + "spectate_failed": ".فشل في حل الهدف! خروج الطيف", + "spectate_yourself": ".لا يمكنك مشاهدة نفسك", + "freeze_yourself": ".لا يمكنك تجميد نفسك", + "spectate_cycle_failed": ".لا يوجد لاعبين" + } + }, + "troll": { + "title": "المزاح", + "options": { + "drunk": "اجعل اللاعب سكران", + "fire": "قم بإحراق اللاعب", + "wild_attack": "قم بإطلاق هجوم بري على اللاعب" + } + } + }, + "info": { + "title": "معلومات اللاعب", + "session_time": "وقت الجلسة", + "play_time": "وقت اللعب", + "joined": "انضم", + "whitelisted_label": "في القائمة", + "whitelisted_notyet": "ليس بعد", + "btn_wl_add": "اضف الى القائمة", + "btn_wl_remove": "حذف من القائمة", + "btn_wl_success": "تم تغير معلوماته في القائمة", + "log_label": "سجل", + "log_empty": ".لم يتم العثور على حظر او تحذيرات", + "log_ban_count": "%{smart_count} حظر |||| %{smart_count} حظر", + "log_warn_count": "%{smart_count} تحذيرات |||| %{smart_count} تحذيرات", + "log_btn": "تفاصيل", + "notes_changed": ".تم تغيير ملاحظة اللاعب", + "notes_placeholder": "... ملاحظات حول هذا اللاعب" + }, + "history": { + "title": "التاريخ ذو الصلة", + "btn_revoke": "سحب او إبطال", + "revoked_success": "تم إبطال الإجراء!", + "banned_by": "%{author} محظور من قبل", + "warned_by": "%{author} حذر من قبل", + "revoked_by": ".%{author} أبطله من قبل", + "expired_at": ".%{date} انتهت في", + "expires_at": ".%{date} تنتهي في" + }, + "ban": { + "title": "لاعب المحظور", + "reason_placeholder": "سبب", + "duration_placeholder": "مدة", + "hours": "ساعات", + "days": "أيام", + "weeks": "أسابيع", + "months": "شهور", + "permanent": "دائم", + "custom": "مخصص", + "helper_text": "الرجاء تحديد المدة", + "submit": "نأكيد الحظر", + "reason_required": ".السبب مطلوب", + "success": "!تم حظر لاعب" + }, + "ids": { + "current_ids": "المعرفات الحالية", + "previous_ids": "المعرفات المستخدمة سابقًا", + "all_hwids": "كافة معرفات الأجهزة" + } + } + } +} diff --git a/locale/bg.json b/locale/bg.json new file mode 100644 index 0000000..dd03c37 --- /dev/null +++ b/locale/bg.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Bulgarian", + "humanizer_language": "bg" + }, + "restarter": { + "server_unhealthy_kick_reason": "сървърът трябва да бъде рестартиран, моля, свържете се отново", + "partial_hang_warn": "Поради частично засичане, сървърът ще се рестартира след 1 минута. Моля излезте сега.", + "partial_hang_warn_discord": "Поради частично засичане, **%{servername}** ще се рестартира след 1 минута.", + "schedule_reason": "Планирано рестартиране в %{time}", + "schedule_warn": "Сървърът е планиран да се рестартира след %{smart_count} минути. Моля излезте сега. |||| Сървърът е планиран да се рестартира след %{smart_count} минути.", + "schedule_warn_discord": "**%{servername}** ще се рестартира след %{smart_count} минути. |||| **%{servername}** ще се рестартира след %{smart_count} минути." + }, + "kick_messages": { + "everyone": "Всички играчи бяха изритани: %{reason}.", + "player": "Вие бяхте изритан: %{reason}.", + "unknown_reason": "по неизвестна причина" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Вие бяхте баннат: \"%{reason}\". Твоята забрана ще изтече след: %{expiration}.", + "kick_permanent": "(%{author}) Вие бяхте перманентно баннат от този сървър с причина: \"%{reason}\".", + "reject": { + "title_permanent": "Вие бяхте перманентно баннат от този сървър.", + "title_temporary": "Вие бяхте временно баннат от този сървър.", + "label_expiration": "Твоят бан ще изтече след", + "label_date": "Дата на бан", + "label_author": "Баннат от", + "label_reason": "Причина на бана", + "label_id": "Бан ID", + "note_multiple_bans": "Забележка: Имате повече от един активен бан на вашите идентификатори.", + "note_diff_license": "Забележка: Горния бан е приложен за друг license, което означава че някои от твоите ID-та/HWID-та съответстват на тези, свързани с този бан." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Сървърът е в Администраторски режим.", + "insufficient_ids": "Вие нямате discord или fivem идентификатори, и поне един от тях трябва да потвърди дали сте txAdmin администратор.", + "deny_message": "Вашите идентификатори не са присвоени на нито един администратор на txAdmin." + }, + "guild_member": { + "mode_title": "Сървърът е в Discord server Member Whitelist режим.", + "insufficient_ids": "Вие не разполагате с discord идентификатор, което се изисква за валидиране, ако сте се присъединили към нашия Discord сървър. Моля, отворете десктоп приложението Discord и опитайте отново (уеб приложението няма да работи).", + "deny_title": "От вас се изисква да се присъедините към нашия Discord сървър, за да се свържете.", + "deny_message": "Моля влезте в discord сървъра %{guildname} и опитайте отново." + }, + "guild_roles": { + "mode_title": "Сървърът е в Discord Role Whitelist режим.", + "insufficient_ids": "Вие не разполагате с discord идентификатор, което се изисква за валидиране, ако сте се присъединили към нашия Discord сървър. Моля, отворете десктоп приложението Discord и опитайте отново (уеб приложението няма да работи).", + "deny_notmember_title": "От вас се изисква да се присъедините към нашия Discord сървър, за да се свържете.", + "deny_notmember_message": "Моля влезте в %{guildname}, вземете една от необходимите роли, след което опитайте отново.", + "deny_noroles_title": "Нямате роля в белия списък, необходима за присъединяване.", + "deny_noroles_message": "За да се присъедините към този сървър, трябва да имате поне една от ролите в белия списък в Discord сървъра %{guildname}." + }, + "approved_license": { + "mode_title": "Сървърът е в License Whitelist режим.", + "insufficient_ids": "Вие не разполагате с license идентификатор, което означава, че сървърът има sv_lan активиран. Ако сте собственик на сървъра, можете да го деактивирате в server.cfg файла.", + "deny_title": "Не сте в белия списък за присъединяване към този сървър.", + "request_id_label": "ID Заявка" + } + }, + "server_actions": { + "restarting": "Сървърът се рестартира, (%{reason}).", + "restarting_discord": "**%{servername}** се рестартира, (%{reason}).", + "stopping": "Сървърът е спрян, (%{reason}).", + "stopping_discord": "**%{servername}** сървърът се спира, (%{reason}).", + "spawning_discord": "**%{servername}** е пуснат." + }, + "nui_warning": { + "title": "ПРЕДУПРЕЖДЕНИЕ", + "warned_by": "Предупреден от:", + "stale_message": "Това предупреждение беше издадено преди да се свържете със сървъра.", + "dismiss_key": "SPACE", + "instruction": "Задръж %{key} за %{smart_count} секунда, за да махнеш предупреждението. |||| Задръж %{key} за %{smart_count} секунди, за да махнеш предупреждението." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin менюто е включено, напиши /tx, за да го отвориш.\nМоже също да го конфигурирате с копче в [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Твоите идентификатори не са съвместими с тези, които са регистрирани в txAdmin.\nАко си регистриран в txAdmin, отиди в Admin Manager и се увери, че идентификаторите са запазиени.", + "menu_auth_failed": "Удостоверяването на txAdmin менюто е неуспешно по причина: %{reason}", + "no_perms": "Нямаш правомощията за това.", + "unknown_error": "Възникна неизвестна грешка..", + "not_enabled": "txAdmin менюто не е включено! Може да го включиш чрез настройките на txAdmin.", + "announcement_title": "Сървърно съобщение от %{author}:", + "dialog_empty_input": "Полето е празно.", + "directmessage_title": "ЛС от администратор %{author}:", + "onesync_error": "Това действие изисква OneSync да бъде включен." + }, + "frozen": { + "froze_player": "Ти замрази играч!", + "unfroze_player": "Ти отмрази играч!", + "was_frozen": "Ти беше замразен от сървърен администратор!" + }, + "common": { + "cancel": "Откажи", + "submit": "Потвърди", + "error": "Грешка!", + "copied": "Копирано." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Използвай %{key} за да навигираш из страниците и из менюто.", + "tooltip_2": "Определени менюта имат свои опции, които могат да бъдат селектирани с лява и дясна стрелка." + }, + "player_mode": { + "title": "Игрален Режим", + "noclip": { + "title": "NoClip", + "label": "Включи режимът NoClip, с което ще си позволиш да минаваш през стени и други обекти.", + "success": "Режим NoClip е включен" + }, + "godmode": { + "title": "Безсмъртие", + "label": "Включи ремижът на безсмъртие, предпазвайки те от умиране.", + "success": "Безсмъртието е включено." + }, + "superjump": { + "title": "Супер скок", + "label": "Превключете режима на супер скок, играчът също ще тича по-бързо", + "success": "Активиран Супер скок" + }, + "normal": { + "title": "Нормално", + "label": "Връща те към нормалният игрален режим, позволявайки да умреш.", + "success": "Върнат към нормален игрален режим." + } + }, + "teleport": { + "title": "Телепорт", + "generic_success": "Успешно изпращане в червеевата дупка!", + "waypoint": { + "title": "Tочка на карта", + "label": "Телпортирай се до точка зададена на картата.", + "error": "Нямаш зададена точка на картата." + }, + "coords": { + "title": "Координати", + "label": "Телепортирай се до определени координати.", + "dialog_title": "Телепорт", + "dialog_desc": "Задай координати в полето за писане във вариант; x, y, z за да преминеш през червеевата дупка.", + "dialog_error": "Инвалидни координати. Трябва да бъдат във вариант: 111, 222, 33" + }, + "back": { + "title": "Върни се", + "label": "Връщаш се към последната локация, съвместима с последното телепортиране.", + "error": "Нямаш последна локация, към която можеш да се телепортираш!" + }, + "copy": { + "title": "Копирай координати", + "label": "Копирай текущите координати." + } + }, + "vehicle": { + "title": "Автомобил", + "not_in_veh_error": "Не си в автомобил!", + "spawn": { + "title": "Създай", + "label": "Създай автомобил чрез моделното ѝ име.", + "dialog_title": "Създай автомобил", + "dialog_desc": "Напиши моделът на автомобила който искаш да създадеш.", + "dialog_success": "Автомобила беше създаден успешно!", + "dialog_error": "Автомобила с моделно име '%{modelName}' не съществува!", + "dialog_info": "Опитваш се да създадеш %{modelName}." + }, + "fix": { + "title": "Поправи", + "label": "Ще поправи автомобила в който си.", + "success": "Автомобила е поправен!" + }, + "delete": { + "title": "Изтрий", + "label": "Изтриване на автомобила в които се намираш.", + "success": "Автомобила е изтрит!" + }, + "boost": { + "title": "Boost", + "label": "Boost-нете автомобила, за да постигнете максимално удоволствие (и може би скорост)", + "success": "Автомобила беше boost-нат!", + "already_boosted": "Този автомобил вече е boost-нат.", + "unsupported_class": "Този клас превозно средство не се поддържа.", + "redm_not_mounted": "Можете да boost-вате само когато яздите кон." + } + }, + "heal": { + "title": "Излекувай", + "myself": { + "title": "Себе си", + "label": "Ще напълни кръвта на моделът 'пед' с който играеш.", + "success_0": "Излекуван!", + "success_1": "Трябва да се чувстваш.. по-добре?", + "success_2": "Напълнен на максимум!", + "success_3": "Болките вече ги няма!" + }, + "everyone": { + "title": "Всички", + "label": "Ще излекува & съживи всички свързани играчи.", + "success": "Всички играчи бяха излекувани и съживени." + } + }, + "announcement": { + "title": "Изпрати съобщение", + "label": "Изпрати съобщение до всички онлайн играчи.", + "dialog_desc": "Напиши съобщението което искаш да изпратиш до всички онлайн играчи.", + "dialog_placeholder": "Твоето съобщение...", + "dialog_success": "Изпращане на съобщение." + }, + "clear_area": { + "title": "Рестартирай зона", + "label": "Рестартирай специфична зона.", + "dialog_desc": "Въведи радиус от който искаш да рестартираш (0-300). Това няма да изтрие неща, които са създадени от сървърът.", + "dialog_success": "Изчистване на зона от радиус - %{radius}m", + "dialog_error": "Невалиден радиус. Опитай отново." + }, + "player_ids": { + "title": "Покзване на ID-та", + "label": "Показване на ID-та на играчи (и други неща) над главите на играчите.", + "alert_show": "Покзване на ID-та в близост.", + "alert_hide": "Скриване на ID-та в близост." + } + }, + "page_players": { + "misc": { + "online_players": "Онлайн играчи", + "players": "Играчи", + "search": "Търси", + "zero_players": "Няма намерени играчи" + }, + "filter": { + "label": "Филтриране по", + "no_filter": "Без филтър", + "is_admin": "Е администратор", + "is_injured": "Е Ранен / Мъртъв", + "in_vehicle": "В превозно средство" + }, + "sort": { + "label": "Сортирай", + "distance": "Дистанция", + "id": "ID", + "joined_first": "Влязъл първи", + "joined_last": "Влязъл последен", + "closest": "В близост", + "farthest": "В далечност" + }, + "card": { + "health": "%{percentHealth}% кръв" + } + }, + "player_modal": { + "misc": { + "error": "Възникна грешка, когато се поитваем да извлечем списъкът с играчи. Грешката е показана по-долу:", + "target_not_found": "Не можахме да намерим играч с ID или име на %{target}" + }, + "tabs": { + "actions": "Действия", + "info": "Информация", + "ids": "ID", + "history": "История", + "ban": "Бан" + }, + "actions": { + "title": "Административни опции", + "command_sent": "Командата успешна!", + "moderation": { + "title": "Модериране", + "options": { + "dm": "ЛС", + "warn": "Предупреди", + "kick": "Изритай", + "set_admin": "Дай права" + }, + "dm_dialog": { + "title": "ЛС", + "description": "Каква е причината, за изпращане на лично съобщение до играча?", + "placeholder": "Причина...", + "success": "Твоето съобщение беше изпратено!" + }, + "warn_dialog": { + "title": "Предупреди", + "description": "Каква е причината, за да предупредиш този играч?", + "placeholder": "Причина...", + "success": "Играчът беше предупреден!" + }, + "kick_dialog": { + "title": "Изритай", + "description": "Каква е причината за изгонването на този играч?", + "placeholder": "Причина...", + "success": "Играчът беше изритан от сървъра!" + } + }, + "interaction": { + "title": "Взаимодействие", + "options": { + "heal": "Излекувай", + "go_to": "Отиди до", + "bring": "Доведи", + "spectate": "Наблюдавай", + "toggle_freeze": "Замръзи" + }, + "notifications": { + "heal_player": "Излекуване на играч", + "tp_player": "Телепортиране до играч", + "bring_player": "Телепортиране на играч до теб", + "spectate_failed": "Не успешно наблюдаване! Излизане от наблюдение.", + "spectate_yourself": "Не можеш да наблюдаваш себе си.", + "freeze_yourself": "Не можеш да замръзиш себе си.", + "spectate_cycle_failed": "Няма играчи, към които да се движите." + } + }, + "troll": { + "title": "Трол", + "options": { + "drunk": "Пиян Ефект", + "fire": "Запали", + "wild_attack": "Атака от диви животни" + } + } + }, + "info": { + "title": "Информация за играч", + "session_time": "Игрално време", + "play_time": "Цяло игрално Време", + "joined": "Влязъл", + "whitelisted_label": "В белия списък", + "whitelisted_notyet": "все още не", + "btn_wl_add": "ДОБАВИ БС", + "btn_wl_remove": "ПРЕМАХНЕТЕ БС", + "btn_wl_success": "Състоянието на белия списък е променено.", + "log_label": "Log", + "log_empty": "Няма намерени банове/предупреждения.", + "log_ban_count": "%{smart_count} бан |||| %{smart_count} банове", + "log_warn_count": "%{smart_count} предупреждение |||| %{smart_count} предупреждения", + "log_btn": "ПОДРОБНОСТИ", + "notes_changed": "Бележката на играча е променена.", + "notes_placeholder": "Бележки за този играч..." + }, + "history": { + "title": "История на Бановете...", + "btn_revoke": "ОТМЕНИ", + "revoked_success": "Действието е отменено!", + "banned_by": "БАННАТ от %{author}", + "warned_by": "ПРЕДУПРЕДЕН от %{author}", + "revoked_by": "Отменено от %{author}.", + "expired_at": "Изтекъл на %{date}.", + "expires_at": "Изтича на %{date}." + }, + "ban": { + "title": "Банни играч", + "reason_placeholder": "Причина", + "duration_placeholder": "Продължителност", + "hours": "Часа", + "days": "Дни", + "weeks": "Седмици", + "months": "Месеци", + "permanent": "Перманентно", + "custom": "Персонализирано", + "helper_text": "Моля избери продължителност", + "submit": "Приложи бан", + "reason_required": "Полето Причина е задължително.", + "success": "Играчът е баннат!" + }, + "ids": { + "current_ids": "Текущи идентификатори", + "previous_ids": "Използвани преди това идентификатори", + "all_hwids": "Всички хардуерни ID-та" + } + } + } +} diff --git a/locale/bs.json b/locale/bs.json new file mode 100644 index 0000000..03be971 --- /dev/null +++ b/locale/bs.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Bosnian", + "humanizer_language": "en" + }, + "restarter": { + "server_unhealthy_kick_reason": "server treba restartovati, molim te ponovo se poveži", + "partial_hang_warn": "Due to a partial hang, this server will restart in 1 minute. Please disconnect now.", + "partial_hang_warn_discord": "Due to a partial hang, **%{servername}** will restart in 1 minute.", + "schedule_reason": "restart je zakazan za %{time}", + "schedule_warn": "Ovaj server je zakazan da se restarta za %{smart_count} minutu. Molim vas se isključitite. |||| Ovaj server ce se restartovati za %{smart_count} minuta.", + "schedule_warn_discord": "**%{servername}** restarta se za %{smart_count} minutu. |||| **%{servername}** restarta se za %{smart_count} minuta." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Zabranjen vam je pristup ovom serveru zbog \"%{reason}\". Vaša zabrana istječe za: %{expiration}.", + "kick_permanent": "(%{author}) Zabranjen vam je pristup ovom serveru za uvjek zbog \"%{reason}\".", + "reject": { + "title_permanent": "You have been permanently banned from this server.", + "title_temporary": "You have been temporarily banned from this server.", + "label_expiration": "Your ban will expire in", + "label_date": "Ban Date", + "label_author": "Banned by", + "label_reason": "Ban Reason", + "label_id": "Ban ID", + "note_multiple_bans": "Note: you have more than one active ban on your identifiers.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "This server is in Admin-only mode.", + "insufficient_ids": "You do not have discord or fivem identifiers, and at least one of them is required to validate if you are a txAdmin administrator.", + "deny_message": "Your identifiers are not assigned to any txAdmin administrator." + }, + "guild_member": { + "mode_title": "This server is in Discord server Member Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "You are required to join our Discord server to connect.", + "deny_message": "Please join the guild %{guildname} then try again." + }, + "guild_roles": { + "mode_title": "This server is in Discord Role Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "You are required to join our Discord server to connect.", + "deny_notmember_message": "Please join %{guildname}, get one of the required roles, then try again.", + "deny_noroles_title": "You do not have a whitelisted role required to join.", + "deny_noroles_message": "To join this server you are required to have at least one of the whitelisted roles on the guild %{guildname}." + }, + "approved_license": { + "mode_title": "This server is in License Whitelist mode.", + "insufficient_ids": "You do not have the license identifier, which means the server has sv_lan enabled. If you are the server owner, you can disable it in the server.cfg file.", + "deny_title": "You are not whitelisted to join this server.", + "request_id_label": "Request ID" + } + }, + "server_actions": { + "restarting": "Server se restartuje: (%{reason}).", + "restarting_discord": "**%{servername}** se restarta zbog: (%{reason}).", + "stopping": "Server je bio zaustavljien: (%{reason}).", + "stopping_discord": "**%{servername}** se gasi: (%{reason}).", + "spawning_discord": "**%{servername}** se pali" + }, + "nui_warning": { + "title": "WARNING", + "warned_by": "Vas je upozorio", + "stale_message": "Ovo upozorenje je izdato prije nego što ste se povezali na server.", + "dismiss_key": "SPACE", + "instruction": "držite %{key} %{smart_count} sekundu da vam se skine ova poruka. |||| držite %{key} %{smart_count} sekundi da vam se skine ova poruka." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menu enabled, type /tx to open it.\nYou can also configure a keybind at [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Your identifiers do not match any admin registered on txAdmin.\nIf you are registered on txAdmin, go to Admin Manager and make sure your identifiers are saved.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "You do not have this permission.", + "unknown_error": "An unknown error occurred.", + "not_enabled": "The txAdmin Menu is not enabled! You can enable it in the txAdmin settings page.", + "announcement_title": "Server Announcement by %{author}:", + "dialog_empty_input": "You cannot have an empty input.", + "directmessage_title": "DM from admin %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "You have frozen the player!", + "unfroze_player": "You have unfrozen the player!", + "was_frozen": "You have been frozen by a server admin!" + }, + "common": { + "cancel": "Cancel", + "submit": "Submit", + "error": "An error occurred", + "copied": "Copied to clipboard." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Use %{key} to switch pages & the arrow keys to navigate menu items", + "tooltip_2": "Certain menu items have sub options which can be selected using the left & right arrow keys" + }, + "player_mode": { + "title": "Player Mode", + "noclip": { + "title": "NoClip", + "label": "Fly around", + "success": "NoClip enabled" + }, + "godmode": { + "title": "God", + "label": "Invincible", + "success": "God Mode enabled" + }, + "superjump": { + "title": "Super Jump", + "label": "Toggle super jump mode, the player will also run faster", + "success": "Super Jump enabled" + }, + "normal": { + "title": "Normal", + "label": "Default mode", + "success": "Returned to default player mode." + } + }, + "teleport": { + "title": "Teleport", + "generic_success": "Sent you into the wormhole!", + "waypoint": { + "title": "Waypoint", + "label": "Go to waypoint set", + "error": "You have no waypoint set." + }, + "coords": { + "title": "Coords", + "label": "Go to specified coords", + "dialog_title": "Teleport", + "dialog_desc": "Provide coordinates in an x, y, z format to go through the wormhole.", + "dialog_error": "Invalid coordinates. Must be in the format of: 111, 222, 33" + }, + "back": { + "title": "Back", + "label": "Go back to last location", + "error": "You don't have a last location to go back to!" + }, + "copy": { + "title": "Copy Coords", + "label": "Copy coords to clipboard." + } + }, + "vehicle": { + "title": "Vehicle", + "not_in_veh_error": "You are not currently in a vehicle!", + "spawn": { + "title": "Spawn", + "label": "Spawn vehicle by model name", + "dialog_title": "Spawn vehicle", + "dialog_desc": "Enter in the model name of the vehicle you want to spawn.", + "dialog_success": "Vehicle spawned!", + "dialog_error": "The vehicle model name '%{modelName}' does not exist!", + "dialog_info": "Trying to spawn %{modelName}." + }, + "fix": { + "title": "Fix", + "label": "Fix the current vehicle", + "success": "Vehicle fixed!" + }, + "delete": { + "title": "Delete", + "label": "Delete the current vehicle", + "success": "Vehicle deleted!" + }, + "boost": { + "title": "Boost", + "label": "Boost the car to achieve max fun (and maybe speed)", + "success": "Vehicle boosted!", + "already_boosted": "This vehicle was already boosted.", + "unsupported_class": "This vehicle class is not supported.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Heal", + "myself": { + "title": "Myself", + "label": "Restores your health", + "success_0": "All healed up!", + "success_1": "You should be feeling good now!", + "success_2": "Restored to full!", + "success_3": "Ouchies fixed!" + }, + "everyone": { + "title": "Everyone", + "label": "Will heal & revive all players", + "success": "Healed and revived all players." + } + }, + "announcement": { + "title": "Send Announcement", + "label": "Send an announcement to all online players.", + "dialog_desc": "Send an announcement to all online players.", + "dialog_placeholder": "Your announcement...", + "dialog_success": "Sending the announcement." + }, + "clear_area": { + "title": "Reset World Area", + "label": "Reset a specified world area to its default state", + "dialog_desc": "Please enter the radius where you wish to reset entities in (0-300). This will not clear entities spawned server side.", + "dialog_success": "Clearing area with radius of %{radius}m", + "dialog_error": "Invalid radius input. Try again." + }, + "player_ids": { + "title": "Toggle Player IDs", + "label": "Toggle showing player IDs (and other info) above the head of all nearby players", + "alert_show": "Showing nearby player NetIDs.", + "alert_hide": "Hiding nearby player NetIDs." + } + }, + "page_players": { + "misc": { + "online_players": "Online Players", + "players": "Players", + "search": "Search", + "zero_players": "No players found." + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Sort by", + "distance": "Distance", + "id": "ID", + "joined_first": "Joined First", + "joined_last": "Joined Last", + "closest": "Closest", + "farthest": "Farthest" + }, + "card": { + "health": "%{percentHealth}% health" + } + }, + "player_modal": { + "misc": { + "error": "An error occurred fetching this users details. The error is shown below:", + "target_not_found": "Was unable to find an online player with ID or a username of %{target}" + }, + "tabs": { + "actions": "Actions", + "info": "Info", + "ids": "IDs", + "history": "History", + "ban": "Ban" + }, + "actions": { + "title": "Player Actions", + "command_sent": "Command sent!", + "moderation": { + "title": "Moderation", + "options": { + "dm": "DM", + "warn": "Warn", + "kick": "Kick", + "set_admin": "Give Admin" + }, + "dm_dialog": { + "title": "Direct Message", + "description": "What is the reason for direct messaging this player?", + "placeholder": "Reason...", + "success": "Your DM has been sent!" + }, + "warn_dialog": { + "title": "Warn", + "description": "What is the reason for direct warning this player?", + "placeholder": "Reason...", + "success": "Player warned!" + }, + "kick_dialog": { + "title": "Kick", + "description": "What is the reason for kicking this player?", + "placeholder": "Reason...", + "success": "Player kicked!" + } + }, + "interaction": { + "title": "Interaction", + "options": { + "heal": "Heal", + "go_to": "Go to", + "bring": "Bring", + "spectate": "Spectate", + "toggle_freeze": "Toggle Freeze" + }, + "notifications": { + "heal_player": "Healing player", + "tp_player": "Teleporting to player", + "bring_player": "Summoning player", + "spectate_failed": "Failed to resolve the target! Exiting spectate.", + "spectate_yourself": "You cannot spectate yourself.", + "freeze_yourself": "You cannot freeze yourself.", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Make Drunk", + "fire": "Set Fire", + "wild_attack": "Wild attack" + } + } + }, + "info": { + "title": "Player info", + "session_time": "Session Time", + "play_time": "Play time", + "joined": "Joined", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "not yet", + "btn_wl_add": "ADD WL", + "btn_wl_remove": "REMOVE WL", + "btn_wl_success": "Whitelist status changed.", + "log_label": "Log", + "log_empty": "No bans/warns found.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} warn |||| %{smart_count} warns", + "log_btn": "DETAILS", + "notes_changed": "Player note changed.", + "notes_placeholder": "Notes about this player..." + }, + "history": { + "title": "Related history", + "btn_revoke": "REVOKE", + "revoked_success": "Action revoked!", + "banned_by": "BANNED by %{author}", + "warned_by": "WARNED by %{author}", + "revoked_by": "Revoked by %{author}.", + "expired_at": "Expired at %{date}.", + "expires_at": "Expires at %{date}." + }, + "ban": { + "title": "Ban player", + "reason_placeholder": "Reason", + "duration_placeholder": "Duration", + "hours": "hours", + "days": "days", + "weeks": "weeks", + "months": "months", + "permanent": "Permanent", + "custom": "Custom", + "helper_text": "Please select a duration", + "submit": "Apply ban", + "reason_required": "The Reason field is required.", + "success": "Player banned!" + }, + "ids": { + "current_ids": "Current Identifiers", + "previous_ids": "Previously Used Identifiers", + "all_hwids": "All Hardware IDs" + } + } + } +} diff --git a/locale/cs.json b/locale/cs.json new file mode 100644 index 0000000..2c06fd8 --- /dev/null +++ b/locale/cs.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Czech", + "humanizer_language": "cs" + }, + "restarter": { + "server_unhealthy_kick_reason": "server je třeba restartovat, prosím, připoj se znovu", + "partial_hang_warn": "Kvůli částečnému přerušení připojení se tento server restartuje za 1 minutu. Odpojte se prosím.", + "partial_hang_warn_discord": "Kvůli částečnému přerušení připojení se **%{servername}** restartuje za 1 minutu.", + "schedule_reason": "plánovaný restart v %{time}", + "schedule_warn": "Tento server má naplánovaný restart za %{smart_count} minutu. Prosím odpojte se nyní. |||| Tento server má naplánovaný restart za %{smart_count} minut.", + "schedule_warn_discord": "**%{servername}** má naplánovaný restart za %{smart_count} minutu. |||| **%{servername}** má naplánovaný restart za %{smart_count} minut." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Byl jsi zabanován z důvodu: \"%{reason}\". Tvůj ban vyprší za: %{expiration}.", + "kick_permanent": "(%{author}) Byl ti permanentně odebrán přístup k serveru a to z důvodu: \"%{reason}\".", + "reject": { + "title_permanent": "Byl jsi permanentně zabanován z tohoto serveru.", + "title_temporary": "Byl jsi dočasně zabanován z tohoto serveru.", + "label_expiration": "Tvůj ban vyprší za", + "label_date": "Datum udělení", + "label_author": "Admin, který ti ban udělil", + "label_reason": "Důvod banu", + "label_id": "ID Banu", + "note_multiple_bans": "Poznámka: Tvoje identifiery mají více aktivních banů.", + "note_diff_license": "Poznámka: Tento ban byl udělen na herní licenci, což znamená že tvoje IDs/HWIDs se shodují s aktivním banem." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Tento server je v režimu Pouze pro Adminy.", + "insufficient_ids": "Nemáš žádné discord nebo fivem identifiery, který je potřeba pro ověření, zda jsi txAdmin admin.", + "deny_message": "Tvé identifiery se neschodují s žádným identifierem admina." + }, + "guild_member": { + "mode_title": "Tento server je v režimu pro Discord Členy.", + "insufficient_ids": "Nemáš vhodný discord identifier, který je potřeba pro ověření, zda jsi na našem Discord serveru. Otevři Discord aplikaci a zkus se připojit znovu (Webová verze nebude fungovat).", + "deny_title": "Musíš být na našem Discord serveru pro připojení.", + "deny_message": "Připoj se prosím na %{guildname} a zkus to znovu." + }, + "guild_roles": { + "mode_title": "Tento server je v režimu Discord Whitelist.", + "insufficient_ids": "Nemáš vhodný discord identifier, který je potřeba pro ověření, zda jsi na našem Discord serveru. Otevři Discord aplikaci a zkus se připojit znovu (Webová verze nebude fungovat).", + "deny_notmember_title": "Musíš být na našem Discord serveru.", + "deny_notmember_message": "Připoj se prosím na %{guildname}, získej jednu s potřebných rolí a připoj se znova.", + "deny_noroles_title": "Nemáš potřebnou whitelist roli.", + "deny_noroles_message": "Pro připojení musíš mít jednu z whitelist rolí na Discord serveru %{guildname}." + }, + "approved_license": { + "mode_title": "Tento server je v režimu License Whitelist.", + "insufficient_ids": "Nemáš žádný license identifier, což znamená že server má zapnutý sv_lan. Pokud jsi vlastník serveru, můžeš toto vypnout v souboru server.cfg.", + "deny_title": "Nejsi oprávněný pro připojení na tento server.", + "request_id_label": "ID Žádosti" + } + }, + "server_actions": { + "restarting": "Restartování serveru (%{reason}).", + "restarting_discord": "**%{servername}** se restartuje, z důvodu: %{reason}", + "stopping": "Server se vypíná, z důvodu: %{reason}", + "stopping_discord": "**%{servername}** se vypíná. Důvod: %{reason}.", + "spawning_discord": "**%{servername}** se spouští." + }, + "nui_warning": { + "title": "VAROVÁNÍ", + "warned_by": "Admin, který tě varoval:", + "stale_message": "Toto varování bylo vydáno předtím, než jste se připojili k serveru.", + "dismiss_key": "MEZERNÍK", + "instruction": "Podrž %{key} po dobu %{smart_count} sekundy pro zavření varování. |||| Podrž %{key} po dobu %{smart_count} sekund pro zavření varování." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menu povoleno, napiš /tx pro otevření.\nmůžeš také nakonfigurovat bind v [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Tvé identifiery se neshodují s identifiery žádného administrátora.\nPokud jsi registrovaný v txAdminu, jdi do sekce Admin Manager a ujisti se, že tvé identifiery jsou uložené.", + "menu_auth_failed": "txAdmin Menu ověření selhalo, s důvodem: %{reason}", + "no_perms": "Na tuto možnost nemáš práva.", + "unknown_error": "Nastala neznámá chyba.", + "not_enabled": "txAdmin menu není povoleno! Můžeš ho povolit v txAdmin nastavení.", + "announcement_title": "Server oznámení od %{author}:", + "dialog_empty_input": "Políčko nemůže být prázdné.", + "directmessage_title": "Soukromá zpráva od admina %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Zmrazil jsi hráče!", + "unfroze_player": "Odmrazil jsi hráče!", + "was_frozen": "Byl jsi zmrazen administrátorem serveru!" + }, + "common": { + "cancel": "Zrušit", + "submit": "Potvrdit", + "error": "Nastala chyba", + "copied": "Zkopírováno do schránky." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Použij %{key} pro změnu stránek & šipky pro pohyb v daném menu", + "tooltip_2": "Některé položky v menu mají další možnosti, které lze vybrat pomocí kláves se šipkami doleva a doprava" + }, + "player_mode": { + "title": "Režim hráče", + "noclip": { + "title": "No-Clip", + "label": "Létání", + "success": "No-Clip povolen" + }, + "godmode": { + "title": "Nesmrtelnost", + "label": "Neporazitelnost", + "success": "Nesmrtelnost povolena" + }, + "superjump": { + "title": "Super Skok", + "label": "Zapne režim super skoku, s tímto také můžeš rychleji běhat", + "success": "Super Skok povolen" + }, + "normal": { + "title": "Normální", + "label": "Základní mód", + "success": "Navráceno do základního hráčského módu." + } + }, + "teleport": { + "title": "Teleportovat se", + "generic_success": "Teleportovat se!", + "waypoint": { + "title": "Bod", + "label": "Teleportovat se na určený bod", + "error": "Nemáš určený žádný bod." + }, + "coords": { + "title": "Souřadnice", + "label": "Teleportovat se na určité souřadnice", + "dialog_title": "Teleportovat se", + "dialog_desc": "Zadejte souřadnice ve formátu x, y, z pro teleportaci.", + "dialog_error": "Neplatné souřadnice. Must be in the format of: 111, 222, 33" + }, + "back": { + "title": "Zpět", + "label": "Teleportovat se zpět na poslední lokaci", + "error": "Nemáš žídnou poslední lokaci pro teleportaci!" + }, + "copy": { + "title": "Zkopírovat souřadnice", + "label": "Zkopírovat souřadnice do schránky." + } + }, + "vehicle": { + "title": "Vozidlo", + "not_in_veh_error": "Momentálně nejsi ve vozidle!", + "spawn": { + "title": "Vytvořit", + "label": "vytvořit vozidlo podle názvu modelu", + "dialog_title": "Vytvočit vozidlo", + "dialog_desc": "Vlož název modelu vozidla pro vytvoření.", + "dialog_success": "Vozidlo vytvořeno!", + "dialog_error": "Název modelu '%{modelName}' neexistuje!", + "dialog_info": "Pokoušíš se vytvořit %{modelName}." + }, + "fix": { + "title": "Opravit", + "label": "Opraví tvé vozidlo", + "success": "Vozidlo opraveno!" + }, + "delete": { + "title": "Smazat", + "label": "Smaže tvé vozidlo vozidlo", + "success": "Vozidlo smazáno!" + }, + "boost": { + "title": "Vylepšení", + "label": "Vylepší tvé vozidlo pro největší zábavu (a možná rychlost)", + "success": "Vozidlo vylepšeno!", + "already_boosted": "Toto vozidlo již je vylepšeno.", + "unsupported_class": "Typ tohoto vozidla není možný vylepšit.", + "redm_not_mounted": "Vylepšit svého koně můžeš jen tehdy, když na něm sedíš." + } + }, + "heal": { + "title": "Vyléčit", + "myself": { + "title": "Sebe", + "label": "Obnoví tvé životy", + "success_0": "Vše obnoveno!", + "success_1": "Nyní by jsi se měl cítit lépe!", + "success_2": "Obnoveno zpět na maximum!", + "success_3": "Hotovo!" + }, + "everyone": { + "title": "Všechny", + "label": "Vyléčí a oživí všechny hráče", + "success": "Všichni hráči oživeni a vyléčeni." + } + }, + "announcement": { + "title": "Odeslat oznámení", + "label": "Odeslat oznámení všem online hráčům.", + "dialog_desc": "Odeslat oznámení všem online hráčům.", + "dialog_placeholder": "Tvé oznámení...", + "dialog_success": "Odesílání oznámení." + }, + "clear_area": { + "title": "Obnovit oblast", + "label": "Obnovit určitou oblast do původního stavu", + "dialog_desc": "Prosím vlož oblast pro obnovení (0-300). Toto nevytvoří entity vytvoření pomocí serveru.", + "dialog_success": "Obnovuji oblast %{radius}m", + "dialog_error": "Neplatná oblast. Zkus to prosím znovu." + }, + "player_ids": { + "title": "Zapnout ID hráčů", + "label": "Zapnout ukazování ID hráčů (a další info) nad hlavou hráčů poblíž", + "alert_show": "Ukazuji ID hráčů poblíž.", + "alert_hide": "Skrývám ID hráčů poblíž." + } + }, + "page_players": { + "misc": { + "online_players": "Online hráči", + "players": "Hráči", + "search": "Vyhledat", + "zero_players": "Žádní hráči nenalezeni" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Seřadit podle", + "distance": "Vzdálenosti", + "id": "ID", + "joined_first": "Připojený jako první", + "joined_last": "Připojený jako poslední", + "closest": "Nejblíže", + "farthest": "Nejdále" + }, + "card": { + "health": "%{percentHealth}% Životů" + } + }, + "player_modal": { + "misc": { + "error": "Nastala chyba při získávání informací o hráči. Chyba je zobrazena níže:", + "target_not_found": "Nebylo možné najít on-line hráče se jménem/ID: %{target}" + }, + "tabs": { + "actions": "Akce", + "info": "Informace", + "ids": "ID", + "history": "Historie", + "ban": "Zabanovat" + }, + "actions": { + "title": "Akce hráčů", + "command_sent": "Příkaz odeslán!", + "moderation": { + "title": "Moderace", + "options": { + "dm": "Soukromá zpráva", + "warn": "Varovat", + "kick": "Vyhodit", + "set_admin": "Přidat administrátora" + }, + "dm_dialog": { + "title": "Soukromá zpráva", + "description": "Důvod odesílání soukromé zprávy", + "placeholder": "Důvod...", + "success": "Tvá soukromá zpráva byla odeslána!" + }, + "warn_dialog": { + "title": "Varovat", + "description": "Důvod varování hráče", + "placeholder": "Důvod...", + "success": "Hráč byl varován!" + }, + "kick_dialog": { + "title": "Vyhodit", + "description": "Důvod vyhození hráče", + "placeholder": "Důvod...", + "success": "Hráč byl vyhozen!" + } + }, + "interaction": { + "title": "Interakce", + "options": { + "heal": "Vyléčit", + "go_to": "Teleportovat se", + "bring": "Přinést", + "spectate": "Pozorovat", + "toggle_freeze": "Zmrazit" + }, + "notifications": { + "heal_player": "Léčím hráče", + "tp_player": "Teleportuji k hráči", + "bring_player": "Přináším hráče", + "spectate_failed": "Nepodařilo se udržet signál pozorování! Vypínám pozorování.", + "spectate_yourself": "Nemůžeš pozorovat sebe.", + "freeze_yourself": "Nemůžeš zmrazit sebe.", + "spectate_cycle_failed": "Žádní dostupní hráči, na které by se dalo přepnout." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Udělat opilým", + "fire": "Zapálit", + "wild_attack": "Zaútočit zvířaty" + } + } + }, + "info": { + "title": "Informace o hráči", + "session_time": "Čas od připojení", + "play_time": "Odehraný čas", + "joined": "Připojeno", + "whitelisted_label": "Whitelist", + "whitelisted_notyet": "nemá", + "btn_wl_add": "PŘIDAT WL", + "btn_wl_remove": "ODEBRAT WL", + "btn_wl_success": "Stav whitelistu byl změněn.", + "log_label": "Log", + "log_empty": "Žádné bany, či varování.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bany", + "log_warn_count": "%{smart_count} varování |||| %{smart_count} varování", + "log_btn": "PODROBNOSTI", + "notes_changed": "Poznámka o hráči změněna.", + "notes_placeholder": "Poznámky o tomto hráči..." + }, + "history": { + "title": "Související historie", + "btn_revoke": "ZRUŠIT", + "revoked_success": "Akce zrušena!", + "banned_by": "ZABANOVÁN adminem %{author}", + "warned_by": "VAROVÁN adminem %{author}", + "revoked_by": "Zrušeno adminem %{author}.", + "expired_at": "Vypršelo %{date}.", + "expires_at": "Vyprší %{date}." + }, + "ban": { + "title": "Zabanovat hráče", + "reason_placeholder": "Důvod", + "duration_placeholder": "Délka trvání", + "hours": "hodiny", + "days": "dny", + "weeks": "týdny", + "months": "měsíce", + "permanent": "Permanentní", + "custom": "Vlastní", + "helper_text": "Vyber délku banu!", + "submit": "Zabanovat", + "reason_required": "Důvod nemůže zůstat prázdný.", + "success": "Hráč byl zabanován!" + }, + "ids": { + "current_ids": "Aktivní identifiery", + "previous_ids": "Dříve použité identifiery", + "all_hwids": "Všechny ID hardwaru" + } + } + } +} diff --git a/locale/da.json b/locale/da.json new file mode 100644 index 0000000..dd92c45 --- /dev/null +++ b/locale/da.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Danish", + "humanizer_language": "da" + }, + "restarter": { + "server_unhealthy_kick_reason": "serveren skal genstartes, venligst opret forbindelse igen", + "partial_hang_warn": "På grund af en delvis hængning genstarter denne server om 1 minut. Afslut venligst forbindelsen nu.", + "partial_hang_warn_discord": "På grund af en delvis hængning genstarter **%{servername}** om 1 minut.", + "schedule_reason": "planlagt genstart kl. %{time}", + "schedule_warn": "Denne server er planlagt til at genstarte om %{smart_count} minut. Afslut venligst forbindelsen nu. |||| Denne server er planlagt til at genstarte om %{smart_count} minutter.", + "schedule_warn_discord": "**%{servername}** er planlagt til at genstarte om %{smart_count} minut. |||| **%{servername}** er planlagt til at genstarte om %{smart_count} minutter." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Du er blevet bannet fra denne server på grund af \"%{reason}\". Dit ban udløber om: %{expiration}.", + "kick_permanent": "(%{author}) Du er blevet permanent udelukket fra denne server på grund af \"%{reason}\".", + "reject": { + "title_permanent": "Du er blevet permanent bannet fra denne server.", + "title_temporary": "Du er blevet midlertidigt bannet fra denne server.", + "label_expiration": "Dit ban udløber om", + "label_date": "Ban Dato", + "label_author": "Bannet af", + "label_reason": "Ban Årsag", + "label_id": "Ban ID", + "note_multiple_bans": "Bemærk: Du har mere end én aktiv ban på dine identifikatorer.", + "note_diff_license": "Bemærk: Bannet ovenfor blev påført en anden licens, hvilket betyder, at nogle af dine IDs/HWIDs matcher dem, der er forbundet med det ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Denne server er i Kun for admin-tilstand.", + "insufficient_ids": "Du har ikke discord eller fivem identifikatorer, og mindst én af dem er påkrævet for at verificere, om du er en txAdmin-administrator.", + "deny_message": "Dine identifikatorer er ikke tilknyttet nogen txAdmin-administrator." + }, + "guild_member": { + "mode_title": "Denne server er i Discord server-medlem Whitelist-tilstand.", + "insufficient_ids": "Du har ikke discord-identifikatoren, som er påkrævet for at verificere, om du er medlem af vores Discord server. Åbn venligst Discord Desktop-appen og prøv igen (webappen fungerer ikke).", + "deny_title": "Du skal være medlem af vores Discord server for at oprette forbindelse.", + "deny_message": "Deltag venligst i guildet %{guildname} og prøv igen." + }, + "guild_roles": { + "mode_title": "Denne server er i Discord Rolle Whitelist-tilstand.", + "insufficient_ids": "Du har ikke discord-identifikatoren, som er påkrævet for at verificere, om du er medlem af vores Discord server. Åbn venligst Discord Desktop-appen og prøv igen (webappen fungerer ikke).", + "deny_notmember_title": "Du skal være medlem af vores Discord server for at oprette forbindelse.", + "deny_notmember_message": "Deltag venligst i %{guildname}, få en af de påkrævede roller, og prøv igen.", + "deny_noroles_title": "Du har ikke en whitelisted rolle, der kræves for at deltage.", + "deny_noroles_message": "For at deltage på denne server kræves det, at du har mindst en af de whitelistede roller på guildet %{guildname}." + }, + "approved_license": { + "mode_title": "Denne server er i Licens Whitelist-tilstand.", + "insufficient_ids": "Du har ikke license-identifikatoren, hvilket betyder, at serveren har sv_lan aktiveret. Hvis du er serverejeren, kan du deaktivere det i server.cfg-filen.", + "deny_title": "Du er ikke whitelisted til at deltage i denne server.", + "request_id_label": "Anmodnings-ID" + } + }, + "server_actions": { + "restarting": "Serveren genstarter: (%{reason}).", + "restarting_discord": "**%{servername}** genstarter: (%{reason}).", + "stopping": "Serveren er stoppet (%{reason}).", + "stopping_discord": "**%{servername}** er lukket ned: (%{reason}).", + "spawning_discord": "**%{servername}** starter." + }, + "nui_warning": { + "title": "ADVARSEL", + "warned_by": "Advarsel afsendt fra:", + "stale_message": "Denne advarsel blev udsendt, før du tilsluttede dig serveren.", + "dismiss_key": "MELLEMRUM", + "instruction": "Hold %{key} nede i %{smart_count} sekund for at afvise denne meddelelse. |||| Hold %{key} nede i %{smart_count} sekunder for at afvise denne meddelelse." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menu aktiveret, skriv /tx for at åbne den.\nDu kan også konfigurere egne keybinds under [Spilindstillinger > Nøglebindinger > FiveM > Menu: Åbn hovedside].", + "menu_not_admin": "Dine identifikatorer matcher ikke nogen admin registreret på txAdmin.\nHvis du er registreret på txAdmin, skal du gå til Admin Manager og sørge for, at dine identifikatorer er gemt.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Du har ikke denne tilladelse.", + "unknown_error": "Der opstod en ukendt fejl.", + "not_enabled": "txAdmin-menuen er ikke aktiveret! Det kan du fra txAdmin-panelet.", + "announcement_title": "Servermeddelelse sendt af %{author}:", + "directmessage_title": "DM from admin %{author}:", + "dialog_empty_input": "der mangler input.", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Du har frosset spilleren fast!", + "unfroze_player": "Du har frigivet spilleren!", + "was_frozen": "Du er blevet frosset, af en serveradministrator!" + }, + "common": { + "cancel": "Anullér", + "submit": "Indsend", + "error": "Der opstod en fejl", + "copied": "Kopiéret til udklipsholderen." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Brug %{key} for at skifte side, piletasterne for at navigere i menupunkter", + "tooltip_2": "Bemærk der findes underkategorier, brug venstre eller højre piletaster" + }, + "player_mode": { + "title": "Spillertilstand", + "noclip": { + "title": "NoClip", + "label": "Flyv rundt", + "success": "NoClip aktiveret" + }, + "godmode": { + "title": "Gud", + "label": "Uovervindelig", + "success": "Gud tilstand aktiveret" + }, + "superjump": { + "title": "Super hop", + "label": "Toggle super hop tilstand, spilleren vil noget hurtigere...", + "success": "Super hop aktiveret" + }, + "normal": { + "title": "Normal", + "label": "Normal tilstand", + "success": "Du er vendt tilbage til alm. tilstand." + } + }, + "teleport": { + "title": "Teleport", + "generic_success": "Der røg du igennem ormehullet!", + "waypoint": { + "title": "Waypoint", + "label": "Gå til- gps lokation", + "error": "Der mangler en gps lokation." + }, + "coords": { + "title": "Koordinater", + "label": "Gå til - angivne koordinater", + "dialog_title": "Teleportér", + "dialog_desc": "Angiv koordinater i et x-, y-, z-format for at gå gennem ormehullet.", + "dialog_error": "Ugyldige koordinater. Skal være i formatet: 111, 222, 33" + }, + "back": { + "title": "Tilbage", + "label": "Gå tilbage til sidste placering", + "error": "Du har ikke et sidste sted at gå tilbage til!" + }, + "copy": { + "title": "Kopier koordinater", + "label": "Kopiér koordinater til udklipsholder." + } + }, + "vehicle": { + "title": "Køretøj", + "not_in_veh_error": "Du mangler et køretøj!", + "spawn": { + "title": "Spawn", + "label": "Spawn køretøj efter modelnavn", + "dialog_title": "Spawn køretøj", + "dialog_desc": "Indtast modelnavnet på det køretøj, du ønsker at spawne.", + "dialog_success": "Køretøjet spawnede!", + "dialog_error": "Køretøjets modelnavn '%{modelName}' findes ikke!", + "dialog_info": "Forsøger at spawne %{modelName}." + }, + "fix": { + "title": "Fiks", + "label": "Ret det aktuelle køretøj", + "success": "Køretøj fikset!" + }, + "delete": { + "title": "Slet", + "label": "Slet det aktuelle køretøj", + "success": "Køretøj slettet!" + }, + "boost": { + "title": "Boost", + "label": "Øg bilen for at opnå maksimal sjov (og måske hastighed)", + "success": "Køretøjet er blevet øget!", + "already_boosted": "Denne bil var allerede øget.", + "unsupported_class": "Denne køretøjklasse understøttes ikke.", + "redm_not_mounted": "Du kan kun øge, når du er monteret på en hest." + } + }, + "heal": { + "title": "Helbred", + "myself": { + "title": "Mig selv", + "label": "Får dig på toppen", + "success_0": "Alle er raske!", + "success_1": "Du burde have det godt nu!", + "success_2": "Gendannet til fuldt liv!", + "success_3": "bummelum rettet!" + }, + "everyone": { + "title": "Alle", + "label": "Vil helbrede og genoplive alle spillere", + "success": "Hellede og genoplivede alle spillere." + } + }, + "announcement": { + "title": "Send meddelelse", + "label": "Send en meddelelse til alle onlinespillere.", + "dialog_desc": "Send en meddelelse til alle online spillere.", + "dialog_placeholder": "Din meddelelse...", + "dialog_success": "Sender meddelelsen." + }, + "clear_area": { + "title": "Nulstil verdensområde", + "label": "Nulstil verdensområde til standardtilstand", + "dialog_desc": "Indtast venligst den radius, hvor du ønsker at nulstille entiteter i (0-300). Dette vil ikke rydde entiteter affødt serverside.", + "dialog_success": "Rydning af område med en radius på %{radius}m", + "dialog_error": "Ugyldig radiusinput. Prøv igen." + }, + "player_ids": { + "title": "Vis spiller-id'er", + "label": "Skift visning af spiller-id'er (og anden info) over hovedet på alle spillere i nærheden.", + "alert_show": "Viser nærliggende spillerens NetID'er.", + "alert_hide": "Skjuler afspillerens NetID'er i nærheden." + } + }, + "page_players": { + "misc": { + "online_players": "Spillere Online", + "players": "Spillere", + "search": "Søg", + "zero_players": "Ingen spillere fundet" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Sortér efter", + "distance": "Afstand", + "id": "ID", + "joined_first": "Tilsluttede først", + "joined_last": "Tilsluttede sidst", + "closest": "Tættest", + "farthest": "Længst" + }, + "card": { + "health": "%{percentHealth}% liv" + } + }, + "player_modal": { + "misc": { + "error": "Der opstod en fejl under indlæsning af spillerdetaljer. Fejlen er vist nedenfor:", + "target_not_found": "Kunne ikke finde spiller med ID eller et brugernavn på %{target}" + }, + "tabs": { + "actions": "Handlinger", + "info": "Info", + "ids": "ID'er", + "history": "Historie", + "ban": "Ban" + }, + "actions": { + "title": "Spillerhandlinger", + "command_sent": "Kommando sendt!", + "moderation": { + "title": "Moderation", + "options": { + "dm": "DM", + "warn": "Advar", + "kick": "Kick", + "set_admin": "Giv Admin" + }, + "dm_dialog": { + "title": "Direkte besked", + "description": "Hvad er årsagen til at sende direkte beskeder til denne afspiller?", + "placeholder": "Årsag...", + "success": "Din DM er blevet sendt!" + }, + "warn_dialog": { + "title": "Advar", + "description": "Hvad er grunden til at advare denne spiller direkte?", + "placeholder": "Årsag...", + "success": "Spilleren blev advaret!" + }, + "kick_dialog": { + "title": "kick", + "description": "Hvad er grunden til at kicke denne spiller?", + "placeholder": "Årsag...", + "success": "Spilleren blev kicket!" + } + }, + "interaction": { + "title": "Interaktion", + "options": { + "heal": "helbred", + "go_to": "Gå til", + "bring": "Bring", + "spectate": "Overvåg", + "toggle_freeze": "Frys spilleren" + }, + "notifications": { + "heal_player": "Helbred spiller", + "tp_player": "Teleportér til spiller", + "bring_player": "HENT spiller til dig(TP)", + "spectate_failed": "Det lykkedes ikke at overvåge målet! Stopper overvågning.", + "spectate_yourself": "Du kan ikke overvåge dig selv.", + "freeze_yourself": "Du kan ikke fryse dig selv.", + "spectate_cycle_failed": "Der er ikke flere spillere på rotationen." + } + }, + "troll": { + "title": "Trold", + "options": { + "drunk": "Gør fuld", + "fire": "Ildspåsætning", + "wild_attack": "Vildt angreb" + } + } + }, + "info": { + "title": "Spiller info", + "session_time": "Sessionstid", + "play_time": "Spilletid", + "joined": "Tilsluttet", + "whitelisted_label": "Allowlisted", + "whitelisted_notyet": "ikke endnu", + "btn_wl_add": "Tilføj AL", + "btn_wl_remove": "FJERN AL", + "btn_wl_success": "ALLOWLIST status ændret.", + "log_label": "Log", + "log_empty": "Ingen bans/advarsler.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} advasler |||| %{smart_count} advarsler", + "log_btn": "DETALJER", + "notes_placeholder": "noter omkring spilleren...", + "notes_changed": "Spiller notat tilføjet." + }, + "ids": { + "current_ids": "Nuværende Identifikatorer", + "previous_ids": "Tidligere Brugte Identifikatorer", + "all_hwids": "Alle Hardware-identifikatorer" + }, + "history": { + "title": "Relateret historie", + "btn_revoke": "TILBAGEFØR", + "revoked_success": "HANDLING TIBAGEFØRT!", + "banned_by": "BANNED af %{author}", + "warned_by": "ADVARET af %{author}", + "revoked_by": "TILBAGEFØRT af %{author}.", + "expired_at": "Udløb d. %{date}.", + "expires_at": "Udløber d. %{date}." + }, + "ban": { + "title": "Forbyd spiller", + "reason_placeholder": "Årsag", + "reason_required": "Grundlagsfelt skal udfyldes.", + "duration_placeholder": "Varighed", + "success": "FARVEL SPILLER!!!1", + "hours": "timer", + "days": "dage", + "weeks": "uger", + "months": "måneder", + "permanent": "Permanent", + "custom": "Brugerdefineret", + "helper_text": "Angiv varighed", + "submit": "Anvend ban" + } + } + } +} diff --git a/locale/de.json b/locale/de.json new file mode 100644 index 0000000..9699699 --- /dev/null +++ b/locale/de.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "German", + "humanizer_language": "de" + }, + "restarter": { + "server_unhealthy_kick_reason": "der server muss neu gestartet werden, bitte verbinde dich erneut", + "partial_hang_warn": "Aufgrund von zeitweiligen Lags startet dieser Server in einer Minute neu. Bitte trenne die Verbindung jetzt.", + "partial_hang_warn_discord": "Aufgrund von zeitweiligen Lags startet **%{servername}** in einer Minute neu.", + "schedule_reason": "Automatisierter Neustart um %{time}", + "schedule_warn": "Der Server wird in einer Minute neu gestartet. Bitte trenne die Verbindung jetzt. |||| Der Server wird in %{smart_count} Minuten neu gestartet.", + "schedule_warn_discord": "**%{servername}** wird in einer Minute neu gestartet. |||| **%{servername}** wird in %{smart_count} Minuten neu gestartet." + }, + "kick_messages": { + "everyone": "Alle Spieler wurden gekickt: %{reason}.", + "player": "Du wurdest gekickt: %{reason}.", + "unknown_reason": "aus unbekanntem Grund" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Du wurdest von diesem Server für %{expiration} gebannt. Grund: \"%{reason}\".", + "kick_permanent": "(%{author}) Du wurdest permanent von diesem Server gebannt. Grund: \"%{reason}\".", + "reject": { + "title_permanent": "Du wurdest permanent von diesem Server gebannt.", + "title_temporary": "Du wurdest temporär von diesem Server gebannt.", + "label_expiration": "Dein Bann läuft ab in", + "label_date": "Bann-Datum", + "label_author": "Gebannt von", + "label_reason": "Grund", + "label_id": "Bann-ID", + "note_multiple_bans": "Info: Es gibt mehr als einen aktiven Bann für diesen Identifier", + "note_diff_license": "Info: der oben angezeigte Ban wurde mit einer anderen license gespeichert. Das bedeutet, dass deine IDs/HWIDs passend sind wie zu diesem Ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Dieser Server befindet sich derzeit im Wartungsmodus", + "insufficient_ids": "Du hast keinen discordoder FiveM Identifier. Mindestens einer von beiden ist erforderlich, um dich als registrierten txAdmin-Administrator zu erkennen.", + "deny_message": "Deine Identifier sind keinem txAdmin-Administrator zugewiesen. Der Zugang wurde verweigert." + }, + "guild_member": { + "mode_title": "Dieser Server befindet sich im Discord-Whitelist Modus.", + "insufficient_ids": "Es konnte kein discord Identifier bei dir gefunden werden. Bitte öffne die Discord-App und versuche es erneut (Die Web-App wird nicht unterstützt).", + "deny_title": "Du musst auf unserem Discord-Server sein, um dich verbinden zu können.", + "deny_message": "Bitte trete unserem Discord %{guildname} bei und versuche es erneut." + }, + "guild_roles": { + "mode_title": "Dieser Server befindet sich im Discord-Rollen-Whitelist Modus.", + "insufficient_ids": "Es konnte kein discord Identifier bei dir gefunden werden. Bitte öffne die Discord-App und versuche es erneut (Die Web-App wird nicht unterstützt).", + "deny_notmember_title": "Du musst auf unserem Discord-Server sein und die nötige Rolle haben, um dich verbinden zu können.", + "deny_notmember_message": "Bitte trete unserem Discord %{guildname} bei und hole dir die erforderliche(n) Rolle(n). Versuche dich anschließend erneut zu verbinden.", + "deny_noroles_title": "Du hast nicht die erforderliche(n) Rolle(n), um dich verbinden zu können.", + "deny_noroles_message": "Um dich verbinden zu können, benötigst du mindestens eine der dafür erforderlichen Rollen. Discord: %{guildname}." + }, + "approved_license": { + "mode_title": "Dieser Server ist im Whitelist Modus.", + "insufficient_ids": "Du hast keinen License Identifier, was bedeutet, dass dieser Server sv_lan aktiviert hat. Solltest du der Serverbesitzer sein, dann kannst du das in der server.cfg ändern.", + "deny_title": "Du bist nicht auf diesem Server gewhitelisted.", + "request_id_label": "Whitelist-ID" + } + }, + "server_actions": { + "restarting": "Server Neustart (%{reason}).", + "restarting_discord": "**%{servername}** wird neu gestartet (%{reason}).", + "stopping": "Server wird heruntergefahren (%{reason}).", + "stopping_discord": "**%{servername}** wird heruntergefahren (%{reason}).", + "spawning_discord": "**%{servername}** startet." + }, + "nui_warning": { + "title": "VERWARNUNG", + "warned_by": "Verwarnt von:", + "stale_message": "Diese Warnung wurde ausgegeben, bevor Sie sich mit dem Server verbunden haben.", + "dismiss_key": "LEERTASTE", + "instruction": "Halte deine %{key} für %{smart_count} Sekunde gedrückt, um diese Mitteilung zu schließen. |||| Halte deine %{key} für %{smart_count} Sekunden gedrückt, um diese Mitteilung zu schließen." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menü aktiviert, tippe /tx ein, um es zu öffnen.\nDu kannst zusätzlich in den Einstellungen eine Taste zuweisen [Einstellungen > Steuerung > FiveM > txAdmin].", + "menu_not_admin": "Deine IDs stimmen mit keiner bei txAdmin registrierten überein.\n Wenn du bei txAdmin registriert bist, stelle sicher, dass die richtigen IDs gespeichert sind.", + "menu_auth_failed": "Authentifizierung vom txAdmin Menü fehlgeschlagen. Grund: %{reason}.", + "no_perms": "Dafür hast du keine Berechtigung.", + "unknown_error": "Ein unbekannter Fehler ist aufgetreten.", + "not_enabled": "Das txAdmin Menü ist nicht aktiviert. Du kannst es in den txAdmin Einstellungen aktivieren.", + "announcement_title": "Serverweite Ankündigung von %{author}:", + "dialog_empty_input": "Du kannst keine leere Eingabe senden.", + "directmessage_title": "DM von Admin %{author}:", + "onesync_error": "Für diese Aktion muss OneSync aktiviert sein." + }, + "frozen": { + "froze_player": "Du hast den Spieler eingefroren.", + "unfroze_player": "Du hast den Spieler aufgetaut.", + "was_frozen": "Du wurdest von einem Teammitglied eingefroren!" + }, + "common": { + "cancel": "Abbrechen", + "submit": "Senden", + "error": "Ein Fehler ist aufgetreten.", + "copied": "In die Zwischenablage kopiert." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Drücke %{key} um die Seiten zu wechseln & verwende die Pfeiltasten um im Menü zu navigieren", + "tooltip_2": "Bestimmte Menüpunkte haben Unteroptionen, die mit den linken und rechten Pfeiltasten ausgewählt werden können" + }, + "player_mode": { + "title": "Spieler Modus", + "noclip": { + "title": "NoClip", + "label": "Fliege herum", + "success": "NoClip aktiviert" + }, + "godmode": { + "title": "God Mode", + "label": "Unbesiegbar", + "success": "God Mode aktiviert" + }, + "superjump": { + "title": "Supersprung", + "label": "Der Spieler springt nun höher. Dadurch wird ebenfalls die Laufgeschwindigkeit erhöht", + "success": "Supersprung aktiviert" + }, + "normal": { + "title": "Normal", + "label": "Standard Modus", + "success": "Standard Modus wiederhergestellt." + } + }, + "teleport": { + "title": "TP", + "generic_success": "Spieler teleportiert", + "waypoint": { + "title": "Wegpunkt", + "label": "Teleportiere zu Wegpunkt", + "error": "Du hast keinen Wegpunkt gesetzt!" + }, + "coords": { + "title": "Koordinaten", + "label": "Teleportiere zu Koordinaten", + "dialog_title": "Teleportieren", + "dialog_desc": "Koordinaten im Format x, y, z eingeben.", + "dialog_error": "Ungültige Koordinaten. Muss im Format 111, 222, 33 sein!" + }, + "back": { + "title": "Zurück", + "label": "Zur letzten Position zurück", + "error": "Du hast keine letzte Position" + }, + "copy": { + "title": "Koordinaten Kopieren", + "label": "Kopiere deine Koordinaten (x,y,z) in die Zwischenablage" + } + }, + "vehicle": { + "title": "Fahrzeug", + "not_in_veh_error": "Du befindest dich in keinem Fahrzeug!", + "spawn": { + "title": "Spawn", + "label": "Spawnname eingeben", + "dialog_title": "Fahrzeug spawnen", + "dialog_desc": "Gebe den Spawnname des Fahrzeugs ein, dass du spawnen möchtest.", + "dialog_success": "Fahrzeug gespawnt!", + "dialog_error": "Der Spawnname '%{modelName}' existiert nicht!", + "dialog_info": "Versuche %{modelName} zu spawnen." + }, + "fix": { + "title": "Reparieren", + "label": "Repariere das Fahrzeug, auf dessen Fahrersitz du dich befindest", + "success": "Fahrzeug repariert." + }, + "delete": { + "title": "Entfernen", + "label": "Entferne das Fahrzeug", + "success": "Fahrzeug entfernt." + }, + "boost": { + "title": "Boost", + "label": "Booste das Fahrzeug", + "success": "Fahrzeug geboosted.", + "already_boosted": "Dieses Fahrzeug wurde schon geboosted!", + "unsupported_class": "Diese Fahrzeugart wird nicht unterstützt.", + "redm_not_mounted": "Du kannst dich nur boosten, wenn du auf einem Pferd sitzt." + } + }, + "heal": { + "title": "Heilen", + "myself": { + "title": "Selbst", + "label": "Heilen und wiederbeleben", + "success_0": "Alles genäht und geheilt!", + "success_1": "Jetzt solltest du dich besser fühlen!", + "success_2": "Ein kleines Pflaster und du bist wieder gesund!", + "success_3": "Tat doch gar nicht weh! Du bist wieder gesund." + }, + "everyone": { + "title": "Alle Spieler", + "label": "Heilen und wiederbeleben", + "success": "Alle Spieler geheilt und wiederbelebt" + } + }, + "announcement": { + "title": "Ankündigung schicken", + "label": "Ankündigung an alle Spieler schicken.", + "dialog_desc": "Gebe die Nachricht ein, die an alle Spieler gesendet werden soll.", + "dialog_placeholder": "Deine Ankündigung ...", + "dialog_success": "Ankündigung an alle Spieler geschickt" + }, + "clear_area": { + "title": "Weltbereich zurücksetzen", + "label": "Setze einen Bereich in seinen Ursprungszustand zurück.", + "dialog_desc": "Bitte gib einen Radius (0-300) ein, in dem du alle Objekte zurücksetzen möchtest. Dadurch werden nicht serverseitig erzeugte Objekte gelöscht.", + "dialog_success": "Zurücksetzen im Radius von %{radius}m", + "dialog_error": "Ungültiger Radius! Gib einen Radius zwischen 0 und 300 ein." + }, + "player_ids": { + "title": "Spieler IDs (NameTags)", + "label": "Schalte die Spieler-IDs (und weitere Informationen) über den Köpfen der Spieler ein oder aus.", + "alert_show": "Spieler IDs (NameTags) aktiviert.", + "alert_hide": "Spieler IDs (NameTags) deaktiviert." + } + }, + "page_players": { + "misc": { + "online_players": "Alle Spieler", + "players": "Spieler", + "search": "Suche", + "zero_players": "Keine Spieler gefunden" + }, + "filter": { + "label": "Filtern nach", + "no_filter": "Kein Filter", + "is_admin": "Ist Admin", + "is_injured": "Ist Verletzt / Tot", + "in_vehicle": "Ist in Fahrzeug" + }, + "sort": { + "label": "Sortieren nach", + "distance": "Entfernung", + "id": "ID", + "joined_first": "steigend", + "joined_last": "fallend", + "closest": "am nächsten", + "farthest": "am weitesten" + }, + "card": { + "health": "%{percentHealth}% Leben" + } + }, + "player_modal": { + "misc": { + "error": "Beim Abrufen der Spielerdetails ist ein Fehler aufgetreten. Der Fehler wird unten angezeigt:", + "target_not_found": "Konnte keinen Spieler mit der ID oder dem Nutzernamen von %{target} finden" + }, + "tabs": { + "actions": "Aktionen", + "info": "Info", + "ids": "IDs", + "history": "Vergangenheit", + "ban": "Ban" + }, + "actions": { + "title": "Spieler Aktionen", + "command_sent": "Befehl geschickt!", + "moderation": { + "title": "Moderation", + "options": { + "dm": "DM", + "warn": "Verwarnen", + "kick": "Kicken", + "set_admin": "Admin verwalten" + }, + "dm_dialog": { + "title": "Direktnachricht", + "description": "Was ist der Grund, diesem Spieler eine Direktnachricht zu senden?", + "placeholder": "Grund...", + "success": "Die Nachricht wurde gesendet." + }, + "warn_dialog": { + "title": "Verwarnen", + "description": "Was ist der Grund, diesen Spieler zu verwarnen?", + "placeholder": "Grund...", + "success": "Der Spieler wurde verwarnt!" + }, + "kick_dialog": { + "title": "Kicken", + "description": "Was ist der Grund, diesen Spieler zu kicken?", + "placeholder": "Grund...", + "success": "Der Spieler wurde gekickt!" + } + }, + "interaction": { + "title": "Interaktionen", + "options": { + "heal": "Heilen", + "go_to": "Gehe zu", + "bring": "Bringen", + "spectate": "Beobachten", + "toggle_freeze": "Einfrieren" + }, + "notifications": { + "heal_player": "Spieler heilen", + "tp_player": "Zu Spieler teleportieren", + "bring_player": "Spieler teleportiert", + "spectate_failed": "Der Spieler konnte nicht beobachtet werden! Beobachtung abgebrochen.", + "spectate_yourself": "Du kannst dich nicht selbst beobachten", + "freeze_yourself": "Du kannst dich nicht selbst einfrieren", + "spectate_cycle_failed": "Es gibt keine Spieler auf die gewechselt werden kann." + } + }, + "troll": { + "title": "Trolling", + "options": { + "drunk": "Betrunken machen", + "fire": "Anzünden", + "wild_attack": "Wildtier Angriff" + } + } + }, + "info": { + "title": "Spielerinfos", + "session_time": "Aktive Sitzung", + "play_time": "Spielzeit", + "joined": "Beigetreten", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "noch nicht", + "btn_wl_add": "WL HINZUFÜGEN", + "btn_wl_remove": "WL ENTFERNEN", + "btn_wl_success": "Whitelist-Status geändert", + "log_label": "Log", + "log_empty": "Keine Banns/Verwarnungen gefunden.", + "log_ban_count": "%{smart_count} Bann |||| %{smart_count} Banns", + "log_warn_count": "%{smart_count} Verwarnung |||| %{smart_count} Verwarnungen", + "log_btn": "DETAILS", + "notes_changed": "Notizen geändert", + "notes_placeholder": "Notizen über diesen Spieler..." + }, + "history": { + "title": "Vergangenheit", + "btn_revoke": "RÜCKGÄNGIG", + "revoked_success": "Aktion rückgängig gemacht!", + "banned_by": "GEBANNT von %{author}", + "warned_by": "VERWARNT von %{author}", + "revoked_by": "RÜCKGÄNGIG GEMACHT von %{author}.", + "expired_at": "Abgelaufen am %{date}.", + "expires_at": "Läuft ab am %{date}." + }, + "ban": { + "title": "Spieler bannen", + "reason_placeholder": "Grund", + "duration_placeholder": "Dauer", + "hours": "Stunden", + "days": "Tage", + "weeks": "Wochen", + "months": "Monate", + "permanent": "Permanent", + "custom": "Benutzerdefiniert", + "helper_text": "Bitte eine Dauer angeben", + "submit": "Bann ausführen", + "reason_required": "Ein Grund muss angegeben werden!", + "success": "Spieler gebannt!" + }, + "ids": { + "current_ids": "Aktuelle Identifier", + "previous_ids": "Zuvor benutzte Identifier", + "all_hwids": "Alle Hardware IDs" + } + } + } +} diff --git a/locale/el.json b/locale/el.json new file mode 100644 index 0000000..0d6ad46 --- /dev/null +++ b/locale/el.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Greek", + "humanizer_language": "el" + }, + "restarter": { + "server_unhealthy_kick_reason": "ο διακομιστής πρέπει να επανεκκινηθεί, παρακαλώ επανασυνδεθείτε", + "partial_hang_warn": "Due to a partial hang, this server will restart in 1 minute. Please disconnect now.", + "partial_hang_warn_discord": "Due to a partial hang, **%{servername}** will restart in 1 minute.", + "schedule_reason": "Προγραμματισμένη επανεκκίνηση σε %{time}", + "schedule_warn": "Αυτός ο διακομιστής έχει προγραμματιμένη επανεκκίνηση σε %{smart_count} λεπτό. Παρακαλώ αποσυνδεθείτε τώρα. |||| Αυτός ο διακομιστής έχει προγραμματισμένη επανεκκίνηση σε %{smart_count} λεπτά.", + "schedule_warn_discord": "Ο **%{servername}** έχει προγραμματισμένη επανεκκίνηση σε %{smart_count} λεπτό. |||| Ο **%{servername}** έχει προγραμματιμένη επανεκκίνηση σε %{smart_count} λεπτά." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Έχει απαγορευτεί η είσοδος σου στον διακομιστή για: \"%{reason}\". Η απαγορευσή σου θα λήξει σε: %{expiration}.", + "kick_permanent": "(%{author}) Έχει απαγορευτεί η είσοδος σου στον διακομιστή για: \"%{reason}\".", + "reject": { + "title_permanent": "Έχετε αποκλειστεί οριστικά από αυτόν τον διακομιστή.", + "title_temporary": "Έχετε αποκλειστεί προσωρινά από αυτόν τον διακομιστή.", + "label_expiration": "Το ban σου θα λήξει", + "label_date": "Ban Date", + "label_author": "Banned by", + "label_reason": "Ban Reason", + "label_id": "Ban ID", + "note_multiple_bans": "Note: you have more than one active ban on your identifiers.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Ο διακομιστής βρίσκεται σε Admin-only mode.", + "insufficient_ids": "Δεν έχεις discord or fivem identifiers, και τουλάχιστον ένα από αυτά απαιτείται για επικύρωση εάν είστε διαχειριστής στο txAdmin.", + "deny_message": "Τα identifiers σου δεν σχετίζονται με κάποιον txAdmin administrator." + }, + "guild_member": { + "mode_title": "Ο διακομιστής βρίσκεται σε Discord server Member Whitelist mode.", + "insufficient_ids": "Δεν έχεις discord identifier, το οποίο απαιτείται για την επικύρωση του αν έχεις μπεί στο Discord μας. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "Πρέπει να είσαι στο Discord μας για να συνδεθείς.", + "deny_message": "Συνδέσου στο %{guildname} και ξανά προσπάθησε." + }, + "guild_roles": { + "mode_title": "Ο διακομιστής βρίσκεται σε Discord Role Whitelist mode.", + "insufficient_ids": "Δεν έχεις discord identifier, το οποίο απαιτείται για την επικύρωση του αν έχεις μπεί στο Discord μας. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "Πρέπει να είσαι στο Discord μας για να συνδεθείς.", + "deny_notmember_message": "Συνδέσου στο %{guildname} και ξανά προσπάθησε.", + "deny_noroles_title": "Δεν έχεις το whitelisted role που απαιτείται για να συνδεθείς.", + "deny_noroles_message": "Για να συνδεθείτε σε αυτόν τον διακομιστή, πρέπει να έχετε τουλάχιστον έναν από τους whitelisted roles στο Discord %{guildname}." + }, + "approved_license": { + "mode_title": "Ο διακομιστής βρίσκεται σε License Whitelist mode.", + "insufficient_ids": "Δεν έχεις το license identifier, το οποίο σημαίνει ότι ο διακομιστής έχει το sv_lan ανοικτό. Αν είσαι ο server owner, πρέπει να το κλείσεις στο server.cfg file.", + "deny_title": "Δεν είσαι whitelisted για να συνδεθείς", + "request_id_label": "Request ID" + } + }, + "server_actions": { + "restarting": "Επανεκκίνηση διακομιστή με λόγο: (%{reason}).", + "restarting_discord": "Εκτελείται επανεκκίνηση του διακομιστή **%{servername}** με λόγο: (%{reason}).", + "stopping": "Ο διακομιστής κλείνει με λόγο: (%{reason}).", + "stopping_discord": "Ο **%{servername}** κλείνει με λόγο: (%{reason}).", + "spawning_discord": "Ο **%{servername}** ανοίγει." + }, + "nui_warning": { + "title": "ΠΡΟΕΙΔΟΠΟΙΗΣΗ", + "warned_by": "Προειδοποιήθηκες από τον:", + "stale_message": "Αυτή η προειδοποίηση εκδόθηκε πριν συνδεθείτε στον διακομιστή.", + "dismiss_key": "SPACE", + "instruction": "Πάτα %{key} για %{smart_count} δευτερόλεπτο για να απορρίψεις αυτό το μήνυμα. |||| Πάτα %{key} για %{smart_count} δευτερόλεπτα για να απορρίψεις αυτό το μήνυμα." + }, + "nui_menu": { + "misc": { + "help_message": "To txAdmin Menu ενεργοποιήθηκε, πληκτρολόγησε /tx για να το ανοίξεις.\nΕπίσης μπορείς να καθορίσεις και keybind [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Τα αναγνωριστικά σου δεν ταιρίαζουν με αυτά κάποιου δηλωμένου Admin στο txAdmin.\nΕαν είσαι δηλωμένος στο txAdmin, πήγαινε στο Admin Manager και επιβεβαίωσε ότι τα αναγνωριστικά σου έχουν αποθηκευτεί.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Δεν έχεις αυτή την άδεια", + "unknown_error": "Προκλήθηκε ένα άγνωστο σφάλμα.", + "not_enabled": "Tο txAdmin Menu δεν είναι ενεργοποιημένο ! Μπορείς να το ενεργοποιήσεις στήν σελίδα ρυθμίσεων του txAdmin.", + "announcement_title": "Ανακοίνωση από τον %{author}:", + "dialog_empty_input": "Δεν μπορείς να μην εισάγεις τίποτα.", + "directmessage_title": "DM from admin %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Έχεις παγώσει έναν παίκτη!", + "unfroze_player": "Έχεις ξεπαγώσει έναν παίκτη!", + "was_frozen": "Έχεις παγώσει άπο έναν Server Admin!" + }, + "common": { + "cancel": "Ακύρωση", + "submit": "Επιβεβαίωση", + "error": "Προκλήθηκε ένα σφάλμα", + "copied": "Αντιγράφηκε στο πρόχειρο." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Χρησιμοποίησε το %{key} για να αλλάξεις σελίδες & και τα βελάκια για να πλοηγηθέις στο Menu", + "tooltip_2": "Οι συγκεκριμένες επιλογές στο Menu έχουν και υπό-επιλογές όπου μπορούν να επιλεχθούν από το δεξί και αριστερό βελάκι" + }, + "player_mode": { + "title": "Player Mode", + "noclip": { + "title": "NoClip", + "label": "Πέτα τριγύρω", + "success": "Το NoClip ενεργοποιήθηκε" + }, + "godmode": { + "title": "God", + "label": "Γίνεσε αόρατος", + "success": "God Mode ενεργοποιήθηκε" + }, + "superjump": { + "title": "Super Jump", + "label": "Toggle super jump mode, the player will also run faster", + "success": "Super Jump enabled" + }, + "normal": { + "title": "Normal", + "label": "Αρχικό mode", + "success": "Επέστρεψες στο αρχικό player mode." + } + }, + "teleport": { + "title": "Teleport", + "generic_success": "Στάλθηκες στη σκουληκότρυπα!", + "waypoint": { + "title": "Waypoint", + "label": "Πηγαίνεις στο σημείο που επέλεξες", + "error": "Δεν έχεις θέσει κάποιο σημείο." + }, + "coords": { + "title": "Coords", + "label": "Πηγαίνεις σε συγκεκριμένες συντεταγμένες", + "dialog_title": "Teleport", + "dialog_desc": "Δώστε συντεταγμένες σε μορφή x, y, z για να περάσετε από τη σκουληκότρυπα.", + "dialog_error": "Λάθος συντεταγμένες. Πρέπει να είναι στην ακόλουθη μορφή: 111, 222, 33" + }, + "back": { + "title": "Back", + "label": "Πηγαίνεις στην τελευταία σου τοποθεσία", + "error": "Δεν έχεις τελευταία τοποθεσία για να πας!" + }, + "copy": { + "title": "Αντιγραφή συντεταγμένων", + "label": "Αντιγραφή συντεταγμένων στο πρόχειρο." + } + }, + "vehicle": { + "title": "Vehicle", + "not_in_veh_error": "Δεν είσαι μέσα σε όχημα!", + "spawn": { + "title": "Spawn", + "label": "Εμφάνησε όχημα με το όνομα του μοντέλου του", + "dialog_title": "Εμφάνησε όχημα", + "dialog_desc": "Πληκτρολόγησε το όνομα του μοντέλου του οχήματος που θέλεις να εμφανήσεις", + "dialog_success": "Το όχημα εμφανίστηκε!", + "dialog_error": "Το όνομα μοντέλου οχήματος '%{modelName}' δεν υπάρχει!", + "dialog_info": "Προσπάθεια εμφάνησης %{modelName}." + }, + "fix": { + "title": "Fix", + "label": "Φτιάξε το όχημα", + "success": "Το όχημα κατασκευάστηκε!" + }, + "delete": { + "title": "Delete", + "label": "Διέγραψε το όχημα", + "success": "Το όχημα διαγράφτηκε!" + }, + "boost": { + "title": "Boost", + "label": "Boost the car to achieve max fun (and maybe speed)", + "success": "Vehicle boosted!", + "already_boosted": "This vehicle was already boosted.", + "unsupported_class": "This vehicle class is not supported.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Heal", + "myself": { + "title": "Τον ευατό σου", + "label": "Αποκαταστέι την υγεία σου", + "success_0": "Όλα εντάξει!", + "success_1": "Πρέπει να είναι όλα καλά τώρα!", + "success_2": "Αποκαταστήθηκε η ζωή σου εντελώς!", + "success_3": "Φτιάχτηκαν οι γρατζουνίες!" + }, + "everyone": { + "title": "Όλους", + "label": "Θα αποκαταστήσει την υγεία και θα ζωντανέψει όλους του παίκτες!", + "success": "Ζωντάνεψαν όλοι οι παίκτες." + } + }, + "announcement": { + "title": "Βγάλε ανακοίνωση", + "label": "Βγάλε ανακοίνωση προς όλους τους παίκτες.", + "dialog_desc": "Βγάλε ανακοίνωση προς όλους τους παίκτες.", + "dialog_placeholder": "Η ανακοίνωση σου...", + "dialog_success": "Η ανακοίνωση ανέβηκε." + }, + "clear_area": { + "title": "Reset World Area", + "label": "Επαναφέρετε μια καθορισμένη περιοχή στην προεπιλεγμένη κατάστασή της", + "dialog_desc": "Εισαγάγετε την ακτίνα στην οποία θέλετε να επαναφέρετε τις οντότητες σε (0-300). Αυτό δεν θα διαγράψει οντότητες που δημιουργούνται από την πλευρά του διακομιστή.", + "dialog_success": "Επαναφέρεται η περιοχή σε ακτίνα %{radius}m", + "dialog_error": "Μη έγκυρη είσοδος ακτίνας. Προσπάθησε ξανά." + }, + "player_ids": { + "title": "Toggle Player IDs", + "label": "Εναλλαγή εμφάνισης αναγνωριστικών παικτών (και άλλων πληροφοριών) πάνω από το κεφάλι όλων των κοντινών παικτών", + "alert_show": "Εμφάνιση NetIDs των κοντινών παικτών.", + "alert_hide": "Εξαφάνηση NetIDs των κοντινών παικτών." + } + }, + "page_players": { + "misc": { + "online_players": "Online Players", + "players": "Παίκτες", + "search": "Αναζήτηση", + "zero_players": "Δεν βρέθηκαν παίκτες" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Ταξινόμηση κατά", + "distance": "Απόστασης", + "id": "Αναγνωριστικού", + "joined_first": "Joined First", + "joined_last": "Joined Last", + "closest": "Closest", + "farthest": "Farthest" + }, + "card": { + "health": "%{percentHealth}% υγεία" + } + }, + "player_modal": { + "misc": { + "error": "Παρουσιάστηκε σφάλμα κατά την ανάκτηση αυτών των στοιχείων του χρήστη. Το σφάλμα φαίνεται παρακάτω:", + "target_not_found": "Δεν ήταν δυνατή η εύρεση ενός παίκτη με αναγνωριστικό ή με ψευδώνυμο: %{target}" + }, + "tabs": { + "actions": "Actions", + "info": "Info", + "ids": "IDs", + "history": "History", + "ban": "Ban" + }, + "actions": { + "title": "Επιλογές Παίκτη", + "command_sent": "Η επιλογή στάλθηκε!", + "moderation": { + "title": "Moderation", + "options": { + "dm": "DM", + "warn": "Warn", + "kick": "Kick", + "set_admin": "Give Admin" + }, + "dm_dialog": { + "title": "Προσωπικό μήνυμα", + "description": "Ποιό είναι το προσωπικό μήνυμα που θέλεις να στείλεις", + "placeholder": "Μήνυμα...", + "success": "Το μήνυμα στάλθηκε επιτυχώς!" + }, + "warn_dialog": { + "title": "Warn", + "description": "Για πιο λόγο θες να προηδοποιήσεις αυτόν τον παίκτη;", + "placeholder": "Λόγος...", + "success": "Ο παίκτης προηδοποιήθηκε!" + }, + "kick_dialog": { + "title": "Kick", + "description": "Ποιός είναι ο λόγος που θέλετε να εκδιώξετε αυτόν παίκτη;", + "placeholder": "Λόγος...", + "success": "Ο παίκτης εκδιώκθηκε!" + } + }, + "interaction": { + "title": "Αλληλεπίδραση", + "options": { + "heal": "Heal", + "go_to": "Go to", + "bring": "Bring", + "spectate": "Spectate", + "toggle_freeze": "Toggle Freeze" + }, + "notifications": { + "heal_player": "Healing player", + "tp_player": "Teleporting to player", + "bring_player": "Bring παίκτη", + "spectate_failed": "Failed to resolve the target! Exiting spectate.", + "spectate_yourself": "You cannot spectate yourself.", + "freeze_yourself": "You cannot freeze yourself.", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Κάντον μεθυσμένο", + "fire": "Βάλ'του φωτία", + "wild_attack": "Επίθεση από άγρια ζώα" + } + } + }, + "info": { + "title": "Πληρφορίες παίκτη", + "session_time": "Τωρινός Online χρόνος", + "play_time": "Συνολικός Online χρόνος", + "joined": "Joined", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "not yet", + "btn_wl_add": "ADD WL", + "btn_wl_remove": "REMOVE WL", + "btn_wl_success": "Whitelist status changed.", + "log_label": "Log", + "log_empty": "No bans/warns found.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} warn |||| %{smart_count} warns", + "log_btn": "DETAILS", + "notes_changed": "Player note changed.", + "notes_placeholder": "Notes about this player..." + }, + "history": { + "title": "Related history", + "btn_revoke": "REVOKE", + "revoked_success": "Action revoked!", + "banned_by": "BANNED by %{author}", + "warned_by": "WARNED by %{author}", + "revoked_by": "Revoked by %{author}.", + "expired_at": "Expired at %{date}.", + "expires_at": "Expires at %{date}." + }, + "ban": { + "title": "Ban player", + "reason_placeholder": "Λόγος", + "duration_placeholder": "Διάρκεια", + "hours": "ώρες", + "days": "μέρες", + "weeks": "εβδομάδες", + "months": "μήνες", + "permanent": "Μόνιμα", + "custom": "Custom", + "helper_text": "Παρακαλώ διαλέξτε διάρκεια", + "submit": "Apply ban", + "reason_required": "The Reason field is required.", + "success": "Player banned!" + }, + "ids": { + "current_ids": "Current Identifiers", + "previous_ids": "Previously Used Identifiers", + "all_hwids": "All Hardware IDs" + } + } + } +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..9bff318 --- /dev/null +++ b/locale/en.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "English (default)", + "humanizer_language": "en" + }, + "restarter": { + "server_unhealthy_kick_reason": "the server needs to be restarted, please reconnect", + "partial_hang_warn": "Due to a partial hang, this server will restart in 1 minute. Please disconnect now.", + "partial_hang_warn_discord": "Due to a partial hang, **%{servername}** will restart in 1 minute.", + "schedule_reason": "scheduled restart at %{time}", + "schedule_warn": "This server is scheduled to restart in %{smart_count} minute. Please disconnect now. |||| This server is scheduled to restart in %{smart_count} minutes.", + "schedule_warn_discord": "**%{servername}** is scheduled to restart in %{smart_count} minute. |||| **%{servername}** is scheduled to restart in %{smart_count} minutes." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) You have been banned from this server for \"%{reason}\". Your ban will expire in: %{expiration}.", + "kick_permanent": "(%{author}) You have been permanently banned from this server for \"%{reason}\".", + "reject": { + "title_permanent": "You have been permanently banned from this server.", + "title_temporary": "You have been temporarily banned from this server.", + "label_expiration": "Your ban will expire in", + "label_date": "Ban Date", + "label_author": "Banned by", + "label_reason": "Ban Reason", + "label_id": "Ban ID", + "note_multiple_bans": "Note: you have more than one active ban on your identifiers.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "This server is in Admin-only mode.", + "insufficient_ids": "You do not have discord or fivem identifiers, and at least one of them is required to validate if you are a txAdmin administrator.", + "deny_message": "Your identifiers are not assigned to any txAdmin administrator." + }, + "guild_member": { + "mode_title": "This server is in Discord server Member Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "You are required to join our Discord server to connect.", + "deny_message": "Please join the guild %{guildname} then try again." + }, + "guild_roles": { + "mode_title": "This server is in Discord Role Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "You are required to join our Discord server to connect.", + "deny_notmember_message": "Please join %{guildname}, get one of the required roles, then try again.", + "deny_noroles_title": "You do not have a whitelisted role required to join.", + "deny_noroles_message": "To join this server you are required to have at least one of the whitelisted roles on the guild %{guildname}." + }, + "approved_license": { + "mode_title": "This server is in License Whitelist mode.", + "insufficient_ids": "You do not have the license identifier, which means the server has sv_lan enabled. If you are the server owner, you can disable it in the server.cfg file.", + "deny_title": "You are not whitelisted to join this server.", + "request_id_label": "Request ID" + } + }, + "server_actions": { + "restarting": "Server restarting (%{reason}).", + "restarting_discord": "**%{servername}** is restarting (%{reason}).", + "stopping": "Server shutting down (%{reason}).", + "stopping_discord": "**%{servername}** is shutting down (%{reason}).", + "spawning_discord": "**%{servername}** is starting." + }, + "nui_warning": { + "title": "WARNING", + "warned_by": "Warned by:", + "stale_message": "This warning was issued before you connected to the server.", + "dismiss_key": "SPACE", + "instruction": "Hold %{key} for %{smart_count} second to dismiss this message. |||| Hold %{key} for %{smart_count} seconds to dismiss this message. " + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menu enabled, type /tx to open it.\nYou can also configure a keybind at [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Your identifiers do not match any admin registered on txAdmin.\nIf you are registered on txAdmin, go to Admin Manager and make sure your identifiers are saved.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "You do not have this permission.", + "unknown_error": "An unknown error occurred.", + "not_enabled": "The txAdmin Menu is not enabled! You can enable it in the txAdmin settings page.", + "announcement_title": "Server Announcement by %{author}:", + "directmessage_title": "DM from admin %{author}:", + "dialog_empty_input": "You cannot have an empty input.", + "onesync_error": "This option requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "You have frozen the player!", + "unfroze_player": "You have unfrozen the player!", + "was_frozen": "You have been frozen by a server admin!" + }, + "common": { + "cancel": "Cancel", + "submit": "Submit", + "error": "An error occurred", + "copied": "Copied to clipboard." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Use %{key} to switch pages & the arrow keys to navigate menu items", + "tooltip_2": "Certain menu items have sub options which can be selected using the left & right arrow keys" + }, + "player_mode": { + "title": "Player Mode", + "noclip": { + "title": "NoClip", + "label": "Toggle NoClip, allowing you to move through walls and other objects", + "success": "NoClip enabled" + }, + "godmode": { + "title": "God", + "label": "Toggle invincibility, preventing you from taking damage", + "success": "God Mode enabled" + }, + "superjump": { + "title": "Super Jump", + "label": "Toggle super jump mode, the player will also run faster", + "success": "Super Jump enabled" + }, + "normal": { + "title": "Normal", + "label": "Returns yourself back to the default/normal player mode", + "success": "Returned to default player mode." + } + }, + "teleport": { + "title": "Teleport", + "generic_success": "Sent you into the wormhole!", + "waypoint": { + "title": "Waypoint", + "label": "Teleport to the custom waypoint set on the map", + "error": "You have no waypoint set." + }, + "coords": { + "title": "Coords", + "label": "Teleport to the provided coordinates", + "dialog_title": "Teleport", + "dialog_desc": "Provide coordinates in an x, y, z format to go through the wormhole.", + "dialog_error": "Invalid coordinates. Must be in the format of: 111, 222, 33" + }, + "back": { + "title": "Back", + "label": "Returns to the location prior to last teleport", + "error": "You don't have a last location to go back to!" + }, + "copy": { + "title": "Copy Coords", + "label": "Copy the current world coordinates to your clipboard" + } + }, + "vehicle": { + "title": "Vehicle", + "not_in_veh_error": "You are not currently in a vehicle!", + "spawn": { + "title": "Spawn", + "label": "Spawn a given vehicle from its model name", + "dialog_title": "Spawn vehicle", + "dialog_desc": "Enter in the model name of the vehicle you want to spawn.", + "dialog_success": "Vehicle spawned!", + "dialog_error": "The vehicle model name '%{modelName}' does not exist!", + "dialog_info": "Trying to spawn %{modelName}." + }, + "fix": { + "title": "Fix", + "label": "Will repair the vehicle to its maximum health", + "success": "Vehicle fixed!" + }, + "delete": { + "title": "Delete", + "label": "Deletes the vehicle the player is currently in", + "success": "Vehicle deleted!" + }, + "boost": { + "title": "Boost", + "label": "Boost the car to achieve max fun (and maybe speed)", + "success": "Vehicle boosted!", + "already_boosted": "This vehicle was already boosted.", + "unsupported_class": "This vehicle class is not supported.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Heal", + "myself": { + "title": "Myself", + "label": "Will heal yourself to the current ped's maximum", + "success_0": "All healed up!", + "success_1": "You should be feeling good now!", + "success_2": "Restored to full!", + "success_3": "Ouchies fixed!" + }, + "everyone": { + "title": "Everyone", + "label": "Will heal & revive all connected players", + "success": "Healed and revived all players." + } + }, + "announcement": { + "title": "Send Announcement", + "label": "Send an announcement to all online players.", + "dialog_desc": "Enter the message you want to broadcast to all players.", + "dialog_placeholder": "Your announcement...", + "dialog_success": "Sending the announcement." + }, + "clear_area": { + "title": "Reset World Area", + "label": "Reset a specified world area to its default state", + "dialog_desc": "Please enter the radius where you wish to reset entities in (0-300). This will not clear entities spawned server side.", + "dialog_success": "Clearing area with radius of %{radius}m", + "dialog_error": "Invalid radius input. Try again." + }, + "player_ids": { + "title": "Toggle Player IDs", + "label": "Toggle showing player IDs (and other info) above the head of all nearby players", + "alert_show": "Showing nearby player NetIDs.", + "alert_hide": "Hiding nearby player NetIDs." + } + }, + "page_players": { + "misc": { + "online_players": "Online Players", + "players": "Players", + "search": "Search", + "zero_players": "No players found." + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Sort by", + "distance": "Distance", + "id": "ID", + "joined_first": "Joined First", + "joined_last": "Joined Last", + "closest": "Closest", + "farthest": "Farthest" + }, + "card": { + "health": "%{percentHealth}% health" + } + }, + "player_modal": { + "misc": { + "error": "An error occurred fetching this users details. The error is shown below:", + "target_not_found": "Was unable to find an online player with ID or a username of %{target}" + }, + "tabs": { + "actions": "Actions", + "info": "Info", + "ids": "IDs", + "history": "History", + "ban": "Ban" + }, + "actions": { + "title": "Player Actions", + "command_sent": "Command sent!", + "moderation": { + "title": "Moderation", + "options": { + "dm": "DM", + "warn": "Warn", + "kick": "Kick", + "set_admin": "Give Admin" + }, + "dm_dialog": { + "title": "Direct Message", + "description": "What is the reason for direct messaging this player?", + "placeholder": "Reason...", + "success": "Your DM has been sent!" + }, + "warn_dialog": { + "title": "Warn", + "description": "What is the reason for direct warning this player?", + "placeholder": "Reason...", + "success": "Player warned!" + }, + "kick_dialog": { + "title": "Kick", + "description": "What is the reason for kicking this player?", + "placeholder": "Reason...", + "success": "Player kicked!" + } + }, + "interaction": { + "title": "Interaction", + "options": { + "heal": "Heal", + "go_to": "Go to", + "bring": "Bring", + "spectate": "Spectate", + "toggle_freeze": "Toggle Freeze" + }, + "notifications": { + "heal_player": "Healing player", + "tp_player": "Teleporting to player", + "bring_player": "Summoning player", + "spectate_failed": "Failed to resolve the target! Exiting spectate.", + "spectate_yourself": "You cannot spectate yourself.", + "freeze_yourself": "You cannot freeze yourself.", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Make Drunk", + "fire": "Set Fire", + "wild_attack": "Wild attack" + } + } + }, + "info": { + "title": "Player info", + "session_time": "Session Time", + "play_time": "Play time", + "joined": "Joined", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "not yet", + "btn_wl_add": "ADD WL", + "btn_wl_remove": "REMOVE WL", + "btn_wl_success": "Whitelist status changed.", + "log_label": "Log", + "log_empty": "No bans/warns found.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} warn |||| %{smart_count} warns", + "log_btn": "DETAILS", + "notes_placeholder": "Notes about this player...", + "notes_changed": "Player note changed." + }, + "ids": { + "current_ids": "Current Identifiers", + "previous_ids": "Previously Used Identifiers", + "all_hwids": "All Hardware IDs" + }, + "history": { + "title": "Related history", + "btn_revoke": "REVOKE", + "revoked_success": "Action revoked!", + "banned_by": "BANNED by %{author}", + "warned_by": "WARNED by %{author}", + "revoked_by": "Revoked by %{author}.", + "expired_at": "Expired at %{date}.", + "expires_at": "Expires at %{date}." + }, + "ban": { + "title": "Ban player", + "reason_placeholder": "Reason", + "reason_required": "The Reason field is required.", + "duration_placeholder": "Duration", + "success": "Player banned!", + "hours": "hours", + "days": "days", + "weeks": "weeks", + "months": "months", + "permanent": "Permanent", + "custom": "Custom", + "helper_text": "Please select a duration", + "submit": "Apply ban" + } + } + } +} diff --git a/locale/es.json b/locale/es.json new file mode 100644 index 0000000..de93c63 --- /dev/null +++ b/locale/es.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Spanish", + "humanizer_language": "es" + }, + "restarter": { + "server_unhealthy_kick_reason": "el servidor necesita reiniciarse, por favor reconéctate", + "partial_hang_warn": "Debido a una suspensión parcial, el servidor se reiniciará en 1 minutos. Por favor, desconectate ahora.", + "partial_hang_warn_discord": "Debido a una suspensión parcial, **%{servername}** se reiniciará en un minuto.", + "schedule_reason": "Reinicio programado a las %{time}", + "schedule_warn": "El servidor se va a reiniciar en %{smart_count} minutos. Por favor desconéctense. |||| El servidor se va a reiniciar en %{smart_count} minutos.", + "schedule_warn_discord": "**%{servername}** El servidor se va a reiniciar en %{smart_count} minutos. |||| **%{servername}** El servidor se va a reiniciar en %{smart_count} minutos." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Has sido baneado del servidor por \"%{reason}\". Tu baneo expirará en: %{expiration}.", + "kick_permanent": "(%{author}) Has sido baneado permanentemente del servidor por \"%{reason}\".", + "reject": { + "title_permanent": "Has sido expulsado permanentemente de este servidor.", + "title_temporary": "Has sido expulsado temporalmente de este servidor.", + "label_expiration": "Tu expulsión expirará en", + "label_date": "Fecha de expulsión", + "label_author": "Expulsado por", + "label_reason": "Razón de la expulsión", + "label_id": "Ban ID", + "note_multiple_bans": "Nota: tiene más de una prohibición activa en sus identificadores.", + "note_diff_license": "Nota: el baneo de arriba fue aplicado para otra license, lo que significa que alguno de tus IDs/HWIDs coinciden con los del baneo asociado." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Este servidor está en modo sólo administrador.", + "insufficient_ids": "No tiene identificadores discord o fivem, y al menos uno de ellos es necesario para validar si es administrador de txAdmin.", + "deny_message": "Sus identificadores no están asignados a ningún administrador de txAdmin." + }, + "guild_member": { + "mode_title": "Este servidor está en modo Lista blanca de miembros de discord.", + "insufficient_ids": "No tienes el identificador discord, que es necesario para validar si te has unido a nuestro discord. Abre Discord Desktop e inténtalo de nuevo (la version Web no funcionará).", + "deny_title": "Es necesario que te unas a nuestro Discord para conectarte.", + "deny_message": "Por favor, únete al discord %{guildname} e inténtalo de nuevo." + }, + "guild_roles": { + "mode_title": "Este servidor está en modo Lista blanca de roles de discord.", + "insufficient_ids": "No tienes el identificador discord, que es necesario para validar si te has unido a nuestro Discord. Abre Discord Desktop e inténtalo de nuevo (la version Web no funcionará).", + "deny_notmember_title": "Es necesario que te unas a nuestra Discord para conectarte.", + "deny_notmember_message": "Por favor, únete a %{guildname}, consigue uno de los roles requeridos, e inténtalo de nuevo.", + "deny_noroles_title": "Usted no tiene un rol en la lista blanca requerido para unirse.", + "deny_noroles_message": "Para unirte a este servidor debes tener al menos uno de los roles de la lista blanca del Discord %{guildname}." + }, + "approved_license": { + "mode_title": "Este servidor está en modo Lista blanca de licencias.", + "insufficient_ids": "No tiene el identificador de licencia, lo que significa que el servidor tiene sv_lan activado. Si usted es el propietario del servidor, puede desactivarlo en el archivo server.cfg.", + "deny_title": "No estás en la lista blanca para unirte a este servidor.", + "request_id_label": "Solicitar ID" + } + }, + "server_actions": { + "restarting": "Reiniciando Servidor (%{reason}).", + "restarting_discord": "**%{servername}** se está reiniciando: (%{reason}).", + "stopping": "Apagando el servidor: (%{reason}).", + "stopping_discord": "**%{servername}** se está apagando (%{reason}).", + "spawning_discord": "**%{servername}** está iniciandose." + }, + "nui_warning": { + "title": "ADVERTENCIA", + "warned_by": "Advertido por:", + "stale_message": "Esta advertencia se emitió antes de que te conectaras al servidor.", + "dismiss_key": "ESPACIO", + "instruction": "Presione %{key} durante %{smart_count} segundo para descartar este mensaje. |||| Presione %{key} durante %{smart_count} segundos para descartar este mensaje." + }, + "nui_menu": { + "misc": { + "help_message": "Menú txAdmin disponible, escribe /tx para abrirlo.\nTambien puedes configurar una asignación de teclas en [Ajustes > Asignación de teclas > FiveM > Menu: Abrir página principal].", + "menu_not_admin": "Tus identificadores no estan registrados en txAdmin.\nSi estas registrado en txAdmin, ve a gestor de administradores y asegurate que tus identificadores estan guardados.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "No tienes este permiso", + "unknown_error": "Ha ocurrido un error.", + "not_enabled": "¡Menú txAdmin no está activado! Puedes activarlo en la página de ajustes de txAdmin.", + "announcement_title": "Anuncio del servidor de %{author}:", + "dialog_empty_input": "La entrada no puede estar vacía.", + "directmessage_title": "MD de admin %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "¡Has congelado al jugador!", + "unfroze_player": "¡Has descongelado al jugador!", + "was_frozen": "¡Has sido congelado por un administrador!" + }, + "common": { + "cancel": "Cancelar", + "submit": "Enviar", + "error": "Ha ocurrido un error", + "copied": "Copiado al portapapeles." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Pulsa %{key} para cambiar entre páginas y las flechas para cambiar entre las opciones del menú", + "tooltip_2": "Algunos elementos del menú tienen sub-opciones que pueden ser seleccionadas usando las flechas izquierda y derecha." + }, + "player_mode": { + "title": "Modo del jugador", + "noclip": { + "title": "NoClip", + "label": "Vuela!", + "success": "NoClip activado" + }, + "godmode": { + "title": "God", + "label": "Invencible", + "success": "God Mode activado" + }, + "superjump": { + "title": "Super Salto", + "label": "Activa el modo super salto, el jugador también correrá más rápido", + "success": "Super Salto Activado" + }, + "normal": { + "title": "Normal", + "label": "Modo por defecto", + "success": "Volviendo al modo por defecto." + } + }, + "teleport": { + "title": "Teletransporte", + "generic_success": "¡A traves del agujero de gusano!", + "waypoint": { + "title": "Marcador", + "label": "Ve al marcador puesto", + "error": "No tienes ningún marcador puesto." + }, + "coords": { + "title": "Coordenadas", + "label": "Ir a coordenadas", + "dialog_title": "Teletransporte", + "dialog_desc": "Proporciona unas coordenadas en formato x, y, z para ir por el agujero de gusano.", + "dialog_error": "Coordenadas inválidas. El formato debe ser: 111, 222, 33" + }, + "back": { + "title": "Volver", + "label": "Vuelve a la última localización", + "error": "¡No tienes una última localización a la que ir!" + }, + "copy": { + "title": "Copiar coordenadas", + "label": "Copia las coordenadas al portapapeles." + } + }, + "vehicle": { + "title": "Vehículo", + "not_in_veh_error": "¡No estás en ningún vehículo!", + "spawn": { + "title": "Generar", + "label": "Genera un vehículo", + "dialog_title": "Generar vehículo", + "dialog_desc": "Escribe el nombre de modelo del vehículo que quieres generar.", + "dialog_success": "¡Vehículo generado!", + "dialog_error": "¡El vehículo '%{modelName}' no existe!", + "dialog_info": "Intentando generar %{modelName}." + }, + "fix": { + "title": "Arreglar", + "label": "Arregla el vehículo actual", + "success": "¡Vehículo arreglado!" + }, + "delete": { + "title": "Borrar", + "label": "Borra el vehículo actual", + "success": "¡Vehículo borrado!" + }, + "boost": { + "title": "Boost", + "label": "Potencia el coche para conseguir la máxima diversión (y quizá velocidad)", + "success": "¡Vehículo potenciado!", + "already_boosted": "Este vehículo ya estaba potenciado.", + "unsupported_class": "Esta clase de vehículo no es compatible.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Curar", + "myself": { + "title": "A mí mismo", + "label": "Regenera tu vida", + "success_0": "¡Todo curado!", + "success_1": "¡Deberías sentirte mejor!", + "success_2": "¡A tope!", + "success_3": "¡Sana, sana, pata de rana!" + }, + "everyone": { + "title": "A todos", + "label": "Cura y revive a todos", + "success": "Todos los jugadores han sido revividos y curados." + } + }, + "announcement": { + "title": "Enviar anuncio", + "label": "Envía un comunicado a todos los jugadores conectados.", + "dialog_desc": "Ingrese el mensaje que quiere enviar a todos los jugadores.", + "dialog_placeholder": "Tu comunicado...", + "dialog_success": "Enviando el anuncio." + }, + "clear_area": { + "title": "Reiniciar área del mundo", + "label": "Reinicia un área específica del mundo a su estado por defecto", + "dialog_desc": "Por favor introduce el radio del área que quieres reiniciar entre (0-300). Esto no borrará entidades creadas por el servidor.", + "dialog_success": "Limpiando área con radio de %{radius}m", + "dialog_error": "Radio inválido. Prueba otra vez." + }, + "player_ids": { + "title": "Altenar IDs de jugadores", + "label": "Alternar el mostrar los IDs de jugadores (y otra información) sobre la cabeza de los jugadores cercanos", + "alert_show": "Enseñado NetIDs de jugadores cercanos.", + "alert_hide": "Ocultando NetIDs de jugadores cercanos." + } + }, + "page_players": { + "misc": { + "online_players": "Jugadores conectados", + "players": "Jugadores", + "search": "Buscar", + "zero_players": "Ningún jugador encontrado" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Ordenar por", + "distance": "Distancia", + "id": "ID", + "joined_first": "Unidos recientes", + "joined_last": "Unidos primeros", + "closest": "Más cercanos", + "farthest": "Más lejanos" + }, + "card": { + "health": "%{percentHealth}% vida" + } + }, + "player_modal": { + "misc": { + "error": "Ha ocurrido un error obteniendo los datos de este usuario. El error se muestra abajo:", + "target_not_found": "Incapaz de encontrar un jugador con la ID o el nombre de usuario de %{target}" + }, + "tabs": { + "actions": "Acciones", + "info": "Info", + "ids": "IDs", + "history": "Historial", + "ban": "Vetar" + }, + "actions": { + "title": "Acciones del jugador", + "command_sent": "¡Comando enviado!", + "moderation": { + "title": "Moderación", + "options": { + "dm": "DM", + "warn": "Avisar", + "kick": "Expulsar", + "set_admin": "Dar Administrador" + }, + "dm_dialog": { + "title": "Mensaje directo", + "description": "¿Cual es la razón por la que le mandas un mensaje directo a este jugador?", + "placeholder": "Razón...", + "success": "¡Tu mensaje ha sido enviado!" + }, + "warn_dialog": { + "title": "Avisar", + "description": "¿Cual es la razón por la que estás advirtiendo a este jugador?", + "placeholder": "Razón...", + "success": "¡El jugador ha sido avisado!" + }, + "kick_dialog": { + "title": "Expulsar", + "description": "¿Cual es la razón por la que estás expulsando a este jugador?", + "placeholder": "Razón...", + "success": "¡El jugador fué expulsado!" + } + }, + "interaction": { + "title": "Interacción", + "options": { + "heal": "Curar", + "go_to": "Ir a", + "bring": "Traer", + "spectate": "Observar", + "toggle_freeze": "Alternar congelación" + }, + "notifications": { + "heal_player": "Curando jugador", + "tp_player": "Teletransportando a jugador", + "bring_player": "Invocando jugador", + "spectate_failed": "¡No se pudo encontrar al objetivo! Saliendo de observar.", + "spectate_yourself": "No te puedes observar a ti mismo.", + "freeze_yourself": "No te puedes congelar a ti mismo.", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Emborrachar", + "fire": "Prender fuego", + "wild_attack": "Ataque salvaje" + } + } + }, + "info": { + "title": "Info del jugador", + "session_time": "Tiempo de sesión", + "play_time": "Tiempo de juege", + "joined": "Se unió", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "todavía no", + "btn_wl_add": "AÑADIR WL", + "btn_wl_remove": "ELIMINAR WL", + "btn_wl_success": "Se ha modificado el estado de la lista blanca.", + "log_label": "Log", + "log_empty": "No se han encontrado prohibiciones/avisos.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} warn |||| %{smart_count} warns", + "log_btn": "DETALLES", + "notes_changed": "Nota de jugador cambiada.", + "notes_placeholder": "Notas sobre este jugador..." + }, + "history": { + "title": "Historial relacionado", + "btn_revoke": "REVOCAR", + "revoked_success": "¡Acción revocada!", + "banned_by": "BANEADO por %{author}", + "warned_by": "ADVERTIDO por %{author}", + "revoked_by": "Revocado por %{author}.", + "expired_at": "Expirado el %{date}.", + "expires_at": "Expirado el %{date}." + }, + "ban": { + "title": "Banear jugador", + "reason_placeholder": "Razón", + "duration_placeholder": "Duración", + "hours": "horas", + "days": "días", + "weeks": "semanas", + "months": "meses", + "permanent": "Permanente", + "custom": "Personalizado", + "helper_text": "Por favor, elige una duración", + "submit": "Aplicar baneo", + "reason_required": "El campo Motivo es obligatorio.", + "success": "¡Jugador expulsado!" + }, + "ids": { + "current_ids": "Identificadores actuales", + "previous_ids": "Identificadores utilizados anteriormente", + "all_hwids": "All Hardware IDs" + } + } + } +} diff --git a/locale/et.json b/locale/et.json new file mode 100644 index 0000000..bdb480d --- /dev/null +++ b/locale/et.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Estonian", + "humanizer_language": "et" + }, + "restarter": { + "server_unhealthy_kick_reason": "server tuleb taaskäivitada, palun ühendu uuesti", + "partial_hang_warn": "Osalise hangumise tõttu taaskäivitub see server 1 minuti pärast. Palun lahkuge serverist kohe.", + "partial_hang_warn_discord": "Osalise hangumise tõttu taaskäivitub **%{servername}** 1 minuti pärast.", + "schedule_reason": "Plaanitud taaskäivitamine kell %{time}", + "schedule_warn": "See server taaskäivitub %{smart_count} minuti pärast. Palun katkestage ühendus kohe. |||| See server taaskäivitub %{smart_count} minuti pärast.", + "schedule_warn_discord": "**%{servername}** taaskäivitub %{smart_count} minuti pärast. |||| **%{servername}** taaskäivitub %{smart_count} minuti pärast." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Teid on sellest serverist ajutiselt keelustatud \"%{reason}\" tõttu. Teie keelustamine aegub: %{expiration}.", + "kick_permanent": "(%{author}) Teid on sellest serverist igavesti keelustatud \"%{reason}\" tõttu. Teie keelustamine ei aegugi.", + "reject": { + "title_permanent": "Teid on sellest serverist igavesti keelustatud.", + "title_temporary": "Teid on sellest serverist ajutiselt keelustatud.", + "label_expiration": "Teie keelustus aegub:", + "label_date": "Keelsutuse kuupäev", + "label_author": "Keelustaja", + "label_reason": "Keelustuse põhjus", + "label_id": "Keelustuse ID", + "note_multiple_bans": "Märkus. Teil on oma identifikaatoritele rohkem kui üks aktiivne keelustus.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "See server on Ainult Administraatori režiimis.", + "insufficient_ids": "Teil pole discord või fivemi identifikaatoreid ja vähemalt üks neist on vajalik kinnitada, kui olete txAdmini administraator.", + "deny_message": "Teie identifikaatoreid ei ole määratud ühelegi txAdmini administraatorile." + }, + "guild_member": { + "mode_title": "See server on Discord Server Liikme Whitelisti režiimis.", + "insufficient_ids": "Teil pole discord identifikaatorit, mis on vajalik kinnitada serveriga liitumiseks, kui olete liitunud meie Discord serveriga. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "Ühendamiseks peate liituma meie Discord serveriga.", + "deny_message": "Palun liituge Discord serveriga %{guildname} ning proovige uuesti." + }, + "guild_roles": { + "mode_title": "See server on Discord Rolli Whitelisti režiimis.", + "insufficient_ids": "Teil pole discord identifikaatorit, mis on vajalik kinnitada serveriga liitumiseks, kui olete liitunud meie Discord serveriga. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "Ühendamiseks peate liituma meie Discord serveriga.", + "deny_notmember_message": "Palun liituge Discord serveriga %{guildname}, saage üks vajalikest rollidest ja proovige uuesti.", + "deny_noroles_title": "Teil pole liitumiseks vajalikku rolli.", + "deny_noroles_message": "Selle serveriga liitumiseks peate saama vähemalt üks whitelistitud rollidest Discord serveris %{guildname}." + }, + "approved_license": { + "mode_title": "See server on Litsentsi Whitelisti režiimis.", + "insufficient_ids": "Teil pole litsentsi identifikaatorit, mis tähendab, et sv_lan on sisse lülitatud. Kui teie olete serveri omanik, siis saate selle välja lülitada server.cfg faili seest.", + "deny_title": "Teil pole liitumiseks vajalikku whitelisti.", + "request_id_label": "Taotle ID" + } + }, + "server_actions": { + "restarting": "Serveri taaskäivitamine (%{reason}).", + "restarting_discord": "**%{servername}** taaskäivitub (%{reason}).", + "stopping": "Server suletakse (%{reason}).", + "stopping_discord": "**%{servername}** suletakse (%{reason}).", + "spawning_discord": "**%{servername}** käivitub." + }, + "nui_warning": { + "title": "HOIATUS", + "warned_by": "Hoiataja:", + "stale_message": "See hoiatus anti välja enne, kui serveriga ühendust võtsite.", + "dismiss_key": "SPACE", + "instruction": "Sellest sõnumist loobumiseks hoidke klahvi %{key} %{smart_count} sekund all. |||| Sellest sõnumist loobumiseks hoidke klahvi %{key} %{smart_count} sekundit all." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmini menüü on lubatud, selle avamiseks kirjutage /tx.\nSaate konfigureerida ka klahvide sidumise menüüs [Mängu seaded > Klahvide sidumised > FiveM > Menüü: ava põhileht].", + "menu_not_admin": "Teie identifikaatorid ei ühti ühegi txAdminis registreeritud administraatoriga.\nKui olete txAdminis registreeritud, minge administraatorihaldurisse ja veenduge, et teie identifikaatorid on salvestatud.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Teil pole selleks luba.", + "unknown_error": "Ilmnes tundmatu viga.", + "not_enabled": "txAdmini menüü pole lubatud! Saate selle lubada txAdmini seadete lehel.", + "announcement_title": "Serveri teadaanne %{author}:", + "dialog_empty_input": "See ei saa olla tühi sisend.", + "directmessage_title": "DM administraatorilt %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Olete mängija külmutanud!", + "unfroze_player": "Olete mängija lahti külmutanud!", + "was_frozen": "Serveri administraator külmutas teid!" + }, + "common": { + "cancel": "Tühista", + "submit": "Kinnita", + "error": "Ilmnes viga", + "copied": "Kopeeriti lõikelauale." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Kasuta %{key} lehekülgede vahetamiseks ja nooleklahve menüüelementide liikumiseks", + "tooltip_2": "Teatud menüüelementidel on alamvalikud, mida saab valida vasak- ja paremnooleklahvide abil" + }, + "player_mode": { + "title": "Mängija režiim", + "noclip": { + "title": "NoClip", + "label": "Lülita sisse NoClip, mis võimaldab teil liikuda läbi seinte ja muude objektide", + "success": "NoClip käivitatud" + }, + "godmode": { + "title": "God", + "label": "Lülita sisse GodMode, mis võimaldab vältida kahju tekitamist", + "success": "GodMode käivitatud" + }, + "superjump": { + "title": "SuperHüpe", + "label": "Lülita sisse SuperHüpe, mis võimaldab ka kiiresti joosta", + "success": "Superhüpe käivitatud" + }, + "normal": { + "title": "Tavaline", + "label": "Naaseb tavalise mängija režiimi", + "success": "Naastatud mängija tavarežiimile." + } + }, + "teleport": { + "title": "Teleporteeru", + "generic_success": "Saatis su ussiauku!", + "waypoint": { + "title": "Märgis", + "label": "Teleportige kaardil määratud teekonnapunkti", + "error": "Teil pole kaardil teekonnapunkti määratud." + }, + "coords": { + "title": "Koordinaadid", + "label": "Teleportige antud koordinaatidele", + "dialog_title": "Teleporteeru", + "dialog_desc": "Ussiaugu läbimiseks esitage koordinaadid x, y, z vormingus.", + "dialog_error": "Valed koordinaadid. Peab olema vormingus: 111, 222, 33" + }, + "back": { + "title": "Tagasi", + "label": "Naaseb asukohta enne viimast teleporti", + "error": "Teil pole viimast asukohta, kuhu tagasi minna!" + }, + "copy": { + "title": "Kopeeri koordinaadid", + "label": "Kopeerige praegused koordinaadid" + } + }, + "vehicle": { + "title": "Sõidukid", + "not_in_veh_error": "Te ei ole praegu sõidukis!", + "spawn": { + "title": "Loo", + "label": "Looge antud sõiduk selle mudelinime järgi", + "dialog_title": "Loo sõiduk", + "dialog_desc": "Sisestage selle sõiduki mudeli nimi, mida soovite luua.", + "dialog_success": "Sõiduk loodud!", + "dialog_error": "Sõiduki mudeli nime '%{modelName}' ei eksisteeri!", + "dialog_info": "Proovin luua %{modelName}." + }, + "fix": { + "title": "Paranda", + "label": "Parandab sõiduki maksimaalselt tervenisti", + "success": "Sõiduk parandatud!" + }, + "delete": { + "title": "Kustuta", + "label": "Kustutab sõiduki, milles mängija parasjagu viibib", + "success": "Sõiduk kustutatud!" + }, + "boost": { + "title": "Kiirenda", + "label": "Kiirenda sõiduk, et saavutada maksimaalne lõbu (Ja võib olla ka kiirus)", + "success": "Sõiduk kiirendatud!", + "already_boosted": "Praegune sõiduk on juba kiirendatud.", + "unsupported_class": "Paregune sõiduki klass ei ole lubatud.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Tervenda", + "myself": { + "title": "Ennast", + "label": "Ravib end praeguse ped-i maksimumini", + "success_0": "Kõik paranenud!", + "success_1": "Sa peaksid end praegu hästi tundma!", + "success_2": "Täielikult taastatud!", + "success_3": "Valud parandatud!" + }, + "everyone": { + "title": "Kõik", + "label": "Ravib ja elustab kõik ühendatud mängijad", + "success": "Tervendas ja elustas kõik mängijad." + } + }, + "announcement": { + "title": "Saada teadaanne", + "label": "Saatke kõigile mängijatele teadaanne.", + "dialog_desc": "Sisestage sõnum, mida soovite kõigile mängijatele edastada.", + "dialog_placeholder": "Teie teadaanne...", + "dialog_success": "Teadaannde saatmine." + }, + "clear_area": { + "title": "Lähtesta maailmapiirkond", + "label": "Lähtestage määratud maailmapiirkond vaikeolekusse", + "dialog_desc": "Sisestage raadius, mis soovite lähtestada (0-300). See ei puhasta olemeid, mille serveri pool tekkis.", + "dialog_success": "Puhastusala raadiusega %{radius}m", + "dialog_error": "Vale raadiuse sisestus. Proovi uuesti." + }, + "player_ids": { + "title": "Mängija ID sisse- ja väljalülitamine", + "label": "Lülitage mängija ID-de (ja muu teabe) kuvamine kõigi läheduses asuvate mängijate pea kohal", + "alert_show": "Kuvatakse lähedalasuvate mängijate ID-d.", + "alert_hide": "Lähedal asuva mängija ID-de peitmine." + } + }, + "page_players": { + "misc": { + "online_players": "Hetkesed mängijad", + "players": "Mängijad", + "search": "Otsi", + "zero_players": "Mängijat ei leitud" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Sorteeri", + "distance": "Kaugus", + "id": "ID", + "joined_first": "Liitus esimest korda", + "joined_last": "Liitus viimati", + "closest": "Lähim", + "farthest": "Kõige kaugemal" + }, + "card": { + "health": "%{percentHealth}% tervis" + } + }, + "player_modal": { + "misc": { + "error": "Selle kasutaja üksikasjade toomisel ilmnes viga. Viga on näidatud allpool:", + "target_not_found": "%{target} ID või kasutajanimega mängijat ei õnnestunud leida" + }, + "tabs": { + "actions": "Tegevused", + "info": "Info", + "ids": "IDs", + "history": "Ajalugu", + "ban": "Keelusta" + }, + "actions": { + "title": "Mängija toimingud", + "command_sent": "Käsk saadetud!", + "moderation": { + "title": "Moderatsioon", + "options": { + "dm": "DM", + "warn": "Hoiata", + "kick": "Kick", + "set_admin": "Anna administraatori õigused" + }, + "dm_dialog": { + "title": "DM", + "description": "Mis on selle mängijaga DM saatmise põhjus?", + "placeholder": "Põhjus...", + "success": "Teie DM on saadetud!" + }, + "warn_dialog": { + "title": "Hoiata", + "description": "Mis on selle mängija hoiatamise põhjus?", + "placeholder": "Põhjus...", + "success": "Mängijat hoiatati!" + }, + "kick_dialog": { + "title": "Kick", + "description": "Mis on selle mängija kickimise põhjus?", + "placeholder": "Põhjus...", + "success": "Mängija kickiti!" + } + }, + "interaction": { + "title": "Interaktsioon", + "options": { + "heal": "Tervenda", + "go_to": "Mine", + "bring": "Too", + "spectate": "Jälgi", + "toggle_freeze": "Külmuta" + }, + "notifications": { + "heal_player": "Tervendad mängija", + "tp_player": "Teleportid mängija juurde", + "bring_player": "Lähed mängija juurde", + "spectate_failed": "Eesmärki ei õnnestunud lahendada! Lahkun Specatatest.", + "spectate_yourself": "Sa ei saa ennast specateda.", + "freeze_yourself": "Te ei saa ennast külmutada.", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Tee purju", + "fire": "Pane mängija põlema", + "wild_attack": "Metsik rünnak" + } + } + }, + "info": { + "title": "Mängija info", + "session_time": "Seansi aeg", + "play_time": "Mängimise aeg", + "joined": "Ühines", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "mitte veel", + "btn_wl_add": "LISA WL", + "btn_wl_remove": "EEMALDA WL", + "btn_wl_success": "Whitelisti olek muudetud.", + "log_label": "Log", + "log_empty": "Mitte ühtegi keelustamist/hoiatust leitud.", + "log_ban_count": "%{smart_count} keelustus |||| %{smart_count} keelustused", + "log_warn_count": "%{smart_count} hoiatus |||| %{smart_count} hoiatused", + "log_btn": "ÜKSIKASJAD", + "notes_changed": "Mängija märge muudetud.", + "notes_placeholder": "Mängija märge..." + }, + "history": { + "title": "Seotud ajalugu", + "btn_revoke": "EEMALDA", + "revoked_success": "Tegevus eemaldatud!", + "banned_by": "KEELUSTAJA %{author}", + "warned_by": "HOIATAJA %{author}", + "revoked_by": "Eemaldaja %{author}.", + "expired_at": "Aegunud kell %{date}.", + "expires_at": "Aegub kell %{date}." + }, + "ban": { + "title": "Keelusta mängija", + "reason_placeholder": "Põhjus", + "duration_placeholder": "Kestvus", + "hours": "Tunnid", + "days": "Päevad", + "weeks": "Nädalad", + "months": "Kuud", + "permanent": "Igavesti", + "custom": "Kohandatud", + "helper_text": "Valige kestus", + "submit": "Rakenda keeld", + "reason_required": "Põhjuse ala on kohustuslik.", + "success": "Mängija keelustatud!" + }, + "ids": { + "current_ids": "Praegused identifikaatorid", + "previous_ids": "Varem kasutatud identifikaatorid", + "all_hwids": "Kõik Hardware IDd" + } + } + } +} diff --git a/locale/fa.json b/locale/fa.json new file mode 100644 index 0000000..7a5b81d --- /dev/null +++ b/locale/fa.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Persian", + "humanizer_language": "fa" + }, + "restarter": { + "server_unhealthy_kick_reason": "سرور باید دوباره راه‌اندازی شود, لطفاً دوباره متصل شوید", + "partial_hang_warn": "Due to a partial hang, this server will restart in 1 minute. Please disconnect now.", + "partial_hang_warn_discord": "Due to a partial hang, **%{servername}** will restart in 1 minute.", + "schedule_reason": "Restart Dar Saat: %{time}", + "schedule_warn": "Server ta %{smart_count} daghighe digar restart mishavad. Lotfan Disconnect Konid. |||| Server ta %{smart_count} daghighe digar restart mishavad.", + "schedule_warn_discord": "**%{servername}** ta %{smart_count} daghighe digar restart mishavad. |||| **%{servername}** ta %{smart_count} daghighe digar restart mishavad." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Shoma Az in Server Ban Shodid, Be Dalil: \"%{reason}\". Etmam Ban Shoma: %{expiration}.", + "kick_permanent": "(%{author}) Shoma Baraye Hamishe Az in Server Ban Shodid, Be Dalil: \"%{reason}\".", + "reject": { + "title_permanent": "You have been permanently banned from this server.", + "title_temporary": "You have been temporarily banned from this server.", + "label_expiration": "Your ban will expire in", + "label_date": "Ban Date", + "label_author": "Banned by", + "label_reason": "Ban Reason", + "label_id": "Ban ID", + "note_multiple_bans": "Note: you have more than one active ban on your identifiers.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "This server is in Admin-only mode.", + "insufficient_ids": "You do not have discord or fivem identifiers, and at least one of them is required to validate if you are a txAdmin administrator.", + "deny_message": "Your identifiers are not assigned to any txAdmin administrator." + }, + "guild_member": { + "mode_title": "This server is in Discord server Member Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "You are required to join our Discord server to connect.", + "deny_message": "Please join the guild %{guildname} then try again." + }, + "guild_roles": { + "mode_title": "This server is in Discord Role Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "You are required to join our Discord server to connect.", + "deny_notmember_message": "Please join %{guildname}, get one of the required roles, then try again.", + "deny_noroles_title": "You do not have a whitelisted role required to join.", + "deny_noroles_message": "To join this server you are required to have at least one of the whitelisted roles on the guild %{guildname}." + }, + "approved_license": { + "mode_title": "This server is in License Whitelist mode.", + "insufficient_ids": "You do not have the license identifier, which means the server has sv_lan enabled. If you are the server owner, you can disable it in the server.cfg file.", + "deny_title": "You are not whitelisted to join this server.", + "request_id_label": "Request ID" + } + }, + "server_actions": { + "restarting": "سرور درحال راه اندازی مجدد، دلیل: (%{reason}).", + "restarting_discord": "**%{servername}** درحال راه اندازی مجدد شدن، دلیل: (%{reason}).", + "stopping": "سرور درحال خاموش شدن، دلیل: : (%{reason}).", + "stopping_discord": "**%{servername}** درحال خاموش شدن، دلیل: (%{reason}).", + "spawning_discord": "**%{servername}** درحال راه اندازی." + }, + "nui_warning": { + "title": "WARNING", + "warned_by": "Warned by:", + "stale_message": "این هشدار قبل از اتصال شما به سرور صادر شده است.", + "dismiss_key": "SPACE", + "instruction": "Dokme %{key} ro baraye %{smart_count} sanie negah darid ta payam rad shavad. |||| Dokme %{key} ro baraye %{smart_count} sanieha negah darid ta payam rad shavad." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menu enabled, type /tx to open it.\nYou can also configure a keybind at [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Your identifiers do not match any admin registered on txAdmin.\nIf you are registered on txAdmin, go to Admin Manager and make sure your identifiers are saved.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "You do not have this permission.", + "unknown_error": "An unknown error occurred.", + "not_enabled": "The txAdmin Menu is not enabled! You can enable it in the txAdmin settings page.", + "announcement_title": "Server Announcement by %{author}:", + "dialog_empty_input": "You cannot have an empty input.", + "directmessage_title": "DM from admin %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "You have frozen the player!", + "unfroze_player": "You have unfrozen the player!", + "was_frozen": "You have been frozen by a server admin!" + }, + "common": { + "cancel": "Cancel", + "submit": "Submit", + "error": "An error occurred", + "copied": "Copied to clipboard." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Use %{key} to switch pages & the arrow keys to navigate menu items", + "tooltip_2": "Certain menu items have sub options which can be selected using the left & right arrow keys" + }, + "player_mode": { + "title": "Player Mode", + "noclip": { + "title": "NoClip", + "label": "Fly around", + "success": "NoClip enabled" + }, + "godmode": { + "title": "God", + "label": "Invincible", + "success": "God Mode enabled" + }, + "superjump": { + "title": "Super Jump", + "label": "Toggle super jump mode, the player will also run faster", + "success": "Super Jump enabled" + }, + "normal": { + "title": "Normal", + "label": "Default mode", + "success": "Returned to default player mode." + } + }, + "teleport": { + "title": "Teleport", + "generic_success": "Sent you into the wormhole!", + "waypoint": { + "title": "Waypoint", + "label": "Go to waypoint set", + "error": "You have no waypoint set." + }, + "coords": { + "title": "Coords", + "label": "Go to specified coords", + "dialog_title": "Teleport", + "dialog_desc": "Provide coordinates in an x, y, z format to go through the wormhole.", + "dialog_error": "Invalid coordinates. Must be in the format of: 111, 222, 33" + }, + "back": { + "title": "Back", + "label": "Go back to last location", + "error": "You don't have a last location to go back to!" + }, + "copy": { + "title": "Copy Coords", + "label": "Copy coords to clipboard." + } + }, + "vehicle": { + "title": "Vehicle", + "not_in_veh_error": "You are not currently in a vehicle!", + "spawn": { + "title": "Spawn", + "label": "Spawn vehicle by model name", + "dialog_title": "Spawn vehicle", + "dialog_desc": "Enter in the model name of the vehicle you want to spawn.", + "dialog_success": "Vehicle spawned!", + "dialog_error": "The vehicle model name '%{modelName}' does not exist!", + "dialog_info": "Trying to spawn %{modelName}." + }, + "fix": { + "title": "Fix", + "label": "Fix the current vehicle", + "success": "Vehicle fixed!" + }, + "delete": { + "title": "Delete", + "label": "Delete the current vehicle", + "success": "Vehicle deleted!" + }, + "boost": { + "title": "Boost", + "label": "Boost the car to achieve max fun (and maybe speed)", + "success": "Vehicle boosted!", + "already_boosted": "This vehicle was already boosted.", + "unsupported_class": "This vehicle class is not supported.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Heal", + "myself": { + "title": "Myself", + "label": "Restores your health", + "success_0": "All healed up!", + "success_1": "You should be feeling good now!", + "success_2": "Restored to full!", + "success_3": "Ouchies fixed!" + }, + "everyone": { + "title": "Everyone", + "label": "Will heal & revive all players", + "success": "Healed and revived all players." + } + }, + "announcement": { + "title": "Send Announcement", + "label": "Send an announcement to all online players.", + "dialog_desc": "Send an announcement to all online players.", + "dialog_placeholder": "Your announcement...", + "dialog_success": "Sending the announcement." + }, + "clear_area": { + "title": "Reset World Area", + "label": "Reset a specified world area to its default state", + "dialog_desc": "Please enter the radius where you wish to reset entities in (0-300). This will not clear entities spawned server side.", + "dialog_success": "Clearing area with radius of %{radius}m", + "dialog_error": "Invalid radius input. Try again." + }, + "player_ids": { + "title": "Toggle Player IDs", + "label": "Toggle showing player IDs (and other info) above the head of all nearby players", + "alert_show": "Showing nearby player NetIDs.", + "alert_hide": "Hiding nearby player NetIDs." + } + }, + "page_players": { + "misc": { + "online_players": "Online Players", + "players": "Players", + "search": "Search", + "zero_players": "No players found." + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Sort by", + "distance": "Distance", + "id": "ID", + "joined_first": "Joined First", + "joined_last": "Joined Last", + "closest": "Closest", + "farthest": "Farthest" + }, + "card": { + "health": "%{percentHealth}% health" + } + }, + "player_modal": { + "misc": { + "error": "An error occurred fetching this users details. The error is shown below:", + "target_not_found": "Was unable to find an online player with ID or a username of %{target}" + }, + "tabs": { + "actions": "Actions", + "info": "Info", + "ids": "IDs", + "history": "History", + "ban": "Ban" + }, + "actions": { + "title": "Player Actions", + "command_sent": "Command sent!", + "moderation": { + "title": "Moderation", + "options": { + "dm": "DM", + "warn": "Warn", + "kick": "Kick", + "set_admin": "Give Admin" + }, + "dm_dialog": { + "title": "Direct Message", + "description": "What is the reason for direct messaging this player?", + "placeholder": "Reason...", + "success": "Your DM has been sent!" + }, + "warn_dialog": { + "title": "Warn", + "description": "What is the reason for direct warning this player?", + "placeholder": "Reason...", + "success": "Player warned!" + }, + "kick_dialog": { + "title": "Kick", + "description": "What is the reason for kicking this player?", + "placeholder": "Reason...", + "success": "Player kicked!" + } + }, + "interaction": { + "title": "Interaction", + "options": { + "heal": "Heal", + "go_to": "Go to", + "bring": "Bring", + "spectate": "Spectate", + "toggle_freeze": "Toggle Freeze" + }, + "notifications": { + "heal_player": "Healing player", + "tp_player": "Teleporting to player", + "bring_player": "Summoning player", + "spectate_failed": "Failed to resolve the target! Exiting spectate.", + "spectate_yourself": "You cannot spectate yourself.", + "freeze_yourself": "You cannot freeze yourself.", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Make Drunk", + "fire": "Set Fire", + "wild_attack": "Wild attack" + } + } + }, + "info": { + "title": "Player info", + "session_time": "Session Time", + "play_time": "Play time", + "joined": "Joined", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "not yet", + "btn_wl_add": "ADD WL", + "btn_wl_remove": "REMOVE WL", + "btn_wl_success": "Whitelist status changed.", + "log_label": "Log", + "log_empty": "No bans/warns found.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} warn |||| %{smart_count} warns", + "log_btn": "DETAILS", + "notes_changed": "Player note changed.", + "notes_placeholder": "Notes about this player..." + }, + "history": { + "title": "Related history", + "btn_revoke": "REVOKE", + "revoked_success": "Action revoked!", + "banned_by": "BANNED by %{author}", + "warned_by": "WARNED by %{author}", + "revoked_by": "Revoked by %{author}.", + "expired_at": "Expired at %{date}.", + "expires_at": "Expires at %{date}." + }, + "ban": { + "title": "Ban player", + "reason_placeholder": "Reason", + "duration_placeholder": "Duration", + "hours": "hours", + "days": "days", + "weeks": "weeks", + "months": "months", + "permanent": "Permanent", + "custom": "Custom", + "helper_text": "Please select a duration", + "submit": "Apply ban", + "reason_required": "The Reason field is required.", + "success": "Player banned!" + }, + "ids": { + "current_ids": "Current Identifiers", + "previous_ids": "Previously Used Identifiers", + "all_hwids": "All Hardware IDs" + } + } + } +} diff --git a/locale/fi.json b/locale/fi.json new file mode 100644 index 0000000..4c8c2c1 --- /dev/null +++ b/locale/fi.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Finnish", + "humanizer_language": "fi" + }, + "restarter": { + "server_unhealthy_kick_reason": "palvelin täytyy käynnistää uudelleen, ole hyvä ja yhdistä uudestaan", + "partial_hang_warn": "Due to a partial hang, this server will restart in 1 minute. Please disconnect now.", + "partial_hang_warn_discord": "Due to a partial hang, **%{servername}** will restart in 1 minute.", + "schedule_reason": "Ajoitettu uudelleenkäynnistys kello %{time}", + "schedule_warn": "Palvelin on ajoitettu käynnistymään uudelleen minuutin kuluttua. Poistu palvelimelta nyt! |||| Palvelin on ajoitettu käynnistymään uudelleen %{smart_count} minuutin kuluttua.", + "schedule_warn_discord": "**%{servername}** on uudelleenkäynnistymässä %{smart_count} minuutin kuluttua. |||| **%{servername}** on ajoitettu käynnistymään uudelleen %{smart_count} minuutin kuluttua." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Olet saanut porttikiellon palvelimelle. Porttikiellon syy: \"%{reason}\". Porttikielto päättyy: %{expiration}.", + "kick_permanent": "(%{author}) Olet saanut pysyvän porttikiellon palvelimelle. Porttikiellon syy: \"%{reason}\".", + "reject": { + "title_permanent": "Olet saanut ikuisen porttikiellon palvelimelle.", + "title_temporary": "Olet saanut porttikiellon palvelimelle.", + "label_expiration": "Porttikieltosi päättyy", + "label_date": "Porttikiellon päivämäärä", + "label_author": "Porttikiellon antanut", + "label_reason": "Porttikiellon syy", + "label_id": "Porttikiellon ID", + "note_multiple_bans": "Huom: you have more than one active ban on your identifiers.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "This server is in Admin-only mode.", + "insufficient_ids": "You do not have discord or fivem identifiers, and at least one of them is required to validate if you are a txAdmin administrator.", + "deny_message": "Your identifiers are not assigned to any txAdmin administrator." + }, + "guild_member": { + "mode_title": "This server is in Discord server Member Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "You are required to join our Discord server to connect.", + "deny_message": "Please join the guild %{guildname} then try again." + }, + "guild_roles": { + "mode_title": "This server is in Discord Role Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "You are required to join our Discord server to connect.", + "deny_notmember_message": "Please join %{guildname}, get one of the required roles, then try again.", + "deny_noroles_title": "You do not have a whitelisted role required to join.", + "deny_noroles_message": "To join this server you are required to have at least one of the whitelisted roles on the guild %{guildname}." + }, + "approved_license": { + "mode_title": "This server is in License Whitelist mode.", + "insufficient_ids": "You do not have the license identifier, which means the server has sv_lan enabled. If you are the server owner, you can disable it in the server.cfg file.", + "deny_title": "You are not whitelisted to join this server.", + "request_id_label": "Request ID" + } + }, + "server_actions": { + "restarting": "Palvelin käynnistyy uudelleen (%{reason}).", + "restarting_discord": "**%{servername}** käynnistyy uudelleen (%{reason}).", + "stopping": "Palvelin sammutettu (%{reason}).", + "stopping_discord": "**%{servername}** sammutettu (%{reason}).", + "spawning_discord": "**%{servername}** käynnistetty." + }, + "nui_warning": { + "title": "VAROITUS", + "warned_by": "Sinua varoitti:", + "stale_message": "Tämä varoitus annettiin ennen kuin yhdistit palvelimeen.", + "dismiss_key": "välilyönti", + "instruction": "Pidä %{key} pohjassa %{smart_count} sekunti jatkaaksesi. |||| Pidä %{key} pohjassa %{smart_count} sekuntia jatkaaksesi." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menu päällä. Kirjoita /tx avataksesi.\nVoit myös asettaa menulle oman näppäimen: [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Tietosi eivät vastaa yhtäkään txAdminiin rekisteröityä ylläpitäjää.\nMikäli olet rekisteröitynyt txAdminiin, siirry kohtaan [Admin Manager] ja varmista että tietosi ovat tallennettu.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Sinulla ei ole oikeuksia tähän.", + "unknown_error": "Tuntematon virhe", + "not_enabled": "txAdmin Menu ei ole käytössä! Voit ottaa sen käyttöön txAdminin asetuksista.", + "announcement_title": "Palvelinilmoitus henkilöltä %{author}:", + "dialog_empty_input": "Tekstikenttä ei voi olla tyhjä.", + "directmessage_title": "Yksityisviesti ylläpitäjältä %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Pelaaja jäädytety!", + "unfroze_player": "Pelaaja vapautettu!", + "was_frozen": "Sinut on jäädytetty ylläpitäjän toimesta!" + }, + "common": { + "cancel": "Takaisin", + "submit": "Lähetä", + "error": "Virhe", + "copied": "Kopioitu leikepöydälle." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Käytä %{key} näppäintä vaihtaaksesi välilehtiä ja nuolinäppäimiä navikoidaksesi listaa", + "tooltip_2": "Osalla listan osioista on lisäosioita, joita voit selata käyttämällä vasenta ja oikeaa nuolinäppäintä" + }, + "player_mode": { + "title": "Pelaajan tila", + "noclip": { + "title": "NoClip", + "label": "Lennä ympäriinsä", + "success": "NoClip päällä" + }, + "godmode": { + "title": "God", + "label": "Kuolematon", + "success": "God Mode päällä" + }, + "superjump": { + "title": "Super Jump", + "label": "Toggle super jump mode, the player will also run faster", + "success": "Super Jump enabled" + }, + "normal": { + "title": "Normaali", + "label": "Palauta pelaajan tila normaaliksi", + "success": "Palautettu takaisin normaaliin tilaan" + } + }, + "teleport": { + "title": "Teleport", + "generic_success": "Lähetin sinut madonreikään!", + "waypoint": { + "title": "Waypoint", + "label": "Siirry valittuun pisteeseen", + "error": "Et ole asettanut haluttua määränpäätä." + }, + "coords": { + "title": "Koordinaatit", + "label": "Siirry syötettyihin koordinaatteihin", + "dialog_title": "Teleport", + "dialog_desc": "Syötä koordinaatit muodossa: x, y, z", + "dialog_error": "Virheellinen koordinaatti. Koordinaattien pitää olla muotoa: 111, 222, 33" + }, + "back": { + "title": "Takaisin", + "label": "Siirry viimeisimpään paikkaan", + "error": "Viimeisintä paikkaa ei löytynyt!" + }, + "copy": { + "title": "Kopioi koordinaatit", + "label": "Kopioi koordinaatit leikepöydälle." + } + }, + "vehicle": { + "title": "Ajoneuvo", + "not_in_veh_error": "Et ole ajoneuvossa!", + "spawn": { + "title": "Luo", + "label": "Luo ajoneuvo nimellä", + "dialog_title": "Luo ajoneuvo", + "dialog_desc": "Aseta ajoneuvon nimi, jonka haluat luoda", + "dialog_success": "Ajoneuvo luotu!", + "dialog_error": "Ajoneuvo '%{modelName}' ei ole olemassa!", + "dialog_info": "Trying to spawn %{modelName}." + }, + "fix": { + "title": "Korjaa", + "label": "Korjaa tämänhetkinen ajoneuvo", + "success": "Ajoneuvo korjattu!" + }, + "delete": { + "title": "Poista", + "label": "Poista tämänhetkinen ajoneuvo", + "success": "Ajoneuvo poistettu!" + }, + "boost": { + "title": "Viritä", + "label": "Viritä ajoneuvosi", + "success": "Ajoneuvo viritetty!", + "already_boosted": "Ajoneuvo on jo viritetty.", + "unsupported_class": "Tätä ajoneuvoluokkaa ei voi virittää.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Elvytä", + "myself": { + "title": "Minut", + "label": "Elvyttää sinut", + "success_0": "Elvytetty!", + "success_1": "Kaikki on taas hyvin!", + "success_2": "Onneks aina voi nostaa!", + "success_3": "Elossa ollaan taas!" + }, + "everyone": { + "title": "Jokainen", + "label": "Elvyttää jokaisen pelaajan", + "success": "Kaikki pelaajat elvytetty." + } + }, + "announcement": { + "title": "Lähetä ilmoitus", + "label": "Lähetä ilmoitus kaikille pelaajille.", + "dialog_desc": "Syötä viesti, jonka haluat pelaajille lähetettävän.", + "dialog_placeholder": "Ilmoituksesi....", + "dialog_success": "Lähetetään ilmoitusta." + }, + "clear_area": { + "title": "Tyhjennä alue", + "label": "Tyhjennä valittu alue", + "dialog_desc": "Aseta haluttu etäisyys väliltä (0-300). Tämä toiminto ei poista palvelimen puolelta (server side) luotuja asioita.", + "dialog_success": "Tyjennetään alueelta: %{radius}m", + "dialog_error": "Virheellinen alue, yritä uudelleen." + }, + "player_ids": { + "title": "Näytä pelaajien ID:t", + "label": "Näytä lähellä olevien pelaajien ID:t", + "alert_show": "Näytetään lähellä olevien pelaajien ID:t.", + "alert_hide": "Piilotetaan lähellä olevien pelaajien ID:t." + } + }, + "page_players": { + "misc": { + "online_players": "Paikalla olevat pelaajat", + "players": "pelaaja(a)", + "search": "Hae", + "zero_players": "Pelaajia ei löytynyt" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Järjestä", + "distance": "Etäisyys", + "id": "ID", + "joined_first": "Liittynyt ensimmäisenä", + "joined_last": "Liittynyt viimeisenä", + "closest": "Lähimpänä", + "farthest": "Kauimpana" + }, + "card": { + "health": "%{percentHealth}% health" + } + }, + "player_modal": { + "misc": { + "error": "Käyttäjätietojen haussa tapahtui virhe. Lisätietoja alhaalla:", + "target_not_found": "Virhe löytäessä pelaajaa ID:llä tai käyttäjänimellä (%{target})" + }, + "tabs": { + "actions": "Toiminnot", + "info": "Info", + "ids": "ID:t", + "history": "Historia", + "ban": "Porttikielto" + }, + "actions": { + "title": "Pelaajan toiminnot", + "command_sent": "Toiminto suoritettu!", + "moderation": { + "title": "Hallinnointi", + "options": { + "dm": "Yksityisviesti", + "warn": "Varoita", + "kick": "Potki", + "set_admin": "Anna ylläpito-oikeudet" + }, + "dm_dialog": { + "title": "Yksityisviesti", + "description": "Mitä haluat pelaajalle lähettää?", + "placeholder": "Viesti...", + "success": "Yksityisviesti lähetetty!" + }, + "warn_dialog": { + "title": "Varoita", + "description": "Mistä haluat pelaajaa varoittaa?", + "placeholder": "Syy...", + "success": "Pelaajaa varoitettu!" + }, + "kick_dialog": { + "title": "Potki", + "description": "Mistä syystä haluat pelaajan potkia?", + "placeholder": "Syy...", + "success": "Pelaaja potkittu palvelimelta!" + } + }, + "interaction": { + "title": "Toiminnot", + "options": { + "heal": "Elvytä", + "go_to": "Mene pelaajan luokse", + "bring": "Tuo pelaaja luoksesi", + "spectate": "Katso pelaajaa", + "toggle_freeze": "Jäädytä" + }, + "notifications": { + "heal_player": "Elvytetään pelaaja", + "tp_player": "Mennään pelaajan luokse", + "bring_player": "Tuodaan pelaaja", + "spectate_failed": "Virhe kohteen tavoittamisessa!", + "spectate_yourself": "Et voi katsoa itseäsi.", + "freeze_yourself": "Et voi jäädyttää itseäsi.", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Humalluta", + "fire": "Aseta tuleen", + "wild_attack": "Wild attack" + } + } + }, + "info": { + "title": "Pelaajatiedot", + "session_time": "Session pituus", + "play_time": "Peliaika", + "joined": "Liittynyt", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "not yet", + "btn_wl_add": "ANNA WL", + "btn_wl_remove": "POISTA WL", + "btn_wl_success": "Whitelistin tila vaihdettu.", + "log_label": "Log", + "log_empty": "Porttikieltoja/varoituksia ei löytynyt.", + "log_ban_count": "%{smart_count} porttikielto |||| %{smart_count} porttikieltoa", + "log_warn_count": "%{smart_count} varoitus |||| %{smart_count} varoitusta", + "log_btn": "LISÄTIEDOT", + "notes_changed": "Pelaajan merkintöjä muutettu.", + "notes_placeholder": "Merkintöjä pelaajasta..." + }, + "history": { + "title": "Historia", + "btn_revoke": "KUMOA", + "revoked_success": "Tapahtuma kumottu!", + "banned_by": "PORTTIKIELLON antanut %{author}", + "warned_by": "VAROITUKSEN antanut %{author}", + "revoked_by": "Kumonnut %{author}.", + "expired_at": "Expired at %{date}.", + "expires_at": "Expires at %{date}." + }, + "ban": { + "title": "Aseta pelaaja porttikieltoon", + "reason_placeholder": "Syy", + "duration_placeholder": "Kesto", + "hours": "tunti(a)", + "days": "päivä(ä)", + "weeks": "viikko(a)", + "months": "kuukautta", + "permanent": "Ikuinen", + "custom": "Mukautettu", + "helper_text": "Aseta porttikiellon kesto", + "submit": "Aseta porttikielto", + "reason_required": "Syötäthän porttikiellon syyn", + "success": "Pelaaja asetettu porttikieltoon!" + }, + "ids": { + "current_ids": "Nykyiset tunnisteet", + "previous_ids": "Aiemmin käytetyt tunnisteet", + "all_hwids": "All Hardware IDs" + } + } + } +} diff --git a/locale/fr.json b/locale/fr.json new file mode 100644 index 0000000..cf8bfeb --- /dev/null +++ b/locale/fr.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "French", + "humanizer_language": "fr" + }, + "restarter": { + "server_unhealthy_kick_reason": "le serveur doit être redémarré, reconnecte-toi s'il te plaît", + "partial_hang_warn": "En raison d'un blocage partiel, le serveur redémarrera dans 1 minute. Veuillez vous déconnecter maintenant.", + "partial_hang_warn_discord": "En raison d'un blocage partiel, **%{servername}** redémarrera dans 1 minute.", + "schedule_reason": "redémarage prévu à %{time}", + "schedule_warn": "Le serveur redémarrera dans %{smart_count} minute. Merci de vous déconnecter. |||| Le serveur redémarrera dans %{smart_count} minutes.", + "schedule_warn_discord": "**%{servername}** redémarrera dans %{smart_count} minute. |||| **%{servername}** redémarrera dans %{smart_count} minutes." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Vous avez été banni pour: \"%{reason}\". Votre bannissement expirera dans: %{expiration}.", + "kick_permanent": "(%{author}) Vous avez été définitivement banni de ce serveur pour: \"%{reason}\".", + "reject": { + "title_permanent": "Vous avez été définitivement banni de ce serveur.", + "title_temporary": "Vous avez été temporairement banni de ce serveur.", + "label_expiration": "Votre sanction expire dans", + "label_date": "Date de la sanction", + "label_author": "Banni par", + "label_reason": "Raison du bannissement", + "label_id": "ID de sanction", + "note_multiple_bans": "Note : vous avez plus d'un bannissement sur votre identifiant.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Ce serveur est en mode Admin Only (maintenance).", + "insufficient_ids": "Vous n'avez pas d'identifiant discord ou fivem, et au moins un d'entre eux est requis pour valider que vous êtes un administrateur txAdmin.", + "deny_message": "Vos identifiants ne sont attribués à aucun administrateur txAdmin." + }, + "guild_member": { + "mode_title": "Ce serveur est en mode Discord Member Whitelist.", + "insufficient_ids": "Vous n'avez pas l'identifiant discord, qui est nécessaire pour valider que vous avez rejoint notre serveur Discord. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "Vous devez rejoindre notre serveur Discord pour vous connecter.", + "deny_message": "Veuillez rejoindre le serveur %{guildname} puis réessayer." + }, + "guild_roles": { + "mode_title": "Ce serveur est en mode Discord Role Whitelist.", + "insufficient_ids": "Vous n'avez pas l'identifiant discord, qui est nécessaire pour valider que vous avez rejoint notre serveur Discord. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "Vous devez rejoindre notre serveur Discord pour vous connecter.", + "deny_notmember_message": "Veuillez rejoindre le serveur %{guildname}, obtenez l'un des rôles requis, puis réessayez.", + "deny_noroles_title": "Vous n'avez pas le rôle requis pour vous connecter.", + "deny_noroles_message": "Pour rejoindre ce serveur, vous devez avoir au moins un des rôles requis du serveur %{guildname}." + }, + "approved_license": { + "mode_title": "Ce serveur est en mode License Whitelist.", + "insufficient_ids": "Vous n'avez pas l'identifiant license, ce qui signifique que l'option sv_lan est activée. Si vous êtes le propriétaire du serveur, vous pouvez le désactivez dans votre ficher server.cfg.", + "deny_title": "Vous n'êtes pas sur la whitelist pour rejoindre ce serveur.", + "request_id_label": "ID de requête" + } + }, + "server_actions": { + "restarting": "Le serveur redémarre (%{reason}).", + "restarting_discord": "**%{servername}** redémarre (%{reason}).", + "stopping": "Fermeture du serveur (%{reason}).", + "stopping_discord": "**%{servername}** est en train de fermer (%{reason}).", + "spawning_discord": "**%{servername}** est en train de démarrer." + }, + "nui_warning": { + "title": "AVERTISSEMENT", + "warned_by": "Avertis par:", + "stale_message": "Cet avertissement a été émis avant que vous ne vous connectiez au serveur.", + "dismiss_key": "ESPACE", + "instruction": "Maintenir %{key} pendant %{smart_count} seconde pour cacher ce message. |||| Maintenir %{key} pendant %{smart_count} secondes pour cacher ce message." + }, + "nui_menu": { + "misc": { + "help_message": "Menu txAdmin activé, ecrivez /tx pour l'ouvrir.\nVous pouvez aussi configurer une touche [Paramètre du Jeu > Configuration des touches > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Votre identifiant ne coïncide avec aucun identifiant d'un administrateur sur txAdmin.\nSi vous êtes enregistré(e) sur txAdmin, allez dans Admin Manager et assurez-vous que votre identifiant est bien enregistré.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Vous n'avez pas les permissions suffisantes.", + "unknown_error": "Une erreur inconnue s'est produite.", + "not_enabled": "Le Menu txAdmin n'est pas activé ! Vous pouvez l'activer dans les paramètres de txAdmin.", + "announcement_title": "Annonce serveur par %{author}:", + "dialog_empty_input": "Vous ne pouvez pas avoir un champ vide.", + "directmessage_title": "Message d'un administrateur : %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Vous avez gelé le joueur !", + "unfroze_player": "Vous avez dégelé le joueur !", + "was_frozen": "Vous avez été gelé par un administrateur du serveur !" + }, + "common": { + "cancel": "Annuler", + "submit": "Soumettre", + "error": "Une erreur s'est produite", + "copied": "Copié dans le presse-papier." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Utilisez %{key} pour changer de pages & et les flèches pour naviguer dans le menu", + "tooltip_2": "Certains éléments de menu ont des sous-options qui peuvent être sélectionnées à l'aide des fléches gauche et droite" + }, + "player_mode": { + "title": "Mode du joueur", + "noclip": { + "title": "NoClip", + "label": "Volez comme un oiseau", + "success": "NoClip activé" + }, + "godmode": { + "title": "Invincible", + "label": "Devenez invincible", + "success": "Invincibilité activée" + }, + "superjump": { + "title": "Super Jump", + "label": "Basculer sur le mode super saut, le joueur courra également plus vite", + "success": "Super Jump activé" + }, + "normal": { + "title": "Normal", + "label": "Mode par défaut", + "success": "Vous êtes de nouveau normal" + } + }, + "teleport": { + "title": "Téléportation", + "generic_success": "Vous avez été téléporté !", + "waypoint": { + "title": "GPS", + "label": "TP sur votre point GPS", + "error": "Aucun point GPS définit." + }, + "coords": { + "title": "Coordonnées", + "label": "TP sur coordonnées", + "dialog_title": "Téléportation", + "dialog_desc": "Fournissez des coordonnées au format x, y, z pour vous téléporter.", + "dialog_error": "Coordonnées invalide. Elles doivent être sous le format suivant: 111, 222, 33" + }, + "back": { + "title": "Retour", + "label": "TP sur ancienne position", + "error": "Vous n'avez aucune dernière position !" + }, + "copy": { + "title": "Copier", + "label": "Copiez votre position" + } + }, + "vehicle": { + "title": "Véhicule", + "not_in_veh_error": "Vous n'êtes pas dans un véhicule !", + "spawn": { + "title": "Spawn", + "label": "Créez un véhicule par modèle", + "dialog_title": "Faire apparaître un vehicule", + "dialog_desc": "Entrer le nom du véhicule que vous voulez faire apparaître.", + "dialog_success": "Véhicule apparu !", + "dialog_error": "Le modèle de véhicule '%{modelName}' n'existe pas !", + "dialog_info": "Tentative de faire apparaître %{modelName}." + }, + "fix": { + "title": "Réparer", + "label": "Réparez le véhicule", + "success": "Véhicule réparé !" + }, + "delete": { + "title": "Supprimer", + "label": "Supprimez le véhicule", + "success": "Véhicule supprimé !" + }, + "boost": { + "title": "Boost", + "label": "Boostez la voiture pour obtenir un maximum de fun (et peut-être de vitesse).", + "success": "Véhicule boosté !", + "already_boosted": "Ce véhicule est déjà boosté.", + "unsupported_class": "Cette classe de véhicule n'est pas supportée.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Soigner", + "myself": { + "title": "Moi", + "label": "Restaurez votre vie", + "success_0": "Santé mise à 100% !", + "success_1": "Vous avez été soigné !", + "success_2": "Vous êtes désormais en pleine forme !", + "success_3": "Vos bobos ont été soigné !" + }, + "everyone": { + "title": "Tous les joueurs", + "label": "Soignez & réanimez", + "success": "Tous les joueurs ont été soigné et réanimé." + } + }, + "announcement": { + "title": "Faire une annonce", + "label": "Faire une annonce pour tous les joueurs actuellement connectés.", + "dialog_desc": "Entrez le message que vous souhaitez diffuser à tous les joueurs..", + "dialog_placeholder": "Message", + "dialog_success": "L'annonce est en train d'être envoyée." + }, + "clear_area": { + "title": "Nettoyer la zone", + "label": "Remettre une zone du monde spécifiée à son état par défaut.", + "dialog_desc": "Entrez un rayon où vous souhaitez que les entités soient supprimées (0-300). Cela ne va pas supprimer les entités créées par le serveur.", + "dialog_success": "Suppression des entités dans un rayon de %{radius} m", + "dialog_error": "Rayon invalide. Veuillez réessayer." + }, + "player_ids": { + "title": "Afficher les ID des joueurs", + "label": "Afficher les identifiants des joueurs (et d'autres informations) au-dessus de la tête de tous les joueurs proches.", + "alert_show": "Les ID des joueurs sont affichés.", + "alert_hide": "Les ID des joueurs ne sont plus affichés." + } + }, + "page_players": { + "misc": { + "online_players": "Joueur(s) en ligne", + "players": "Joueur(s)", + "search": "Rechercher", + "zero_players": "Aucun joueur trouvé" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Trier par", + "distance": "Distance", + "id": "ID", + "joined_first": "Premières connexions", + "joined_last": "Dernières connexions", + "closest": "Le - loin", + "farthest": "Le + loin" + }, + "card": { + "health": "Santé : %{percentHealth}%" + } + }, + "player_modal": { + "misc": { + "error": "Une erreur s'est produite lors de la récupération des détails de cet utilisateur. L'erreur est indiquée ci-dessous:", + "target_not_found": "Impossible de trouver un joueur en ligne avec cet ID ou le pseudo de %{target}" + }, + "tabs": { + "actions": "Actions", + "info": "Infos", + "ids": "Identifiants", + "history": "Historique", + "ban": "Bannir" + }, + "actions": { + "title": "Actions du joueur", + "command_sent": "Commande envoyée !", + "moderation": { + "title": "Modération", + "options": { + "dm": "PM", + "warn": "Avertir", + "kick": "Exclure", + "set_admin": "Donner droits admin" + }, + "dm_dialog": { + "title": "Message privé", + "description": "Que souhaitez-vous envoyer à ce joueur ?", + "placeholder": "Message", + "success": "Votre message a été envoyé !" + }, + "warn_dialog": { + "title": "Avertir", + "description": "Pour quelle(s) raison(s) souhaitez-vous avertir ce joueur ?", + "placeholder": "Raison", + "success": "Le joueur a été averti !" + }, + "kick_dialog": { + "title": "Exclure", + "description": "Pour quelle(s) raison(s) souhaitez-vous exclure ce joueur ?", + "placeholder": "Raison", + "success": "Le joueur a été exclu !" + } + }, + "interaction": { + "title": "Intéractions", + "options": { + "heal": "Soigner", + "go_to": "TP à lui", + "bring": "TP à soi", + "spectate": "Surveiller", + "toggle_freeze": "Geler/Dégeler" + }, + "notifications": { + "heal_player": "Le joueur a été soigné", + "tp_player": "Téléportation au joueur", + "bring_player": "Le joueur a été téléporté à vous", + "spectate_failed": "Impossible de trouver le joueur ! Désactivation du mode surveillance.", + "spectate_yourself": "Vous ne pouvez pas vous surveiller vous-même.", + "freeze_yourself": "Vous ne pouvez pas vous geler vous-même.", + "spectate_cycle_failed": "Il n'y a pas de joueurs vers lesquels faire du vélo." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Saouler", + "fire": "Enflammer", + "wild_attack": "Attaque sauvage" + } + } + }, + "info": { + "title": "Informations du joueur", + "session_time": "Durée de la session", + "play_time": "Temps de jeu", + "joined": "Connecté le", + "whitelisted_label": "whitelisté", + "whitelisted_notyet": "pas encore", + "btn_wl_add": "AJOUTER WL", + "btn_wl_remove": "RETIRER WL", + "btn_wl_success": "Status de la whitelist changée.", + "log_label": "Log", + "log_empty": "Aucune sanction trouvée.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} warn |||| %{smart_count} warns", + "log_btn": "DÉTAILS", + "notes_changed": "Note du joueur changée.", + "notes_placeholder": "Notes à propos de ce joueur..." + }, + "history": { + "title": "Historique", + "btn_revoke": "RÉVOQUER", + "revoked_success": "Action révoquée !", + "banned_by": "BANNI par %{author}", + "warned_by": "WARN par %{author}", + "revoked_by": "Révoqué par %{author}.", + "expired_at": "Expiré le %{date}.", + "expires_at": "Expire le %{date}." + }, + "ban": { + "title": "Bannir le joueur", + "reason_placeholder": "Raison", + "duration_placeholder": "Durée", + "hours": "heure(s)", + "days": "jour(s)", + "weeks": "semaine(s)", + "months": "mois", + "permanent": "Permanente", + "custom": "Personnalisée", + "helper_text": "Selectionnez une durée", + "submit": "Bannir", + "reason_required": "Vous devez inclure une raison de sanction.", + "success": "Joueur banni." + }, + "ids": { + "current_ids": "Identifiants actuels", + "previous_ids": "Précedents identifiants", + "all_hwids": "All Hardware IDs" + } + } + } +} diff --git a/locale/hr.json b/locale/hr.json new file mode 100644 index 0000000..299a744 --- /dev/null +++ b/locale/hr.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Croatian", + "humanizer_language": "hr" + }, + "restarter": { + "server_unhealthy_kick_reason": "server se mora restartati, molim te ponovo se spoji", + "partial_hang_warn": "Zbog djelomičnog zastoja, ovaj će se server ugasiti za 1 minutu . Molimo vas da izađete sa servera.", + "partial_hang_warn_discord": "Zbog djelomičnog zastoja, **%{servername}** će se restartati za 1 minutu.", + "schedule_reason": "Restart je zakazan za %{time}", + "schedule_warn": "Ovaj server je zakazan da se restarta za %{smart_count} minutu. Molimo da izađete sa servera. |||| Ovaj server će se restartati za %{smart_count} minuta.", + "schedule_warn_discord": "**%{servername}** se restarta za %{smart_count} minutu. |||| **%{servername}** se restarta za %{smart_count} minuta." + }, + "kick_messages": { + "everyone": "Svi igrači izbačeni: %{reason}.", + "player": "Izbačen si: %{reason}.", + "unknown_reason": "iz nepoznatog razloga" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Zabranjeni ste sa servera zbog \"%{reason}\". Vaša zabrana ističe za: %{expiration}.", + "kick_permanent": "(%{author}) Zabranjeni ste sa servera zauvijek zbog \"%{reason}\".", + "reject": { + "title_permanent": "Zabranjeni ste sa servera zauvijek.", + "title_temporary": "Privremeno ste zabranjeni sa servera.", + "label_expiration": "Vaša zabrana će isteči za", + "label_date": "Datum zabrane", + "label_author": "Zabranjen od", + "label_reason": "Razlog zabrane", + "label_id": "ID zabrane", + "note_multiple_bans": "Poruka: Imaš više od jedne zabrane na ovom profilu.", + "note_diff_license": "Poruka: Gornja zabrana je stavljen na license, što znači da jedan od tvojih IDova/HWIDova se slažu sa tim koji ima zabranu." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Ovaj server je u Admin-only modu.", + "insufficient_ids": "Nemaš Discord discord ili fivem profil, i barem jedan od njih je potreban za potvrdu ako ste txAdmin administrator.", + "deny_message": "Ni jedan tvoj profil nije spojen sa txAdmin administratorom." + }, + "guild_member": { + "mode_title": "Ovaj server je u Discord Whitelist modu.", + "insufficient_ids": "Nemaš Discord Discord profil, koji je potreban kako bi se potvrdilo da si ušao na Discord server. Molim vas otvorite Discord da radnoj površini i pokušajte ponovno (Web aplikacija neće raditi).", + "deny_title": "Moraš se pridružiti Discord da bi mogao ući na server.", + "deny_message": "Molim vas uđite na %{guildname} pa pokušajte ponovno." + }, + "guild_roles": { + "mode_title": "Ovaj server je u Discord Role Whitelist modu.", + "insufficient_ids": "Nemaš Discord Discord profil, koji je potreban kako bi se potvrdilo da si ušao na Discord server. Molim vas otvorite Discord da radnoj površini i pokušajte ponovno (Web aplikacija neće raditi).", + "deny_notmember_title": "Moraš se pridružiti Discord da bi mogao ući na server.", + "deny_notmember_message": "Molim vas uđite na %{guildname}, zatraži jedan od traženih rolova, pa pokušaj ponovno.", + "deny_noroles_title": "Nemaš traženi rol koji se traži da bi ušao na server.", + "deny_noroles_message": "Da bi ušao na ovaj server moraš imati barem jedan od traženih rolova na %{guildname}." + }, + "approved_license": { + "mode_title": "Ovaj server je u License Whitelist modu.", + "insufficient_ids": "Nemaš fivem profil, što znači da server ima sv_lan upaljen. Ako si vlasnik servera, mozes ga ugasit u server.cfg fajlu.", + "deny_title": "Nisi whitelistan da bi ušao na server.", + "request_id_label": "Zatraženi ID" + } + }, + "server_actions": { + "restarting": "Server se restarta: (%{reason}).", + "restarting_discord": "**%{servername}** se restarta zbog: (%{reason}).", + "stopping": "Server je ugašen: (%{reason}).", + "stopping_discord": "**%{servername}** se gasi: (%{reason}).", + "spawning_discord": "**%{servername}** se pali" + }, + "nui_warning": { + "title": "UPOZORENJE", + "warned_by": "Vas je upozorio", + "stale_message": "Ovo upozorenje je izdano prije nego što ste se spojili na server.", + "dismiss_key": "SPACE", + "instruction": "Držite %{key} na %{smart_count} sekundu da vam se skine ova poruka. |||| Držite %{key} na %{smart_count} sekunde da vam se skine ova poruka." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Meni upaljen, napiši /tx da bi ga otvorio.\nIsto tako možeš staviti svoji keybind u [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Vaši profili ne odgovaraju niti jednom registriranom adminu na txAdmin.\nAko ste registrirani na txAdmin, idite na Admin Manager i provjerite jesu li vaši identifikatori spremljeni.", + "menu_auth_failed": "Provjera autentičnosti izbornika txAdmin nije uspjela s razlogom: %{reason}", + "no_perms": "Nemaš permisiju.", + "unknown_error": "Nepoznata greška se desila.", + "not_enabled": "txAdmin Meni nije omogučen! Možete ga omogučiti u txAdmin web postavkama.", + "announcement_title": "Server obavještenje od %{author}:", + "dialog_empty_input": "Ne možete imati prazan unos.", + "directmessage_title": "DM od admina %{author}:", + "onesync_error": "Ova akcija zahtjeva da OneSnyc bude omogučen." + }, + "frozen": { + "froze_player": "Zaledio si igrača!", + "unfroze_player": "Odledio si igrača!", + "was_frozen": "Zaleđen si od strane administracije!" + }, + "common": { + "cancel": "Prekini", + "submit": "Potvrdi", + "error": "Desila se greška", + "copied": "Kopirano." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Koristi %{key} da bi promijenio stranice i strelice kako bi navigirao izbornikom", + "tooltip_2": "Određene stavke izbornika imaju podopcije koje se mogu odabrati pomoću tipki sa strelicama lijevo i desno" + }, + "player_mode": { + "title": "Modovi igrača", + "noclip": { + "title": "NoClip", + "label": "Leti okolo", + "success": "NoClip upaljen" + }, + "godmode": { + "title": "Nepobjediv", + "label": "Nepobjedivi mod", + "success": "Nepobjedivi mod upaljen" + }, + "superjump": { + "title": "Super Moći", + "label": "Upali Super Moći, igrač će također brže trčati i skakati", + "success": "Super Moći upaljene" + }, + "normal": { + "title": "Normalan", + "label": "Normalan mod", + "success": "Vračen u normalan mod." + } + }, + "teleport": { + "title": "Teleport", + "generic_success": "Poslan u svemir!", + "waypoint": { + "title": "Putna Točka", + "label": "Idi do putne točke", + "error": "Nemaš postavljenu putnu točku." + }, + "coords": { + "title": "Kordinate", + "label": "Idi na određenu kordinatu", + "dialog_title": "Teleport", + "dialog_desc": "Postavi kordinate u x, y, z formatu da bih se teleportao.", + "dialog_error": "Krive kordinate. Moraju biti u formatu: 111, 222, 33" + }, + "back": { + "title": "Natrag", + "label": "Idi natrag na zadnju lokaciju", + "error": "Nemas zadnju lokaciju na koju bi otišao!" + }, + "copy": { + "title": "Kopiraj kordinate", + "label": "Kordinate kopirane." + } + }, + "vehicle": { + "title": "Auto", + "not_in_veh_error": "Trenutno nisi u vozilu!", + "spawn": { + "title": "Stvori", + "label": "Stvori auto preko imena modela", + "dialog_title": "Stvori auto", + "dialog_desc": "Napiši model vozila koji želiš stvoriti.", + "dialog_success": "Auto stvoreno!", + "dialog_error": "Ime za model vozila '%{modelName}' ne postoji!", + "dialog_info": "Pokušavam stvoriti %{modelName}." + }, + "fix": { + "title": "Popravi", + "label": "Popravi trenutno auto", + "success": "Auto popravljeno!" + }, + "delete": { + "title": "Izbriši", + "label": "Izbriši trenutno auto", + "success": "Auto izbrisano!" + }, + "boost": { + "title": "Pojačanje", + "label": "Pojačaj auto da bi osjetio pravu zabavu (možda i brzinu)", + "success": "Auto pojačano!", + "already_boosted": "Ovaj auto je več pojačan.", + "unsupported_class": "Ova klasa auta nije podržana.", + "redm_not_mounted": "Jedino možeš pojačati ako si na konju." + } + }, + "heal": { + "title": "Izliječi", + "myself": { + "title": "Sebe", + "label": "Puni vaše zdravlje", + "success_0": "Svi su izliječeni!", + "success_1": "Sada bi se trebao osječati puno bolje!", + "success_2": "Napunjen do kraja!", + "success_3": "Bolovi uklonjeni!" + }, + "everyone": { + "title": "Sve", + "label": "Će izlječiti i oživiti sve igrače", + "success": "Izlječeni i oživljeni su svi igrači." + } + }, + "announcement": { + "title": "Pošalji obavještenje", + "label": "Pošalji obavještenje svim online igračima.", + "dialog_desc": "Pošalji obavještenje svim online igračima.", + "dialog_placeholder": "Tvoje obavještenje...", + "dialog_success": "Slanje obavještenja." + }, + "clear_area": { + "title": "Očisti područje mape", + "label": "Očisti specificiranu mapu", + "dialog_desc": "Molim vas unesite radius u kojem želite očistiti mapu na početno u (0-300). Ovo neče očistiti stvorene stvari od strane servera.", + "dialog_success": "Čišćenje mape u radiusu od %{radius}m", + "dialog_error": "Krivi input radiusa. Pokušaj ponovno." + }, + "player_ids": { + "title": "Upali ID-ove", + "label": "Upali ID-ove (i ostale informacije) iznad glava svih igrača koji su u blizini", + "alert_show": "Pokazivanje ID-ova svih igrača u blizini.", + "alert_hide": "Sakrivanje ID-ova svih igrača u blizini." + } + }, + "page_players": { + "misc": { + "online_players": "Online igrači", + "players": "Igrači", + "search": "Pretraži", + "zero_players": "Nema pronađenih igrača" + }, + "filter": { + "label": "Filtriraj po", + "no_filter": "Bez Filtera", + "is_admin": "Je Admin", + "is_injured": "Je Ranjen/ Mrtav", + "in_vehicle": "U Vozilu" + }, + "sort": { + "label": "Poredaj po", + "distance": "Daljini", + "id": "ID-u", + "joined_first": "Ušao prvi", + "joined_last": "Ušao zadnji", + "closest": "Najbliži", + "farthest": "Najdalji" + }, + "card": { + "health": "%{percentHealth}% zdravlja" + } + }, + "player_modal": { + "misc": { + "error": "Došlo je do pogreške prilikom dohvaćanja pojedinosti o ovom igraču. Greška je prikazana u nastavku:", + "target_not_found": "Nije moguće pronaći online igrača s ID-om ili imenom profila od %{target}" + }, + "tabs": { + "actions": "Akcije", + "info": "Info", + "ids": "ID-ovi", + "history": "Povijest", + "ban": "Zabrane" + }, + "actions": { + "title": "Akcije igrača", + "command_sent": "Komanda poslana!", + "moderation": { + "title": "Moderacija", + "options": { + "dm": "DM", + "warn": "Upozori", + "kick": "Izbaci", + "set_admin": "Daj admina" + }, + "dm_dialog": { + "title": "Direktna poruka", + "description": "Koji je razlog slanja direktne poruke?", + "placeholder": "Razlog...", + "success": "Tvoj DM je uspješno poslan!" + }, + "warn_dialog": { + "title": "Upozori", + "description": "Koji je razlog za upozoravanje igrača?", + "placeholder": "Razlog...", + "success": "Igrač je upozoren!" + }, + "kick_dialog": { + "title": "Izbaci", + "description": "Koji je razlog da bi izbacio ovog igrača?", + "placeholder": "Razlog...", + "success": "Igrač je izbačen!" + } + }, + "interaction": { + "title": "Interakcije", + "options": { + "heal": "Zaliječi", + "go_to": "Idi do", + "bring": "Dovuci", + "spectate": "Gledaj", + "toggle_freeze": "Zaledi" + }, + "notifications": { + "heal_player": "Liječenje igrača", + "tp_player": "Teleportiranje do igrača", + "bring_player": "Dovuci igrača", + "spectate_failed": "Neuspješno dohvaćanje igrača! Izlaženje iz gledanja.", + "spectate_yourself": "Ne možeš sam sebe gledat.", + "freeze_yourself": "Ne možeš sam sebe zalediti.", + "spectate_cycle_failed": "Nema igrača koje bi gledao." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Napravi pijanog", + "fire": "Zapali", + "wild_attack": "Napad čudovišta" + } + } + }, + "info": { + "title": "Info igrača", + "session_time": "Trenutno vrijeme na serveru", + "play_time": "Vrijeme igranja", + "joined": "Ušao", + "whitelisted_label": "Whitelistan", + "whitelisted_notyet": "ne još", + "btn_wl_add": "DAJ WL", + "btn_wl_remove": "MAKNI WL", + "btn_wl_success": "Status Whiteliste promijenjen.", + "log_label": "Log", + "log_empty": "Nema zabrana/upozorenja pronađenih.", + "log_ban_count": "%{smart_count} zabrani |||| %{smart_count} zabrana", + "log_warn_count": "%{smart_count} upozori |||| %{smart_count} upozorenja", + "log_btn": "DETALJI", + "notes_changed": "Bilješka promijenjena.", + "notes_placeholder": "Bilješke o igraču..." + }, + "history": { + "title": "Povezana povijest", + "btn_revoke": "MAKNI", + "revoked_success": "Akcija maknuta!", + "banned_by": "Zabrana data od %{author}", + "warned_by": "UPOZOREN od %{author}", + "revoked_by": "Maknut od %{author}.", + "expired_at": "Isteko u %{date}.", + "expires_at": "Ističe u %{date}." + }, + "ban": { + "title": "Zabrani ulazak igraču", + "reason_placeholder": "Razlog", + "duration_placeholder": "Duljina", + "hours": "sati", + "days": "dana", + "weeks": "tjedana", + "months": "mjeseca", + "permanent": "Zauvijek", + "custom": "Custom", + "helper_text": "Odaberi dužinu", + "submit": "Potvrdi zabranu", + "reason_required": "Polje za razlog mora biti popunjeno.", + "success": "Igraču zabranjen ulazak!" + }, + "ids": { + "current_ids": "Trenutni profili", + "previous_ids": "Prijašnji igračevi profili", + "all_hwids": "Svi HWID-ovi" + } + } + } +} diff --git a/locale/hu.json b/locale/hu.json new file mode 100644 index 0000000..2444639 --- /dev/null +++ b/locale/hu.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Hungarian", + "humanizer_language": "hu" + }, + "restarter": { + "server_unhealthy_kick_reason": "a szervert újra kell indítani, kérlek csatlakozz újra", + "partial_hang_warn": "Részleges lefagyás miatt a szerver 1 perc múlva újraindul. Kérjük, lépj ki.", + "partial_hang_warn_discord": "Részleges lefagyás miatt, a **%{servername}** 1 perc múlva újraindul.", + "schedule_reason": "Ütemezett újraindítás %{time}", + "schedule_warn": "A szerver ütemezett újraindítása lesz %{smart_count} perc múlva. Kérjük, lépj ki!. |||| A szerver ütemezett újraindítása lesz %{smart_count} perc múlva.", + "schedule_warn_discord": "A(z) **%{servername}** ütemezett újraindítása lesz %{smart_count} perc múlva. |||| **%{servername}** ütemezett újraindítása lesz %{smart_count} perc múlva." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Ki lettél tiltva a szerverről. Indok: \"%{reason}\". Lejár: %{expiration}.", + "kick_permanent": "(%{author}) Véglegesen ki lettél tiltva a szerverről. Indok: \"%{reason}\".", + "reject": { + "title_permanent": "Véglegesen ki lettél tiltva a szerverről.", + "title_temporary": "Átmenetileg ki vagy tiltva a szerverről.", + "label_expiration": "Újra csatlakozhatsz ekkor", + "label_date": "Kitiltás dátuma", + "label_author": "Kitiltó neve", + "label_reason": "Kitiltás indoka", + "label_id": "Kitiltás ID", + "note_multiple_bans": "Figyelem: Több mint egy aktív kitiltásod van ezen a fiókon.", + "note_diff_license": "Megjegyzés: a kitiltás a license miatt van, ami azt jelenti, hogy néhány azonosítód/hardver azonosítód egyezik a kiltiltásban lévő adatokkal." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Ez a szerver csak Adminok módban van.", + "insufficient_ids": "Nincs discord vagy fivem azonosítód, valamelyik szükséges ahhoz, hogy azonosítsd magad ha txAdmin adminisztrátor vagy.", + "deny_message": "Az azonosítód nem tartozik egy txAdmin adminisztrátorhoz sem." + }, + "guild_member": { + "mode_title": "Ez a szerver Discord szerver tag whitelist módban van.", + "insufficient_ids": "A discord azonosítód nem elérhető, ami szükséges annak az ellenőrzéséhez, hogy fent vagy-e a szerveren. Kérlek nyisd meg a Discord asztali alkalmazást, és próbáld újra (a webalkalmazás nem fog működni).", + "deny_title": "Kérlek lépj be a szerverünkre a csatlakozáshoz", + "deny_message": "Kérlek csatlakozz ide %{guildname}, és próbáld újra." + }, + "guild_roles": { + "mode_title": "Ez a szerver Discord whitelist rang módban van.", + "insufficient_ids": "A discord azonosítód nem elérhető, ami szükséges annak az ellenőrzéséhez, hogy fent vagy-e a szerveren. Kérlek nyisd meg a Discord asztali alkalmazást, és próbáld újra (a webalkalmazás nem fog működni).", + "deny_notmember_title": "Kérlek lépj be a szerverünkre a csatlakozáshoz", + "deny_notmember_message": "Kérlek csatlakozz ide %{guildname}, szerezd meg a szükséges rangot és próbáld újra.", + "deny_noroles_title": "Nem vagy engedélyezve a Discord szerveren (Nincs meg a megfelelő rang).", + "deny_noroles_message": "A csatlakozáshoz szükséged lesz a whitelistelt rangra a következő szerveren: %{guildname}." + }, + "approved_license": { + "mode_title": "Ez a szerver License Whitelist módban van.", + "insufficient_ids": "Nincs license azonosítód, ami azt jelenti, hogy a szerveren az sv_lan be van kapcsolva. Ha te vagy a tulajdonos, a server.cfg fájlban ki tudod kapcsolni.", + "deny_title": "Nem vagy engedélyezve a szerveren.", + "request_id_label": "Kérés ID" + } + }, + "server_actions": { + "restarting": "Szerver újraindítása: (%{reason}).", + "restarting_discord": "**%{servername}** újraindul (%{reason}).", + "stopping": "A szerver leáll (%{reason}).", + "stopping_discord": "**%{servername}**-t leállítják (%{reason}).", + "spawning_discord": "**%{servername}** indul." + }, + "nui_warning": { + "title": "FIGYELMEZTETÉS", + "warned_by": "Figyelmeztetett:", + "stale_message": "Ezt a figyelmeztetést még azelőtt adták ki, hogy csatlakoztál volna a szerverhez.", + "dismiss_key": "SPACE", + "instruction": "Tartsd lenyomva a %{key}-t %{smart_count} másodpercig, hogy eltüntesd az üzenetet. |||| Tartsd lenyomva a %{key}-t %{smart_count} másodpercig, hogy eltüntesd az üzenetet." + }, + "nui_menu": { + "misc": { + "help_message": "A txAdmin rendelkezik játékon belüli menüvel!\nGyorsgomb [Game Settings > Key Bindings > FiveM > Open the txAdmin Menu] Próbáld ki!", + "menu_not_admin": "Az azonosítód nem egyezik a txAdminban található admin azonosítókkal.\nHa regisztrálva vagy txAdminban, bizonyosodj meg róla hogy el van mentve az Admin Managerben.", + "menu_auth_failed": "A txAdmin menü hitelesítés meghiúsult ezzel az okkal: %{reason}", + "no_perms": "Ehhez nincs engedélyed.", + "unknown_error": "Ismeretlen hiba lépett fel.", + "not_enabled": "A txAdmin menü nincs engedélyezve! Engedélyezheted a txAdmin beállítások fülön.", + "announcement_title": "Szerver felhívás %{author}:", + "dialog_empty_input": "Nem lehet üresen hagyni.", + "directmessage_title": "Privát üzenet admintól %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Lefagyasztottad a játékost!", + "unfroze_player": "Feloldottad a fagyasztást a játékoson!", + "was_frozen": "Lefagyasztott egy szerveradmin!" + }, + "common": { + "cancel": "Vissza", + "submit": "Küldés", + "error": "Hiba lépett fel", + "copied": "Vágólapra másolva." + }, + "page_main": { + "tooltips": { + "tooltip_1": "%{key} a lapozáshoz és [NYILAK] a menüben való navigáláshoz", + "tooltip_2": "Néhány menü rendelkezik al-opcióval melyek a bal és jobb nyíllal választhatók" + }, + "player_mode": { + "title": "Játékos mód", + "noclip": { + "title": "NoClip", + "label": "Körbe repülés", + "success": "NoClip bekapcsolva" + }, + "godmode": { + "title": "Isten", + "label": "Halhatatlanság", + "success": "God mód bekapcsolva" + }, + "superjump": { + "title": "Nagy ugrás", + "label": "Magas ugrás és gyors futás", + "success": "Nagy ugrás bekapcsolva" + }, + "normal": { + "title": "Normál", + "label": "Normál mód", + "success": "Visszaállítva a normál játékos módba." + } + }, + "teleport": { + "title": "Teleportálás", + "generic_success": "Átléptél egy féreglyukon!", + "waypoint": { + "title": "Úticél", + "label": "Teleportálás az úticélhoz", + "error": "Nincs úticél kijelölve." + }, + "coords": { + "title": "Koordináta", + "label": "Menj egy megadott koordinátára", + "dialog_title": "Teleportálás", + "dialog_desc": "A koordinátákat x, y, z formában kell megadnod.", + "dialog_error": "Hibás koordináta. Ilyen formát használj: 111, 222, 33" + }, + "back": { + "title": "Vissza", + "label": "Vissza az utolsó pozíciódra", + "error": "Nincs hová visszamenned!" + }, + "copy": { + "title": "Másolás", + "label": "Jelenlegi koordináta másolása" + } + }, + "vehicle": { + "title": "Jármű", + "not_in_veh_error": "Jelenleg nem ülsz járműben!", + "spawn": { + "title": "Lehívás", + "label": "Jármű lehívása név alapján", + "dialog_title": "Jármű lehívása", + "dialog_desc": "Add meg a modell nevét amit le szeretnél hívni.", + "dialog_success": "Jármű lehívva!", + "dialog_error": "Jármű ezzel a modell névvel '%{modelName}' nem létezik!", + "dialog_info": "%{modelName} lehívása..." + }, + "fix": { + "title": "Javítás", + "label": "Jelenlegi jármű javítása", + "success": "Jármű megjavítva!" + }, + "delete": { + "title": "Törlés", + "label": "Jelenlegi jármű törlése", + "success": "Jármű törölve!" + }, + "boost": { + "title": "Boost", + "label": "Boostold a járművedet!", + "success": "Jármű boostolva!", + "already_boosted": "Ezt a járművet már boostoltad.", + "unsupported_class": "Ez a járműtípus nem támogatott.", + "redm_not_mounted": "Csak lóra ülve tudsz boostolni." + } + }, + "heal": { + "title": "Gyógyítás", + "myself": { + "title": "Magamat", + "label": "Életerőd helyreállítása", + "success_0": "Minden seb begyógyítva!", + "success_1": "Mostmár jól kell hogy érezd magad!", + "success_2": "Életerő helyreállítva!", + "success_3": "Bibik megpuszilva!" + }, + "everyone": { + "title": "Mindenki", + "label": "Mindenkit meggyógyít/feléleszt", + "success": "Mindenki meggyógyítva és felélesztve." + } + }, + "announcement": { + "title": "Felhívás küldése", + "label": "Felhívás küldése minden elérhető játékosnak.", + "dialog_desc": "Írd be az üzenetet, amelyet el szeretnél küldeni minden játékosnak.", + "dialog_placeholder": "Felhívás...", + "dialog_success": "Felhívás elküldve." + }, + "clear_area": { + "title": "Terület visszaállítása", + "label": "Egy adott terület visszaállítása az alapértelmezett állapotba", + "dialog_desc": "Állítsd be a sugarát annak a területnek ahol az entitásokat visszaállítanád (0-300). A szerver oldali entitások nem kerülnek visszaállításra", + "dialog_success": "Terület visszaállítása %{radius}m sugárban", + "dialog_error": "Érvénytelen sugár bevitel. Próbáld újra." + }, + "player_ids": { + "title": "ID mód váltása", + "label": "Közeli játékosok ID (és egyéb infó) megjelenítése a fejük fölött", + "alert_show": "Közeli játékosok ID-jének mutatása.", + "alert_hide": "Közeli játékosok ID-jének elrejtése." + } + }, + "page_players": { + "misc": { + "online_players": "Játékban lévő játékosok", + "players": "Játékosok", + "search": "Keresés", + "zero_players": "Nem található játékos" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Rendezés alapja", + "distance": "Távolság", + "id": "ID", + "joined_first": "Legkorábbi csatlakozás", + "joined_last": "Legkésőbbi csatlakozás", + "closest": "Legközelebbi", + "farthest": "Legtávolabbi" + }, + "card": { + "health": "%{percentHealth}% HP" + } + }, + "player_modal": { + "misc": { + "error": "Hiba a játékos adatainak lekérésekor. A hiba alább látható:", + "target_not_found": "Nem található játékos ezzel a névvel vagy ID-vel %{target}" + }, + "tabs": { + "actions": "Műveletek", + "info": "Infó", + "ids": "ID-k", + "history": "Előzmények", + "ban": "Kitiltás" + }, + "actions": { + "title": "Játékos műveletek", + "command_sent": "Parancs elküldve!", + "moderation": { + "title": "Moderálás", + "options": { + "dm": "Privát üzenet", + "warn": "Figyelmeztetés", + "kick": "Kirúgás", + "set_admin": "Admin rang adás" + }, + "dm_dialog": { + "title": "Közvetlen üzenet", + "description": "Mivel kapcsolatban üzensz a játékosnak?", + "placeholder": "Indok...", + "success": "Üzenet elküldve!" + }, + "warn_dialog": { + "title": "Figyelmeztetés", + "description": "Milyen indokkal figyelmezeteted a játékost?", + "placeholder": "Indok...", + "success": "A játékos figyelmeztetve!" + }, + "kick_dialog": { + "title": "Kirúgás", + "description": "Milyen okból rúgod ki a játékost?", + "placeholder": "Indok...", + "success": "Játékos kirúgva!" + } + }, + "interaction": { + "title": "Interakció", + "options": { + "heal": "Gyógyítás", + "go_to": "Teleportálás", + "bring": "Megidézés", + "spectate": "Megfigyelés", + "toggle_freeze": "Fagyasztás" + }, + "notifications": { + "heal_player": "Játékos gyógyítása", + "tp_player": "Teleportálás a játékoshoz", + "bring_player": "Játékos megidézve", + "spectate_failed": "Hiba a játékos megfigyelésénél! Kilépés a megfigyelésből.", + "spectate_yourself": "Magadat nem figyelheted meg.", + "freeze_yourself": "Magadat nem fagyaszthatod le.", + "spectate_cycle_failed": "Nincs megfigyelhető játékos." + } + }, + "troll": { + "title": "Trollkodás", + "options": { + "drunk": "Részeg mód", + "fire": "Felgyújtás", + "wild_attack": "Állat támadás" + } + } + }, + "info": { + "title": "Játékos infó", + "session_time": "Szerveren töltött idő", + "play_time": "Játékidő", + "joined": "Csatlakozva", + "whitelisted_label": "Engedélyezve", + "whitelisted_notyet": "Még nem", + "btn_wl_add": "Engedélyezés megadása", + "btn_wl_remove": "Engedélyezés visszavonása", + "btn_wl_success": "Az engedélyezési lista állapota megváltozott.", + "log_label": "Napló", + "log_empty": "Nem található tiltás/figyelmeztetés.", + "log_ban_count": "%{smart_count} kitiltás |||| %{smart_count} kitiltások", + "log_warn_count": "%{smart_count} figyelmeztetés |||| %{smart_count} figyelmeztetések", + "log_btn": "Részletek", + "notes_changed": "Játékos jegyzete változott.", + "notes_placeholder": "Jegyzetek ehhez a játékoshoz..." + }, + "history": { + "title": "Kapcsolódó előzmények", + "btn_revoke": "Visszavon", + "revoked_success": "Művelet visszavonva!", + "banned_by": "KITILTVA %{author} által", + "warned_by": "FIGYELMEZTETVE %{author} által", + "revoked_by": "Visszavonva %{author} által.", + "expired_at": "Lejárat ekkor: %{date}.", + "expires_at": "Lejárat ekkor: %{date}." + }, + "ban": { + "title": "Játékos kitiltása", + "reason_placeholder": "Indok", + "duration_placeholder": "Időtartam", + "hours": "Óra", + "days": "Nap", + "weeks": "Hét", + "months": "Hónap", + "permanent": "Végleges", + "custom": "Egyedi", + "helper_text": "Kérlek válassz időtartamot", + "submit": "Kitiltás véglegesítése", + "reason_required": "Az indoklás mező kitöltése kötelező.", + "success": "Játékos tiltva!" + }, + "ids": { + "current_ids": "Jelenlegi azonosítók", + "previous_ids": "Előző azonosítók", + "all_hwids": "Összes hardver ID" + } + } + } +} diff --git a/locale/id.json b/locale/id.json new file mode 100644 index 0000000..ad10499 --- /dev/null +++ b/locale/id.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Indonesian", + "humanizer_language": "id" + }, + "restarter": { + "server_unhealthy_kick_reason": "server perlu di-restart, silakan sambung kembali", + "partial_hang_warn": "Karena sebagian hang, server ini akan restart dalam 1 menit. Silakan putuskan sambungan sekarang.", + "partial_hang_warn_discord": "Karena sebagian hang, **%{servername}** akan restart dalam 1 menit.", + "schedule_reason": "restart terjadwal pada %{time}", + "schedule_warn": "Server ini dijadwalkan untuk restart dalam %{smart_count} menit. Silakan putuskan sambungan sekarang. |||| Server ini dijadwalkan untuk restart dalam %{smart_count} menit.", + "schedule_warn_discord": "**%{servername}** dijadwalkan untuk restart dalam %{smart_count} menit. |||| **%{servername}** dijadwalkan untuk restart dalam %{smart_count} menit." + }, + "kick_messages": { + "everyone": "Semua pemain dikeluarkan: %{reason}.", + "player": "Anda telah dikeluarkan: %{reason}.", + "unknown_reason": "karena alasan yang tidak diketahui" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Anda telah diblokir dari server ini karena \"%{reason}\". Blokir Anda akan berakhir dalam: %{expiration}.", + "kick_permanent": "(%{author}) Anda telah diblokir secara permanen dari server ini karena \"%{reason}\".", + "reject": { + "title_permanent": "Anda telah diblokir secara permanen dari server ini.", + "title_temporary": "Anda telah diblokir sementara dari server ini.", + "label_expiration": "Blokir Anda akan berakhir dalam", + "label_date": "Tanggal Blokir", + "label_author": "Diblokir oleh", + "label_reason": "Alasan Blokir", + "label_id": "ID Blokir", + "note_multiple_bans": "Catatan: Anda memiliki lebih dari satu blokir aktif pada identitas Anda.", + "note_diff_license": "Catatan: blokir di atas diterapkan untuk license yang berbeda, yang berarti beberapa ID/HWID Anda cocok dengan yang terkait dengan blokir tersebut." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Server ini dalam mode Admin-only.", + "insufficient_ids": "Anda tidak memiliki identifier discord atau fivem, dan setidaknya salah satu dari mereka diperlukan untuk memverifikasi apakah Anda adalah administrator txAdmin.", + "deny_message": "Identifier Anda tidak terdaftar sebagai administrator txAdmin." + }, + "guild_member": { + "mode_title": "Server ini dalam mode Whitelist Anggota Discord server.", + "insufficient_ids": "Anda tidak memiliki identifier discord, yang diperlukan untuk memverifikasi apakah Anda telah bergabung dengan Discord server kami. Silakan buka aplikasi Desktop Discord dan coba lagi (aplikasi Web tidak akan berfungsi).", + "deny_title": "Anda diharuskan untuk bergabung dengan Discord server kami untuk terhubung.", + "deny_message": "Silakan bergabung dengan guild %{guildname} lalu coba lagi." + }, + "guild_roles": { + "mode_title": "Server ini dalam mode Whitelist Role Discord.", + "insufficient_ids": "Anda tidak memiliki identifier discord, yang diperlukan untuk memverifikasi apakah Anda telah bergabung dengan Discord server kami. Silakan buka aplikasi Desktop Discord dan coba lagi (aplikasi Web tidak akan berfungsi).", + "deny_notmember_title": "Anda diharuskan untuk bergabung dengan Discord server kami untuk terhubung.", + "deny_notmember_message": "Silakan bergabung dengan %{guildname}, dapatkan salah satu role yang diperlukan, lalu coba lagi.", + "deny_noroles_title": "Anda tidak memiliki role whitelist yang diperlukan untuk bergabung.", + "deny_noroles_message": "Untuk bergabung dengan server ini Anda diharuskan memiliki setidaknya salah satu role whitelist di guild %{guildname}." + }, + "approved_license": { + "mode_title": "Server ini dalam mode Whitelist Lisensi.", + "insufficient_ids": "Anda tidak memiliki identifier license, yang berarti server memiliki sv_lan diaktifkan. Jika Anda adalah pemilik server, Anda dapat menonaktifkannya di file server.cfg.", + "deny_title": "Anda tidak di-whitelist untuk bergabung dengan server ini.", + "request_id_label": "ID Permintaan" + } + }, + "server_actions": { + "restarting": "Server sedang restart (%{reason}).", + "restarting_discord": "**%{servername}** sedang restart (%{reason}).", + "stopping": "Server sedang dimatikan (%{reason}).", + "stopping_discord": "**%{servername}** sedang dimatikan (%{reason}).", + "spawning_discord": "**%{servername}** sedang memulai." + }, + "nui_warning": { + "title": "PERINGATAN", + "warned_by": "Diperingatkan oleh:", + "stale_message": "Peringatan ini diterbitkan sebelum Anda terhubung ke server.", + "dismiss_key": "SPACE", + "instruction": "Tahan %{key} selama %{smart_count} detik untuk mengabaikan pesan ini. |||| Tahan %{key} selama %{smart_count} detik untuk mengabaikan pesan ini." + }, + "nui_menu": { + "misc": { + "help_message": "Menu txAdmin diaktifkan, ketik /tx untuk membukanya.\nAnda juga dapat mengatur keybind di [Pengaturan Game > Key Bindings > FiveM > Menu: Buka Halaman Utama].", + "menu_not_admin": "Identifier Anda tidak cocok dengan admin yang terdaftar di txAdmin.\nJika Anda terdaftar di txAdmin, buka Admin Manager dan pastikan identifier Anda sudah disimpan.", + "menu_auth_failed": "Autentikasi Menu txAdmin gagal dengan alasan: %{reason}", + "no_perms": "Anda tidak memiliki izin ini.", + "unknown_error": "Terjadi kesalahan yang tidak diketahui.", + "not_enabled": "Menu txAdmin tidak diaktifkan! Anda dapat mengaktifkannya di halaman pengaturan txAdmin.", + "announcement_title": "Pengumuman Server oleh %{author}:", + "directmessage_title": "DM dari admin %{author}:", + "dialog_empty_input": "Anda tidak dapat memasukkan input kosong.", + "onesync_error": "Opsi ini memerlukan OneSync diaktifkan." + }, + "frozen": { + "froze_player": "Anda telah membekukan pemain!", + "unfroze_player": "Anda telah mencairkan pemain!", + "was_frozen": "Anda telah dibekukan oleh admin server!" + }, + "common": { + "cancel": "Batalkan", + "submit": "Kirim", + "error": "Terjadi kesalahan", + "copied": "Disalin ke clipboard." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Gunakan %{key} untuk beralih halaman & tombol panah untuk menavigasi item menu", + "tooltip_2": "Beberapa item menu memiliki sub opsi yang dapat dipilih menggunakan tombol panah kiri & kanan" + }, + "player_mode": { + "title": "Mode Pemain", + "noclip": { + "title": "NoClip", + "label": "Alihkan NoClip, memungkinkan Anda bergerak melalui dinding dan objek lainnya", + "success": "NoClip diaktifkan" + }, + "godmode": { + "title": "Tuhan", + "label": "Alihkan ketidakbisaan mati, mencegah Anda menerima kerusakan", + "success": "Mode Tuhan diaktifkan" + }, + "superjump": { + "title": "Lompat Super", + "label": "Alihkan mode lompat super, pemain juga akan berlari lebih cepat", + "success": "Lompat Super diaktifkan" + }, + "normal": { + "title": "Normal", + "label": "Mengembalikan diri Anda kembali ke mode pemain default/normal", + "success": "Kembali ke mode pemain default." + } + }, + "teleport": { + "title": "Teleportasi", + "generic_success": "Mengirim Anda ke lubang cacing!", + "waypoint": { + "title": "Titik Tujuan", + "label": "Teleportasi ke titik tujuan kustom yang ditetapkan di peta", + "error": "Anda tidak memiliki titik tujuan yang ditetapkan." + }, + "coords": { + "title": "Koordinat", + "label": "Teleportasi ke koordinat yang disediakan", + "dialog_title": "Teleportasi", + "dialog_desc": "Berikan koordinat dalam format x, y, z untuk melewati lubang cacing.", + "dialog_error": "Koordinat tidak valid. Harus dalam format: 111, 222, 33" + }, + "back": { + "title": "Kembali", + "label": "Kembali ke lokasi sebelum teleportasi terakhir", + "error": "Anda tidak memiliki lokasi terakhir untuk kembali!" + }, + "copy": { + "title": "Salin Koordinat", + "label": "Salin koordinat dunia saat ini ke clipboard Anda" + } + }, + "vehicle": { + "title": "Kendaraan", + "not_in_veh_error": "Anda saat ini tidak berada di dalam kendaraan!", + "spawn": { + "title": "Munculkan", + "label": "Munculkan kendaraan tertentu dari nama modelnya", + "dialog_title": "Munculkan kendaraan", + "dialog_desc": "Masukkan nama model kendaraan yang ingin Anda munculkan.", + "dialog_success": "Kendaraan muncul!", + "dialog_error": "Nama model kendaraan '%{modelName}' tidak ada!", + "dialog_info": "Mencoba untuk munculkan %{modelName}." + }, + "fix": { + "title": "Perbaiki", + "label": "Akan memperbaiki kendaraan hingga kesehatan maksimumnya", + "success": "Kendaraan diperbaiki!" + }, + "delete": { + "title": "Hapus", + "label": "Menghapus kendaraan yang saat ini dinaiki pemain", + "success": "Kendaraan dihapus!" + }, + "boost": { + "title": "Dorong", + "label": "Dorong mobil untuk mencapai kecepatan maksimum (dan mungkin kecepatan)", + "success": "Kendaraan didorong!", + "already_boosted": "Kendaraan ini sudah didorong.", + "unsupported_class": "Kelas kendaraan ini tidak didukung.", + "redm_not_mounted": "Anda hanya bisa mendongkrak saat menunggang kuda." + } + }, + "heal": { + "title": "Penyembuhan", + "myself": { + "title": "Diri Saya", + "label": "Akan menyembuhkan diri Anda hingga maksimum ped saat ini", + "success_0": "Semua sembuh!", + "success_1": "Anda seharusnya merasa baik sekarang!", + "success_2": "Dipulihkan sepenuhnya!", + "success_3": "Luka-luka diperbaiki!" + }, + "everyone": { + "title": "Semua Orang", + "label": "Akan menyembuhkan & menghidupkan kembali semua pemain yang terhubung", + "success": "Menyembuhkan dan menghidupkan kembali semua pemain." + } + }, + "announcement": { + "title": "Kirim Pengumuman", + "label": "Kirim pengumuman ke semua pemain online.", + "dialog_desc": "Masukkan pesan yang ingin Anda siarkan ke semua pemain.", + "dialog_placeholder": "Pengumuman Anda...", + "dialog_success": "Mengirim pengumuman." + }, + "clear_area": { + "title": "Reset Area Dunia", + "label": "Reset area dunia tertentu ke keadaan defaultnya", + "dialog_desc": "Silakan masukkan radius di mana Anda ingin mereset entitas (0-300). Ini tidak akan menghapus entitas yang dihasilkan server side.", + "dialog_success": "Membersihkan area dengan radius %{radius}m", + "dialog_error": "Input radius tidak valid. Coba lagi." + }, + "player_ids": { + "title": "Alihkan ID Pemain", + "label": "Alihkan menampilkan ID pemain (dan info lainnya) di atas kepala semua pemain terdekat", + "alert_show": "Menampilkan NetIDs pemain terdekat.", + "alert_hide": "Menyembunyikan NetIDs pemain terdekat." + } + }, + "page_players": { + "misc": { + "online_players": "Pemain Online", + "players": "Pemain", + "search": "Cari", + "zero_players": "Tidak ada pemain yang ditemukan." + }, + "filter": { + "label": "Filter berdasarkan", + "no_filter": "Tanpa Filter", + "is_admin": "Adalah Admin", + "is_injured": "Terluka / Mati", + "in_vehicle": "Di Kendaraan" + }, + "sort": { + "label": "Urutkan berdasarkan", + "distance": "Jarak", + "id": "ID", + "joined_first": "Bergabung Pertama", + "joined_last": "Bergabung Terakhir", + "closest": "Terdekat", + "farthest": "Terjauh" + }, + "card": { + "health": "%{percentHealth}% kesehatan" + } + }, + "player_modal": { + "misc": { + "error": "Terjadi kesalahan saat mengambil detail pengguna ini. Kesalahan ditunjukkan di bawah ini:", + "target_not_found": "Tidak dapat menemukan pemain online dengan ID atau nama pengguna %{target}" + }, + "tabs": { + "actions": "Aksi", + "info": "Info", + "ids": "ID", + "history": "Riwayat", + "ban": "Ban" + }, + "actions": { + "title": "Aksi Pemain", + "command_sent": "Command dikirim!", + "moderation": { + "title": "Moderasi", + "options": { + "dm": "DM", + "warn": "Peringatan", + "kick": "Tendang", + "set_admin": "Jadikan Admin" + }, + "dm_dialog": { + "title": "Pesan Langsung", + "description": "Apa alasan Anda mengirim DM langsung ke pemain ini?", + "placeholder": "Alasan...", + "success": "Pean lansung terkirim!" + }, + "warn_dialog": { + "title": "Peringatan", + "description": "Apa alasan Anda memperingatkan pemain ini?", + "placeholder": "Alasan...", + "success": "Pemain diberi peringatan!" + }, + "kick_dialog": { + "title": "Tendang", + "description": "Apa alasan Anda menendang pemain ini?", + "placeholder": "Alasan...", + "success": "Pemain ditendang!" + } + }, + "interaction": { + "title": "Interaksi", + "options": { + "heal": "Sembuhkan", + "go_to": "Pergi ke", + "bring": "Panggil", + "spectate": "Pantau", + "toggle_freeze": "Beralih Bekukan" + }, + "notifications": { + "heal_player": "Menyembuhkan pemain", + "tp_player": "Teleportasi ke pemain", + "bring_player": "Membawa pemain", + "spectate_failed": "Pemain tidak ditemukan.", + "spectate_yourself": "Anda tidak dapat memantau diri sendiri.", + "freeze_yourself": "Anda tidak dapat membekukan diri sendiri.", + "spectate_cycle_failed": "Tidak ada pemain lain yang dapat dipantau." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Buat Mabuk", + "fire": "Bakar", + "wild_attack": "Beri Azab" + } + } + }, + "info": { + "title": "Info Pemain", + "session_time": "Waktu Sesi", + "play_time": "Waktu Bermain", + "joined": "Bergabung", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "Belum", + "btn_wl_add": "TAMBAH WL", + "btn_wl_remove": "HAPUS WL", + "btn_wl_success": "Status whitelist diganti.", + "log_label": "Log", + "log_empty": "Tidak ada ban atau peringatan yang tercatat.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} peringatan |||| %{smart_count} peringatan", + "log_btn": "RINCIAN", + "notes_placeholder": "Catatan pemain...", + "notes_changed": "Catatan pemain diubah." + }, + "ids": { + "current_ids": "Identitas Saat Ini", + "previous_ids": "Identitas Sebelumnya", + "all_hwids": "Semua HWIDs" + }, + "history": { + "title": "Riwayat Pemain", + "btn_revoke": "Cabut", + "revoked_success": "Ban dicabut.", + "banned_by": "BANNED oleh %{author}", + "warned_by": "PERINGATAN oleh %{author}", + "revoked_by": "Dicabut oleh %{author}.", + "expired_at": "Kadaluarsa pada %{date}.", + "expires_at": "Kadaluarsa pada %{date}." + }, + "ban": { + "title": "Ban player", + "reason_placeholder": "Alasan...", + "reason_required": "Alasan diperlukan", + "duration_placeholder": "Durasi", + "success": "Player banned!", + "hours": "jam", + "days": "hari", + "weeks": "minggu", + "months": "bulan", + "permanent": "Permanen", + "custom": "Custom", + "helper_text": "Pilih durasi", + "submit": "Terapkan ban" + } + } + } +} diff --git a/locale/it.json b/locale/it.json new file mode 100644 index 0000000..2f72002 --- /dev/null +++ b/locale/it.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Italian", + "humanizer_language": "it" + }, + "restarter": { + "server_unhealthy_kick_reason": "il server deve essere riavviato, per favore riconnettiti", + "partial_hang_warn": "A causa di un blocco parziale, questo server verrà riavviato tra 1 minuto. Si prega di disconnettersi ora.", + "partial_hang_warn_discord": "A causa di un blocco parziale, **%{servername}** si riavvierà tra 1 minuto.", + "schedule_reason": "Riavvio programmato alle %{time}", + "schedule_warn": "Questo server è programmato per riavviarsi in %{smart_count} minuti. Per cortesia uscite ora! |||| Questo server è programmato per riavviarsi in %{smart_count} minuti.", + "schedule_warn_discord": "**%{servername}** è programmato per riavviarsi in %{smart_count} minuti. |||| **%{servername}** è programmato per riavviarsi in %{smart_count} minuti." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Sei stato bannato da questo server per \"%{reason}\". Il tuo ban scadrà in: %{expiration}.", + "kick_permanent": "(%{author}) Sei stato bannato permanentemente da questo server per \"%{reason}\".", + "reject": { + "title_permanent": "Sei stato bannato permanentemente da questo server.", + "title_temporary": "Sei stato bannato temporaneamente da questo server.", + "label_expiration": "Il tuo ban scadrà tra", + "label_date": "Data Ban", + "label_author": "Bannato da", + "label_reason": "Motivo Ban", + "label_id": "ID Ban", + "note_multiple_bans": "Nota: hai piu di un ban sui tuoi identificativi.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Questo server è in modalità Solo-Admin.", + "insufficient_ids": "Non siamo riusciti a recuperare il tuo identificatore discord o il tuo identificatore fivem, almeno uno degli identificatori è necessario per verificare che tu sia un Amministratore.", + "deny_message": "I tuoi identificatori non sono assegnati a nessun account amministratore su txAdmin." + }, + "guild_member": { + "mode_title": "Questo server è in modalità Discord Member Whitelist.", + "insufficient_ids": "Non hai l'identificatore discord, che è richiesto per verificare se sei entrato nel nostro server Discord. Apri l'applicazione desktop di Discord e riprova (l'app Web non funzionerà).", + "deny_title": "Devi unirti al nostro server discord per poter giocare.", + "deny_message": "Per favore, unisciti al server discord %{guildname} e riprova." + }, + "guild_roles": { + "mode_title": "Questo server è in modalità Discord Role Whitelist.", + "insufficient_ids": "Non hai l'identificatore discord, che è richiesto per verificare se sei entrato nel nostro server Discord. Apri l'applicazione desktop di Discord e riprova (l'app Web non funzionerà).", + "deny_notmember_title": "Devi unirti al nostro server Discord per poter giocare.", + "deny_notmember_message": "Per favore, unisciti al server Discord %{guildname} richiedi il ruolo Whitelist e riprova a connetterti.", + "deny_noroles_title": "Non hai un ruolo autorizzato richiesto per unirti.", + "deny_noroles_message": "Per entrare a giocare su questo server devi avere almeno uno dei ruoli richiesti sul server Discord: %{guildname}." + }, + "approved_license": { + "mode_title": "Questo server è in modalità Solo Licenza Autorizzata.", + "insufficient_ids": "Non hai l'identificatore license, il che significa che il server ha sv_lan abilitato. Se sei il proprietario del server, puoi disabilitarlo nel file server.cfg.", + "deny_title": "Non sei whitelistato su questo server.", + "request_id_label": "ID Richiesta:" + } + }, + "server_actions": { + "restarting": "Riavvio del server (%{reason}).", + "restarting_discord": "**%{servername}** si sta riavviando (%{reason}).", + "stopping": "Il Server si sta spegnendo (%{reason}).", + "stopping_discord": "**%{servername}** si sta spegnendo (%{reason}).", + "spawning_discord": "**%{servername}** si sta avviando." + }, + "nui_warning": { + "title": "WARNING", + "warned_by": "Avvisato da:", + "stale_message": "Questo avviso è stato emesso prima di collegarti al server.", + "dismiss_key": "SPACE", + "instruction": "Tieni premuto %{key} per %{smart_count} secondo per rimuovere questo messaggio. |||| Tieni premuto %{key} per %{smart_count} secondi per rimuovere questo messaggio." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menu attivato, scrivi /tx per aprirlo.\nPuoi bindare un tasto in [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "I tuoi identificativi non corrispondono ad un admin registrato nel txAdmin.\nSe sei registrato al txAdmin, vai nella sezione Admin Manager e controlla che siano presenti i tuoi identificativi.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Non hai questo permesso.", + "unknown_error": "Si è verificato un errore sconosciuto.", + "not_enabled": "Il menu txAdmin non è attivo! Puoi attivarlo dalle impostazioni del txAdmin.", + "announcement_title": "Annuncio da %{author}:", + "dialog_empty_input": "Non puoi dare un input vuoto.", + "directmessage_title": "DM dall'admin %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Hai freezato il player!", + "unfroze_player": "Hai unfreezato il player!", + "was_frozen": "Sei stato freezato da uno staffer!" + }, + "common": { + "cancel": "Cancella", + "submit": "Conferma", + "error": "Si è verificato un'errore.", + "copied": "Copiato negli appunti." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Usa %{key} per cambiare pagina e usa le freccette per navigare nel menu", + "tooltip_2": "Alcune voci del menu hanno opzioni secondarie che possono essere selezionate utilizzando i tasti freccia sinistra e destra" + }, + "player_mode": { + "title": "Modalità", + "noclip": { + "title": "NoClip", + "label": "Vola", + "success": "NoClip attivo" + }, + "godmode": { + "title": "God", + "label": "Invincibilità", + "success": "God Mode attiva" + }, + "superjump": { + "title": "Super Jump", + "label": "Toggle super jump mode, the player will also run faster", + "success": "Super Jump enabled" + }, + "normal": { + "title": "Normale", + "label": "Nodalità normale", + "success": "Tornato alla modalità normale." + } + }, + "teleport": { + "title": "Teletrasporto", + "generic_success": "Mandato nel wormhole!", + "waypoint": { + "title": "Waypoint", + "label": "Vai al waypoint impostato", + "error": "Non hai un waypoint impostato." + }, + "coords": { + "title": "Coordinate", + "label": "Vai alle coordinate specificate", + "dialog_title": "Teletrasporto", + "dialog_desc": "Inserisci delle coordinate nel formato x, y, z per attraversare il wormhole.", + "dialog_error": "Coordinate errate. Devono essere nel formato: 111, 222, 33" + }, + "back": { + "title": "Indietro", + "label": "Torna alla tua ultima posizione", + "error": "Non hai un'ultima posizione in cui andare!" + }, + "copy": { + "title": "Copia coordinate", + "label": "Copia le coordinate negli appunti." + } + }, + "vehicle": { + "title": "Veicolo", + "not_in_veh_error": "Non sei su un veicolo!", + "spawn": { + "title": "Spawna", + "label": "Spawna un veicolo dal nmome", + "dialog_title": "Spawna veicolo", + "dialog_desc": "Inserisci il nome del modello del veicolo che vuoi spawnare.", + "dialog_success": "Veicolo spawnato!", + "dialog_error": "Il modello '%{modelName}' non esiste!", + "dialog_info": "Provando a spawnare %{modelName}." + }, + "fix": { + "title": "Ripara", + "label": "Ripara il veicolo", + "success": "Veicolo riparato!" + }, + "delete": { + "title": "Elimina", + "label": "Elimina il veicolo", + "success": "Veicolo eliminato!" + }, + "boost": { + "title": "Boost", + "label": "Boosta l'auto per un maggiore divertimento (e forse velocità)", + "success": "Veicolo boostato!", + "already_boosted": "This vehicle was already boosted.", + "unsupported_class": "This vehicle class is not supported.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Cura", + "myself": { + "title": "Te Stesso", + "label": "Cura te stesso", + "success_0": "Tutti curati!", + "success_1": "Dovresti stare meglio ora!", + "success_2": "Curato al massimo!", + "success_3": "Dolore risolto!" + }, + "everyone": { + "title": "Tutti", + "label": "Cura e Rianima tutti i giocatori", + "success": "Curati e rianimati tutti i giocatori." + } + }, + "announcement": { + "title": "Manda Annuncio", + "label": "Manda un annuncio a tutti i giocatori online.", + "dialog_desc": "Inserisci il messaggio da mandare a tutti i giocatori.", + "dialog_placeholder": "Il tuo annuncio...", + "dialog_success": "Inviando l'annuncio." + }, + "clear_area": { + "title": "Resetta l'area", + "label": "Resetta una specifica area rendendola default", + "dialog_desc": "Inserisci il raggio in cui desideri reimpostare le entità (0-300). Questo non cancellerà le entità generate sul lato server.", + "dialog_success": "Pulendo l'area in un raggio di %{radius}.", + "dialog_error": "Inserimento raggio non valido. Riprova." + }, + "player_ids": { + "title": "Toggle ID dei Player", + "label": "Toggle mostra ID dei giocatori (e altre informazioni) sopra la testa dei giocatori vicini", + "alert_show": "Mostra gli ID dei player vicini.", + "alert_hide": "Nascondi gli ID dei player vicini." + } + }, + "page_players": { + "misc": { + "online_players": "Giocatori Online", + "players": "Giocatori", + "search": "Cerca", + "zero_players": "Nessun giocatore trovato." + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Ordina per", + "distance": "Distanza", + "id": "ID", + "joined_first": "Entrato Prima", + "joined_last": "Entrato Dopo", + "closest": "Più Vicino", + "farthest": "Più Lontano" + }, + "card": { + "health": "%{percentHealth}% vita" + } + }, + "player_modal": { + "misc": { + "error": "Si è verificato un errore durante il recupero dei dettagli di questo utente. L'errore è mostrato sotto:", + "target_not_found": "Impossibile trovare un giocatore online con ID o nome utente di %{target}" + }, + "tabs": { + "actions": "Azioni", + "info": "Info", + "ids": "IDs", + "history": "Cronologia", + "ban": "Ban" + }, + "actions": { + "title": "Azioni Player", + "command_sent": "Comando inviato!", + "moderation": { + "title": "Moderazione", + "options": { + "dm": "DM", + "warn": "Warn", + "kick": "Kick", + "set_admin": "Dai Admin" + }, + "dm_dialog": { + "title": "Messaggio Diretto", + "description": "Per che motivo stai inviando un messaggio diretto?", + "placeholder": "Motivo...", + "success": "Il tuo messaggio è stato inviato!" + }, + "warn_dialog": { + "title": "Warn", + "description": "Per che motivo stai warnando questo giocatore?", + "placeholder": "Motivo...", + "success": "Il player è stato warnato!" + }, + "kick_dialog": { + "title": "Kick", + "description": "Per che motivo stai kickando questo giocatore?", + "placeholder": "Motivo...", + "success": "Il player è stato kickato!" + } + }, + "interaction": { + "title": "Interazioni", + "options": { + "heal": "Cura", + "go_to": "Vai da", + "bring": "Porta da te", + "spectate": "Specta", + "toggle_freeze": "Toggle Freeze" + }, + "notifications": { + "heal_player": "Curando il Giocatore", + "tp_player": "Teletrasportandosi dal Giocatore", + "bring_player": "Bringando il Giocatore", + "spectate_failed": "Impossibile risolvere il bersaglio! Uscendo dallo Spectate.", + "spectate_yourself": "Non puoi spectarti da solo.", + "freeze_yourself": "Non puoi freezarti da solo.", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Fai Ubriacare", + "fire": "Dai Fuoco", + "wild_attack": "Attacco di Scimmie" + } + } + }, + "info": { + "title": "Info sul Giocatore", + "session_time": "Tempo della Sesione", + "play_time": "Tempo di Gioco", + "joined": "Entrato", + "whitelisted_label": "Whitelistato", + "whitelisted_notyet": "non ancora", + "btn_wl_add": "Aggiungi WL", + "btn_wl_remove": "Rimuovi WL", + "btn_wl_success": "Stato della whitelist cambiato.", + "log_label": "Log", + "log_empty": "No ban/warn trovati.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} warn |||| %{smart_count} warns", + "log_btn": "DETTAGLI", + "notes_changed": "Note del giocatore cambiate.", + "notes_placeholder": "Note riguardo il giocatore..." + }, + "history": { + "title": "Cronologia", + "btn_revoke": "REVOCA", + "revoked_success": "Azione revocata!", + "banned_by": "BANNATO da %{author}", + "warned_by": "WARNATO da %{author}", + "revoked_by": "Revocato da %{author}.", + "expired_at": "Scaduto il %{date}.", + "expires_at": "Scade il %{date}." + }, + "ban": { + "title": "Banna il Giocatore", + "reason_placeholder": "Motivo", + "duration_placeholder": "Durata", + "hours": "Ore", + "days": "Giorni", + "weeks": "Settimane", + "months": "Mesi", + "permanent": "Permanente", + "custom": "Personalizzato", + "helper_text": "Seleziona una durata", + "submit": "Banna", + "reason_required": "La motivazione è richiesta.", + "success": "Giocatore bannato!" + }, + "ids": { + "current_ids": "Identificativi correnti", + "previous_ids": "Identificativi usati precedentemente", + "all_hwids": "All Hardware IDs" + } + } + } +} diff --git a/locale/ja.json b/locale/ja.json new file mode 100644 index 0000000..51136d3 --- /dev/null +++ b/locale/ja.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Japanese", + "humanizer_language": "ja" + }, + "restarter": { + "server_unhealthy_kick_reason": "サーバーは再起動が必要です、再接続してください", + "partial_hang_warn": "部分的な障害により、このサーバーは1分後に再起動します。今すぐ切断してください。", + "partial_hang_warn_discord": "部分的な障害により、**%{servername}**は1分後に再起動します。", + "schedule_reason": "%{time}にリスタート予定", + "schedule_warn": "このサーバーは%{smart_count}分後に再起動予定です。今すぐ切断してください。 |||| このサーバーは%{smart_count}分後に再起動予定です。", + "schedule_warn_discord": "**%{servername}**サーバーは%{smart_count}分後に再起動予定です。 |||| **%{servername}**サーバーは%{smart_count}分後に再起動予定です。" + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) あなたは次の理由によりBANされました \"%{reason}\" BAN期間: %{expiration}", + "kick_permanent": "(%{author}) あなたは次の理由により永久BANされました \"%{reason}\"", + "reject": { + "title_permanent": "あなたはこのサーバーから永久BANされています。", + "title_temporary": "あなたはこのサーバーから一時的にBANされています。", + "label_expiration": "BAN期間:", + "label_date": "BANされた日", + "label_author": "BANした人", + "label_reason": "BANされた理由", + "label_id": "BAN ID", + "note_multiple_bans": "注: あなたのIDには、有効なBANが複数記録されています。", + "note_diff_license": "注: 上記のBANは別のライセンスに対して適用されたもので、あなたのID/HWIDの一部がそのBANに関連するものと一致することを意味します。" + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "このサーバーは管理者専用モードになっています。", + "insufficient_ids": "あなたはDiscordFivemのIDを持っておらず、txAdminの管理者であるかを確認するためには、少なくともどちらかのIDが必要です。", + "deny_message": "あなたのIDはどのtxAdmin管理者にも割り当てられていません。" + }, + "guild_member": { + "mode_title": "このサーバーはDiscordサーバーメンバーホワイトリストモードになっています。", + "insufficient_ids": "Discordサーバーに参加しているかを確認するために必要なDiscord IDを持っていません。Discordデスクトップアプリを開いて再度お試しください (ウェブ版は使用できません)。", + "deny_title": "接続にはDiscordサーバーへの参加が必要です。", + "deny_message": "%{guildname}サーバーに参加してから再度お試しください。" + }, + "guild_roles": { + "mode_title": "このサーバーはDiscordロールホワイトリストモードになっています。", + "insufficient_ids": "Discordサーバーに参加しているかを確認するために必要なDiscord IDを持っていません。Discordデスクトップアプリを開いて再度お試しください (ウェブ版は使用できません)。", + "deny_notmember_title": "接続にはDiscordサーバーへの参加が必要です。", + "deny_notmember_message": "%{guildname}サーバーに参加して、必要なロールの1つを取得してから、再度お試しください。", + "deny_noroles_title": "参加に必要なロールを持っていません。", + "deny_noroles_message": "このサーバーに接続するには、%{guildname}サーバーのホワイトリストに登録されているロールの少なくとも1つを持っている必要があります。" + }, + "approved_license": { + "mode_title": "このサーバーはライセンスホワイトリストモードになっています。", + "insufficient_ids": "あなたはライセンス IDを持っていません、これはサーバーでsv_lanが有効になっていることを意味します。もしあなたがサーバーの所有者なら、server.cfgファイルで無効にできます。", + "deny_title": "あなたはこのサーバーに参加するためのホワイトリストに登録されていません。", + "request_id_label": "要求 ID" + } + }, + "server_actions": { + "restarting": "サーバーを再起動しています (%{reason})。", + "restarting_discord": "**%{servername}**を再起動しています (%{reason})。", + "stopping": "サーバーを停止しています (%{reason})。", + "stopping_discord": "**%{servername}**を停止しています (%{reason})。", + "spawning_discord": "**%{servername}**を開始しています。" + }, + "nui_warning": { + "title": "警告", + "warned_by": "警告した人:", + "stale_message": "この警告はサーバーに接続する前に発行されました。", + "dismiss_key": "スペース", + "instruction": "%{key}を%{smart_count}秒間押し続けると、このメッセージは解除されます。 |||| %{key}を%{smart_count}秒間押し続けると、このメッセージは解除されます。" + }, + "nui_menu": { + "misc": { + "help_message": "txAdminメニューが有効になっている場合は、/tx と入力して開いてください。\nまた、[ゲーム設定 > キーバインド > FiveM > メニュー: メインページを開く]でキーバインドを設定することもできます。", + "menu_not_admin": "あなたのIDがtxAdminに登録されている管理者と一致しません。\ntxAdminに登録されている場合は、IDが保存されていることを確認してください。", + "menu_auth_failed": "txAdminメニューの認証に失敗しました 理由: %{reason}", + "no_perms": "権限を持っていません。", + "unknown_error": "不明なエラーが発生しました。", + "not_enabled": "txAdminメニューが有効になっていません! txAdminの設定ページで有効にできます。", + "announcement_title": "%{author}によるサーバーに関するお知らせ:", + "directmessage_title": "管理者%{author}からのDM:", + "dialog_empty_input": "空の入力はできない。", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "プレイヤーを静止させた!", + "unfroze_player": "プレイヤーの静止を解除した!", + "was_frozen": "サーバー管理者によって静止されました!" + }, + "common": { + "cancel": "キャンセル", + "submit": "送信", + "error": "エラーが発生しました。", + "copied": "クリップボードにコピーしました。" + }, + "page_main": { + "tooltips": { + "tooltip_1": "%{key}キーでページを切り替え、矢印キーでメニューを移動する。", + "tooltip_2": "一部のメニュー項目には、左右の矢印キーで選択できるサブオプションがあります。" + }, + "player_mode": { + "title": "状態", + "noclip": { + "title": "ノークリップ", + "label": "ノークリップの切り替えにより、壁や他のオブジェクトを通り抜けることができます。", + "success": "ノークリップが有効化された。" + }, + "godmode": { + "title": "ゴッド", + "label": "無敵状態を切り替え、ダメージを受けないようにする。", + "success": "ゴッドモードが有効化された。" + }, + "superjump": { + "title": "スーパージャンプ", + "label": "スーパージャンプ状態を切り替えると、より速く走ることができる。", + "success": "スーパージャンプが有効化された。" + }, + "normal": { + "title": "デフォルト", + "label": "デフォルト/通常プレイヤー状態に戻る", + "success": "デフォルトのプレイヤー状態に戻りました。" + } + }, + "teleport": { + "title": "テレポート", + "generic_success": "あなたをワームホールに送った!", + "waypoint": { + "title": "ウェイポイント", + "label": "地図上に設定されたカスタムウェイポイントにテレポートする。", + "error": "ウェイポイントが設定されていない。" + }, + "coords": { + "title": "座標", + "label": "指定された座標にテレポート", + "dialog_title": "テレポート", + "dialog_desc": "ワームホールを通過するための座標をx、y、zの形式で指定する。", + "dialog_error": "座標が無効です。次の形式でなければなりません: 111, 222, 33" + }, + "back": { + "title": "戻る", + "label": "最後にテレポートした前の場所に戻る。", + "error": "戻る最後の場所がない!" + }, + "copy": { + "title": "座標をコピー", + "label": "現在のワールド座標をクリップボードにコピーする。" + } + }, + "vehicle": { + "title": "車両", + "not_in_veh_error": "あなたは現在車両に乗っていない!", + "spawn": { + "title": "生成", + "label": "モデル名から指定された車両を生成する", + "dialog_title": "車両を生成", + "dialog_desc": "生成したい車両のモデル名を入力する。", + "dialog_success": "車両が生成された!", + "dialog_error": "車両モデル名'%{modelName}'が存在しません!", + "dialog_info": "%{modelName}を生成しようとしています。" + }, + "fix": { + "title": "修理", + "label": "車両を最大限の状態まで修理する。", + "success": "車両は修理された!" + }, + "delete": { + "title": "削除", + "label": "プレイヤーが現在乗っている車両を削除する。", + "success": "車両は削除された!" + }, + "boost": { + "title": "強化", + "label": "最大限の楽しさ(と多分スピード)を得るために車両を強化する。", + "success": "車両が強化された!", + "already_boosted": "この車両は既に強化されています。", + "unsupported_class": "この車両クラスはサポートされていません。", + "redm_not_mounted": "強化できるのは馬に乗っているときだけです。" + } + }, + "heal": { + "title": "回復", + "myself": { + "title": "自分自身", + "label": "現在のキャラクターの最大値まで自分を回復させる。", + "success_0": "完治した!", + "success_1": "もう気分は良いはずだ!", + "success_2": "完全回復!", + "success_3": "痛いところが治った!" + }, + "everyone": { + "title": "全員", + "label": "接続している全プレイヤーを回復&復活させる。", + "success": "全プレイヤーを回復&復活した!" + } + }, + "announcement": { + "title": "アナウンスを送信", + "label": "全オンラインプレイヤーにアナウンスを送る。", + "dialog_desc": "アナウンスしたいメッセージを入力します。", + "dialog_placeholder": "あなたのアナウンスは...", + "dialog_success": "アナウンスを送信しています。" + }, + "clear_area": { + "title": "ワールドエリアをリセット", + "label": "指定したワールドエリアをデフォルト状態に戻す。", + "dialog_desc": "エンティティをリセットしたい半径を入力してください(0-300)。サーバーサイドで生成されたエンティティはリセットされません。", + "dialog_success": "半径%{radius}mのエリアをリセットしています。", + "dialog_error": "無効な半径が入力されました。再度入力してください。" + }, + "player_ids": { + "title": "プレイヤーIDの切り替え", + "label": "プレイヤーID(およびその他の情報)を、近くにいる全プレイヤーの頭上に表示するかの切り替え。", + "alert_show": "近くのプレイヤーのNetIDを表示しています。", + "alert_hide": "近くのプレイヤーのNetIDを隠しています。" + } + }, + "page_players": { + "misc": { + "online_players": "オンラインプレイヤー", + "players": "プレイヤー", + "search": "検索", + "zero_players": "プレイヤーが見つかりませんでした" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "ソート", + "distance": "距離", + "id": "ID", + "joined_first": "最初に参加した時間", + "joined_last": "最後に参加した時間", + "closest": "最も近い", + "farthest": "最も遠い" + }, + "card": { + "health": "体力 %{percentHealth}%" + } + }, + "player_modal": { + "misc": { + "error": "このユーザーの詳細を取得する際にエラーが発生しました。エラーの内容は次の通りです:", + "target_not_found": "IDまたはユーザー名が%{target}のオンラインプレイヤーを見つけることができませんでした。" + }, + "tabs": { + "actions": "アクション", + "info": "情報", + "ids": "IDs", + "history": "履歴", + "ban": "Ban" + }, + "actions": { + "title": "プレイヤーのアクション", + "command_sent": "コマンドが送信された!", + "moderation": { + "title": "管理", + "options": { + "dm": "DM", + "warn": "警告", + "kick": "キック", + "set_admin": "管理者にする" + }, + "dm_dialog": { + "title": "DM", + "description": "このプレイヤーにDMを送信する理由は何ですか?", + "placeholder": "理由...", + "success": "DMが送信された!" + }, + "warn_dialog": { + "title": "警告", + "description": "このプレイヤーを警告する理由は何ですか?", + "placeholder": "理由...", + "success": "プレイヤーは警告された!" + }, + "kick_dialog": { + "title": "キック", + "description": "このプレイヤーをキックする理由は何ですか?", + "placeholder": "理由...", + "success": "プレイヤーがキックされた!" + } + }, + "interaction": { + "title": "インタラクション", + "options": { + "heal": "回復", + "go_to": "行く", + "bring": "召喚", + "spectate": "観戦", + "toggle_freeze": "静止の切り替え" + }, + "notifications": { + "heal_player": "プレイヤーを回復中", + "tp_player": "プレイヤーをテレポート中", + "bring_player": "プレイヤーを召喚中", + "spectate_failed": "ターゲットの解決に失敗しました!観戦を終了します", + "spectate_yourself": "自分自身は観戦できない", + "freeze_yourself": "自分自身を静止することはできない", + "spectate_cycle_failed": "切り替えられる他のプレイヤーがいない" + } + }, + "troll": { + "title": "荒らす", + "options": { + "drunk": "泥酔させる", + "fire": "火をつける", + "wild_attack": "野生動物の襲来" + } + } + }, + "info": { + "title": "プレイヤー情報", + "session_time": "参加時間", + "play_time": "プレイ時間", + "joined": "参加", + "whitelisted_label": "ホワイトリスト", + "whitelisted_notyet": "未登録", + "btn_wl_add": "ホワイトリストに追加", + "btn_wl_remove": "ホワイトリストから削除", + "btn_wl_success": "ホワイトリストのステータスが変更されました。", + "log_label": "ログ", + "log_empty": "BAN/警告が見つかりませんでした。", + "log_ban_count": "%{smart_count} BAN |||| %{smart_count} BAN", + "log_warn_count": "%{smart_count} 警告 |||| %{smart_count} 警告", + "log_btn": "詳細", + "notes_placeholder": "このプレイヤーについてのメモ...", + "notes_changed": "プレイヤーのメモが変更された。" + }, + "ids": { + "current_ids": "現在のID", + "previous_ids": "以前のID", + "all_hwids": "全てのハードウェアID" + }, + "history": { + "title": "関連履歴", + "btn_revoke": "取り消し", + "revoked_success": "アクションを取り消した!", + "banned_by": "%{author}によってBANされた", + "warned_by": "%{author}によって警告された", + "revoked_by": "%{author}によって取り消された", + "expired_at": "%{date}に失効した", + "expires_at": "%{date}に失効する" + }, + "ban": { + "title": "プレイヤーをBAN", + "reason_placeholder": "理由", + "reason_required": "理由欄は必須です。", + "duration_placeholder": "期間", + "success": "プレイヤーがBANされた!", + "hours": "時間", + "days": "日", + "weeks": "週間", + "months": "ヶ月", + "permanent": "永久", + "custom": "カスタム", + "helper_text": "期間を選択してください", + "submit": "BANを適用" + } + } + } +} diff --git a/locale/lt.json b/locale/lt.json new file mode 100644 index 0000000..4248edb --- /dev/null +++ b/locale/lt.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Lithuanian", + "humanizer_language": "lt" + }, + "restarter": { + "server_unhealthy_kick_reason": "serveris turi būti paleistas iš naujo, prašome prisijungti dar kartą", + "partial_hang_warn": "Dėl pakibimo, serveris persijungs automatiškai po 1 min. Atsijunkite nedelsiant.", + "partial_hang_warn_discord": "Dėl pakibimo, **%{servername}** serveris persijungs po 1 min.", + "schedule_reason": "Automatinis %{time} perkrovimas", + "schedule_warn": "Serveris persikraus už %{smart_count} min. Prašome atsijungti. |||| Serveris persikraus už %{smart_count} min. Prašome atsijungti.", + "schedule_warn_discord": "**%{servername}** automatiškai persikraus už %{smart_count} min. |||| **%{servername}** automatiškai persikraus už %{smart_count} min." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Buvote užblokuotas iš serverio dėl \"%{reason}\". Blokavimas bus baigtas: %{expiration}.", + "kick_permanent": "(%{author}) Buvote užblokuotas iš serverio visam laikui dėl \"%{reason}\".", + "reject": { + "title_permanent": "Esate visam laikui užblokuotas nuo prisijungimo į serverį.", + "title_temporary": "Esate laikinai užblokuotas nuo prisijungimo į serverį.", + "label_expiration": "Tavo užblokavimas baigsis po", + "label_date": "Užblokavimo data", + "label_author": "Tave užblokavo", + "label_reason": "Užblokavimo priežastis", + "label_id": "Užblokavimo ID", + "note_multiple_bans": "P.S. Tu turi daugiau nei vieną užblokavimą ant savo identifikatorių", + "note_diff_license": "Prierašas: šis užblokavimas skirtas kitai licenzijai, tai reiškią jūsų kai kurie IDs/HWIDs sutampa su esančiais tame užblokavime" + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Šis serveris yra Tik Administratoriams rėžime.", + "insufficient_ids": "Tu neturi discord arba fivem identifikatorių, ar bent vieno iš jų, jog patvirtinti, kad esi txAdmin administratorius.", + "deny_message": "Tavo identifikatoriai nėra sujungti su txAdmin administratoriumi." + }, + "guild_member": { + "mode_title": "Šois serveris yra Discord Gildijos (Serverio) Nario Baltojo Sąrašo rėžime", + "insufficient_ids": "Tu neturi discord identifikatoriaus, kuris yra reikalingas patvirtinti, jog prisijungėte į Discord gildiją (serverį). Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "Tau reikia prisijungti į Discord gildiją (serverį)", + "deny_message": "Prašome prisijungti į %{guildname} gildiją (serverį) ir bandyti vėl." + }, + "guild_roles": { + "mode_title": "Šis serveris yra Discord Rolių Baltojo Sąrašo rėžime.", + "insufficient_ids": "Tu neturi discord identifikatoriaus, kuris yra reikalingas patvirtinti, jog prisijungėte į Discord gildiją (serverį). Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "Tau reikia prisijungti į Discord gildiją (serverį), kad prisijungti.", + "deny_notmember_message": "Prašome prisijungti į %{guildname}, gauti reikiamą rolę ir bandyti vėl.", + "deny_noroles_title": "Tu neturi reikiamos rolės, jog prisijungti.", + "deny_noroles_message": "Jog prisijungti į serverį, tau reikia turėti bent vieną baltąjame sąraše esančių rolių %{guildname} Discord gildijoje (serveryje)." + }, + "approved_license": { + "mode_title": "Šis serveris yra Licenzijos Baltojo Sąrašo rėžime.", + "insufficient_ids": "Tu neturi reikiamo license identifikatoriaus, kas rodo, jog serveris yra įjungęs sv_lan. Jei esi serverio savininkas, gali tai pakeisti server.cfg faile.", + "deny_title": "Neesi baltąjame sąraše, jog galėtum prisijungti į šį serverį.", + "request_id_label": "Prašymo ID" + } + }, + "server_actions": { + "restarting": "Serverio perkrovimas (%{reason}).", + "restarting_discord": "**%{servername}** yra perkraunamas (%{reason}).", + "stopping": "Serveris išjungiamas (%{reason}).", + "stopping_discord": "**%{servername}** yra išjungiamas (%{reason}).", + "spawning_discord": "**%{servername}** yra įjungiamas." + }, + "nui_warning": { + "title": "ĮSPĖJIMAS", + "warned_by": "Įspėjimą skyrė:", + "stale_message": "Šis įspėjimas buvo išduotas prieš jums prisijungiant prie serverio.", + "dismiss_key": "SPACE", + "instruction": "Laikykite %{key} %{smart_count} sekundę, kad išjungtum šį pranešimą. |||| Laikykite %{key} %{smart_count} sekundes, kad išjungtum šį pranešimą." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Meniu įjungtas, rašykite /tx norėdami jį atidaryti.\nMygtuką galite priskirti ar pakeisti [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Jūsų identifikavimas txAdmin sistemai nebuvo rastas.\nJei esate užregistruotas txAdmin sistemoje, eikite į Admin Manager ir įsitikinkite, kad jūsų atpažinimas išsaugotas.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Neturite tam teisių.", + "unknown_error": "Nežinoma klaida.", + "not_enabled": "txAdmin meniu nėra įjungtas! Tai galite padaryti per txAdmin nustatymų (settings) puslapį.", + "announcement_title": "Serverio pranešimas nuo: %{author}:", + "dialog_empty_input": "Turite užpildyti įvesties laukelius.", + "directmessage_title": "PŽ. iš administratoriaus %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Užšaldėte žaidėją!", + "unfroze_player": "Atšaldėte žaidėją!", + "was_frozen": "Serverio administratorius jus užšaldė!" + }, + "common": { + "cancel": "Atšaukti", + "submit": "Patvirtinti", + "error": "Įvyko klaida", + "copied": "Nukopijuota." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Naudokite %{key} norėdami pakeisti puslapį, rodyklių mygtukus norėdami naviguoti meniu pasirinkimus.", + "tooltip_2": "Kai kurie meniu pasirinkimai gali būti pakeisti naudojant kairės ir dešinės rodyklių mygtukus." + }, + "player_mode": { + "title": "Žaidėjo rėžimas", + "noclip": { + "title": "NoClip", + "label": "Skraidymas", + "success": "Skraidymas įjungtas" + }, + "godmode": { + "title": "Dievo rėžimas", + "label": "Nemirtingumas", + "success": "Nemirtingumas įjungtas" + }, + "superjump": { + "title": "Super Šuolis", + "label": "Įjungti/išjungti super šuolio rėžimą, jūsų veikėjas taippat bėgs greičiau", + "success": "Super Šuolis įjungtas" + }, + "normal": { + "title": "Normalusis", + "label": "Numatytasis rėžimas", + "success": "Grįžote į numatytąjį rėžimą." + } + }, + "teleport": { + "title": "Teleportacija", + "generic_success": "Pakeisti savo poziciją!", + "waypoint": { + "title": "Pažymėtas taškas (Waypoint)", + "label": "Nusikelkite į pažymėtą žemėlapyje vietą", + "error": "Pirma pažymėkite vietą žemėlapyje." + }, + "coords": { + "title": "Koordinatės", + "label": "Persikelkite į įvestas koordinates", + "dialog_title": "Persikelti", + "dialog_desc": "Įveskite x, y, z koordinates į kurias norite nusikelti.", + "dialog_error": "Netinkamos koordinatės, tinkamas formatas: 111, 222, 33" + }, + "back": { + "title": "Grįžti", + "label": "Grįžti į paskutinę vietą", + "error": "Neturite išsaugotos vietos grįžimui!" + }, + "copy": { + "title": "Kopijuoti koordinates", + "label": "Nukopijuoti koordinates įklijavimui." + } + }, + "vehicle": { + "title": "Tr. priemonė", + "not_in_veh_error": "Nesate jokioje tr.priemonėje!", + "spawn": { + "title": "Spawn'inimas", + "label": "Spawn'inti tr. priemonę pagal pavadinimą", + "dialog_title": "Spawn'inti tr. priemonę", + "dialog_desc": "Įveskite tr. priemonės modelio pavadinimą.", + "dialog_success": "Tr. priemonė sukurta!", + "dialog_error": "Modelio pavadinimas '%{modelName}' neegzistuoja!", + "dialog_info": "Bandoma at'spawn'inti %{modelName}." + }, + "fix": { + "title": "Remontas", + "label": "Suremontuoti tr. priemonę", + "success": "Tr. priemonė suremontuota!" + }, + "delete": { + "title": "Ištrinti", + "label": "Ištrinti dabartinę tr. priemonę", + "success": "Tr. priemonė ištrinta!" + }, + "boost": { + "title": "Turbo", + "label": "Paturbink tr. priemonę, kad pasiektum maksimalų džiaugsmo (ir galbūt greičio) lygį.", + "success": "Tr. priemonė paturbinta", + "already_boosted": "Ši tr. priemonė jau yra paturbinta.", + "unsupported_class": "Ši tr. priemonių klasė nėra palaikoma.", + "redm_not_mounted": "Galite greitėti tik jodami ant arklio" + } + }, + "heal": { + "title": "Gydyti", + "myself": { + "title": "Save", + "label": "Atstato gyvybes jums", + "success_0": "Jūs pagydytas!", + "success_1": "Turėtumėte jaustis kur kas geriau!", + "success_2": "Stebuklingų galių pagalba vėl jaučiatės geriau!", + "success_3": "Skausmai panaikinti!" + }, + "everyone": { + "title": "Visus", + "label": "Pagydys ir prikels visus žaidėjus", + "success": "Visi žaidėjai pagydyti ir prikelti." + } + }, + "announcement": { + "title": "Siųsti pranešimą", + "label": "Siųsti pranešimą visiems serverio žaidėjams.", + "dialog_desc": "Įveskite viešojo pranešimo tekstą", + "dialog_placeholder": "Jūsų pranešimas...", + "dialog_success": "Siunčiamas pranešimas." + }, + "clear_area": { + "title": "Atstatyti pasaulio aplinką", + "label": "Atstatyti pasaulio aplinką į numatytąją", + "dialog_desc": "Įveskite kokiu spinduliu norėtumėte atstatyti objektus (0-300). Šis pasirinkimas neištrins serverio sukurtų objektų", + "dialog_success": "Ištrinami objektai %{radius} metrų spinduliu", + "dialog_error": "Netinkamas skaičius." + }, + "player_ids": { + "title": "Žaidėjų ID rodymas", + "label": "Įjungti žaidėjų ID (ir kitos informacijos) rodyma virš jų galvų", + "alert_show": "Žaidėjų NetID rodomi.", + "alert_hide": "Žaidėjų NetID paslėpti." + } + }, + "page_players": { + "misc": { + "online_players": "Prisijungę žaidėjai", + "players": "Žaidėjai", + "search": "Paieška", + "zero_players": "Žaidėjų nerasta" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Rikiuoti pagal", + "distance": "Atstumą", + "id": "ID", + "joined_first": "Pirmi prisijungę", + "joined_last": "Paskutiniai prisijungę", + "closest": "Artimiausi", + "farthest": "Tolimiausi" + }, + "card": { + "health": "%{percentHealth}% gyvybių" + } + }, + "player_modal": { + "misc": { + "error": "Klaida gaunant žaidėjo duomenis. Klaida:", + "target_not_found": "Nepavyko rasti žaidėjo su šiuo ID arba vardu - %{target}" + }, + "tabs": { + "actions": "Veiksmai", + "info": "Informacija", + "ids": "ID", + "history": "Istorija", + "ban": "Blokuoti" + }, + "actions": { + "title": "Žaidėjo veiksmai", + "command_sent": "Komanda išsiųsta!", + "moderation": { + "title": "Moderavimas", + "options": { + "dm": "DM", + "warn": "Įspėjimas", + "kick": "Išmesti", + "set_admin": "Paskirti administratoriumi" + }, + "dm_dialog": { + "title": "Privati žinutė (DM)", + "description": "Privačios žinutės tekstas.", + "placeholder": "Žinutė...", + "success": "Privati žinutė išsiųsta!" + }, + "warn_dialog": { + "title": "Įspėti", + "description": "Dėl kokios priežasties įspėjate žaidėją?", + "placeholder": "Priežastis...", + "success": "Žaidėjas įspėtas!" + }, + "kick_dialog": { + "title": "Išmesti", + "description": "Dėl kokios priežasties išmetate žaidėją?", + "placeholder": "Priežastis...", + "success": "Žaidėjas išmestas!" + } + }, + "interaction": { + "title": "Veiksmai", + "options": { + "heal": "Pagydyti", + "go_to": "Nusikelti iki", + "bring": "Atkelti iki savęs", + "spectate": "Stebėti (spectate)", + "toggle_freeze": "Šaldyti" + }, + "notifications": { + "heal_player": "Gydomas žaidėjas", + "tp_player": "Nusikeliama iki žaidėjo", + "bring_player": "Žaidėjas atkeliamas", + "spectate_failed": "Žaidėjas nerastas! Stebėjimas išjungiamas.", + "spectate_yourself": "Negalite stebėti savęs.", + "freeze_yourself": "Negalite užšaldyti savęs.", + "spectate_cycle_failed": "Nėra kitų žaidėjų" + } + }, + "troll": { + "title": "Trolinimas", + "options": { + "drunk": "Padaryti girtu", + "fire": "Padegti", + "wild_attack": "Laukinių gyvūnų užpuolimas" + } + } + }, + "info": { + "title": "Žaidėjo informacija", + "session_time": "Sesijos laikas", + "play_time": "Pražaista laiko", + "joined": "Prisijungęs", + "whitelisted_label": "Pridėtas prie WL", + "whitelisted_notyet": "dar ne", + "btn_wl_add": "PRIDĖTI WL", + "btn_wl_remove": "PANAIKINTI WL", + "btn_wl_success": "WL rėžimas pakeistas", + "log_label": "Istorija", + "log_empty": "Jokiu užblokavimų/perspėjimų nerasta.", + "log_ban_count": "%{smart_count} užblokavimas |||| %{smart_count} užblokavimai", + "log_warn_count": "%{smart_count} perspėjimas |||| %{smart_count} perspėjimai", + "log_btn": "DETALĖS", + "notes_changed": "Žaidėjo užrašai pakeisti", + "notes_placeholder": "Užrašai apie žaidėją..." + }, + "history": { + "title": "Susijusi informacija", + "btn_revoke": "ATŠAUKTI", + "revoked_success": "Veiksmas atšauktas!", + "banned_by": "Užblokuotas %{author}", + "warned_by": "Perspėtas %{author}", + "revoked_by": "Atimta %{author}.", + "expired_at": "Baigė galioti %{date}.", + "expires_at": "Baigs galioti %{date}." + }, + "ban": { + "title": "Užblokuoti žaidėją", + "reason_placeholder": "Priežastis", + "duration_placeholder": "Laikas", + "hours": "valandos", + "days": "dienos", + "weeks": "savaitės", + "months": "mėnesiai", + "permanent": "Visam laikui", + "custom": "Pasirinktinis", + "helper_text": "Pasirinkti laiką", + "submit": "Užblokuoti", + "reason_required": "Priežasties laukas yra privalomas!", + "success": "Žaidėjas užblokuotas!" + }, + "ids": { + "current_ids": "Dabartiniai Identifikatoriai", + "previous_ids": "Ankščiau Naudoti Identifikatoriai", + "all_hwids": "Visi HWID Identifikatoriai" + } + } + } +} diff --git a/locale/lv.json b/locale/lv.json new file mode 100644 index 0000000..45c40ba --- /dev/null +++ b/locale/lv.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Latviešu", + "humanizer_language": "lv" + }, + "restarter": { + "server_unhealthy_kick_reason": "serverim jārestartē, lūdzu, pievienojies vēlreiz", + "partial_hang_warn": "Nepilnīgas pakāršanās dēļ šis serveris tiks restartēts pēc 1 minūtes. Lūdzu, atvienojieties tagad.", + "partial_hang_warn_discord": "Nepilnīgas pakāršanās dēļ, **%{servername}** tiks restartēts pēc 1 minūtes.", + "schedule_reason": "Ieplānotais restarts %{time}", + "schedule_warn": "Šī servera restartēšana ir ieplānota pēc %{smart_count} minūtes. Lūdzu, atvienojieties tagad. |||| Šī servera restartēšana ir ieplānota pēc %{smart_count} minūtēm.", + "schedule_warn_discord": "**%{servername}** ir ieplānots restarts pēc %{smart_count} minūtēm(s). |||| **%{servername}** ir ieplānots restarts pēc %{smart_count} minūtēm." + }, + "kick_messages": { + "everyone": "Visi spēlētāji tika izmesti: %{reason}.", + "player": "Tu tiki izmests no servera: %{reason}.", + "unknown_reason": "nezināma iemesla dēļ" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Tu esi uz laiku bloķēts no šī servera par \"%{reason}\". Tava bloķēšana beigsies pēc: %{expiration}.", + "kick_permanent": "(%{author}) Tu esi mūžīgi bloķēts no šī servera par \"%{reason}\".", + "reject": { + "title_permanent": "Tu esi mūžīgi bloķēts no šī servera.", + "title_temporary": "Tu esi bloķēts no šī servera.", + "label_expiration": "Tava bloķēšana beigsies pēc", + "label_date": "Bloķēšanas datums", + "label_author": "Administrātors", + "label_reason": "Bloķēšanas iemesls", + "label_id": "Ban ID", + "note_multiple_bans": "Piezīme: Tev ir vairāk nekā viena aktīva bloķēšana taviem identifikatoriem.", + "note_diff_license": "Piezīme: augstāk redzamā bloķēšana tika piemērota citai license, kas nozīmē, ka daži no taviem ID/HWID sakrīt ar tiem, kas saistīti ar šo bloķēšanu." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Šim serverim ir ieslēgts Tikai administratoriem režīms.", + "insufficient_ids": "Tev nav discord vai fivem identifikatoru, un vismaz viens no tiem ir nepieciešams, lai pārbaudītu, vai esi txAdmin administrators.", + "deny_message": "Tavi identifikatori nav piesaistīti nevienam txAdmin administratoram." + }, + "guild_member": { + "mode_title": "Šim serverim ir ieslēgts Discord server Member Whitelist režīms.", + "insufficient_ids": "Tev nav discord identifikators, kas ir nepieciešams, lai pārbaudītu, vai esi pievienojies mūsu Discord Serverim. Lūdzu, atver Discord un mēģini vēlreiz (tīmekļa lietotne nedarbosies).", + "deny_title": "Tev ir jāpievienojas mūsu Discord server, lai pieslēgtos.", + "deny_message": "Lūdzu, pievienojies %{guildname}, tad mēģini vēlreiz." + }, + "guild_roles": { + "mode_title": "Šim serverim ir ieslēgts Discord Role Whitelist režīms.", + "insufficient_ids": "Tev nav discord identifikators, kas ir nepieciešams, lai pārbaudītu, vai esi pievienojies mūsu Discord Serverim. Lūdzu, atver Discord un mēģini vēlreiz (tīmekļa lietotne nedarbosies).", + "deny_notmember_title": "Tev ir jāpievienojas mūsu Discord Serverim, lai pieslēgtos.", + "deny_notmember_message": "Lūdzu, pievienojies %{guildname}, iegūsti vienu no nepieciešamajiem roles un tad mēģini vēlreiz.", + "deny_noroles_title": "Tev nav nepieciešamo whitelisted roles, lai pievienotos.", + "deny_noroles_message": "Lai pievienotos šim serverim, tev ir jābūt vismaz vienai no whitelisted roles, iekš %{guildname} Serverim." + }, + "approved_license": { + "mode_title": "Šim serverim ir ieslēgts License Whitelist režīms.", + "insufficient_ids": "Tev nav license identifikators, kas nozīmē, ka serverim ir ieslēgts sv_lan. Ja esi servera īpašnieks, vari to atspējot server.cfg failā.", + "deny_title": "Tev nav piešķirts whitelists, lai pievienotos šim serverim.", + "request_id_label": "Pieprasījuma ID" + } + }, + "server_actions": { + "restarting": "Serveris tiek restartēts (%{reason}).", + "restarting_discord": "**%{servername}** tiek restartēts (%{reason}).", + "stopping": "Serveris tiek izslēgts (%{reason}).", + "stopping_discord": "**%{servername}** tiek izslēgts (%{reason}).", + "spawning_discord": "**%{servername}** tiek ieslēgts" + }, + "nui_warning": { + "title": "BRĪDINĀJUMS", + "warned_by": "Brīdinājumu izdeva:", + "stale_message": "Šis brīdinājums tika izdots, pirms tu pievienojies serverim.", + "dismiss_key": "SPACE", + "instruction": "Turiet %{key} %{smart_count} sekundi, lai noraidītu šo ziņojumu. |||| Turiet %{key} %{smart_count} sekundes, lai noraidītu šo ziņojumu." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menu ir iespējots, ieraksti /tx, lai to atvērtu.\nTu vari arī iestatīt taustiņu pats uzejot [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Tavi identifikatori neatbilst nevienam txAdmin reģistrētam administratoram.\nJa esi reģistrēts txAdmin, dodies uz Admin Manager un pārliecinies, ka tavi identifikatori ir saglabāti.", + "menu_auth_failed": "txAdmin Menu autentifikācija neizdevās ar iemeslu: %{reason}", + "no_perms": "Tev nav šīs atļaujas.", + "unknown_error": "Radās nezināma kļūda.", + "not_enabled": "txAdmin Menu nav iespējots! Tu vari to iespējot txAdmin iestatījumu lapā.", + "announcement_title": "Servera paziņojums no %{author}:", + "dialog_empty_input": "Ievade nevar būt tukša.", + "directmessage_title": "DM no admina %{author}:", + "onesync_error": "Šai darbībai ir nepieciešams iespējot OneSync." + }, + "frozen": { + "froze_player": "Tu iefreezoji spēlētāju!", + "unfroze_player": "Tu atfreezoji spēlētāju!", + "was_frozen": "Tevi iefreezoja servera administrators!" + }, + "common": { + "cancel": "Atcelt", + "submit": "Apstiprināt", + "error": "Radās kļūda", + "copied": "Teksts nokopēts." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Izmanto %{key}, lai pārslēgtu lapas, un bulttaustiņus, lai pārvietotos pa izvēlnes vienībām", + "tooltip_2": "Dažiem izvēlnes vienumiem ir papildu iespējas, kuras var izvēlēties, izmantojot kreiso un labo bulttaustiņu" + }, + "player_mode": { + "title": "Spēlētāja režīmi", + "noclip": { + "title": "NoClip", + "label": "NoClip", + "success": "NoClip ieslēgts" + }, + "godmode": { + "title": "Godmode", + "label": "Godmode", + "success": "Godmode režīms ieslēgts" + }, + "superjump": { + "title": "Superjump", + "label": "Pārslēdz superjump režīmu, spēlētājs arī skries ātrāk", + "success": "Superjump ieslēgts" + }, + "normal": { + "title": "Default", + "label": "Noklusējuma režīms", + "success": "Atgriezti noklusējuma režīmi." + } + }, + "teleport": { + "title": "Teleport", + "generic_success": "Tu tiki nosūtīts uz lokāciju!", + "waypoint": { + "title": "Waypoint", + "label": "Doties uz iestatīto waypointu", + "error": "Tev nav iestatīts neviens waypoints." + }, + "coords": { + "title": "Coords", + "label": "Doties uz norādītajām koordinātēm", + "dialog_title": "Teleportēties", + "dialog_desc": "Norādi koordinātes x, y, z formātā, lai teleportētos uz koordinātēm.", + "dialog_error": "Nederīgas koordinātes. Tām jābūt šādā formātā: 111, 222, 33" + }, + "back": { + "title": "Atpakaļ", + "label": "Doties atpakaļ uz pēdējo atrašanās vietu", + "error": "Nav pēdējās atrašanās vietas, kur atgriezties!" + }, + "copy": { + "title": "Kopēt koordinātes", + "label": "Kopēt koordinātes." + } + }, + "vehicle": { + "title": "Vehicle", + "not_in_veh_error": "Tu pašlaik neesi transportlīdzeklī!", + "spawn": { + "title": "Spawn", + "label": "Iespawnot transportlīdzekli pēc modeļa nosaukuma", + "dialog_title": "Iespawnot transportlīdzekli", + "dialog_desc": "Ievadi transportlīdzekļa modeļa nosaukumu, kuru vēlies iespawnot.", + "dialog_success": "Transportlīdzeklis iespawnots!", + "dialog_error": "Transportlīdzekļa modelis '%{modelName}' neeksistē!", + "dialog_info": "Mēģina iespawnot %{modelName}." + }, + "fix": { + "title": "Salabot", + "label": "Salabot pašreizējo transportlīdzekli", + "success": "Transportlīdzeklis salabots!" + }, + "delete": { + "title": "Dzēst", + "label": "Dzēst pašreizējo transportlīdzekli", + "success": "Transportlīdzeklis dzēsts!" + }, + "boost": { + "title": "Pastiprināt", + "label": "Pastiprināt transportlīdzekli, lai sasniegtu maksimālu jautrību (un varbūt ātrumu)", + "success": "Transportlīdzeklis pastiprināts!", + "already_boosted": "Šis transportlīdzeklis jau bija pastiprināts.", + "unsupported_class": "Šī transportlīdzekļa klase netiek atbalstīta.", + "redm_not_mounted": "Tu vari pastiprināt tikai tad, ja esi uz zirga." + } + }, + "heal": { + "title": "Izārstēt", + "myself": { + "title": "Sevi", + "label": "Atjaunot veselību", + "success_0": "Pilnībā izārstēts!", + "success_1": "Tagad tev vajadzētu justies labi!", + "success_2": "Viss atjaunots!", + "success_3": "Sāpes novērstas!" + }, + "everyone": { + "title": "Visi", + "label": "Izārstēt un atdzīvināt visus spēlētājus", + "success": "Visi spēlētāji izārstēti un atdzīvināti." + } + }, + "announcement": { + "title": "Sūtīt paziņojumu", + "label": "Sūtīt paziņojumu visiem tiešsaistē esošajiem spēlētājiem.", + "dialog_desc": "Sūtīt paziņojumu visiem tiešsaistē esošajiem spēlētājiem.", + "dialog_placeholder": "Tavs paziņojums...", + "dialog_success": "Paziņojums tika nosūtīts." + }, + "clear_area": { + "title": "Atiestatīt pasaules apgabalu", + "label": "Atiestatīt konkrētu pasaules apgabalu uz tā noklusējuma stāvokli", + "dialog_desc": "Lūdzu, ievadi rādiusu (0-300), kurā vēlies atiestatīt objektus. Tas neizdzēsīs objektus, kas izsaukti servera pusē.", + "dialog_success": "Tīru apgabalu ar rādiusu %{radius}m", + "dialog_error": "Nederīga rādiusa vērtība. Mēģini vēlreiz." + }, + "player_ids": { + "title": "Pārslēgt spēlētāju ID", + "label": "Pārslēgt spēlētāju ID (un citu informāciju) rādīšanu virs tuvāko spēlētāju galvām", + "alert_show": "Parādu tuvāko spēlētāju NetID.", + "alert_hide": "Slēpju tuvāko spēlētāju NetID." + } + }, + "page_players": { + "misc": { + "online_players": "Tiešsaistes spēlētāji", + "players": "Spēlētāji", + "search": "Meklēt", + "zero_players": "Nav atrasts neviens spēlētājs." + }, + "filter": { + "label": "Filtrēt pēc", + "no_filter": "Bez filtra", + "is_admin": "Ir administrators", + "is_injured": "Savainots / miris", + "in_vehicle": "Transportlīdzeklī" + }, + "sort": { + "label": "Kārtot pēc", + "distance": "Attālums", + "id": "ID", + "joined_first": "Pievienojās pirmais", + "joined_last": "Pievienojās pēdējais", + "closest": "Tuvākais", + "farthest": "Tālākais" + }, + "card": { + "health": "%{percentHealth}% veselība" + } + }, + "player_modal": { + "misc": { + "error": "Radās kļūda, iegūstot šī lietotāja informāciju. Kļūda ir redzama zemāk:", + "target_not_found": "Neizdevās atrast tiešsaistē spēlētāju ar ID vai lietotājvārdu %{target}" + }, + "tabs": { + "actions": "Darbības", + "info": "Informācija", + "ids": "ID", + "history": "Vēsture", + "ban": "Ban" + }, + "actions": { + "title": "Spēlētāja darbības", + "command_sent": "Komanda nosūtīta!", + "moderation": { + "title": "Moderācija", + "options": { + "dm": "DM", + "warn": "Brīdināt", + "kick": "Izmest", + "set_admin": "Iedot Admin" + }, + "dm_dialog": { + "title": "Privātā Ziņa", + "description": "Kāds ir iemesls, kādēļ vēlies nosūtīt privāto ziņu šim spēlētājam?", + "placeholder": "Iemesls...", + "success": "Tava privātā ziņa ir nosūtīta!" + }, + "warn_dialog": { + "title": "Brīdināt", + "description": "Kāds ir iemesls, kādēļ vēlies brīdināt šo spēlētāju?", + "placeholder": "Iemesls...", + "success": "Spēlētājs brīdināts!" + }, + "kick_dialog": { + "title": "Izmest", + "description": "Kāds ir iemesls, kādēļ vēlies izmest šo spēlētāju?", + "placeholder": "Iemesls...", + "success": "Spēlētājs izmests!" + } + }, + "interaction": { + "title": "Interakcijas", + "options": { + "heal": "Heal", + "go_to": "Go to", + "bring": "Bring", + "spectate": "Specatate", + "toggle_freeze": "Toggle Freeze" + }, + "notifications": { + "heal_player": "Spēlētājs tiek izārstēts", + "tp_player": "Teleportēties pie spēlētāja", + "bring_player": "Atvilkt spēlētāju", + "spectate_failed": "Neizdevās atrast spēlētāju! Beidz vērošanu.", + "spectate_yourself": "Tu nevari vērot sevi.", + "freeze_yourself": "Tu nevari iesaldēt sevi.", + "spectate_cycle_failed": "Nav neviena cita spēlētāja, ko vērot." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Padarīt reibuma stāvoklī", + "fire": "Aizdedzināt", + "wild_attack": "Dzivnieku Uzbrukšana" + } + } + }, + "info": { + "title": "Spēlētāja informācija", + "session_time": "Sesijas laiks", + "play_time": "Nospēlētais laiks", + "joined": "Pievienojās", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "vēl nav", + "btn_wl_add": "PIEVIENOT WL", + "btn_wl_remove": "NOŅEMT WL", + "btn_wl_success": "Whitelist statuss mainīts.", + "log_label": "Logi", + "log_empty": "Nav atrasti bloķējumi vai brīdinājumi.", + "log_ban_count": "%{smart_count} bans |||| %{smart_count} bani", + "log_warn_count": "%{smart_count} brīdinājums |||| %{smart_count} brīdinājumi", + "log_btn": "SĪKĀK", + "notes_changed": "Spēlētāja piezīmes ir mainītas.", + "notes_placeholder": "Piezīmes par šo spēlētāju..." + }, + "history": { + "title": "Saistītā vēsture", + "btn_revoke": "Atcelt", + "revoked_success": "Darbība atsaukta!", + "banned_by": "BLOĶĒJA %{author}", + "warned_by": "BRĪDINĀJA %{author}", + "revoked_by": "Atsaukta: %{author}.", + "expired_at": "Beidzās %{date}.", + "expires_at": "Beigsies %{date}." + }, + "ban": { + "title": "Banot spēlētāju", + "reason_placeholder": "Iemesls", + "duration_placeholder": "Ilgums", + "hours": "stundas", + "days": "dienas", + "weeks": "nedēļas", + "months": "mēneši", + "permanent": "Neatgriezeniski", + "custom": "Pielāgots", + "helper_text": "Lūdzu, izvēlies ilgumu", + "submit": "Apstiprināt Banu", + "reason_required": "Iemesla lauks ir obligāts.", + "success": "Spēlētājs bloķēts!" + }, + "ids": { + "current_ids": "Pašreizējie identifikatori", + "previous_ids": "Iepriekš izmantotie identifikatori", + "all_hwids": "Visi HWID ID" + } + } + } +} diff --git a/locale/mn.json b/locale/mn.json new file mode 100644 index 0000000..b9ee8b2 --- /dev/null +++ b/locale/mn.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Mongolian", + "humanizer_language": "mn" + }, + "restarter": { + "server_unhealthy_kick_reason": "сэрвэр дахин эхлүүлэх шаардлагатай, дахин холбогдоорой", + "partial_hang_warn": "Хэсэгчилсэн саатлын улмаас энэ сервер 1 минутын дараа дахин асах болно. Одоо салгана уу.", + "partial_hang_warn_discord": "Хэсэгчилсэн саатлын улмаас, **%{servername}** сервер 1 минутын дараа дахин асах болно", + "schedule_reason": "Ээлжит шинэчлэлт эхлэх хугацаа %{time}", + "schedule_warn": "This server is scheduled to restart in %{smart_count} minute. Please disconnect now. |||| This server is scheduled to restart in %{smart_count} minutes.", + "schedule_warn_discord": "**%{servername}** серверийн ээлжит шинэчлэлт %{smart_count} минутын дараа болох гэж байна. |||| **%{servername}** хотын ээлжит шинэчлэлт хүртэл %{smart_count} минут дутуу байна." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Таны нэвтрэх эрх хязгаарлагдсан байна. Шалтгаан: \"%{reason}\". Хязгаарлагдах хугацаа: %{expiration}.", + "kick_permanent": "(%{author}) Та тус серверт үүрд нэвтрэх эрхгүй болсон байна. \"%{reason}\".", + "reject": { + "title_permanent": "Та BAN -дуулсан буюу үүрд нэвтэрч чадахгүй болсон байна.", + "title_temporary": "Та тодорхой хугацаанд BAN -дуулсан байна.", + "label_expiration": "Эрх хязгаарлагдах хугацаа", + "label_date": "Эрхийн хязгаар дуусах өдөр", + "label_author": "Хязгаарлалт хийсэн", + "label_reason": "Шалтгаан", + "label_id": "БАН КОД", + "note_multiple_bans": "Сануулга: та хэтэрхий олон BAN -тай байна.", + "note_diff_license": "Сануулга: таны license Rockstar ID, HWID BAN буюу их хавтангаар эрхээ хязгаарлуулсан байна." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Тус сервер Зөвхөн-админ горимд орсон байна.", + "insufficient_ids": "Танд discord онцгой код байхгүй байна, Discord серверт нэгдсэний дараа дахин оролдоно уу. (Web Discord ажиллахгүй болно.)", + "deny_message": "Таны лицензүүд ямар ч админд хуваарилагдаагүй байна." + }, + "guild_member": { + "mode_title": "Тус серверт Discord Гишүүн болсон хүмүүс орох боломжтой.", + "insufficient_ids": "Танд discord таниулбар байхгүй бөгөөд хэрэв та манай Discord server-д нэгдсэн бол дахин шалгах шаардлагатай. Discord Desktop програмыг нээгээд дахин оролдоно уу (Вэб програм ажиллахгүй).", + "deny_title": "Та манай Discord серверт байх ёстой", + "deny_message": "Та %{guildname} Discord серверт нэгдсэний дараа дахин оролдоно уу." + }, + "guild_roles": { + "mode_title": "MNFCANDY NETWORK", + "insufficient_ids": "Танд discord онцгой код байхгүй байна, Discord серверт нэгдсэний дараа дахин оролдоно уу. (Web Discord ажиллахгүй болно.)", + "deny_notmember_title": "Та Discord серверт байхгүй байна.", + "deny_notmember_message": "Та манай %{guildname} Discord серверт нэгдсэний дараа дахин оролдоно уу. (Web Discord ажиллахгүй болно.)", + "deny_noroles_title": "Та дүрэмтэй танилцаагүй байна.", + "deny_noroles_message": "Та манай %{guildname} Discord серверт дүрэмтэйгээ танилцаж, Reaction дарсны дараа дахин оролдоно уу." + }, + "approved_license": { + "mode_title": "Энэхүү сервер нь Лиценз горимд байна.", + "insufficient_ids": "Танд license таниулбар байхгүй байгаа нь сервер sv_lan-г идэвхжүүлсэн гэсэн үг юм. Хэрэв та сервер эзэмшигч бол server.cfg файлаас үүнийг идэвхгүй болгож болно.", + "deny_title": "Та энэ серверт нэгдэх зөвшөөрөгдсөн жагсаалтад ороогүй байна.", + "request_id_label": "Хүсэлтийн ID" + } + }, + "server_actions": { + "restarting": "Сервер дахин асаж байна (%{reason}).", + "restarting_discord": "**%{servername}** төлөвлөгдөөгүй рестарт (%{reason}).", + "stopping": "Хот унтрав. (%{reason}).", + "stopping_discord": "**%{servername}** сервер унтарч байна (%{reason}).", + "spawning_discord": "**%{servername}** сервер асаж байна." + }, + "nui_warning": { + "title": "АНХААРУУЛГА!", + "warned_by": "Анхааруулсан:", + "stale_message": "Энэхүү сануулгыг серверт холбогдохоосоо өмнө гаргасан байна.", + "dismiss_key": "SPACE", + "instruction": "%{key} товчийг %{smart_count} секунд дарж арилгана уу. |||| %{key} товчийг %{smart_count} секунд дарж арилгана уу." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Цэс идэвхжсэн, нээхийн тулд /tx гэж бичнэ үү.\nМөн та товчлуурын холбоосыг [Game Settings > Key Bindings > FiveM > Menu: Open Main Page] хэсгээс тохируулах боломжтой.", + "menu_not_admin": "Caugh 4k HD, чиний бүх ID Log-д хадгалагдсан.", + "menu_auth_failed": "txAdmin Цэсийн баталгаажуулалт дараах шалтгааны улмаас амжилтгүй болсон : %{reason}", + "no_perms": "Чи шааж болохгүй. Чамайг бид харж байна. . .", + "unknown_error": "Үл мэдэгдэх алдаа гарлаа.", + "not_enabled": "txAdmin цэс идэвхжээгүй байна! Та үүнийг txAdmin тохиргооны хуудаснаас идэвхжүүлж болно.", + "announcement_title": "Зарлал - %{author}:", + "directmessage_title": "Нууц шивнээ %{author}:", + "dialog_empty_input": "Та хоосон бичигтэй байж болохгүй.", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Та тоглогчийг царцаасан байна!", + "unfroze_player": "Та тоглогчийн царцаалтыг тайлсан байна!", + "was_frozen": "Таныг серверийн админ царцаасан байна!" + }, + "common": { + "cancel": "Цуцлах", + "submit": "Илгээх", + "error": "Алдаа гарлаа", + "copied": "Түр санах ой руу хуулсан." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Хуудас сэлгэхийн тулд %{key}-г, цэсийн зүйл рүү шилжихийн тулд сум товчийг ашиглана уу", + "tooltip_2": "Цэсийн зарим зүйлд зүүн болон баруун сумны товчлууруудыг ашиглан сонгох боломжтой дэд сонголтууд байдаг" + }, + "player_mode": { + "title": "Тоглогчийн горим", + "noclip": { + "title": "NoClip", + "label": "NoClip-г асаах/унтрааж, хана болон бусад объектоор шилжих боломжийг танд олгоно", + "success": "NoClip идэвхжсэн" + }, + "godmode": { + "title": "Бурхан", + "label": "Ялагдашгүй байдлыг асааж, хохирол амсахаас сэргийлнэ", + "success": "АДМИН АМЖИЛТТАЙ ХҮЧИРХИЙЛЛЭЭ!" + }, + "superjump": { + "title": "Супер үсрэлт", + "label": "Супер үсрэлт горимыг асаавал тоглогч илүү хурдан гүйх болно", + "success": "АДМИН АМЖИЛТТАЙ ХҮЧИРХИЙЛЛЭЭ!" + }, + "normal": { + "title": "Ердийн", + "label": "Анхдагч/ердийн тоглогчийн горим руу буцна", + "success": "Анхдагч/ердийн тоглогчийн горим руу буцсан." + } + }, + "teleport": { + "title": "Телепорт", + "generic_success": "Чамайг хар нүх рүү явуулсан!", + "waypoint": { + "title": "Замын цэг", + "label": "Газрын зураг дээр тохируулсан замын цэг рүү телепорт хийх", + "error": "Танд зам тавих цэг байхгүй байна." + }, + "coords": { + "title": "Coords", + "label": "Өгөгдсөн координат руу телепорт хийх", + "dialog_title": "Телепорт", + "dialog_desc": "Хар нүхээр дамжин өнгөрөх координатуудыг x, y, z форматаар өгнө үү.", + "dialog_error": "Координат буруу байна. 111, 222, 33 форматтай байх ёстой" + }, + "back": { + "title": "Буцах", + "label": "Сүүлийн телепортын өмнөх байршил руу буцна", + "error": "Танд буцаж очих сүүлчийн байршил алга!" + }, + "copy": { + "title": "Коордуудыг хуулах", + "label": "Одоогийн дэлхийн координатуудыг санах ойдоо хуулна уу" + } + }, + "vehicle": { + "title": "Тээврийн хэрэгсэл", + "not_in_veh_error": "Та одоогоор тээврийн хэрэгсэлд суугаагүй байна!", + "spawn": { + "title": "Гаргаж ирэх", + "label": "Өгөгдсөн тээврийн хэрэгслийг загварын нэрнээс нь гаргаж ирнэ", + "dialog_title": "Машиныг гаргаж ирэх", + "dialog_desc": "Та гаргаж ирэхийг хүсч буй тээврийн хэрэгслийнхээ загварын нэрийг оруулна уу.", + "dialog_success": "АДМИН АМЖИЛТТАЙ ХҮЧИРХИЙЛЛЭЭ!", + "dialog_error": "'%{modelName}' тээврийн хэрэгслийн загварын нэр байхгүй байна!", + "dialog_info": "%{modelName}-г үүсгэхийг оролдож байна." + }, + "fix": { + "title": "Засах", + "label": "Тээврийн хэрэгслийг дээд зэргээр засна", + "success": "АДМИН АМЖИЛТТАЙ ХҮЧИРХИЙЛЛЭЭ!" + }, + "delete": { + "title": "Устгах", + "label": "Тоглогчийн сууж буй тээврийн хэрэгслийг устгана", + "success": "Тээврийн хэрэгслийг устгасан!" + }, + "boost": { + "title": "Тус дэм", + "label": "Хамгийн их зугаа цэнгэлд (болон магадгүй хурд) хүрэхийн тулд машинаа хүчирхэгжүүлээрэй.", + "success": "АДМИН АМЖИЛТТАЙ ХҮЧИРХИЙЛЛЭЭ!", + "already_boosted": "Энэ машиныг аль хэдийн хүчирхэгжүүлсэн.", + "unsupported_class": "Энэ тээврийн хэрэгслийн ангиллыг дэмждэггүй.", + "redm_not_mounted": "Морь унасан үед л ахиж чадна." + } + }, + "heal": { + "title": "Эдгээх", + "myself": { + "title": "Өөрийгөө", + "label": "Одоогийн ped-ийн дээд хэмжээнд хүртэл өөрийгөө эдгээх болно", + "success_0": "Бүгд эдгэрсэн!", + "success_1": "Та одоо сайхан санагдаж байна!", + "success_2": "Бүрэн сэргээсэн!", + "success_3": "Өө, зассан!" + }, + "everyone": { + "title": "Бүгд", + "label": "Холбогдсон бүх тоглогчдыг эдгээж, сэргээх болно", + "success": "Бүх тоглогчдыг эдгээж, сэргээсэн." + } + }, + "announcement": { + "title": "Зар илгээх", + "label": "Бүх онлайн тоглогчдод мэдэгдэл илгээнэ үү.", + "dialog_desc": "Бүх тоглогчдод дамжуулахыг хүссэн мессежээ оруулна уу.", + "dialog_placeholder": "Таны мэдэгдэл...", + "dialog_success": "Мэдэгдэл илгээж байна." + }, + "clear_area": { + "title": "Дэлхийн бүсийг дахин тохируулах", + "label": "Заасан дэлхийн бүсийг өгөгдмөл байдалд нь дахин тохируулна уу", + "dialog_desc": "Байгууллагуудыг дахин тохируулах радиусыг (0-300) оруулна уу. Энэ нь серверийн талд үүсгэсэн байгууллагуудыг арилгахгүй.", + "dialog_success": "%{radius}m радиустай талбайг цэвэрлэх", + "dialog_error": "Буруу радиусын оролт. Дахин оролд." + }, + "player_ids": { + "title": "Тоглогчийн ID-г харуулах", + "label": "Ойролцоох бүх тоглогчдын толгойн дээр тоглогчийн ID-г (болон бусад мэдээллийг) харуулах", + "alert_show": "Ойролцоох тоглогчийн NetID-г харуулж байна.", + "alert_hide": "Ойролцоох тоглогчийн NetID-г нууж байна." + } + }, + "page_players": { + "misc": { + "online_players": "Онлайн тоглогчид", + "players": "Тоглогчид", + "search": "Хайх", + "zero_players": "Тоглогч олдсонгүй" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Эрэмбэлэх", + "distance": "Зай", + "id": "ID", + "joined_first": "Эхлээд нэгдсэн", + "joined_last": "Хамгийн сүүлд нэгдсэн", + "closest": "Хамгийн ойр", + "farthest": "Хамгийн хол" + }, + "card": { + "health": "%{percentHealth}% амь" + } + }, + "player_modal": { + "misc": { + "error": "Энэ хэрэглэгчийн мэдээллийг татахад алдаа гарлаа. Алдааг доор харуулав.", + "target_not_found": "ID эсвэл %{target} хэрэглэгчийн нэртэй онлайн тоглуулагчийг олж чадсангүй." + }, + "tabs": { + "actions": "Үйлдлүүд", + "info": "Мэдээлэл", + "ids": "IDs", + "history": "Түүх", + "ban": "Бан" + }, + "actions": { + "title": "Тоглогчийн үйлдлүүд", + "command_sent": "Тушаал илгээсэн!", + "moderation": { + "title": "Зохицуулалт", + "options": { + "dm": "Шивнэх", + "warn": "Анхааруулах", + "kick": "Серверээс гаргах", + "set_admin": "Админ өгөх" + }, + "dm_dialog": { + "title": "Шууд мессеж", + "description": "Энэ тоглогч руу шууд мессеж илгээх болсон шалтгаан нь юу вэ?", + "placeholder": "Шалтгаан...", + "success": "Таны DM илгээгдлээ!" + }, + "warn_dialog": { + "title": "Анхааруулах", + "description": "Энэ тоглогчид анхааруулга өгөх болсон шалтгаан юу вэ?", + "placeholder": "Шалтгаан...", + "success": "Тоглогчийг анхааруулсан!" + }, + "kick_dialog": { + "title": "Серверээс гаргах", + "description": "Энэ тоглогчийг серверээс гаргах болсон шалтгаан нь юу вэ?", + "placeholder": "Шалтгаан...", + "success": "Тоглогчийг серверээс гаргасан!" + } + }, + "interaction": { + "title": "Харилцаа холбоо", + "options": { + "heal": "Эдгээх", + "go_to": "Очих", + "bring": "Авчирах", + "spectate": "Үзэх", + "toggle_freeze": "Хөлдөөхийг асаах/унтраах" + }, + "notifications": { + "heal_player": "Тоглогчийг эдгээж байна", + "tp_player": "Тоглогч руу шилжиж байна", + "bring_player": "Тоглогчийг авчирч байна", + "spectate_failed": "Зорилгоо шийдэж чадсангүй! Үзэсгэлэнгээс гарах.", + "spectate_yourself": "Та өөрийгөө харж чадахгүй.", + "freeze_yourself": "Та өөрийгөө хөлдөөж чадахгүй.", + "spectate_cycle_failed": "Өөр тоглогч байхгүй." + } + }, + "troll": { + "title": "ПЯЗДА?", + "options": { + "drunk": "Хараа өгөх", + "fire": "Шатаах", + "wild_attack": "Мичин гарагийн бослого" + } + } + }, + "info": { + "title": "Тоглогчийн мэдээлэл", + "session_time": "Тоглосон цаг", + "play_time": "Нийт тоглосон цаг", + "joined": "Нэгдсэн", + "whitelisted_label": "Цагаан жагсаалтад орсон", + "whitelisted_notyet": "ороогүй", + "btn_wl_add": "WL нэмэх", + "btn_wl_remove": "WL-г УСТГАХ", + "btn_wl_success": "Цагаан жагсаалтын статус өөрчлөгдсөн.", + "log_label": "Бүртгэл", + "log_empty": "Ямар ч хориг/сэрэмжлүүлэг олдсонгүй.", + "log_ban_count": "%{smart_count} хориг |||| %{smart_count} хориг", + "log_warn_count": "%{smart_count} сэрэмжлүүлэг |||| %{smart_count} сэрэмжлүүлэг", + "log_btn": "ДЭЛГЭРЭНГҮЙ", + "notes_placeholder": "Энэ тоглогчийн тухай тэмдэглэл...", + "notes_changed": "Тоглогчийн тэмдэглэл өөрчлөгдсөн." + }, + "ids": { + "current_ids": "Одоогийн танигч", + "previous_ids": "Өмнө нь ашигласан танигч", + "all_hwids": "Бүх тоног төхөөрөмжийн ID" + }, + "history": { + "title": "Холбогдох түүх", + "btn_revoke": "Цуцлах", + "revoked_success": "Хориг/Сэрэмжлүүлэг-ийг цуцалсан!", + "banned_by": "ХОРИГЛОСОН %{author}", + "warned_by": "АНХААРУУЛГА %{author}", + "revoked_by": "Хүчингүй болгосон %{author}.", + "expired_at": "Хугацаа дууссан %{date}.", + "expires_at": "Дуусах цаг %{date}." + }, + "ban": { + "title": "Тоглогчийг хориглох", + "reason_placeholder": "Шалтгаан", + "reason_required": "Шалтгаан талбарыг оруулах шаардлагатай.", + "duration_placeholder": "Үргэлжлэх хугацаа", + "success": "Тоглогчийг хориглосон!", + "hours": "цаг", + "days": "өдөр", + "weeks": "7 хоног", + "months": "сар", + "permanent": "Үүрдийн", + "custom": "Тусгай", + "helper_text": "Хугацаа сонгоно уу", + "submit": "Хоригийг эхлүүлэх" + } + } + } +} diff --git a/locale/ne.json b/locale/ne.json new file mode 100644 index 0000000..8a6b884 --- /dev/null +++ b/locale/ne.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Nepali", + "humanizer_language": "en" + }, + "restarter": { + "server_unhealthy_kick_reason": "सर्भर पुनः सुरु गर्न आवश्यक छ, कृपया पुन: जडान गर्नुहोस्", + "partial_hang_warn": "आंशिक ह्याङ्गको कारण, यो सर्भर १ मिनेटमा पुनः सुरु हुनेछ। कृपया अहिले नै डिस्कनेक्ट गर्नुहोस्।", + "partial_hang_warn_discord": "आंशिक ह्याङ्गको कारण, **%{servername}** १ मिनेटमा पुनः सुरु हुनेछ।", + "schedule_reason": "%{time} मा तालिकाबद्ध पुनः सुरुवात", + "schedule_warn": "यो सर्भर %{smart_count} मिनेटमा पुनः सुरु हुन तालिकाबद्ध छ। कृपया अहिले नै डिस्कनेक्ट गर्नुहोस्। |||| यो सर्भर %{smart_count} मिनेटमा पुनः सुरु हुन तालिकाबद्ध छ।", + "schedule_warn_discord": "**%{servername}** %{smart_count} मिनेटमा पुनः सुरु हुन तालिकाबद्ध छ। |||| **%{servername}** %{smart_count} मिनेटमा पुनः सुरु हुन तालिकाबद्ध छ।" + }, + "kick_messages": { + "everyone": "सबैलाई निकालियो कारण: %{reason}।", + "player": "तिमीलाई निकालियो कारण: %{reason}।", + "unknown_reason": "अज्ञात कारण" + }, + "ban_messages": { + "kick_temporary": "(%{author}) तपाईंलाई \"%{reason}\" को कारणले यो सर्भरबाट प्रतिबन्धित गरिएको छ। तपाईंको प्रतिबन्ध %{expiration} मा समाप्त हुनेछ।", + "kick_permanent": "(%{author}) तपाईंलाई \"%{reason}\" को कारणले यो सर्भरबाट स्थायी रूपमा प्रतिबन्धित गरिएको छ।", + "reject": { + "title_permanent": "तपाईंलाई यो सर्भरबाट स्थायी रूपमा प्रतिबन्धित गरिएको छ।", + "title_temporary": "तपाईंलाई यो सर्भरबाट अस्थायी रूपमा प्रतिबन्धित गरिएको छ।", + "label_expiration": "तपाईंको प्रतिबन्ध समाप्त हुने समय", + "label_date": "प्रतिबन्धित मिति", + "label_author": "प्रतिबन्धित गर्ने व्यक्ति", + "label_reason": "प्रतिबन्धको कारण", + "label_id": "प्रतिबन्ध आईडी", + "note_multiple_bans": "नोट: तपाईंको पहिचानकर्ताहरूमा एकभन्दा बढी सक्रिय प्रतिबन्ध छन्।", + "note_diff_license": "नोट: माथिको प्रतिबन्ध अर्को लाइसेन्सको लागि लागू गरिएको थियो, जसको अर्थ तपाईंका केही आईडी/HWIDहरू त्यो प्रतिबन्धसँग सम्बन्धित भएकाहरूसँग मेल खान्छन्।" + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "यो सर्भर एडमिन-मात्र मोडमा छ।", + "insufficient_ids": "तपाईंसँग डिस्कोर्ड वा fivem पहिचानकर्ताहरू छैनन्, र तपाईं txAdmin प्रशासक हुनुहुन्छ भनेर प्रमाणित गर्न कम्तीमा एउटा आवश्यक छ।", + "deny_message": "तपाईंका पहिचानकर्ताहरू कुनै पनि txAdmin प्रशासकलाई असाइन गरिएको छैन।" + }, + "guild_member": { + "mode_title": "यो सर्भर डिस्कोर्ड गिल्ड सदस्य श्वेतसूची मोडमा छ।", + "insufficient_ids": "तपाईंसँग डिस्कोर्ड पहिचानकर्ता छैन, जुन तपाईं हाम्रो डिस्कोर्ड गिल्डमा सामेल हुनुभएको छ भनेर प्रमाणित गर्न आवश्यक छ। कृपया डिस्कोर्ड डेस्कटप एप खोल्नुहोस् र फेरि प्रयास गर्नुहोस् (वेब एप काम गर्दैन)।", + "deny_title": "जडान गर्नको लागि तपाईंले हाम्रो डिस्कोर्ड गिल्डमा सामेल हुनु आवश्यक छ।", + "deny_message": "कृपया %{guildname} गिल्डमा सामेल हुनुहोस् र फेरि प्रयास गर्नुहोस्।" + }, + "guild_roles": { + "mode_title": "यो सर्भर डिस्कोर्ड भूमिका श्वेतसूची मोडमा छ।", + "insufficient_ids": "तपाईंसँग डिस्कोर्ड पहिचानकर्ता छैन, जुन तपाईं हाम्रो डिस्कोर्ड गिल्डमा सामेल हुनुभएको छ भनेर प्रमाणित गर्न आवश्यक छ। कृपया डिस्कोर्ड डेस्कटप एप खोल्नुहोस् र फेरि प्रयास गर्नुहोस् (वेब एप काम गर्दैन)।", + "deny_notmember_title": "जडान गर्नको लागि तपाईंले हाम्रो डिस्कोर्ड गिल्डमा सामेल हुनु आवश्यक छ।", + "deny_notmember_message": "कृपया %{guildname} मा सामेल हुनुहोस्, आवश्यक भूमिकाहरू मध्ये एक प्राप्त गर्नुहोस्, र फेरि प्रयास गर्नुहोस्।", + "deny_noroles_title": "तपाईंसँग जडान गर्न आवश्यक श्वेतसूचीकृत भूमिका छैन।", + "deny_noroles_message": "यो सर्भरमा जडान गर्नको लागि तपाईंसँग %{guildname} गिल्डमा कम्तीमा एउटा श्वेतसूचीकृत भूमिका हुनु आवश्यक छ।" + }, + "approved_license": { + "mode_title": "यो सर्भर लाइसेन्स श्वेतसूची मोडमा छ।", + "insufficient_ids": "तपाईंसँग लाइसेन्स पहिचानकर्ता छैन, जसको अर्थ सर्भरमा sv_lan सक्षम छ। यदि तपाईं सर्भर मालिक हुनुहुन्छ भने, तपाईंले यसलाई server.cfg फाइलमा अक्षम गर्न सक्नुहुन्छ।", + "deny_title": "तपाईंलाई यो सर्भरमा जडान गर्न श्वेतसूचीकृत गरिएको छैन।", + "request_id_label": "अनुरोध आईडी" + } + }, + "server_actions": { + "restarting": "सर्भर पुनः सुरु हुँदैछ (%{reason})।", + "restarting_discord": "**%{servername}** पुनः सुरु हुँदैछ (%{reason})।", + "stopping": "सर्भर बन्द हुँदैछ (%{reason})।", + "stopping_discord": "**%{servername}** बन्द हुँदैछ (%{reason})।", + "spawning_discord": "**%{servername}** सुरु हुँदैछ।" + }, + "nui_warning": { + "title": "चेतावनी", + "warned_by": "चेतावनी दिने व्यक्ति:", + "stale_message": "यो चेतावनी अब पुरानो भएको छ र स्वचालित रूपमा बन्द हुनेछ।", + "dismiss_key": "[SPACE]", + "instruction": "यो सन्देश हटाउन %{key} %{smart_count} सेकेन्डसम्म थिच्नुहोस्। |||| यो सन्देश हटाउन %{key} एक सेकेन्ड थिच्नुहोस्।" + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin मेनु सक्षम छ, /tx टाइप गरेर खोल्नुहोस्।\nतपाईंले [Game Settings > Key Bindings > FiveM > Menu: Open Main Page] मा कीबाइन्ड पनि कन्फिगर गर्न सक्नुहुन्छ।", + "menu_not_admin": "तपाईंका पहिचानकर्ताहरू txAdmin मा दर्ता कुनै पनि प्रशासकसँग मेल खाँदैनन्।\nयदि तपाईं txAdmin मा दर्ता हुनुहुन्छ भने, प्रशासक प्रबन्धकमा जानुहोस् र तपाईंका पहिचानकर्ताहरू सुरक्षित गरिएको छ भनी सुनिश्चित गर्नुहोस्।", + "menu_auth_failed": "txAdmin मेनु प्रमाणीकरण असफल भयो, कारण: %{reason}", + "no_perms": "तपाईंसँग यो अनुमति छैन।", + "unknown_error": "अज्ञात त्रुटि भयो।", + "not_enabled": "txAdmin मेनु सक्षम छैन! तपाईंले यसलाई txAdmin सेटिङ्ग्स पृष्ठमा सक्षम गर्न सक्नुहुन्छ।", + "announcement_title": "%{author} द्वारा सर्भर घोषणा:", + "directmessage_title": "प्रशासक %{author} बाट डीएम:", + "dialog_empty_input": "तपाईंसँग खाली इनपुट हुन सक्दैन।", + "onesync_error": "यो विकल्पको लागि OneSync सक्षम हुनु आवश्यक छ।" + }, + "frozen": { + "froze_player": "तपाईंले खेलाडीलाई जमाउनुभयो!", + "unfroze_player": "तपाईंले खेलाडीलाई अनजमाउनुभयो!", + "was_frozen": "तपाईंलाई सर्भर प्रशासकद्वारा जमाइएको छ!" + }, + "common": { + "cancel": "रद्द गर्नुहोस्", + "submit": "पेश गर्नुहोस्", + "error": "त्रुटि भयो", + "copied": "क्लिपबोर्डमा प्रतिलिपि गरियो।" + }, + "page_main": { + "tooltips": { + "tooltip_1": "पृष्ठहरू स्विच गर्न %{key} प्रयोग गर्नुहोस् र मेनु आइटमहरू नेभिगेट गर्न तीर कुञ्जीहरू प्रयोग गर्नुहोस्", + "tooltip_2": "केही मेनु आइटमहरूमा उप-विकल्पहरू छन् जुन बायाँ र दायाँ तीर कुञ्जीहरू प्रयोग गरेर चयन गर्न सकिन्छ" + }, + "player_mode": { + "title": "खेलाडी मोड", + "noclip": { + "title": "नोक्लिप", + "label": "नोक्लिप टगल गर्नुहोस्, जसले तपाईंलाई भित्ताहरू र अन्य वस्तुहरू भित्र जान अनुमति दिन्छ", + "success": "नोक्लिप सक्षम गरियो" + }, + "godmode": { + "title": "देवता", + "label": "अजेयता टगल गर्नुहोस्, जसले तपाईंलाई क्षति लिनबाट रोक्छ", + "success": "देवता मोड सक्षम गरियो" + }, + "superjump": { + "title": "सुपर जम्प", + "label": "सुपर जम्प मोड टगल गर्नुहोस्, खेलाडी छिटो पनि दौडनेछ", + "success": "सुपर जम्प सक्षम गरियो" + }, + "normal": { + "title": "सामान्य", + "label": "आफूलाई पूर्वनिर्धारित/सामान्य खेलाडी मोडमा फर्काउनुहोस्", + "success": "पूर्वनिर्धारित खेलाडी मोडमा फर्कियो।" + } + }, + "teleport": { + "title": "टेलिपोर्ट", + "generic_success": "तपाईंलाई वर्महोलमा पठाइयो!", + "waypoint": { + "title": "वेपोइन्ट", + "label": "नक्शामा सेट गरिएको कस्टम वेपोइन्टमा टेलिपोर्ट गर्नुहोस्", + "error": "तपाईंले कुनै वेपोइन्ट सेट गर्नुभएको छैन।" + }, + "coords": { + "title": "निर्देशांकहरू", + "label": "प्रदान गरिएका निर्देशांकहरूमा टेलिपोर्ट गर्नुहोस्", + "dialog_title": "टेलिपोर्ट", + "dialog_desc": "वर्महोल भित्र जान x, y, z ढाँचामा निर्देशांकहरू प्रदान गर्नुहोस्।", + "dialog_error": "अवैध निर्देशांकहरू। यो ढाँचामा हुनुपर्छ: 111, 222, 33" + }, + "back": { + "title": "पछाडि", + "label": "अघिल्लो टेलिपोर्ट भन्दा पहिलेको स्थानमा फर्कन्छ", + "error": "तपाईंसँग फर्कने कुनै अघिल्लो स्थान छैन!" + }, + "copy": { + "title": "निर्देशांकहरू कपी गर्नुहोस्", + "label": "हालको विश्व निर्देशांकहरूलाई तपाईंको क्लिपबोर्डमा कपी गर्नुहोस्" + } + }, + "vehicle": { + "title": "सवारी साधन", + "not_in_veh_error": "तपाईं हाल कुनै सवारी साधनमा हुनुहुन्न!", + "spawn": { + "title": "स्पन गर्नुहोस्", + "label": "यसको मोडेल नामबाट दिइएको सवारी साधन स्पन गर्नुहोस्", + "dialog_title": "सवारी साधन स्पन गर्नुहोस्", + "dialog_desc": "तपाईंले स्पन गर्न चाहनुभएको सवारी साधनको मोडेल नाम प्रविष्ट गर्नुहोस्।", + "dialog_success": "सवारी साधन स्पन गरियो!", + "dialog_error": "सवारी साधन मोडेल नाम '%{modelName}' अवस्थित छैन!", + "dialog_info": "%{modelName} स्पन गर्ने प्रयास गर्दै।" + }, + "fix": { + "title": "मर्मत गर्नुहोस्", + "label": "सवारी साधनलाई यसको अधिकतम स्वास्थ्यमा मर्मत गर्नेछ", + "success": "सवारी साधन मर्मत गरियो!" + }, + "delete": { + "title": "मेटाउनुहोस्", + "label": "खेलाडी हाल भएको सवारी साधन मेटाउँछ", + "success": "सवारी साधन मेटाइयो!" + }, + "boost": { + "title": "बूस्ट", + "label": "अधिकतम मजा (र सायद गति) प्राप्त गर्न कारलाई बूस्ट गर्नुहोस्", + "success": "सवारी साधन बूस्ट गरियो!", + "already_boosted": "यो सवारी साधन पहिले नै बूस्ट गरिएको थियो।", + "unsupported_class": "यो सवारी साधन वर्ग समर्थित छैन।", + "redm_not_mounted": "तपाईं घोडामा सवार हुँदा मात्र बूस्ट गर्न सक्नुहुन्छ।" + } + }, + "heal": { + "title": "निको पार्नुहोस्", + "myself": { + "title": "मलाई", + "label": "हालको पेडको अधिकतम स्वास्थ्यमा आफूलाई निको पार्नेछ", + "success_0": "पूर्ण रूपमा निको पारियो!", + "success_1": "तपाईं अहिले राम्रो महसुस गर्नुपर्छ!", + "success_2": "पूर्ण रूपमा पुनर्स्थापित!", + "success_3": "चोटपटक ठीक भयो!" + }, + "everyone": { + "title": "सबैजना", + "label": "सबै जडान भएका खेलाडीहरूलाई निको पार्नेछ र पुनर्जीवित गर्नेछ", + "success": "सबै खेलाडीहरूलाई निको पारियो र पुनर्जीवित गरियो।" + } + }, + "announcement": { + "title": "घोषणा पठाउनुहोस्", + "label": "सबै अनलाइन खेलाडीहरूलाई घोषणा पठाउनुहोस्।", + "dialog_desc": "सबै खेलाडीहरूलाई प्रसारण गर्न चाहनुभएको सन्देश प्रविष्ट गर्नुहोस्।", + "dialog_placeholder": "तपाईंको घोषणा...", + "dialog_success": "घोषणा पठाउँदै।" + }, + "clear_area": { + "title": "विश्व क्षेत्र रिसेट गर्नुहोस्", + "label": "निर्दिष्ट विश्व क्षेत्रलाई यसको पूर्वनिर्धारित अवस्थामा रिसेट गर्नुहोस्", + "dialog_desc": "कृपया तपाईंले इकाइहरू रिसेट गर्न चाहनुभएको त्रिज्या प्रविष्ट गर्नुहोस् (0-300)। यसले सर्भर साइडमा स्पन गरिएका इकाइहरू खाली गर्नेछैन।", + "dialog_success": "%{radius}मी त्रिज्याको क्षेत्र खाली गर्दै", + "dialog_error": "अवैध त्रिज्या इनपुट। फेरि प्रयास गर्नुहोस्।" + }, + "player_ids": { + "title": "खेलाडी आईडीहरू टगल गर्नुहोस्", + "label": "सबै नजिकका खेलाडीहरूको टाउको माथि खेलाडी आईडीहरू (र अन्य जानकारी) देखाउने टगल गर्नुहोस्", + "alert_show": "नजिकका खेलाडी NetID हरू देखाउँदै।", + "alert_hide": "नजिकका खेलाडी NetID हरू लुकाउँदै।" + } + }, + "page_players": { + "misc": { + "online_players": "अनलाइन खेलाडीहरू", + "players": "खेलाडीहरू", + "search": "खोज्नुहोस्", + "zero_players": "कुनै खेलाडीहरू फेला परेनन्।" + }, + "filter": { + "label": "यसद्वारा फिल्टर गर्नुहोस्", + "no_filter": "कुनै फिल्टर छैन", + "is_admin": "प्रशासक हो", + "is_injured": "घाइते / मृत छ", + "in_vehicle": "सवारी साधनमा छ" + }, + "sort": { + "label": "यसद्वारा क्रमबद्ध गर्नुहोस्", + "distance": "दूरी", + "id": "आईडी", + "joined_first": "पहिले जडान भएको", + "joined_last": "पछि जडान भएको", + "closest": "नजिकको", + "farthest": "टाढाको" + }, + "card": { + "health": "%{percentHealth}% स्वास्थ्य" + } + }, + "player_modal": { + "misc": { + "error": "यस प्रयोगकर्ताको विवरणहरू प्राप्त गर्दा त्रुटि भयो। त्रुटि तल देखाइएको छ:", + "target_not_found": "आईडी वा प्रयोगकर्ता नाम %{target} भएको अनलाइन खेलाडी फेला पार्न असमर्थ" + }, + "tabs": { + "actions": "कार्यहरू", + "info": "जानकारी", + "ids": "आईडीहरू", + "history": "इतिहास", + "ban": "प्रतिबन्ध" + }, + "actions": { + "title": "खेलाडी कार्यहरू", + "command_sent": "आदेश पठाइयो!", + "moderation": { + "title": "मध्यस्थता", + "options": { + "dm": "डीएम", + "warn": "चेतावनी", + "kick": "किक", + "set_admin": "प्रशासक बनाउनुहोस्" + }, + "dm_dialog": { + "title": "प्रत्यक्ष सन्देश", + "description": "यस खेलाडीलाई प्रत्यक्ष सन्देश पठाउनुको कारण के हो?", + "placeholder": "कारण...", + "success": "तपाईंको डीएम पठाइएको छ!" + }, + "warn_dialog": { + "title": "चेतावनी", + "description": "यस खेलाडीलाई प्रत्यक्ष चेतावनी दिनुको कारण के हो?", + "placeholder": "कारण...", + "success": "खेलाडीलाई चेतावनी दिइयो!" + }, + "kick_dialog": { + "title": "Kick", + "description": "यस खेलाडीलाई Kick गर्नुको कारण के हो?", + "placeholder": "कारण...", + "success": "खेलाडीलाई किक गरियो!" + } + }, + "interaction": { + "title": "अन्तरक्रिया", + "options": { + "heal": "निको पार्नुहोस्", + "go_to": "जानुहोस्", + "bring": "ल्याउनुहोस्", + "spectate": "निगरानी गर्नुहोस्", + "toggle_freeze": "जमाउने टगल गर्नुहोस्" + }, + "notifications": { + "heal_player": "खेलाडीलाई निको पार्दै", + "tp_player": "खेलाडीकहाँ टेलिपोर्ट गर्दै", + "bring_player": "खेलाडीलाई बोलाउँदै", + "spectate_failed": "लक्ष्य समाधान गर्न असफल! निगरानी बाहिर निस्कँदै।", + "spectate_yourself": "तपाईं आफैंलाई निगरानी गर्न सक्नुहुन्न।", + "freeze_yourself": "तपाईं आफैंलाई जमाउन सक्नुहुन्न।", + "spectate_cycle_failed": "साइकल गर्न कुनै खेलाडीहरू छैनन्।" + } + }, + "troll": { + "title": "ट्रोल", + "options": { + "drunk": "मातेको बनाउनुहोस्", + "fire": "आगो लगाउनुहोस्", + "wild_attack": "जंगली आक्रमण" + } + } + }, + "info": { + "title": "खेलाडी जानकारी", + "session_time": "सत्र समय", + "play_time": "खेल समय", + "joined": "सामेल भएको", + "whitelisted_label": "श्वेतसूचीकृत", + "whitelisted_notyet": "अझै छैन", + "btn_wl_add": "श्वेतसूचीमा थप्नुहोस्", + "btn_wl_remove": "श्वेतसूचीबाट हटाउनुहोस्", + "btn_wl_success": "श्वेतसूची स्थिति परिवर्तन गरियो।", + "log_label": "लग", + "log_empty": "कुनै प्रतिबन्ध/चेतावनीहरू फेला परेनन्।", + "log_ban_count": "%{smart_count} प्रतिबन्ध |||| %{smart_count} प्रतिबन्धहरू", + "log_warn_count": "%{smart_count} चेतावनी |||| %{smart_count} चेतावनीहरू", + "log_btn": "विवरणहरू", + "notes_placeholder": "यस खेलाडीको बारेमा टिप्पणीहरू...", + "notes_changed": "खेलाडी टिप्पणी परिवर्तन गरियो।" + }, + "ids": { + "current_ids": "हालका पहिचानकर्ताहरू", + "previous_ids": "पहिले प्रयोग गरिएका पहिचानकर्ताहरू", + "all_hwids": "सबै हार्डवेयर आईडीहरू" + }, + "history": { + "title": "सम्बन्धित इतिहास", + "btn_revoke": "खारेज गर्नुहोस्", + "revoked_success": "कार्य खारेज गरियो!", + "banned_by": "%{author} द्वारा प्रतिबन्धित", + "warned_by": "%{author} द्वारा चेतावनी दिइएको", + "revoked_by": "%{author} द्वारा खारेज गरिएको।", + "expired_at": "%{date} मा समाप्त भयो।", + "expires_at": "%{date} मा समाप्त हुन्छ।" + }, + "ban": { + "title": "खेलाडीलाई प्रतिबन्ध लगाउनुहोस्", + "reason_placeholder": "कारण", + "reason_required": "कारण फिल्ड आवश्यक छ।", + "duration_placeholder": "अवधि", + "success": "खेलाडीलाई प्रतिबन्ध लगाइयो!", + "hours": "घण्टा", + "days": "दिन", + "weeks": "हप्ता", + "months": "महिना", + "permanent": "स्थायी", + "custom": "कस्टम", + "helper_text": "कृपया एक अवधि चयन गर्नुहोस्", + "submit": "प्रतिबन्ध लागू गर्नुहोस्" + } + } + } +} diff --git a/locale/nl.json b/locale/nl.json new file mode 100644 index 0000000..ad9b314 --- /dev/null +++ b/locale/nl.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Dutch", + "humanizer_language": "nl" + }, + "restarter": { + "server_unhealthy_kick_reason": "de server moet opnieuw worden gestart, maak alsjeblieft opnieuw verbinding", + "partial_hang_warn": "De server is vastgelopen en zal over 1 minuut herstarten. Verlaat de server nu.", + "partial_hang_warn_discord": "**%{servername}** is vastgelopen en zal over 1 minuut herstarten.", + "schedule_reason": "Geplande restart om %{time}", + "schedule_warn": "De server heeft een geplande restart over %{smart_count} minuut. Verlaat de server. |||| De server heeft een geplande restart over %{smart_count} minuten.", + "schedule_warn_discord": "**%{servername}** heeft een geplande restart over %{smart_count} minuut. |||| **%{servername}** heeft een geplande restart over %{smart_count} minuten." + }, + "kick_messages": { + "everyone": "Alle spelers gekicked: %{reason}.", + "player": "Je bent gekicked: %{reason}.", + "unknown_reason": "voor onbekende reden" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Je bent tijdelijk verbannen van deze server met de reden: \"%{reason}\". Je ban zal vervallen over: %{expiration}.", + "kick_permanent": "(%{author}) Je bent permanent verbannen van deze server met de reden: \"%{reason}\".", + "reject": { + "title_permanent": "Je bent permanent verbannen van deze server.", + "title_temporary": "Je bent tijdelijk verbannen van deze server.", + "label_expiration": "Je ban zal verlopen over", + "label_date": "Gebanned Op", + "label_author": "Gebanned Door", + "label_reason": "Ban Reden", + "label_id": "Ban ID", + "note_multiple_bans": "Opmerking: U hebt meer dan één actieve ban op uw Identifiers.", + "note_diff_license": "Opmerking: de bovenstaande ban werd toegepast voor een andere license, wat betekent dat sommige van jouw IDs/HWIDs matchen met degene die zijn geassocieerd met die ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Deze server is in Admin-only modus.", + "insufficient_ids": "Je hebt geen discord of fivem identifiers, op zijn minst is een van deze vereist om te valideren of jij een txAdmin administrator bent.", + "deny_message": "Jouw identifiers zijn niet gekoppeld aan een txAdmin administrator." + }, + "guild_member": { + "mode_title": "Deze server is in Discord server Member Whitelist modus.", + "insufficient_ids": "Je hebt niet de discord identifier, welke is vereist om te valideren of jij onze Discord server bent gejoind. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "Je moet onze Discord server joinen om verbinding te maken.", + "deny_message": "Join de guild %{guildname}, probeer het daarna opnieuw." + }, + "guild_roles": { + "mode_title": "Deze server is in Discord Role Whitelist modus.", + "insufficient_ids": "Je hebt niet de discord identifier, welke is vereist om te valideren of jij onze Discord server bent gejoind. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "Je moet onze Discord server joinen om verbinding te maken.", + "deny_notmember_message": "Join %{guildname}, krijg een van de vereiste roles, probeer het daarna opnieuw.", + "deny_noroles_title": "Je hebt geen whitelisted role die vereist is om te joinen.", + "deny_noroles_message": "Om deze server te joinen ben je op zijn minst vereist om een van de whitelisted roles te hebben in de guild %{guildname}." + }, + "approved_license": { + "mode_title": "Deze server is in License Whitelist modus.", + "insufficient_ids": "Je hebt niet de license identifier, wat betekent dat de server sv_lan enabled heeft. Als je de server eigenaar bent, kan je dit uitzetten in de server.cfg file.", + "deny_title": "Je bent niet gewhitelist om deze server te joinen.", + "request_id_label": "Request ID" + } + }, + "server_actions": { + "restarting": "Server wordt herstart (%{reason}).", + "restarting_discord": "**%{servername}** is aan het herstarten (%{reason}).", + "stopping": "Server wordt uitgezet (%{reason}).", + "stopping_discord": "**%{servername}** is aan het stoppen (%{reason}).", + "spawning_discord": "**%{servername}** is aan het opstarten." + }, + "nui_warning": { + "title": "WAARSCHUWING", + "warned_by": "Gewaarschuwd door:", + "stale_message": "Deze waarschuwing is afgegeven voordat je verbinding maakte met de server.", + "dismiss_key": "SPATIE", + "instruction": "Houd %{key} %{smart_count} seconde ingedrukt om dit bericht te verbergen. |||| Houd %{key} %{smart_count} seconden ingedrukt om dit bericht te verbergen." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menu ingeschakeld, type /tx om het te openen.\nJe kan ook een keybind registreren in [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Jouw identifiers zijn niet gelijk aan degenen die in txAdmin zijn geregistreerd.\nAls je wel geregistreerd staat bij txAdmin, ga naar Admin Manager en sla je identifiers op.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Je hebt hier geen permissie voor.", + "unknown_error": "Er is een onbekende error opgetreden.", + "not_enabled": "Het txAdmin Menu is uitgeschakeld! Je kan hem inschakelen bij de txAdmin instellingen pagina.", + "announcement_title": "Server Mededeling door %{author}:", + "dialog_empty_input": "Je moet iets invullen.", + "directmessage_title": "Privé Bericht van %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Je hebt de speler gefreezed!", + "unfroze_player": "Je hebt de speler geunfreezed!", + "was_frozen": "Je bent gefreezed door een staff lid!" + }, + "common": { + "cancel": "Annuleer", + "submit": "Opslaan", + "error": "Een error is opgetreden", + "copied": "Gekopieerd naar klembord." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Gebruik %{key} om van pagina te verwisselen & de pijltjes om in het menu te navigeren", + "tooltip_2": "Menu subopties kunnen worden geselecteerd door de linker en rechter pijltjes toetsen te gebruiken" + }, + "player_mode": { + "title": "Speler Modus", + "noclip": { + "title": "NoClip", + "label": "Vlieg rond", + "success": "NoClip ingeschakeld" + }, + "godmode": { + "title": "God", + "label": "Onzichtbaar", + "success": "God Mode ingeschakeld" + }, + "superjump": { + "title": "SuperJump", + "label": "Schakel de supersprongmodus in, de speler zal ook sneller rennen", + "success": "SuperJump ingeschakeld" + }, + "normal": { + "title": "Normaal", + "label": "Normale modus", + "success": "Teruggekeerd naar normale modus." + } + }, + "teleport": { + "title": "Teleporteren", + "generic_success": "Je bent geteleporteerd!", + "waypoint": { + "title": "Waypoint", + "label": "Ga naar de gezette waypoint", + "error": "Je hebt geen waypoint gezet." + }, + "coords": { + "title": "Coördinaten", + "label": "Ga naar de gespecificeerde coördinaten", + "dialog_title": "Teleporteer", + "dialog_desc": "Geef coördinaten in een x, y, z formaat om te teleporteren.", + "dialog_error": "Ongeldige coördinaten. Geef ze in het volgende format: 111, 222, 33" + }, + "back": { + "title": "Terug", + "label": "Ga terug naar je laatste locatie", + "error": "Je hebt geen locatie om naar terug te gaan!" + }, + "copy": { + "title": "Kopieer coördinaten", + "label": "Kopieer coördinaten naar het klembord." + } + }, + "vehicle": { + "title": "Voertuig", + "not_in_veh_error": "Je zit niet in een voertuig!", + "spawn": { + "title": "Spawn", + "label": "Spawn een voertuig door middel van de spawn naam", + "dialog_title": "Spawn voertuig", + "dialog_desc": "Vul de spawn naam in om het voertuig te spawnen.", + "dialog_success": "Voertuig gespawned!", + "dialog_error": "Het voertuig model '%{modelName}' bestaat niet!", + "dialog_info": "Proberen %{modelName} te spawnen." + }, + "fix": { + "title": "Repareer", + "label": "Repareer het huidige voertuig", + "success": "Voertuig gerepareerd!" + }, + "delete": { + "title": "Verwijder", + "label": "Verwijder het huidige voertuig", + "success": "Voertuig verwijderd!" + }, + "boost": { + "title": "Opvoeren", + "label": "Voer het voertuig op voor maximaal plezier (en misschien snelheid)", + "success": "Voertuig opgevoerd!", + "already_boosted": "Dit voertuig is al opgevoerd.", + "unsupported_class": "Deze voertuigklasse wordt niet ondersteund.", + "redm_not_mounted": "Je kan alleen boosten als je op een paard zit." + } + }, + "heal": { + "title": "Heal", + "myself": { + "title": "Jezelf", + "label": "Herstel je health", + "success_0": "Volledig genezen!", + "success_1": "Je zou je nu goed moeten voelen!", + "success_2": "Helemaal genezen!", + "success_3": "Pijntjes verholpen!" + }, + "everyone": { + "title": "Iedereen", + "label": "Heal & revive alle spelers", + "success": "Alle spelers zijn gehealed & gerevived." + } + }, + "announcement": { + "title": "Verstuur Mededeling", + "label": "Stuur een mededeling aan alle online spelers", + "dialog_desc": "Typ het bericht dat je naar alle spelers wilt versturen.", + "dialog_placeholder": "Jouw mededeling...", + "dialog_success": "Mededeling aan het verzenden." + }, + "clear_area": { + "title": "Herstel de wereld", + "label": "Herstel een specifiek deel van de wereld.", + "dialog_desc": "Kies een radius (0-300), om alle entities van te verwijderen. Dit verwijderd geen server sided gespawnede entities", + "dialog_success": "Gebied met de radius %{radius}m aan het herstellen", + "dialog_error": "Ongeldige invoer. Probeer het opnieuw." + }, + "player_ids": { + "title": "Schakel Speler IDs in/uit", + "label": "Schakel het tonen van Speler IDs (en andere info) boven de hoofden van alle spelers in jouw buurt in.", + "alert_show": "Toon de NetIDs van spelers in jouw buurt.", + "alert_hide": "Verberg NetIDs van spelers in jouw buurt." + } + }, + "page_players": { + "misc": { + "online_players": "Online Spelers", + "players": "Spelers", + "search": "Zoeken", + "zero_players": "Geen spelers gevonden" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Verwond / Dood", + "in_vehicle": "In Voertuig" + }, + "sort": { + "label": "Sorteren op", + "distance": "Afstand", + "id": "ID", + "joined_first": "Eerst gejoined", + "joined_last": "Laatst gejoined", + "closest": "Dichstbijzijnde", + "farthest": "Verste weg" + }, + "card": { + "health": "%{percentHealth}% health" + } + }, + "player_modal": { + "misc": { + "error": "Er is een error opgetreden bij het ophalen van de details van deze speler. De error kan je hieronder zien:", + "target_not_found": "Kon geen speler matchend met dit ID of deze naam %{target} vinden" + }, + "tabs": { + "actions": "Acties", + "info": "Info", + "ids": "IDs", + "history": "Geschiedenis", + "ban": "Ban" + }, + "actions": { + "title": "Speler Acties", + "command_sent": "Commando verzonden!", + "moderation": { + "title": "Moderatie", + "options": { + "dm": "DM", + "warn": "Waarschuw", + "kick": "Kick", + "set_admin": "Geef Perms" + }, + "dm_dialog": { + "title": "Privé Bericht", + "description": "Wat wil je als privébericht sturen?", + "placeholder": "Bericht...", + "success": "Jouw DM is verzonden!" + }, + "warn_dialog": { + "title": "Waarschuw", + "description": "Waarom wil je deze speler waarschuwen?", + "placeholder": "Reden...", + "success": "De speler is gewaarschuwd!" + }, + "kick_dialog": { + "title": "Kick", + "description": "Waarom wil je deze speler kicken?", + "placeholder": "Reden...", + "success": "De speler is gekicked!" + } + }, + "interaction": { + "title": "Interactie", + "options": { + "heal": "Heal", + "go_to": "Ga naar", + "bring": "Breng", + "spectate": "Spectate", + "toggle_freeze": "Freeze aan/uit" + }, + "notifications": { + "heal_player": "Speler aan het healen", + "tp_player": "Naar speler aan het teleporteren", + "bring_player": "Speler aan het brengen", + "spectate_failed": "Kon de speler niet vinden! Spectate aan het afsluiten.", + "spectate_yourself": "Je kan jezelf niet spectaten.", + "freeze_yourself": "Je kan jezelf niet freezen.", + "spectate_cycle_failed": "Er zijn geen spelers om naar door te cyclen." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Maak Dronken", + "fire": "Zet in Vuur", + "wild_attack": "Wilde Dieren aanval" + } + } + }, + "info": { + "title": "Speler info", + "session_time": "Sessie Tijd", + "play_time": "Speel Tijd", + "joined": "Gejoined", + "whitelisted_label": "Gewhitelist", + "whitelisted_notyet": "nog niet", + "btn_wl_add": "WL Toevoegen", + "btn_wl_remove": "WL Verwijderen", + "btn_wl_success": "Whitelist status gewijzigd.", + "log_label": "Log", + "log_empty": "Geen verbanningen/waarschuwingen gevonden.", + "log_ban_count": "%{smart_count} verbanning |||| %{smart_count} verbanningen", + "log_warn_count": "%{smart_count} waarschuwing |||| %{smart_count} waarschuwingen", + "log_btn": "DETAILS", + "notes_changed": "Speler Notities veranderd.", + "notes_placeholder": "Notities over deze speler..." + }, + "history": { + "title": "Gerelateerde geschiedenis", + "btn_revoke": "INTREKKEN", + "revoked_success": "Actie Ingetrokken!", + "banned_by": "Verbannen Door %{author}", + "warned_by": "Gewaarschuwd Door %{author}", + "revoked_by": "Ingetrokken Door %{author}.", + "expired_at": "Verlopen Op %{date}.", + "expires_at": "Verloopt Op %{date}." + }, + "ban": { + "title": "Ban speler", + "reason_placeholder": "Reden", + "duration_placeholder": "Lengte", + "hours": "uren", + "days": "dagen", + "weeks": "weken", + "months": "maanden", + "permanent": "Permanent", + "custom": "Custom", + "helper_text": "Selecteer een lengte", + "submit": "Ban Geven", + "reason_required": "Je moet een reden opgeven.", + "success": "Speler verbannen!" + }, + "ids": { + "current_ids": "Huidige Identifiers", + "previous_ids": "Eerder gebruikte Identifiers", + "all_hwids": "Alle Hardware IDs" + } + } + } +} diff --git a/locale/no.json b/locale/no.json new file mode 100644 index 0000000..44d0fa6 --- /dev/null +++ b/locale/no.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Norwegian (Bokml)", + "humanizer_language": "no" + }, + "restarter": { + "server_unhealthy_kick_reason": "serveren må startes på nytt, vennligst koble til på nytt", + "partial_hang_warn": "Due to a partial hang, this server will restart in 1 minute. Please disconnect now.", + "partial_hang_warn_discord": "Due to a partial hang, **%{servername}** will restart in 1 minute.", + "schedule_reason": "et omstart er planlagt kl. %{time}", + "schedule_warn": "En planlagt omstart av serveren vil skje om %{smart_count} minutt. Vennligst koble fra serveren nå. |||| Et planlagt omstart av serveren vil skje om %{smart_count} minutter.", + "schedule_warn_discord": "**%{servername}** har blitt planlagt om å starte på nytt om %{smart_count} minutt. |||| **%{servername}** skal omstartes om %{smart_count} minutter." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) You have been banned from this server for \"%{reason}\". Your ban will expire in: %{expiration}.", + "kick_permanent": "(%{author}) You have been permanently banned from this server for \"%{reason}\".", + "reject": { + "title_permanent": "You have been permanently banned from this server.", + "title_temporary": "You have been temporarily banned from this server.", + "label_expiration": "Your ban will expire in", + "label_date": "Ban Date", + "label_author": "Banned by", + "label_reason": "Ban Reason", + "label_id": "Ban ID", + "note_multiple_bans": "Note: you have more than one active ban on your identifiers.", + "note_diff_license": "Merk: utstengelsen du ser over ble gitt til en annen license, som betyr at noen av dine IDer/HWIDer er assosiert med den utestengingen." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "This server is in Admin-only mode.", + "insufficient_ids": "You do not have discord or fivem identifiers, and at least one of them is required to validate if you are a txAdmin administrator.", + "deny_message": "Your identifiers are not assigned to any txAdmin administrator." + }, + "guild_member": { + "mode_title": "This server is in Discord server Member Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "You are required to join our Discord server to connect.", + "deny_message": "Please join the guild %{guildname} then try again." + }, + "guild_roles": { + "mode_title": "This server is in Discord Role Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "You are required to join our Discord server to connect.", + "deny_notmember_message": "Please join %{guildname}, get one of the required roles, then try again.", + "deny_noroles_title": "You do not have a whitelisted role required to join.", + "deny_noroles_message": "To join this server you are required to have at least one of the whitelisted roles on the guild %{guildname}." + }, + "approved_license": { + "mode_title": "This server is in License Whitelist mode.", + "insufficient_ids": "You do not have the license identifier, which means the server has sv_lan enabled. If you are the server owner, you can disable it in the server.cfg file.", + "deny_title": "You are not whitelisted to join this server.", + "request_id_label": "Request ID" + } + }, + "server_actions": { + "restarting": "Serveren omstartes (%{reason}).", + "restarting_discord": "**%{servername}** blir omstartet (%{reason}).", + "stopping": "Serveren har blitt avslått (%{reason}).", + "stopping_discord": "**%{servername}** blir avslått (%{reason}).", + "spawning_discord": "**%{servername}** starter opp." + }, + "nui_warning": { + "title": "ADVARSEL", + "warned_by": "Advarsel fra:", + "stale_message": "Denne advarselen ble utstedt før du koblet deg til serveren.", + "dismiss_key": "MELLOMROM", + "instruction": "Vennligst hold %{key} i %{smart_count} sekund for å ta bort denne meldingen. |||| Vennligst hold %{key} i %{smart_count} sekunder for å ta bort denne meldingen." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menu enabled, type /tx to open it.\nYou can also configure a keybind at [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Your identifiers do not match any admin registered on txAdmin.\nIf you are registered on txAdmin, go to Admin Manager and make sure your identifiers are saved.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "You do not have this permission.", + "unknown_error": "An unknown error occurred.", + "not_enabled": "The txAdmin Menu is not enabled! You can enable it in the txAdmin settings page.", + "announcement_title": "Server Announcement by %{author}:", + "dialog_empty_input": "You cannot have an empty input.", + "directmessage_title": "DM from admin %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "You have frozen the player!", + "unfroze_player": "You have unfrozen the player!", + "was_frozen": "You have been frozen by a server admin!" + }, + "common": { + "cancel": "Cancel", + "submit": "Submit", + "error": "An error occurred", + "copied": "Copied to clipboard." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Use %{key} to switch pages & the arrow keys to navigate menu items", + "tooltip_2": "Certain menu items have sub options which can be selected using the left & right arrow keys" + }, + "player_mode": { + "title": "Player Mode", + "noclip": { + "title": "NoClip", + "label": "Fly around", + "success": "NoClip enabled" + }, + "godmode": { + "title": "God", + "label": "Invincible", + "success": "God Mode enabled" + }, + "superjump": { + "title": "Super Jump", + "label": "Toggle super jump mode, the player will also run faster", + "success": "Super Jump enabled" + }, + "normal": { + "title": "Normal", + "label": "Default mode", + "success": "Returned to default player mode." + } + }, + "teleport": { + "title": "Teleport", + "generic_success": "Sent you into the wormhole!", + "waypoint": { + "title": "Waypoint", + "label": "Go to waypoint set", + "error": "You have no waypoint set." + }, + "coords": { + "title": "Coords", + "label": "Go to specified coords", + "dialog_title": "Teleport", + "dialog_desc": "Provide coordinates in an x, y, z format to go through the wormhole.", + "dialog_error": "Invalid coordinates. Must be in the format of: 111, 222, 33" + }, + "back": { + "title": "Back", + "label": "Go back to last location", + "error": "You don't have a last location to go back to!" + }, + "copy": { + "title": "Copy Coords", + "label": "Copy coords to clipboard." + } + }, + "vehicle": { + "title": "Vehicle", + "not_in_veh_error": "You are not currently in a vehicle!", + "spawn": { + "title": "Spawn", + "label": "Spawn vehicle by model name", + "dialog_title": "Spawn vehicle", + "dialog_desc": "Enter in the model name of the vehicle you want to spawn.", + "dialog_success": "Vehicle spawned!", + "dialog_error": "The vehicle model name '%{modelName}' does not exist!", + "dialog_info": "Trying to spawn %{modelName}." + }, + "fix": { + "title": "Fix", + "label": "Fix the current vehicle", + "success": "Vehicle fixed!" + }, + "delete": { + "title": "Delete", + "label": "Delete the current vehicle", + "success": "Vehicle deleted!" + }, + "boost": { + "title": "Boost", + "label": "Boost the car to achieve max fun (and maybe speed)", + "success": "Vehicle boosted!", + "already_boosted": "This vehicle was already boosted.", + "unsupported_class": "This vehicle class is not supported.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Heal", + "myself": { + "title": "Myself", + "label": "Restores your health", + "success_0": "All healed up!", + "success_1": "You should be feeling good now!", + "success_2": "Restored to full!", + "success_3": "Ouchies fixed!" + }, + "everyone": { + "title": "Everyone", + "label": "Will heal & revive all players", + "success": "Healed and revived all players." + } + }, + "announcement": { + "title": "Send Announcement", + "label": "Send an announcement to all online players.", + "dialog_desc": "Send an announcement to all online players.", + "dialog_placeholder": "Your announcement...", + "dialog_success": "Sending the announcement." + }, + "clear_area": { + "title": "Reset World Area", + "label": "Reset a specified world area to its default state", + "dialog_desc": "Please enter the radius where you wish to reset entities in (0-300). This will not clear entities spawned server side.", + "dialog_success": "Clearing area with radius of %{radius}m", + "dialog_error": "Invalid radius input. Try again." + }, + "player_ids": { + "title": "Toggle Player IDs", + "label": "Toggle showing player IDs (and other info) above the head of all nearby players", + "alert_show": "Showing nearby player NetIDs.", + "alert_hide": "Hiding nearby player NetIDs." + } + }, + "page_players": { + "misc": { + "online_players": "Online Players", + "players": "Players", + "search": "Search", + "zero_players": "No players found." + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Sort by", + "distance": "Distance", + "id": "ID", + "joined_first": "Joined First", + "joined_last": "Joined Last", + "closest": "Closest", + "farthest": "Farthest" + }, + "card": { + "health": "%{percentHealth}% health" + } + }, + "player_modal": { + "misc": { + "error": "An error occurred fetching this users details. The error is shown below:", + "target_not_found": "Was unable to find an online player with ID or a username of %{target}" + }, + "tabs": { + "actions": "Actions", + "info": "Info", + "ids": "IDs", + "history": "History", + "ban": "Ban" + }, + "actions": { + "title": "Player Actions", + "command_sent": "Command sent!", + "moderation": { + "title": "Moderation", + "options": { + "dm": "DM", + "warn": "Warn", + "kick": "Kick", + "set_admin": "Give Admin" + }, + "dm_dialog": { + "title": "Direct Message", + "description": "What is the reason for direct messaging this player?", + "placeholder": "Reason...", + "success": "Your DM has been sent!" + }, + "warn_dialog": { + "title": "Warn", + "description": "What is the reason for direct warning this player?", + "placeholder": "Reason...", + "success": "Player warned!" + }, + "kick_dialog": { + "title": "Kick", + "description": "What is the reason for kicking this player?", + "placeholder": "Reason...", + "success": "Player kicked!" + } + }, + "interaction": { + "title": "Interaction", + "options": { + "heal": "Heal", + "go_to": "Go to", + "bring": "Bring", + "spectate": "Spectate", + "toggle_freeze": "Toggle Freeze" + }, + "notifications": { + "heal_player": "Healing player", + "tp_player": "Teleporting to player", + "bring_player": "Summoning player", + "spectate_failed": "Failed to resolve the target! Exiting spectate.", + "spectate_yourself": "You cannot spectate yourself.", + "freeze_yourself": "You cannot freeze yourself.", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Make Drunk", + "fire": "Set Fire", + "wild_attack": "Wild attack" + } + } + }, + "info": { + "title": "Player info", + "session_time": "Session Time", + "play_time": "Play time", + "joined": "Joined", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "not yet", + "btn_wl_add": "ADD WL", + "btn_wl_remove": "REMOVE WL", + "btn_wl_success": "Whitelist status changed.", + "log_label": "Log", + "log_empty": "No bans/warns found.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} warn |||| %{smart_count} warns", + "log_btn": "DETAILS", + "notes_changed": "Player note changed.", + "notes_placeholder": "Notes about this player..." + }, + "history": { + "title": "Related history", + "btn_revoke": "REVOKE", + "revoked_success": "Action revoked!", + "banned_by": "BANNED by %{author}", + "warned_by": "WARNED by %{author}", + "revoked_by": "Revoked by %{author}.", + "expired_at": "Expired at %{date}.", + "expires_at": "Expires at %{date}." + }, + "ban": { + "title": "Ban player", + "reason_placeholder": "Reason", + "duration_placeholder": "Duration", + "hours": "hours", + "days": "days", + "weeks": "weeks", + "months": "months", + "permanent": "Permanent", + "custom": "Custom", + "helper_text": "Please select a duration", + "submit": "Apply ban", + "reason_required": "The Reason field is required.", + "success": "Player banned!" + }, + "ids": { + "current_ids": "Current Identifiers", + "previous_ids": "Previously Used Identifiers", + "all_hwids": "All Hardware IDs" + } + } + } +} diff --git a/locale/pl.json b/locale/pl.json new file mode 100644 index 0000000..27fe803 --- /dev/null +++ b/locale/pl.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Polish", + "humanizer_language": "pl" + }, + "restarter": { + "server_unhealthy_kick_reason": "serwer musi zostać zrestartowany, proszę połącz się ponownie", + "partial_hang_warn": "Z powodu częściowego zawieszenia, serwer zostanie ponownie uruchomiony za 1 minutę. Prosimy o rozłączenie się.", + "partial_hang_warn_discord": "Z powodu częściowego zawieszenia, **%{servername}** zostanie uruchomiony ponownie za 1 minutę.", + "schedule_reason": "Planowany restart o %{time}", + "schedule_warn": "Ten serwer ma zaplanowany restart w ciągu %{smart_count} minuty. Proszę opuścić serwer. |||| Ten serwer ma zaplanowany restart w ciągu %{smart_count} minut.", + "schedule_warn_discord": "**%{servername}** ma zaplanowany restart w ciągu %{smart_count} minuty. |||| **%{servername}** ma zaplanowany restart w ciągu %{smart_count} minut." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Zostałeś zbanowany na tym serwerze z powodu \"%{reason}\". Twój ban wygaśnie za: %{expiration}.", + "kick_permanent": "(%{author}) Zostałeś permanentnie zbanowany na tym serwerze za \"%{reason}\".", + "reject": { + "title_permanent": "Zostałeś permanentnie zbanowany na tym serwerze.", + "title_temporary": "Zostałeś tymczasowo zbanowany na tym serwerze.", + "label_expiration": "Twój ban wygaśnie za", + "label_date": "Data zbanowania", + "label_author": "Zbanowany przez", + "label_reason": "Powód bana", + "label_id": "Identyfikator bana", + "note_multiple_bans": "Uwaga: masz więcej niż jedną aktywną blokadę na swoje identyfikatory.", + "note_diff_license": "Uwaga: powyższy ban został nadany na inną licencję, co oznacza że któryś z twoich identyfikatorów pokrywa się z tymi zbanowanymi." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Ten serwer jest w trybie Admin-only.", + "insufficient_ids": "Nie posiadasz identyfikatora discord lub fivem, przynajmniej jeden z nich jest wymagany jeśli posiadasz konto w txAdmin'ie.", + "deny_message": "Żaden z twoich identyfikatorów nie jest przypisany do konta txAdmin." + }, + "guild_member": { + "mode_title": "Ten serwer jest w trybie Discord server Member Whitelist.", + "insufficient_ids": "Nie posiadasz identyfikatora discord, który jest wymagany by zweryfikować czy jesteś na naszym serwerze Discord. Otwórz aplikację Discord i spróbuj ponownie (aplikacja internetowa nie będzie działać).", + "deny_title": "Musisz dołączyć na nasz serwer Discord aby połączyć się na serwer.", + "deny_message": "Dołącz na serwer Discord %{guildname} i spróbuj ponownie." + }, + "guild_roles": { + "mode_title": "Ten serwer jest w trybie Discord Role Whitelist.", + "insufficient_ids": "Nie posiadasz identyfikatora discord, który jest wymagany by zweryfikować czy jesteś na naszym serwerze Discord. Otwórz aplikację Discord i spróbuj ponownie (aplikacja internetowa nie będzie działać).", + "deny_notmember_title": "Musisz dołączyć na nasz serwer Discord aby połączyć się na serwer.", + "deny_notmember_message": "Dołącz na serwer Discord %{guildname}, zdobądź jedną z wymaganych ról, i spróbuj ponownie.", + "deny_noroles_title": "Nie posiadasz wymaganej roli aby się połączyć.", + "deny_noroles_message": "Aby połączyć się z tym serwerem, musisz posiadać jedną z wymaganych ról na serwerze Discord %{guildname}." + }, + "approved_license": { + "mode_title": "Ten serwer jest w trybie License Whitelist.", + "insufficient_ids": "Nie posiadasz identyfikatora license, co oznacza że ten serwer ma uruchomiony sv_lan. Jeśli jesteś właścicielem tego serwera, możesz go wyłączyć w pliku server.cfg.", + "deny_title": "Nie jesteś uprawniony aby dołączyć na ten serwer.", + "request_id_label": "Request ID" + } + }, + "server_actions": { + "restarting": "Trwa restart (%{reason}).", + "restarting_discord": "**%{servername}** jest w trakcie restartu (%{reason}).", + "stopping": "Serwer jest wyłączany (%{reason}).", + "stopping_discord": "**%{servername}** jest wyłączany (%{reason}).", + "spawning_discord": "**%{servername}** jest uruchamiany." + }, + "nui_warning": { + "title": "OSTRZEŻENIE", + "warned_by": "Ostrzeżony przez:", + "stale_message": "To ostrzeżenie zostało wydane przed połączeniem się z serwerem.", + "dismiss_key": "SPACJA", + "instruction": "Przytrzymaj %{key} przez %{smart_count} sekundę, aby odrzucić tę wiadomość. |||| Przytrzymaj %{key} przez %{smart_count} sekundy, aby odrzucić tę wiadomość." + }, + "nui_menu": { + "misc": { + "help_message": "Menu txAdmin jest włączone, wpisz /tx aby je otworzyć.\nMożesz również skonfigurować inny bind w [Ustawienia/Przypisane klawisze/FiveM/Menu: Open Main Page]", + "menu_not_admin": "Twoje identyfikatory nie są zgodne z żadnym administratorem zarejestrowanym w txAdmin, udaj się do menadżera administratorów i upewnij się, że Twoje identyfikatory są przypisane do utworzonego konta.", + "menu_auth_failed": "Uwierzytelnianie Menu txAdmin nie powiodło się przez: %{reason}", + "no_perms": "Nie posiadasz tego uprawnienia.", + "unknown_error": "Wystąpił nieznany błąd.", + "not_enabled": "Menu txAdmin nie jest uruchomione! Możesz je włączyć z poziomu ustawień txAdmin.", + "announcement_title": "Ogłoszenie nadane przez %{author}:", + "dialog_empty_input": "Nie możesz pozostawić pustego pola tekstowego.", + "directmessage_title": "Wiadomość od %{author}:", + "onesync_error": "Ta czynność wymaga włączenia OneSync." + }, + "frozen": { + "froze_player": "Zamroziłeś gracza!", + "unfroze_player": "Odmroziłeś gracza!", + "was_frozen": "Zostałeś zamrożony przez admina!" + }, + "common": { + "cancel": "Anuluj", + "submit": "Prześlij", + "error": "Wystąpił błąd", + "copied": "Skopiowano do schowka" + }, + "page_main": { + "tooltips": { + "tooltip_1": "Skorzystaj z %{key} aby przełączać się między stronami oraz strzałek aby przełączać się między opcjami w menu", + "tooltip_2": "Niektóre pozycje w menu posiadają opcje podrzędne, które można wybrać za pomocą strzałek w lewo i w prawo" + }, + "player_mode": { + "title": "Tryb gracza", + "noclip": { + "title": "NoClip", + "label": "Lataj tu i tam", + "success": "Znajdujesz się w trybie NoClip" + }, + "godmode": { + "title": "Bóg", + "label": "Nieśmiertelność", + "success": "Jesteś nieśmiertelny" + }, + "superjump": { + "title": "Super Jump", + "label": "Tryb wysokiego skoku, oraz szybszego poruszania się", + "success": "Włączony Super Jump" + }, + "normal": { + "title": "Normalny", + "label": "Tryb domyślny", + "success": "Przywrócono domyślny tryb gracza." + } + }, + "teleport": { + "title": "Teleport", + "generic_success": "Zostałeś wysłany do tunelu czasoprzestrzennego!", + "waypoint": { + "title": "Znacznik", + "label": "Teleportuj się do ustawionego punktu na mapie", + "error": "Nie masz ustawionego punktu na mapie." + }, + "coords": { + "title": "Koordynaty", + "label": "Teleportuj się do wybranych koordynatów", + "dialog_title": "Teleport", + "dialog_desc": "Podaj koordynaty w formacie x, y, z, aby przejść przez tunel czasoprzestrzenny.", + "dialog_error": "Niepoprawne koordynaty. Muszą mieć format: 111, 222, 33" + }, + "back": { + "title": "Powrót", + "label": "Wróć do swojej poprzedniej lokalizacji", + "error": "Nie masz ostatniej lokalizacji, do której mógłbyś wrócić!" + }, + "copy": { + "title": "Skopiuj koordynaty", + "label": "Skopiuj koordynaty do schowka" + } + }, + "vehicle": { + "title": "Pojazd", + "not_in_veh_error": "Nie jesteś w żadnym pojeździe!", + "spawn": { + "title": "Stwórz", + "label": "Stwórz pojazd po nazwie modelu", + "dialog_title": "Stwórz pojazd", + "dialog_desc": "Wprowadź nazwę modelu pojazdu, który chcesz stworzyć.", + "dialog_success": "Stworzono pojazd!", + "dialog_error": "Model pojazdu o nazwie '%{modelName}' nie istnieje!", + "dialog_info": "Próbuję zrespić %{modelName}." + }, + "fix": { + "title": "Napraw", + "label": "Napraw obecny pojazd", + "success": "Naprawiono pojazd!" + }, + "delete": { + "title": "Usuń", + "label": "Usuń obecny pojazd", + "success": "Usunięto pojazd!" + }, + "boost": { + "title": "Przyśpiesz", + "label": "Przyśpiesz pojazd, aby osiągnąć maksymalną frajdę oraz prędkość", + "success": "Pojazd został przyśpieszony!", + "already_boosted": "Ten pojazd był już przyśpieszany.", + "unsupported_class": "Ta klasa pojazdów nie jest obsługiwana.", + "redm_not_mounted": "Możesz użyć przyśpieszenia tylko wtedy, gdy jesteś na koniu." + } + }, + "heal": { + "title": "Ulecz", + "myself": { + "title": "Siebie", + "label": "Przywraca zdrowie", + "success_0": "Wszystko wyleczone!", + "success_1": "Powinieneś czuć się teraz dobrze!", + "success_2": "Przywrócono do full'a!", + "success_3": "Ała, już naprawione!" + }, + "everyone": { + "title": "Wszystkich", + "label": "Uleczy i ożywi wszystkich graczy", + "success": "Uzdrowiono i uleczono wszystkich graczy." + } + }, + "announcement": { + "title": "Wyślij ogłoszenie", + "label": "Wyślij ogłoszenie do wszystkich graczy na serwerze", + "dialog_desc": "Wyślij ogłoszenie do wszystkich obecnych graczy na serwerze.", + "dialog_placeholder": "Twoje ogłoszenie...", + "dialog_success": "Wysyłanie ogłoszenia." + }, + "clear_area": { + "title": "Zrestartuj obszar świata", + "label": "Zresetuj określony obszar świata do stanu domyślnego", + "dialog_desc": "Wprowadź promień, w którym chcesz zresetować jednostki w (0-300). Nie usunie to bytów stworzonych po stronie serwera.", + "dialog_success": "Oczyszczono obszar w promieniu %{radius}m", + "dialog_error": "Wprowadzono niepoprawny promień. Spróbuj ponownie." + }, + "player_ids": { + "title": "Przełącz identyfikatory graczy", + "label": "Przełącz pokazywanie identyfikatorów graczy (i innych informacji) nad głowami pobliskich graczy", + "alert_show": "Włączono pokazywanie informacji o pobliskich graczach.", + "alert_hide": "Schowano informacje o pobliskich graczach." + } + }, + "page_players": { + "misc": { + "online_players": "Gracze online", + "players": "Gracze", + "search": "Szukaj", + "zero_players": "Nie znaleziono żadnych graczy" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Sortuj po", + "distance": "Dystansie", + "id": "ID", + "joined_first": "Dołączyli dawno", + "joined_last": "Dołączyli ostatnio", + "closest": "Najbliżej", + "farthest": "Najdalej" + }, + "card": { + "health": "%{percentHealth}% zdrowia" + } + }, + "player_modal": { + "misc": { + "error": "Wystąpił błąd podczas pobierania szczegółów tego użytkownika. Błąd jest pokazany poniżej:", + "target_not_found": "Nie udało się znaleźć gracza online z identyfikatorem lub nazwą użytkownika %{target}" + }, + "tabs": { + "actions": "Akcje", + "info": "Info", + "ids": "Identyfikatory", + "history": "Historia", + "ban": "Bany" + }, + "actions": { + "title": "Akcje gracza", + "command_sent": "Wysłano polecenie!", + "moderation": { + "title": "Moderacja", + "options": { + "dm": "Wiadomość", + "warn": "Ostrzeżenie", + "kick": "Wyrzucenie", + "set_admin": "Nadaj Admina" + }, + "dm_dialog": { + "title": "Bezpośrednia wiadomość", + "description": "Jaki jest powód bezpośredniego wysyłania wiadomości do tego gracza?", + "placeholder": "Wiadomość...", + "success": "Twój wiadomość została wysłana!" + }, + "warn_dialog": { + "title": "Ostrzeżenie", + "description": "Jaki jest powód bezpośredniego ostrzeżenia tego gracza ?", + "placeholder": "Powód...", + "success": "Gracz został ostrzeżony!" + }, + "kick_dialog": { + "title": "Wyrzucenie", + "description": "Jaki jest powód wyrzucenia tego gracza ?", + "placeholder": "Powód...", + "success": "Gracz został wyrzucony!" + } + }, + "interaction": { + "title": "Interakcje", + "options": { + "heal": "Ulecz", + "go_to": "Teleportuj siebie do gracza", + "bring": "Teleportuj gracza do siebie", + "spectate": "Obserwuj", + "toggle_freeze": "Przełącz zamrożenie" + }, + "notifications": { + "heal_player": "Leczenie gracza", + "tp_player": "Teleportowanie do gracza", + "bring_player": "Teleportowanie gracza", + "spectate_failed": "Nie udało się rozwiązać celu! Wyjście z Obserwacji.", + "spectate_yourself": "Nie możesz obserwować samego siebie.", + "freeze_yourself": "Nie możesz zamrozić samego siebie.", + "spectate_cycle_failed": "Nie ma już więcej graczy do obserwowania." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Upij", + "fire": "Podpal", + "wild_attack": "Dziki atak" + } + } + }, + "info": { + "title": "Informacje o graczu", + "session_time": "Długość sesji", + "play_time": "Czas gry", + "joined": "Dołączył", + "whitelisted_label": "Whitelist", + "whitelisted_notyet": "Brak", + "btn_wl_add": "DAJ WL", + "btn_wl_remove": "USUŃ WL", + "btn_wl_success": "Status whitelisty został zmieniony.", + "log_label": "Historia", + "log_empty": "Nie znaleziono żadnych banów/ostrzeżeń.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bany", + "log_warn_count": "%{smart_count} ostrzeżenie |||| %{smart_count} ostrzeżenia", + "log_btn": "DETALE", + "notes_changed": "Zmieniono notatkę gracza.", + "notes_placeholder": "Notatki dotyczące tego gracza..." + }, + "history": { + "title": "Powiązana historia", + "btn_revoke": "UNIEWAŻNIJ", + "revoked_success": "Akcja unieważniona!", + "banned_by": "ZBANOWANY przez %{author}", + "warned_by": "OSTRZEŻONY przez %{author}", + "revoked_by": "Unieważnione przez %{author}.", + "expired_at": "Wygasł: %{date}.", + "expires_at": "Wygasa: %{date}." + }, + "ban": { + "title": "Zbanuj gracza", + "reason_placeholder": "Powód", + "duration_placeholder": "Długość", + "hours": "godziny", + "days": "dni", + "weeks": "tygodnie", + "months": "miesiące", + "permanent": "Permanentny", + "custom": "Własny", + "helper_text": "Wybierz czas trwania", + "submit": "Nałóż bana", + "reason_required": "Wymagany jest powód.", + "success": "Gracz został zbanowany!" + }, + "ids": { + "current_ids": "Bieżące Identyfikatory", + "previous_ids": "Wcześniej Używane Identyfikatory", + "all_hwids": "Wszystkie Identyfikatory HWID" + } + } + } +} diff --git a/locale/pt.json b/locale/pt.json new file mode 100644 index 0000000..6ba65c0 --- /dev/null +++ b/locale/pt.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Portuguese", + "humanizer_language": "pt" + }, + "restarter": { + "server_unhealthy_kick_reason": "o servidor precisa ser reiniciado, por favor reconecte", + "partial_hang_warn": "Devido a um congelamento parcial, este servidor será reiniciado em 1 minuto. Por favor, desconecte-se agora.", + "partial_hang_warn_discord": "Devido a um congelamento parcial, **%{servername}** será reiniciado em 1 minuto.", + "schedule_reason": "agendado para %{time}", + "schedule_warn": "Este servidor está agendado para reiniciar em %{smart_count} minuto. Por favor, desconecte-se agora! |||| Este servidor está agendado para reiniciar em %{smart_count} minutos.", + "schedule_warn_discord": "**%{servername}** está agendado para reiniciar em %{smart_count} minuto. |||| **%{servername}** está agendado para reiniciar em %{smart_count} minutos." + }, + "kick_messages": { + "everyone": "Todos os jogadores foram expulsos: %{reason}.", + "player": "Você foi expulso: %{reason}.", + "unknown_reason": "razão desconhecida" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Você foi banido deste servidor por \"%{reason}\". Seu ban vai expirar em: %{expiration}.", + "kick_permanent": "(%{author}) Você foi permanentemente banido deste servidor por \"%{reason}\".", + "reject": { + "title_permanent": "Você foi permanentemente banido deste servidor.", + "title_temporary": "Você foi temporariamente banido deste servidor.", + "label_expiration": "Seu ban vai expirar em", + "label_date": "Data do ban", + "label_author": "Autor", + "label_reason": "Motivo", + "label_id": "ID do ban", + "note_multiple_bans": "Nota: você tem mais de um ban ativo em seus ids.", + "note_diff_license": "Nota: o ban acima foi aplicado em outra license, o que significa que um de seus IDs/HWIDs condiz com algum deste ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Este servidor está em modo Manutenção.", + "insufficient_ids": "Você não tem um ID discord ou fivem, e é necessário ter ao menos um deles para validar se você é um administrador.", + "deny_message": "Seus IDs não estão associados a nenhum administrador." + }, + "guild_member": { + "mode_title": "Este servidor está em modo Whitelist por Discord.", + "insufficient_ids": "Você não tem um ID discord, que é necessário para validar se você entrou no nosso Discord. Por favor abra o Discord Desktop e tente novamente (a versão Web não vai funcionar).", + "deny_title": "Você precisa entrar em nosso Discord para poder se conectar.", + "deny_message": "Por favor entre no %{guildname} e tente novamente." + }, + "guild_roles": { + "mode_title": "Este servidor está em modo Whitelist por grupo de Discord.", + "insufficient_ids": "Você não tem um ID discord, que é necessário para validar se você entrou no nosso Discord. Por favor abra o Discord Desktop e tente novamente (a versão Web não vai funcionar).", + "deny_notmember_title": "Você precisa entrar em nosso Discord para poder se conectar.", + "deny_notmember_message": "Por favor entre no %{guildname}, adquira um dos grupos necessários e tente novamente.", + "deny_noroles_title": "Você não tem um dos grupos de Discord necessários para se conectar.", + "deny_noroles_message": "Para se conectar, você precisa ter pelo menos um dos grupos de whitelist no servidor do Discord %{guildname}." + }, + "approved_license": { + "mode_title": "Este servidor está em modo Whitelist por Licença.", + "insufficient_ids": "Você não tem o ID license, o que significa que o servidor habilitou sv_lan. Se você é o dono do servidor, você pode desabilitar este modo modificando o server.cfg.", + "deny_title": "Você não foi aprovado(a) para entrar neste servidor.", + "request_id_label": "ID de Requisição" + } + }, + "server_actions": { + "restarting": "Servidor reiniciando (%{reason}).", + "restarting_discord": "**%{servername}** está reiniciando (%{reason}).", + "stopping": "Servidor desligando (%{reason}).", + "stopping_discord": "**%{servername}** está desligando (%{reason}).", + "spawning_discord": "**%{servername}** está iniciando." + }, + "nui_warning": { + "title": "ADVERTÊNCIA", + "warned_by": "Advertido por:", + "stale_message": "Esta advertência foi emitida antes de você se conectar ao servidor.", + "dismiss_key": "ESPAÇO", + "instruction": "Aperte %{key} por %{smart_count} segundo para fechar esta advertência. |||| Aperte %{key} por %{smart_count} segundos para fechar esta advertência." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menu habilitado, digite /tx para abri-lo.\nVocê também pode configurar uma tecla atalho [Configurações do jogo > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Seus IDs não estão cadastrados como administrador no txAdmin.\nSe você tiver acesso ao txAdmin web, acesse a página Admin Manager e adicione seus IDs pro seu usuário.", + "menu_auth_failed": "A autenticação do menu falhou com motivo: %{reason}", + "no_perms": "Você não tem permissão para esta atividade.", + "unknown_error": "Ocorreu um erro inesperado.", + "not_enabled": "O menu do txAdmin não está habilitado! Você pode habilita-lo na página de configurações do txAdmin web.", + "announcement_title": "Aviso ao servidor por %{author}:", + "dialog_empty_input": "Você precisa digitar alguma coisa.", + "directmessage_title": "DM do admin %{author}:", + "onesync_error": "Esta ação requer que o OneSync esteja habilitado." + }, + "frozen": { + "froze_player": "Jogador congelado!", + "unfroze_player": "Jogador descongelado!", + "was_frozen": "Você foi congelado por um administrador!" + }, + "common": { + "cancel": "Cancelar", + "submit": "Enviar", + "error": "Ocorreu um erro inesperado.", + "copied": "Copiado para a área de transferência." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Aperte %{key} para mudar para a próxima aba, e as setinhas para navegar no menu.", + "tooltip_2": "Algumas opções do menu podem ser selecionadas com as setinhas da esquerda/direita." + }, + "player_mode": { + "title": "Modo do Jogador", + "noclip": { + "title": "NoClip", + "label": "Voe pelo mapa", + "success": "NoClip habilitado." + }, + "godmode": { + "title": "Deus", + "label": "Invencível", + "success": "Modo Deus habilitado." + }, + "superjump": { + "title": "Super Pulo", + "label": "Pular muito alto, e correr muito rápido", + "success": "Super Pulo habilitado." + }, + "normal": { + "title": "Normal", + "label": "Modo padrão", + "success": "Jogador retornou ao modo normal." + } + }, + "teleport": { + "title": "Teleportar", + "generic_success": "Entrando no buraco negro!", + "waypoint": { + "title": "Marcador", + "label": "Ir para posição no mapa", + "error": "Você não tem nenhuma posição marcada no mapa." + }, + "coords": { + "title": "Coords", + "label": "Ir para coordenadas", + "dialog_title": "Teleport", + "dialog_desc": "Insira coordenadas no formato x, y, z para ser teleportado.", + "dialog_error": "Coordenadas inválidas. Elas precisam estar no formato: 111, 222, 33" + }, + "back": { + "title": "Voltar", + "label": "Voltar para última posição", + "error": "Você não possui nenhuma posição anterior salva." + }, + "copy": { + "title": "Copiar Coords", + "label": "Copiar coordenadas para área de transferência" + } + }, + "vehicle": { + "title": "Veículo", + "not_in_veh_error": "Você não está em um veículo", + "spawn": { + "title": "Criar", + "label": "Criar um veículo pelo nome", + "dialog_title": "Criar veículo", + "dialog_desc": "Insira o nome do modelo do veículo que deseja criar.", + "dialog_success": "Veículo criado!", + "dialog_error": "Não existe um veículo com modelo '%{modelName}'!", + "dialog_info": "Criando %{modelName}." + }, + "fix": { + "title": "Consertar", + "label": "Consertar veículo atual", + "success": "Veículo consertado!" + }, + "delete": { + "title": "Deletar", + "label": "Deletar veículo atual", + "success": "Veículo deletado!" + }, + "boost": { + "title": "Nitro", + "label": "Aumenta as caracterísiticas do veículo para atingir máxima diversão (e talvez velocidade)", + "success": "Nitro adicionado!", + "already_boosted": "Esse carro já tem nitro.", + "unsupported_class": "Esta classe de veículo não é suportada.", + "redm_not_mounted": "Você só pode usar \"nitro\" se estiver montado em um cavalo." + } + }, + "heal": { + "title": "Curar", + "myself": { + "title": "Eu Mesmo", + "label": "Restaura a sua vida.", + "success_0": "Personagem curado!", + "success_1": "Você já deve estar se sentindo melhor!", + "success_2": "Vida restaurada!", + "success_3": "Dodói já passou!" + }, + "everyone": { + "title": "Todo Mundo", + "label": "Cura e revive todos os jogadores", + "success": "Todos os jogadores foram curados." + } + }, + "announcement": { + "title": "Enviar Aviso Global", + "label": "Envia uma mensagem para todos os jogadores online", + "dialog_desc": "Insira a mensagem a ser enviada para todos os jogadores.", + "dialog_placeholder": "Seu aviso...", + "dialog_success": "Enviando anúncio." + }, + "clear_area": { + "title": "Resetar Área", + "label": "Limpa as entidades do jogo próximas ao jogador.", + "dialog_desc": "Insira a distância a qual deseja limpar entidades (0-300). Esta ação não vai remover entidades criadas no servidor.", + "dialog_success": "Limpando entidades dentro de %{radius}m", + "dialog_error": "Distância inválida." + }, + "player_ids": { + "title": "Mostrar/Esconder IDs", + "label": "Ligar/desligar mostrar ID (e outros dados) acima da cabeça dos jogadores próximos", + "alert_show": "Mostrando os NetIDs próximos.", + "alert_hide": "Escondendo os NetIDs próximos." + } + }, + "page_players": { + "misc": { + "online_players": "Jogadores Online", + "players": "Jogadores", + "search": "Buscar", + "zero_players": "Nenhum jogador encontrado." + }, + "filter": { + "label": "Filtrar por", + "no_filter": "Sem Filtro", + "is_admin": "É Admin", + "is_injured": "Está Machucado / Morto", + "in_vehicle": "Está em Veículo" + }, + "sort": { + "label": "Ordenar por", + "distance": "Distância", + "id": "ID", + "joined_first": "Conectou primeiro", + "joined_last": "Conectou por último", + "closest": "Mais próximo", + "farthest": "Mais distante" + }, + "card": { + "health": "Vida em %{percentHealth}%" + } + }, + "player_modal": { + "misc": { + "error": "Erro ao buscar os dos dados do jogador:", + "target_not_found": "Não foi possível encontrar um jogador online com ID ou username %{target}" + }, + "tabs": { + "actions": "Ações", + "info": "Informações", + "ids": "IDs", + "history": "Histórico", + "ban": "Banir" + }, + "actions": { + "title": "Ações do Jogador", + "command_sent": "Comando enviado!", + "moderation": { + "title": "Moderação", + "options": { + "dm": "DM", + "warn": "Advertir", + "kick": "Kickar", + "set_admin": "Dar Admin" + }, + "dm_dialog": { + "title": "Mensagem Privada para", + "description": "O que deseja enviar ao jogador?", + "placeholder": "Alguma mensagem...", + "success": "Sua mensagem privada foi enviada!" + }, + "warn_dialog": { + "title": "Advertir", + "description": "Qual a razão para advertir este jogador?", + "placeholder": "Razão...", + "success": "O jogador foi advertido!" + }, + "kick_dialog": { + "title": "Kickar (expulsar)", + "description": "Qual a razão para expulsar este jogador?", + "placeholder": "Razão...", + "success": "O jogador foi expulso!" + } + }, + "interaction": { + "title": "Interação", + "options": { + "heal": "Curar", + "go_to": "Ir Até", + "bring": "Trazer", + "spectate": "Observar", + "toggle_freeze": "Congelar" + }, + "notifications": { + "heal_player": "Curando jogador", + "tp_player": "Teleportando ao jogador", + "bring_player": "Trazendo o jogador", + "spectate_failed": "Erro ao resolver o alvo! Cancelando modo observar.", + "spectate_yourself": "Você não pode observar você mesmo.", + "freeze_yourself": "Você não pode congelar você mesmo.", + "spectate_cycle_failed": "Não há mais players para observar." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Deixar Bêbado", + "fire": "Atear Fogo", + "wild_attack": "Ataque Selvagem" + } + } + }, + "info": { + "title": "Informações do Jogador", + "session_time": "Tempo de sessão", + "play_time": "Tempo no servidor", + "joined": "Primeira conexão", + "whitelisted_label": "Whitelist adicionado", + "whitelisted_notyet": "ainda não", + "btn_wl_add": "ADD WL", + "btn_wl_remove": "REMOVER WL", + "btn_wl_success": "Status do whitelist alterado.", + "log_label": "Log", + "log_empty": "Nenhum ban ou aviso encontrado.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} aviso |||| %{smart_count} avisos", + "log_btn": "DETALHES", + "notes_changed": "Notas do jogador alteradas.", + "notes_placeholder": "Notas sobre este jogador..." + }, + "history": { + "title": "Histórico Relacionado", + "btn_revoke": "REVOGAR", + "revoked_success": "Ação revogada!", + "banned_by": "BANIDO por %{author}", + "warned_by": "ADVERTIDO por %{author}", + "revoked_by": "Revogado por %{author}.", + "expired_at": "Expirado em %{date}.", + "expires_at": "Expira em %{date}." + }, + "ban": { + "title": "Banir Jogador", + "reason_placeholder": "Razão", + "duration_placeholder": "Duração", + "hours": "horas", + "days": "dias", + "weeks": "semanas", + "months": "meses", + "permanent": "Permanente", + "custom": "Customizado", + "helper_text": "Por favor selecione a duração", + "submit": "Aplicar ban", + "reason_required": "O campo motivo é obrigatório.", + "success": "Jogador banido!" + }, + "ids": { + "current_ids": "IDs atuais", + "previous_ids": "IDs usados anteriormente", + "all_hwids": "Todos IDs de Hardware" + } + } + } +} diff --git a/locale/ro.json b/locale/ro.json new file mode 100644 index 0000000..d1cf2e1 --- /dev/null +++ b/locale/ro.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Romanian", + "humanizer_language": "ro" + }, + "restarter": { + "server_unhealthy_kick_reason": "serverul trebuie repornit, te rog reconectează-te", + "partial_hang_warn": "Din cauza unei întreruperi parțiale, acest server va fi repornit în 1 minut. Vă rugăm să vă deconectați acum.", + "partial_hang_warn_discord": "Din cauza unei întreruperi parțiale, **%{servername}** se va restarta într-un minut.", + "schedule_reason": "restartare programata la %{time}", + "schedule_warn": "Acest server este programat sa se restarteze in %{smart_count} minut. Va rugam sa va deconctati. |||| Acest server este programat sa se restarteze in %{smart_count} minute.", + "schedule_warn_discord": "**%{servername}** este programat sa se restarteze in %{smart_count} minut. |||| **%{servername}** este programat sa se restarteze in %{smart_count} minute." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Ai fost interzis de pe acest server pentru \"%{reason}\". Interzicerea ta va expira în: %{expiration}.", + "kick_permanent": "(%{author}) Ai fost banat permanent de pe acest server pentru \"%{reason}\".", + "reject": { + "title_permanent": "You have been permanently banned from this server.", + "title_temporary": "You have been temporarily banned from this server.", + "label_expiration": "Your ban will expire in", + "label_date": "Ban Date", + "label_author": "Banned by", + "label_reason": "Ban Reason", + "label_id": "Ban ID", + "note_multiple_bans": "Note: you have more than one active ban on your identifiers.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "This server is in Admin-only mode.", + "insufficient_ids": "You do not have discord or fivem identifiers, and at least one of them is required to validate if you are a txAdmin administrator.", + "deny_message": "Your identifiers are not assigned to any txAdmin administrator." + }, + "guild_member": { + "mode_title": "This server is in Discord server Member Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "You are required to join our Discord server to connect.", + "deny_message": "Please join the guild %{guildname} then try again." + }, + "guild_roles": { + "mode_title": "This server is in Discord Role Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "You are required to join our Discord server to connect.", + "deny_notmember_message": "Please join %{guildname}, get one of the required roles, then try again.", + "deny_noroles_title": "You do not have a whitelisted role required to join.", + "deny_noroles_message": "To join this server you are required to have at least one of the whitelisted roles on the guild %{guildname}." + }, + "approved_license": { + "mode_title": "This server is in License Whitelist mode.", + "insufficient_ids": "You do not have the license identifier, which means the server has sv_lan enabled. If you are the server owner, you can disable it in the server.cfg file.", + "deny_title": "You are not whitelisted to join this server.", + "request_id_label": "Request ID" + } + }, + "server_actions": { + "restarting": "Serverul se restarteaza: (%{reason}).", + "restarting_discord": "**%{servername}** se restarteaza (%{reason}).", + "stopping": "Serverul a fost oprit: (%{reason}).", + "stopping_discord": "**%{servername}** a fost oprit: (%{reason}).", + "spawning_discord": "**%{servername}** a fost pornit." + }, + "nui_warning": { + "title": "AVERTIZARE", + "warned_by": "Avertizat de:", + "stale_message": "Acest avertisment a fost emis înainte de a vă conecta la server.", + "dismiss_key": "SPACE", + "instruction": "Țineți %{key} timp de %{smart_count} secundă pentru a respinge acest mesaj. |||| Țineți %{key} timp de %{smart_count} secunde pentru a respinge acest mesaj." + }, + "nui_menu": { + "misc": { + "help_message": "Meniul txAdmin este activat, Scrie /tx pentru a îl deschide.\nPoți reconfigura keybind-ul utilizând [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Identificatorii dvs. nu se potrivesc cu niciun administrator înregistrat pe txAdmin.\nDacă sunteți înregistrat pe txAdmin, accesați Admin Manager și asigurați-vă că identificatorii dvs. bine configurați.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Nu ai această permisie.", + "unknown_error": "A apărut o eroare necunoscută.", + "not_enabled": "Meniul txAdmin nu este activat! Puteți să o activați din pagina de setări txAdmin.", + "announcement_title": "Anunț de la %{author}:", + "dialog_empty_input": "Nu poți avea un input gol.", + "directmessage_title": "DM from admin %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Ai înghețat jucătorul!", + "unfroze_player": "Ai dezghețat jucătorul!", + "was_frozen": "Ai fost înghețat de un administrator!" + }, + "common": { + "cancel": "Anulează", + "submit": "Trimite", + "error": "S-a produs o eroare", + "copied": "Copiat în clipboard." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Folosește %{key} pentru a comuta paginile și săgețile pentru a naviga prin itemele meniului", + "tooltip_2": "Anumite elemente de meniu au subopțiuni care pot fi selectate cu ajutorul tastelor săgeată stânga și dreapta" + }, + "player_mode": { + "title": "Mod Jucător", + "noclip": { + "title": "NoClip", + "label": "Zboară", + "success": "NoClip activat" + }, + "godmode": { + "title": "God", + "label": "Invincibilitate", + "success": "God Mode activat" + }, + "superjump": { + "title": "Super Jump", + "label": "Toggle super jump mode, the player will also run faster", + "success": "Super Jump enabled" + }, + "normal": { + "title": "Normal", + "label": "Mod Normal", + "success": "Ai revenit la modul de jucător." + } + }, + "teleport": { + "title": "Teleport", + "generic_success": "Te-ai teleportat cu succes!", + "waypoint": { + "title": "Waypoint", + "label": "Mergi la waypoint-ul setat", + "error": "Nu ai setat un waypoint." + }, + "coords": { + "title": "Coordonate", + "label": "Mergi la coordonatele specifice", + "dialog_title": "Teleport", + "dialog_desc": "Introdu coordonatele în formatul x, y, z pentru a te teleporta.", + "dialog_error": "Coordonate invalide. Trebuie să fie în formatul: 111, 222, 333" + }, + "back": { + "title": "Înapoi", + "label": "Mergi înapoi la ultima ta locație", + "error": "Nu ai o ultimă locație la care să te teleportezi!" + }, + "copy": { + "title": "Copiază coordonatele", + "label": "Copiază coordonatele în clipboard." + } + }, + "vehicle": { + "title": "Vehicul", + "not_in_veh_error": "Nu te aflii într-un vehicul!", + "spawn": { + "title": "Spawn", + "label": "Spawnează un vehicul după numele modelului", + "dialog_title": "Spawnează vehiculul", + "dialog_desc": "Introduceți numele modelului de vehicul pe care doriți să îl spawnați.", + "dialog_success": "Vehicul spawnat!", + "dialog_error": "Vehiculul cu modelul '%{modelName} nu există!", + "dialog_info": "Încercarea de a spawna %{modelName}." + }, + "fix": { + "title": "Repară", + "label": "Repară vehicul curent", + "success": "Vehiculul a fost reparat!" + }, + "delete": { + "title": "Șterge", + "label": "Șterge vehiculul curent", + "success": "Vechiulul a fost șters!" + }, + "boost": { + "title": "Boost", + "label": "Boost the car to achieve max fun (and maybe speed)", + "success": "Vehicle boosted!", + "already_boosted": "This vehicle was already boosted.", + "unsupported_class": "This vehicle class is not supported.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Heal", + "myself": { + "title": "Ție însuti", + "label": "Îți restabilește viața", + "success_0": "Totul s-a vindecat!", + "success_1": "Ar trebui să te simți bine acum!", + "success_2": "Restabilit în totalitate!", + "success_3": "Loviturile au fost vindecate!" + }, + "everyone": { + "title": "Toată Lumea", + "label": "Va vindeca și da revive la toți jucătorii", + "success": "Ai vindecat și dat revive la toți jucătorii." + } + }, + "announcement": { + "title": "Trimite un anunț", + "label": "Trimiteți un anunț tuturor jucătorilor online.", + "dialog_desc": "Trimiteți un anunț tuturor jucătorilor online.", + "dialog_placeholder": "Anunțul dumneavoastră...", + "dialog_success": "Transmite anunțul." + }, + "clear_area": { + "title": "Resetarea zonei", + "label": "Resetarea unei zone specifice", + "dialog_desc": "Vă rugăm să introduceți raza în care doriți să resetați entitățile (0-300). Acest lucru nu va șterge entitățile generate de server-side.", + "dialog_success": "Se curăță zona în raza de %{radius}m", + "dialog_error": "Raza nu este validă. Încercați din nou." + }, + "player_ids": { + "title": "Comută ID-urile jucătorilor", + "label": "Comutarea afișării ID-urilor jucătorilor deasupra capului tuturor jucătorilor din apropiere", + "alert_show": "Afișarea NetID-urilor jucătorilor din apropiere.", + "alert_hide": "Ascunderea NetID-urilor jucătorilor din apropiere." + } + }, + "page_players": { + "misc": { + "online_players": "Jucători online", + "players": "Jucători", + "search": "Caută", + "zero_players": "Nu ai fost jucători găsiți" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Sortează", + "distance": "Distantă", + "id": "ID", + "joined_first": "De la intrare", + "joined_last": "De la ieșire", + "closest": "Apropiere", + "farthest": "Depărtare" + }, + "card": { + "health": "%{percentHealth}% viață" + } + }, + "player_modal": { + "misc": { + "error": "S-a produs o eroare la preluarea detaliilor acestui utilizator. Eroarea este afișată mai jos:", + "target_not_found": "Nu am reușit să găsesc un jucător online cu ID-ul sau un nume de utilizator de %{target}" + }, + "tabs": { + "actions": "Acțiuni", + "info": "Informații", + "ids": "ID-uri", + "history": "Istoric", + "ban": "Ban" + }, + "actions": { + "title": "Acțiuni jucător", + "command_sent": "Comandă trimisă!", + "moderation": { + "title": "Moderare", + "options": { + "dm": "Mesaj Privat", + "warn": "Avertizează", + "kick": "Kick", + "set_admin": "Oferă Admin" + }, + "dm_dialog": { + "title": "Mesaj Privat", + "description": "Care este motivul pentru a trimite mesaje directe acestui jucător?", + "placeholder": "Motiv...", + "success": "Mesajul tău privat a fost trimis!" + }, + "warn_dialog": { + "title": "Avertizează", + "description": "Care este motivul pentru care vrei să avertizezi jucătorul?", + "placeholder": "Motiv...", + "success": "Jucătorul a fost avertizat!" + }, + "kick_dialog": { + "title": "Kick", + "description": "Care este motivul pentru care îi dai kick acestui jucător?", + "placeholder": "Motiv...", + "success": "Jucătorul a fost dat afară!" + } + }, + "interaction": { + "title": "Interacțiuni", + "options": { + "heal": "Oferă viață", + "go_to": "Mergi la", + "bring": "Adu la", + "spectate": "Spectate", + "toggle_freeze": "Îngheață jucătorul" + }, + "notifications": { + "heal_player": "Vindecă jucătorul", + "tp_player": "Teleportarea către jucător", + "bring_player": "Teleportarea jucătorului", + "spectate_failed": "Nu a reușit comunicarea cu jucătorul! Ieșire din spectatori.", + "spectate_yourself": "Nu poți fi spectator tu însuți.", + "freeze_yourself": "Nu te poți îngheța.", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Îmbată", + "fire": "Arde", + "wild_attack": "Atacu cu animale" + } + } + }, + "info": { + "title": "Informații jucător", + "session_time": "Timp sesiune", + "play_time": "Timp jucat", + "joined": "Alăturat", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "not yet", + "btn_wl_add": "ADD WL", + "btn_wl_remove": "REMOVE WL", + "btn_wl_success": "Whitelist status changed.", + "log_label": "Log", + "log_empty": "No bans/warns found.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} warn |||| %{smart_count} warns", + "log_btn": "DETAILS", + "notes_changed": "Player note changed.", + "notes_placeholder": "Notes about this player..." + }, + "history": { + "title": "Istoric", + "btn_revoke": "REVOKE", + "revoked_success": "Action revoked!", + "banned_by": "BANNED by %{author}", + "warned_by": "WARNED by %{author}", + "revoked_by": "Revoked by %{author}.", + "expired_at": "Expired at %{date}.", + "expires_at": "Expires at %{date}." + }, + "ban": { + "title": "Banează jucător", + "reason_placeholder": "Motiv", + "duration_placeholder": "Durată", + "hours": "ore", + "days": "zile", + "weeks": "săptămâni", + "months": "luni", + "permanent": "Permanent", + "custom": "Personalizat", + "helper_text": "Vă rugăm să selectați o durată", + "submit": "Aplică ban-ul", + "reason_required": "The Reason field is required.", + "success": "Player banned!" + }, + "ids": { + "current_ids": "Current Identifiers", + "previous_ids": "Previously Used Identifiers", + "all_hwids": "All Hardware IDs" + } + } + } +} diff --git a/locale/ru.json b/locale/ru.json new file mode 100644 index 0000000..5d7e1c1 --- /dev/null +++ b/locale/ru.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Russian", + "humanizer_language": "ru" + }, + "restarter": { + "server_unhealthy_kick_reason": "сервер нужно перезапустить, пожалуйста, подключись снова", + "partial_hang_warn": "Из-за частичного зависания этот сервер перезагрузится через 1 минуту. Пожалуйста, отключитесь сейчас же.", + "partial_hang_warn_discord": "Из-за частичного зависания, **%{servername}** перезагрузится через 1 минуту.", + "schedule_reason": "перезагрузка %{time}", + "schedule_warn": "Сервер будет перезагружен через %{smart_count} минуту. Пожалуйста, отключитесь от сервера. |||| Сервер будет перезагружен через %{smart_count} минут.", + "schedule_warn_discord": "**%{servername}** будет перезагружен через %{smart_count} минуту. |||| **%{servername}** будет перезагружен через %{smart_count} минут." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Вы были заблокированы на этом сервере за \"%{reason}\". Срок вашей блокировки истекает в: %{expiration}.", + "kick_permanent": "(%{author}) Вы были навсегда заблокированы на этом сервере за \"%{reason}\".", + "reject": { + "title_permanent": "Вы были навсегда заблокированы на этом сервере.", + "title_temporary": "Вы были временно заблокированы на этом сервере.", + "label_expiration": "Срок вашего бана истечет через", + "label_date": "Дата бана", + "label_author": "Забанил", + "label_reason": "Причина бана", + "label_id": "Бан ID", + "note_multiple_bans": "Примечание: у вас более одного активного запрета на ваши идентификаторы.", + "note_diff_license": "Примечание: вышеуказанный запрет был применен для другой license, это означает, что некоторые из ваших идентификаторов/HWID совпадают с теми, которые связаны с этим запретом." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Этот сервер находится в режиме только для администратора.", + "insufficient_ids": "У вас нет идентификаторов discord или fivem, один из них необходим для подтверждения того, являетесь ли вы txAdmin администратором.", + "deny_message": "Ваши идентификаторы не назначены ни одному администратору txAdmin." + }, + "guild_member": { + "mode_title": "Этот сервер находится в режиме Discord Whitelist.", + "insufficient_ids": "У вас нет идентификатора discord, который необходим для подтверждения того, что вы присоединились к нашей гильдии Discord. Пожалуйста, откройте настольное приложение Discord и повторите попытку (веб-приложение не будет работать).", + "deny_title": "Вам необходимо вступить в наш гильдию Discord, чтобы подключиться.", + "deny_message": "Пожалуйста, вступите в гильдию %{guildname} а затем попробуйте еще раз." + }, + "guild_roles": { + "mode_title": "Этот сервер находится в режиме Discord роль Whitelist.", + "insufficient_ids": "У вас нет идентификатора discord, который необходим для подтверждения того, что вы присоединились к нашей гильдии Discord. Пожалуйста, откройте настольное приложение Discord и повторите попытку (веб-приложение не будет работать).", + "deny_notmember_title": "Для подключения вам необходимо вступить в нашу гильдию Discord.", + "deny_notmember_message": "Пожалуйста, присоединитесь к %{guildname}, получите одну из требуемых ролей, а затем попробуйте еще раз.", + "deny_noroles_title": "У вас нет роли, внесенной в белый список, необходимой для присоединения.", + "deny_noroles_message": "Чтобы присоединиться к этому серверу, у вас должна быть хотя бы одна из ролей, внесенных в белый список гильдии %{guildname}." + }, + "approved_license": { + "mode_title": "Этот сервер находится в режиме License Whitelist.", + "insufficient_ids": "У вас нет идентификатора лицензии, что означает, что на сервере включен sv_lan. Если вы являетесь владельцем сервера, вы можете отключить его в файле server.cfg.", + "deny_title": "Вы не внесены в белый список для подключения к этому серверу.", + "request_id_label": "Запрос ID" + } + }, + "server_actions": { + "restarting": "Сервер перезагружается (%{reason}).", + "restarting_discord": "**%{servername}** перезагружается (%{reason}).", + "stopping": "Сервер выключен (%{reason}).", + "stopping_discord": "**%{servername}** выключен (%{reason}).", + "spawning_discord": "**%{servername}** запущен." + }, + "nui_warning": { + "title": "ПРЕДУПРЕЖДЕНИЕ", + "warned_by": "Предупреждение выдал:", + "stale_message": "Это предупреждение было выдано до вашего подключения к серверу.", + "dismiss_key": "ПРОБЕЛ", + "instruction": "Удерживайте %{key} %{smart_count} секунду, чтобы закрыть это сообщение. |||| Удерживайте %{key} %{smart_count} секунд, чтобы закрыть это сообщение." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Меню включено, введите /tx чтобы открыть его.\nВы также можете настроить привязку клавиш используя [Настройки игры > Привязки клавиш > FiveM > Меню: Открыть Главную Страницу].", + "menu_not_admin": "Ваши идентификаторы не совпадают ни с одним администратором, зарегистрированным в txAdmin.\nЕсли вы зарегистрированы в txAdmin, перейдите в Admin Manager убедитесь, что ваши идентификаторы сохранены.", + "menu_auth_failed": "Ошибка проверки подлинности txAdmin Меню по причине: %{reason}", + "no_perms": "У вас нет такого разрешения.", + "unknown_error": "Произошла неизвестная ошибка.", + "not_enabled": "txAdmin Меню не включено! Вы можете включить его на странице настроек txAdmin.", + "announcement_title": "Объявление сервера от %{author}:", + "dialog_empty_input": "У вас не может быть пустого ввода.", + "directmessage_title": "DM от администратора %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Вы заморозили игрока!", + "unfroze_player": "Вы разморозили игрока!", + "was_frozen": "Вы были заморожены администратором сервера!" + }, + "common": { + "cancel": "Отменить", + "submit": "Применить", + "error": "Произошла ошибка", + "copied": "Скопировано в буфер обмена." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Воспользуйся %{key} для переключения страниц и клавиш со стрелками для навигации по пунктам меню", + "tooltip_2": "Некоторые пункты меню имеют дополнительные опции, которые можно выбрать с помощью клавиш со стрелками влево и вправо" + }, + "player_mode": { + "title": "Режим игрока", + "noclip": { + "title": "Noclip", + "label": "Летать вокруг", + "success": "Noclip включен" + }, + "godmode": { + "title": "Бог", + "label": "Непобедимый", + "success": "Режим Бога включен" + }, + "superjump": { + "title": "Super Jump", + "label": "Переключите режим Super Jump, игрок также будет бегать быстрее", + "success": "Super Jump включен" + }, + "normal": { + "title": "Обычный", + "label": "Режим по умолчанию", + "success": "Возвращен в режим обычного игрока." + } + }, + "teleport": { + "title": "Телепорт", + "generic_success": "Отправил тебя в червоточину!", + "waypoint": { + "title": "Точка маршрута", + "label": "Телепортироваться к точка маршрута", + "error": "У вас нет установленной точки маршрута." + }, + "coords": { + "title": "Координаты", + "label": "Перейти к указанным координатам", + "dialog_title": "Телепортироваться", + "dialog_desc": "Предоставьте координаты в X, Y, Z формат для прохождения через червоточину.", + "dialog_error": "Неверные координаты. Должно быть в формате: 111, 222, 33" + }, + "back": { + "title": "Назад", + "label": "Вернитесь к последнему местоположению", + "error": "У вас нет последнего места, куда можно вернуться!" + }, + "copy": { + "title": "Копировать Координаты", + "label": "Копирование координат в буфер обмена." + } + }, + "vehicle": { + "title": "Транспорт", + "not_in_veh_error": "В данный момент вы не находитесь в транспортном средстве!", + "spawn": { + "title": "Создание", + "label": "Создание транспорта по названию модели", + "dialog_title": "Транспорт для создания", + "dialog_desc": "Введите название модели транспорта, который вы хотите создать.", + "dialog_success": "Транспорт создан!", + "dialog_error": "Название модели транспорта '%{modelName}' не существует!", + "dialog_info": "Пытаемся создать %{modelName}." + }, + "fix": { + "title": "Починка", + "label": "Починить текущий транспорт", + "success": "Транспорт починен!" + }, + "delete": { + "title": "Удалить", + "label": "Удалить текущий транспорт", + "success": "Транспорт удален!" + }, + "boost": { + "title": "Boost", + "label": "Ускоряйте транспорт, чтобы получить максимальное удовольствие (и, возможно, скорость)", + "success": "Транспорт ускорен!", + "already_boosted": "Этот транспорт уже был ускорен.", + "unsupported_class": "Этот класс транспортных средств не поддерживается.", + "redm_not_mounted": "Вы можете использовать boost только, когда находитесь верхом на лошади." + } + }, + "heal": { + "title": "Лечить", + "myself": { + "title": "Себя", + "label": "Восстановить ваше здоровье", + "success_0": "Все зажило!", + "success_1": "Сейчас вы должны почувствовать себя хорошо!", + "success_2": "Восстановлен в полном объеме!", + "success_3": "Ой, починили!" + }, + "everyone": { + "title": "Всех", + "label": "Исцелит и оживит всех игроков", + "success": "Исцелил и оживил всех игроков." + } + }, + "announcement": { + "title": "Отправить Объявление", + "label": "Отправить объявление всем игрокам.", + "dialog_desc": "Введите сообщение, которое вы хотите транслировать всем игрокам.", + "dialog_placeholder": "Ваше объявление...", + "dialog_success": "Отправка объявления." + }, + "clear_area": { + "title": "Сбросить Мировую область", + "label": "Сбросить указанную область мира в состояние по умолчанию", + "dialog_desc": "Пожалуйста, введите радиус, в котором вы хотите сбросить объекты в (0-300). Это не приведет к очистке объектов, созданных на стороне сервера", + "dialog_success": "Площадь расчистки с радиусом %{radius}m", + "dialog_error": "Неверный ввод радиуса. Пробовать снова." + }, + "player_ids": { + "title": "Переключать идентификаторы игроков", + "label": "Переключите отображение ID игроков (и другой информации) над головами всех ближайших игроков", + "alert_show": "Отображаются сетевые идентификаторы ближайших игроков.", + "alert_hide": "Скрытие идентификаторов ближайших игроков в сети." + } + }, + "page_players": { + "misc": { + "online_players": "Онлайн Игроки", + "players": "Игроки", + "search": "Поиск", + "zero_players": "Игроки не найдены" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Сортировать по", + "distance": "Расстояние", + "id": "ID", + "joined_first": "Присоединился Первым", + "joined_last": "Присоединился Последним", + "closest": "Самый Ближний", + "farthest": "Самый Дальний" + }, + "card": { + "health": "%{percentHealth}% здоровье" + } + }, + "player_modal": { + "misc": { + "error": "Произошла ошибка при получении сведений об этом пользователе. Ошибка показана ниже:", + "target_not_found": "Не удалось найти онлайн игрока с идентификатором или именем пользователя %{target}" + }, + "tabs": { + "actions": "Действия", + "info": "Инфо", + "ids": "Идентификаторы", + "history": "История", + "ban": "Запрет" + }, + "actions": { + "title": "Действия игрока", + "command_sent": "Отправленная команда!", + "moderation": { + "title": "Модерирование", + "options": { + "dm": "Сообщение", + "warn": "Предупреждать", + "kick": "Отключить", + "set_admin": "Назначить Администратором" + }, + "dm_dialog": { + "title": "Прямое Сообщение", + "description": "В чем причина прямого сообщения игроку?", + "placeholder": "Причина...", + "success": "Ваше сообщение было отправлено!" + }, + "warn_dialog": { + "title": "Предупреждение", + "description": "В чем причина предупреждения этому игроку?", + "placeholder": "Причина...", + "success": "Игрок был предупрежден!" + }, + "kick_dialog": { + "title": "Отключить", + "description": "В чем причина того, чтобы отключить этого игрока?", + "placeholder": "Причина...", + "success": "Игрока был отключен!" + } + }, + "interaction": { + "title": "Взаимодействие", + "options": { + "heal": "Лечить", + "go_to": "Телепортироваться", + "bring": "Телепортировать", + "spectate": "Наблюдать", + "toggle_freeze": "Переключить Замораживание" + }, + "notifications": { + "heal_player": "Исцеляющая молитва", + "tp_player": "Телепортируемся к игроку", + "bring_player": "Телепортируем игрока", + "spectate_failed": "Не удалось найти цель! Выходим из наблюдения.", + "spectate_yourself": "Вы не можете наблюдать за самим собой.", + "freeze_yourself": "Вы не можете заморозить себя.", + "spectate_cycle_failed": "Нет игроков, к которым можно переключиться." + } + }, + "troll": { + "title": "Пакости", + "options": { + "drunk": "Напоить", + "fire": "Поджечь", + "wild_attack": "Дикая атака" + } + } + }, + "info": { + "title": "Информация об игроке", + "session_time": "Время сеанса", + "play_time": "Время игры", + "joined": "Присоединился", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "ещё нет", + "btn_wl_add": "Добавить WL", + "btn_wl_remove": "Удалить WL", + "btn_wl_success": "Изменен статус whitelist.", + "log_label": "Логи", + "log_empty": "Никаких банов/предупреждений не обнаружено.", + "log_ban_count": "%{smart_count} бан |||| %{smart_count} баны", + "log_warn_count": "%{smart_count} предупреждение |||| %{smart_count} предупреждения", + "log_btn": "ПОДРОБНОСТИ", + "notes_changed": "Примечание игрока изменено.", + "notes_placeholder": "Заметки об этом игроке..." + }, + "history": { + "title": "Связанная история", + "btn_revoke": "ОТМЕНА", + "revoked_success": "Действие отменено!", + "banned_by": "Забанил %{author}", + "warned_by": "Предупредил %{author}", + "revoked_by": "Отменил %{author}.", + "expired_at": "Истек %{date}.", + "expires_at": "Истекает %{date}." + }, + "ban": { + "title": "Забанить игрока", + "reason_placeholder": "Причина", + "duration_placeholder": "Продолжительность", + "hours": "часы", + "days": "дни", + "weeks": "недели", + "months": "месяцы", + "permanent": "Постоянный", + "custom": "Обычай", + "helper_text": "Пожалуйста, выберите продолжительность", + "submit": "Применить бан", + "reason_required": "Поле причина является обязательным.", + "success": "Игрок забанен!" + }, + "ids": { + "current_ids": "Текущие идентификаторы", + "previous_ids": "Ранее использовавшиеся идентификаторы", + "all_hwids": "Все идентификаторы оборудования" + } + } + } +} diff --git a/locale/sl.json b/locale/sl.json new file mode 100644 index 0000000..cefb8f5 --- /dev/null +++ b/locale/sl.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Slovenian", + "humanizer_language": "sl" + }, + "restarter": { + "server_unhealthy_kick_reason": "strežnik je treba znova zagnati, prosim, ponovno se poveži", + "partial_hang_warn": "Zaradi delnega crasha strežnika, se bo avtomatsko ponovno zagnal čez 1 minuto. Prosimo, zapustite strežnik.", + "partial_hang_warn_discord": "Zaradi delnega crasha strežnika, se bo **%{servername}** ponovno zagnal čez 1 minuto.", + "schedule_reason": "načrtovan restart ob %{time}", + "schedule_warn": "Ta strežnik se bo ponovno zagnal čez %{smart_count} minuto. Prosimo, zapustite strežnik. |||| Ta strežnik se bo ponovno zagnal čez %{smart_count} minut/i/e.", + "schedule_warn_discord": "**%{servername}** se bo ponovno zagnal čez %{smart_count} minuto. |||| **%{servername}** se bo ponovno zagnal čez %{smart_count} minut/i/e." + }, + "kick_messages": { + "everyone": "Vsi igralci so bili kick-ani: %{reason}.", + "player": "Bili ste kick-ani: %{reason}.", + "unknown_reason": "iz neznanega razloga" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Bil si odstranjen iz strežnika zaradi \"%{reason}\". Tvoj BAN se izteče čez: %{expiration}.", + "kick_permanent": "(%{author}) Bil si odstranjen iz strežnika za vedno \"%{reason}\".", + "reject": { + "title_permanent": "Trajno ste odstranjeni iz strežnika", + "title_temporary": "Iz strežnika ste začasno odstranjeni", + "label_expiration": "Vaš ban poteče čez", + "label_date": "Datum ban-a", + "label_author": "Ban dodeljen iz strani", + "label_reason": "Razlog ban-a", + "label_id": "Ban ID", + "note_multiple_bans": "Opomba: Imate več kot en ban na vaših identifierjih.", + "note_diff_license": "Opomba: zgoraj omenjen ban, je bil dodeljen drugi licenci, torej se tvoji in njihovi IDs/HWIDs, ki so povezani z banom, ujemajo." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Ta strežnik je v Admin-only načinu.", + "insufficient_ids": "Nimate discord ali fivem identifijerjev in vsaj en od teh je potreben, da preverimo, če ste txAdmin administrator.", + "deny_message": "Your identifiers are not assigned to any txAdmin administrator." + }, + "guild_member": { + "mode_title": "Ta strežnik je v Discord server Member Whitelist načinu.", + "insufficient_ids": "Nimate ustreznega discord identifierja, ki je potreben, da preverimo, če ste ste pridružili Discord Strežniku. Odprite Discord namizno aplikacijo in poskusite ponovno (spletna verzija Discorda ne deluje!).", + "deny_title": "Pridružiti se morate našemu Discordu, da se lahko povežete.", + "deny_message": "Pridružite se %{guildname} in poskusite ponovno." + }, + "guild_roles": { + "mode_title": "Ta strežnik je v Discord Role Whitelist načinu.", + "insufficient_ids": "Nimate ustreznega discord identifierja, ki je potreben, da preverimo, če ste ste pridružili Discord Strežniku. Odprite Discord namizno aplikacijo in poskusite ponovno (spletna verzija Discorda ne deluje!).", + "deny_notmember_title": "Pridružiti se morate našemu Discordu, da se lahko povežete.", + "deny_notmember_message": "Pridružite se %{guildname}, pridobite eno od zahtevanih rol, in poskusite ponovno.", + "deny_noroles_title": "Nimate role potrebne za dostop na strežnik.", + "deny_noroles_message": "Da ste pridružite temu strežniku, potrebujete vsaj eno od whitelistanih rol na discord strežniku %{guildname}." + }, + "approved_license": { + "mode_title": "Ta strežnik je v License Whitelist načinu.", + "insufficient_ids": "Nimate ustreznega license identifierja, kar pomeni, da ima server vklopljen sv_lan. Če ste skrbnik strežnika, lahko to izklopite v server.cfg datoteki.", + "deny_title": "Nimate whiteliste za dostop do tega strežnika.", + "request_id_label": "Request ID" + } + }, + "server_actions": { + "restarting": "Ponovni zagon strežnika (%{reason}).", + "restarting_discord": "**%{servername}** se ponovno zaganja (%{reason}).", + "stopping": "Strežnik je bil zaustavljen (%{reason}).", + "stopping_discord": "**%{servername}** se zaustavlja (%{reason}).", + "spawning_discord": "**%{servername}** se zaganja." + }, + "nui_warning": { + "title": "OPOZORILO", + "warned_by": "Opozorjen s strani:", + "stale_message": "To opozorilo je bilo izdano, preden ste se povezali s strežnikom.", + "dismiss_key": "SPACE", + "instruction": "Drži %{key} za %{smart_count} sekundo, da to sporočilo izgine. |||| Drži %{key} za %{smart_count} sekund, da to sporočilo izgine." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Meni omogočen, napiši /tx da ga odpreš.\nTipko za txAdmin Meni lahko tudi spremeniš [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Tvoji podatki se ne ujemajo z nobenim obstoječim txAdmin profilom.\nČe si registriran na txAdminu, pojdi v upravitelja administratorjev in se prepričaj, da so tvoji podatki pravilni shranjeni.", + "menu_auth_failed": "txAdmin Menu prijava neuspešna z razlogom: %{reason}", + "no_perms": "Nimaš tega dovoljenja.", + "unknown_error": "Prišlo je do neznane težave.", + "not_enabled": "txAdmin Meni ni omogočen! Lahko ga omogočiš v txAdmin nastavitvah.", + "announcement_title": "Obvestilo od %{author}:", + "dialog_empty_input": "Ne moreš imeti praznega vnosa.", + "directmessage_title": "DM od admina %{author}:", + "onesync_error": "To dejanje potrebuje vklopljen OneSync." + }, + "frozen": { + "froze_player": "Zamrznil si igralca!", + "unfroze_player": "Odmrznil si igralca!", + "was_frozen": "Bil si zamrznjen s strani administratorja!" + }, + "common": { + "cancel": "Prekliči", + "submit": "Potrdi", + "error": "Pojavila se je napaka", + "copied": "Kopirano v odložišče." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Uporabi %{key} da izbiraš med stranmi in puščične tipke za krmarjenje po elementih menija", + "tooltip_2": "Nekateri elementi menija imajo podmožnosti, ki jih je mogoče izbrati z levo in desno puščično tipko" + }, + "player_mode": { + "title": "Tvoje možnosti", + "noclip": { + "title": "NoClip", + "label": "Uporabi NoClip, ko se želiš premikati skozi stene in druge predmete", + "success": "NoClip omogočen" + }, + "godmode": { + "title": "God", + "label": "Uporabi God Mode, da postaneš nesmrten", + "success": "God Mode omogočen" + }, + "superjump": { + "title": "Super Jump", + "label": "Toggle super jump mode, the player will also run faster", + "success": "Super Jump enabled" + }, + "normal": { + "title": "Normal", + "label": "Vrne se nazaj v privzeti/običajni način igralca", + "success": "Vrnil si se na privzeti način." + } + }, + "teleport": { + "title": "Teleportacija", + "generic_success": "Uspešna teleportacija!", + "waypoint": { + "title": "Točka", + "label": "Teleportiraj se do točke po meri", + "error": "Nimaš določene točke teleportacije." + }, + "coords": { + "title": "Koordinati", + "label": "Teleportiraj se do danih koordinatov", + "dialog_title": "Teleportacija", + "dialog_desc": "Zapiši koordinate v x, y, z formatu, da se teleportiraš do tiste lokacije.", + "dialog_error": "Neveljavni koordinati. Mora biti v formatu kot je: 111, 222, 33" + }, + "back": { + "title": "Nazaj", + "label": "Vrneš se na lokacijo pred zadnjo teleportacijo", + "error": "Nimaš zadnje lokacije, kamor bi se lahko vrnil!" + }, + "copy": { + "title": "Kopiraj koordinate", + "label": "Kopiraj trenutne koordinate v odložišče" + } + }, + "vehicle": { + "title": "Vozila", + "not_in_veh_error": "Trenutno nisi v vozilu!", + "spawn": { + "title": "Spawn", + "label": "Spawnaj vozilo z imenom modela", + "dialog_title": "Spawnaj vozilo", + "dialog_desc": "Vstavi ime modela vozila, ki bi ga rad spawnal.", + "dialog_success": "Vozilo spawnano!", + "dialog_error": "Ime modela vozila '%{modelName}' ne obstaja!", + "dialog_info": "Spawnanje vozila %{modelName}." + }, + "fix": { + "title": "Popravi", + "label": "Popravi vozilo", + "success": "Vozilo popravljeno!" + }, + "delete": { + "title": "Izbriši", + "label": "Izbriše vozilo, v katerem se trenutno nahajaš", + "success": "Vozilo izbrisano!" + }, + "boost": { + "title": "Pospeši", + "label": "Pospešite vozilo, da dosežete max zabavo (in mogoče hitrost)", + "success": "Vozilo pospeseno!", + "already_boosted": "To vozilo je že pospešeno.", + "unsupported_class": "Ta vrsta vozila ni podprta!", + "redm_not_mounted": "Pospešite lahko, samo ko ste na konju." + } + }, + "heal": { + "title": "Ozdravi", + "myself": { + "title": "Sebe", + "label": "Te bo ozdravilo in dodalo max hrano/pijačo", + "success_0": "Ozdravljen!", + "success_1": "Zdaj bi se moral počutiti dobro!", + "success_2": "Izgledaš kot prerojen!", + "success_3": "Zdaj si zdrav kot riba!" + }, + "everyone": { + "title": "Vse", + "label": "Ozdravi in oživi vse igralce", + "success": "Vsi igralci so bili ozdravljeni in oživljeni." + } + }, + "announcement": { + "title": "Pošlji obvestilo", + "label": "Pošlji obvestilo vsem igralcem na strežniku.", + "dialog_desc": "Napiši sporočilo, za katerega želiš, da ga vidijo vsi igralci na strežniku.", + "dialog_placeholder": "Tvoje obvestilo...", + "dialog_success": "Pošiljam obvestilo." + }, + "clear_area": { + "title": "Resetiraj območje mape", + "label": "Ponastavi določeno območje mape na privzeto stanje", + "dialog_desc": "Vnesi polmer v katerem želiš ponastaviti območje (0-300). To NE BO izbrisalo stvari spawnanih s strani strežnika.", + "dialog_success": "Resetirano območje v polmeru %{radius}m", + "dialog_error": "Neveljavni polmer. Poskusi ponovno." + }, + "player_ids": { + "title": "Prižgi IDje igralcev", + "label": "Prižgani IDji igralcev (in ostale informacije) nad glavo vseh bližnjih igralcev", + "alert_show": "IDji bližnjih igralcev vklopljeni.", + "alert_hide": "IDji bližnjih igralcev izklopljeni." + } + }, + "page_players": { + "misc": { + "online_players": "Trenutni igralci", + "players": "Igralci", + "search": "Išči", + "zero_players": "Najden ni bil noben igralec" + }, + "filter": { + "label": "Filtrirajte po", + "no_filter": "Ni filtra", + "is_admin": "Je admin", + "is_injured": "Je poškodovan / Mrtev", + "in_vehicle": "V vozilu" + }, + "sort": { + "label": "Razvrsti po", + "distance": "Dolžina", + "id": "ID", + "joined_first": "Najmanjši ID prvo", + "joined_last": "Največji ID prvo", + "closest": "Najbližji igralci", + "farthest": "Igralci najdlje stran" + }, + "card": { + "health": "%{percentHealth}% zdravje" + } + }, + "player_modal": { + "misc": { + "error": "Pri pridobivanju podatkov o tem uporabniku je prišlo do napake. Napaka je opisana spodaj:", + "target_not_found": "Ni bilo najdenega igralca z danim IDjem ali steam imenom: %{target}" + }, + "tabs": { + "actions": "Dejanja", + "info": "Informacije", + "ids": "IDji", + "history": "Zgodovina", + "ban": "Ban" + }, + "actions": { + "title": "Dejanja igralca", + "command_sent": "Komanda poslana!", + "moderation": { + "title": "Moderiranje", + "options": { + "dm": "DM", + "warn": "Opozorilo", + "kick": "Kick", + "set_admin": "Daj admina" + }, + "dm_dialog": { + "title": "Neposredno sporočilo", + "description": "Kaj je razlog za neposredno sporočanje temu igralcu?", + "placeholder": "Razlog...", + "success": "Tvoj DM je bil poslan!" + }, + "warn_dialog": { + "title": "Opozorilo", + "description": "Kaj je razlog za neposredno opozorilo tega igralca?", + "placeholder": "Razlog...", + "success": "Igralec je bil opozorjen!" + }, + "kick_dialog": { + "title": "Kick", + "description": "Kaj je razlog za neposreden kick tega igralca?", + "placeholder": "Razlog...", + "success": "Igralec je bil Kick-an!" + } + }, + "interaction": { + "title": "Interakcija", + "options": { + "heal": "Pozdravi", + "go_to": "TP nanj", + "bring": "TP sem", + "spectate": "Spectate", + "toggle_freeze": "Zamrzni" + }, + "notifications": { + "heal_player": "Ozdravljam igralca", + "tp_player": "Teleportiram do igralca", + "bring_player": "TPjam igralca", + "spectate_failed": "Igralca več ni bilo mogoče najti! Izstopam iz Spectate načina.", + "spectate_yourself": "Ne moreš spectate-at sam sebe.", + "freeze_yourself": "Sam sebe ne moreš zamrzniti.", + "spectate_cycle_failed": "Ni igralca, na katerega bi lahko preklopili." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Pijan", + "fire": "Ogenj", + "wild_attack": "Divji napad" + } + } + }, + "info": { + "title": "Informacije o igralcu", + "session_time": "Čas seje", + "play_time": "Čas igranja", + "joined": "Vstopil", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "ne še", + "btn_wl_add": "DODAJ WL", + "btn_wl_remove": "ODSTRANI WL", + "btn_wl_success": "Status WL spremenjen.", + "log_label": "Evidenca", + "log_empty": "Ni najdenih banov/opozoril", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} banov", + "log_warn_count": "%{smart_count} opozorilo |||| %{smart_count} opozoril", + "log_btn": "PODROBNOSTI", + "notes_changed": "Opombe spremenjene.", + "notes_placeholder": "Opombe o igralcu..." + }, + "history": { + "title": "Povezana zgodovina", + "btn_revoke": "RAZVELJAVI", + "revoked_success": "Dejanje preklicano!", + "banned_by": "BANNAL %{author}", + "warned_by": "OPOZORJEN od %{author}", + "revoked_by": "Preklical %{author}.", + "expired_at": "Poteče %{date}.", + "expires_at": "Poteče %{date}." + }, + "ban": { + "title": "Odstrani igralca", + "reason_placeholder": "Razlog", + "duration_placeholder": "Čas bana", + "hours": "ur", + "days": "dni", + "weeks": "tednov", + "months": "mesecev", + "permanent": "Za vedno", + "custom": "Po meri", + "helper_text": "Prosim, izberite trajanje", + "submit": "Potrdi odstranitev", + "reason_required": "To polje je obvezno.", + "success": "Igralec banan!" + }, + "ids": { + "current_ids": "Trenutni identifierji", + "previous_ids": "Prej uporabljeni identifierji", + "all_hwids": "Strojni IDs" + } + } + } +} diff --git a/locale/sv.json b/locale/sv.json new file mode 100644 index 0000000..0bca9c5 --- /dev/null +++ b/locale/sv.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Swedish", + "humanizer_language": "sv" + }, + "restarter": { + "server_unhealthy_kick_reason": "servern måste startas om, vänligen anslut igen", + "partial_hang_warn": "På grund av fördröjningar startar servern om. Vänligen koppla från nu.", + "partial_hang_warn_discord": "På grund av fördröjningar startar **%{servername}** om inom en minut.", + "schedule_reason": "Schemalagd omstart om %{time}", + "schedule_warn": "Den här servern kommer startas om inom %{smart_count} minuter. Vänligen lämna. |||| Den här servern kommer startas om inom %{smart_count} minuter.", + "schedule_warn_discord": "**%{servername}** är schemalagd att startas om inom %{smart_count} minuter. |||| **%{servername}** är schemalagd att startas om inom %{smart_count} minuter." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Du har blivit bannlyst från denna server på grund av \"%{reason}\". Din bannlysning går ut : %{expiration}.", + "kick_permanent": "(%{author}) Du har blivit permanent bannlyst från denna server på grund av \"%{reason}\".", + "reject": { + "title_permanent": "Du har blivit permanent bannlyst från servern.", + "title_temporary": "Du har blivit temorärt bannlyst.", + "label_expiration": "Din bannlysning går ut inom", + "label_date": "bannlysning datum", + "label_author": "Bannlyst av", + "label_reason": "Bannlysnings anledning", + "label_id": "Bannlysnings ID", + "note_multiple_bans": "OBS Du kan vara bannlyst på flera identifikationer.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Denna server är i Endast Admin läge.", + "insufficient_ids": "Du har inte en discord eller fivem identifikation, och minst en av dem är nödvändiga för att validera om du är en txAdmin administratör.", + "deny_message": "Dina identifikationer är inte tilldelade till något txAdmin administratörs konto." + }, + "guild_member": { + "mode_title": "Denna server har Discord Server Medlems Whitelist.", + "insufficient_ids": "Du har inte en discord identifikation, som är nödvändigt för att validera om du har gått med i våran Discord Server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "Du måste gå med i våran Discord Server för att kunna ansluta till servern.", + "deny_message": "Var vänlig gå med i %{guildname} och sedan försök igen." + }, + "guild_roles": { + "mode_title": "Denna servern har Discord Rolls Whitelist.", + "insufficient_ids": "Du har inte en discord identifikation, vilket är nödvändigt för att validera om du gått med i våran Discord Server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "Du måste gå med i våran Discord Server för att kunna ansluta till servern.", + "deny_notmember_message": "Var vänlig gå med i %{guildname}, och skaffa rollen som är nödvändig för att ansluta, sedan försök igen.", + "deny_noroles_title": "Du har inte rollen som krävs för att kun", + "deny_noroles_message": "För att kunna ansluta till denna servern måste du ha minst en av de roller som krävs i Discord servern %{guildname}." + }, + "approved_license": { + "mode_title": "Denna servern har Licens Whitelist läge.", + "insufficient_ids": "Du har inte en licens identifikation, vilket betyder att servern har sv_lan påslaget. Om du är server ägaren kan du stänga av detta i server.cfg filen.", + "deny_title": "Du är inte whitelistad i denna server.", + "request_id_label": "Begärelse ID" + } + }, + "server_actions": { + "restarting": "Servern omstart (%{reason}).", + "restarting_discord": "**%{servername}** startas om (%{reason}).", + "stopping": "Servern stängs av (%{reason}).", + "stopping_discord": "**%{servername}** håller på att stängas av (%{reason}).", + "spawning_discord": "**%{servername}** håller på att startas." + }, + "nui_warning": { + "title": "VARNING", + "warned_by": "Varnad av:", + "stale_message": "Den här varningen utfärdades innan du anslöt till servern.", + "dismiss_key": "SPACE", + "instruction": "Håll in %{key} i %{smart_count} sekund för att dölja detta meddelande. |||| Håll in %{key} i %{smart_count} sekunder för att dölja detta meddelande." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menyn är aktiverad, skriv /tx i chatten för att öppna den.\nDu kan ändra knapp på [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Dina id stämmer inte överns med dom som är registerade i txAdmin regisrtert.\nOm du är registerad på Txadmin,gå till administratör hanteraren och dubbelkolla att id:en är korrekta.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Du har inte behörighet för detta.", + "unknown_error": "Ett okänt problem har uppstått", + "not_enabled": "Txadmin menyn är inte aktiverad! Du kan aktivera den på Txadmin panelen.", + "announcement_title": "Server meddelande skickat av %{author}:", + "dialog_empty_input": "Du kan inte ha ett tomt fält.", + "directmessage_title": "DM from admin %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Du har frusit spelaren", + "unfroze_player": "Du har avfrusit spelaren", + "was_frozen": "Du har blivit frusen av en Server administratör" + }, + "common": { + "cancel": "Avbryt", + "submit": "Överlämmna", + "error": "Ett fel har uppstått", + "copied": "Kopierat till urklipp." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Använd %{key} för att ändra sida & piltangeterna för att navigera mellan menyernas alternativ", + "tooltip_2": "Visa menyer har under kationriger vilket kan användas med hjälp av piltangernterna" + }, + "player_mode": { + "title": "Spelar läge", + "noclip": { + "title": "Noclip", + "label": "Flyga runt", + "success": "Ingen Noclip" + }, + "godmode": { + "title": "Gud", + "label": "Oförstörbar", + "success": "Gudläge aktiverat" + }, + "superjump": { + "title": "Super Jump", + "label": "Toggle super jump mode, the player will also run faster", + "success": "Super Jump enabled" + }, + "normal": { + "title": "Vanlig", + "label": "Vanlig spelare", + "success": "Återvände till vanlig spelare." + } + }, + "teleport": { + "title": "Teleportera", + "generic_success": "Teleportering lyckad!", + "waypoint": { + "title": "Position", + "label": "Gå till positionen satt", + "error": "Du har ingen position utmärkt." + }, + "coords": { + "title": "Kordinater", + "label": "Gå till specifika kordinater", + "dialog_title": "Teleportera", + "dialog_desc": "Skriv kordinaterna i x, y, z format för att kunna teleportera.", + "dialog_error": "Ogiltiga kordinater. Det måste vara i formatet: 111, 222, 33" + }, + "back": { + "title": "Gå tillbaka", + "label": "Gå tillbaka till senaste positionen", + "error": "Du har ingen tidigare position" + }, + "copy": { + "title": "Kopiera kordinaterna", + "label": "Kopiera kordinaterna till urklipp." + } + }, + "vehicle": { + "title": "Fordon", + "not_in_veh_error": "Du är för tillfället inte i fordonet!", + "spawn": { + "title": "Ta fram", + "label": "Ta fram fordon med moddel namn", + "dialog_title": "Ta fram fordon", + "dialog_desc": "Skriv in moddel namnet på fordonet du vill ta fram.", + "dialog_success": "Fordon framtaget!", + "dialog_error": "Fordonet '%{modelName}' finns inte!", + "dialog_info": "Försöker ta fram %{modelName}." + }, + "fix": { + "title": "Laga", + "label": "Laga nuvarande fordon", + "success": "Fordon lagat!" + }, + "delete": { + "title": "Ta bort", + "label": "Ta bort nuvarande fordon", + "success": "Fordon bortaget!" + }, + "boost": { + "title": "Trimma", + "label": "Trimma bilen och nå maximalt nöje (och kanske hastighet)", + "success": "Vehicle boosted!", + "already_boosted": "This vehicle was already boosted.", + "unsupported_class": "This vehicle class is not supported.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Läka", + "myself": { + "title": "Dig själv", + "label": "Återställer din hälsa", + "success_0": "Alla är läkta!", + "success_1": "Du bör må bra nu!", + "success_2": "Återställd till max!", + "success_3": "Smärtan fixad!" + }, + "everyone": { + "title": "Alla", + "label": "Detta kommer läka och återuppliva alla spelare", + "success": "Helad och återupplivade alla spelare." + } + }, + "announcement": { + "title": "Skicka ut medelande", + "label": "Skicka ett meddelande till alla spelare inne på servern", + "dialog_desc": "Skriv in meddelandet du vill skicka till alla spelare.", + "dialog_placeholder": "Ditt Medelande...", + "dialog_success": "Ditt medelande är utsickat." + }, + "clear_area": { + "title": "Återställ världs ytan", + "label": "Återställer ett angivet världsområde till dess standardläge", + "dialog_desc": "Vänligen skriv in radien du vill rensa objekt i (0-300). Detta kommer inte rensa saker servern har spawnat", + "dialog_success": "Rensar area med %{radius}m", + "dialog_error": "Ogiltig radie, försök igen." + }, + "player_ids": { + "title": "Sätt på spelares id", + "label": "När du sätter på detta kommer du kunna se alla spelares id i närheten.", + "alert_show": "Visa spelares id i närheten.", + "alert_hide": "Dölj spelares id i närheten." + } + }, + "page_players": { + "misc": { + "online_players": "Spelare inne", + "players": "Spelare", + "search": "Sök", + "zero_players": "Inga spelare funna" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Sortera efter", + "distance": "Avstånd", + "id": "ID", + "joined_first": "Gick med först", + "joined_last": "Gick med senast", + "closest": "Närmast", + "farthest": "Längst" + }, + "card": { + "health": "%{percentHealth}% Hälsa" + } + }, + "player_modal": { + "misc": { + "error": "Kunde inte ta emot spelarens information. Felkod syns nedan:", + "target_not_found": "Kunde inte hitta en spelare med den identifikationen %{target}" + }, + "tabs": { + "actions": "Alternativ", + "info": "Information", + "ids": "identifikationer", + "history": "Historik", + "ban": "Bannlys" + }, + "actions": { + "title": "Spelare alternativ", + "command_sent": "Kommando skickat", + "moderation": { + "title": "Moderation", + "options": { + "dm": "Direktmeddelande", + "warn": "Varna", + "kick": "Sparka", + "set_admin": "Ge administratör" + }, + "dm_dialog": { + "title": "Direktmeddelande", + "description": "Vad vill du skriva i ditt direktmeddelande till spelaren", + "placeholder": "Anledning", + "success": "Ditt direktmeddelande har skickats" + }, + "warn_dialog": { + "title": "Varna", + "description": "Vad är anledningen till att du varnar spelaren?", + "placeholder": "Anlednig", + "success": "Spelaren är varnad!" + }, + "kick_dialog": { + "title": "Sparka", + "description": "Varför vill du sparka denna spelare", + "placeholder": "Anledning", + "success": "Spelaren är sparkad!" + } + }, + "interaction": { + "title": "Interkationer", + "options": { + "heal": "Läk", + "go_to": "Gå till", + "bring": "Ta spelare till mig", + "spectate": "Åskåda", + "toggle_freeze": "Växla frysläge" + }, + "notifications": { + "heal_player": "Spelare är läkt", + "tp_player": "Spelare teleporterad till dig", + "bring_player": "Tog spelare till dig", + "spectate_failed": "Misslyckades att hitta spelaren, avslutar åskådarläget.", + "spectate_yourself": "Du kan inte åskåda dig själv.", + "freeze_yourself": "Du kan inte frysa dig själv.", + "spectate_cycle_failed": "Där finns inga spelare att gå mellan." + } + }, + "troll": { + "title": "Trolla", + "options": { + "drunk": "Gör full", + "fire": "Tänd Eld", + "wild_attack": "Vild Djursattack" + } + } + }, + "info": { + "title": "Spelar information", + "session_time": "Session tid", + "play_time": "Speltid", + "joined": "Anslöt", + "whitelisted_label": "Vitlistad", + "whitelisted_notyet": "Inte ännu vitlistad", + "btn_wl_add": "Lägg till vitlistning", + "btn_wl_remove": "Ta bort vitlistning", + "btn_wl_success": "Vitlistnings status ändrad.", + "log_label": "Historik", + "log_empty": "Inga bannlysningar eller varnningar funna.", + "log_ban_count": "%{smart_count} Bannlysning |||| %{smart_count} Bannlysningar", + "log_warn_count": "%{smart_count} Varning |||| %{smart_count} Varnningar", + "log_btn": "Detaljer", + "notes_changed": "Spelar notering ändrad.", + "notes_placeholder": "Noteringar om spelare..." + }, + "history": { + "title": "Historik", + "btn_revoke": "Dra tillbaka", + "revoked_success": "Handling tillbaka dragen", + "banned_by": "Bannlyst av %{author}", + "warned_by": "Varnad av %{author}", + "revoked_by": "Tillbaka dragen av %{author}.", + "expired_at": "Gick ut den %{date}.", + "expires_at": "Går ut den %{date}." + }, + "ban": { + "title": "Bannlys spelare", + "reason_placeholder": "Anledning", + "duration_placeholder": "Varaktighet", + "hours": "Timmar", + "days": "Dagar", + "weeks": "Veckor", + "months": "Månader", + "permanent": "Permanent", + "custom": "Anpassad", + "helper_text": "Vänligen välj en varaktighet", + "submit": "Tillämpa bannlysning", + "reason_required": "Du måste fylla i anledning.", + "success": "Spelare är bannlyst" + }, + "ids": { + "current_ids": "Nuvarande identifikationer", + "previous_ids": "Tidigare använda identifikationer", + "all_hwids": "Alla hårdvaru identifikationer" + } + } + } +} diff --git a/locale/th.json b/locale/th.json new file mode 100644 index 0000000..6fdb953 --- /dev/null +++ b/locale/th.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Thai", + "humanizer_language": "th" + }, + "restarter": { + "server_unhealthy_kick_reason": "เซิร์ฟเวอร์จำเป็นต้องรีสตาร์ท, กรุณาเชื่อมต่ออีกครั้ง", + "partial_hang_warn": "เนื่องจากพบอาการแฮงค์ในบางส่วน เซิร์ฟเวอร์นี้จะรีสตาร์ทใน 1 นาที กรุณายกเลิกการเชื่อมต่อในขณะนี้", + "partial_hang_warn_discord": "เนื่องจากพบอาการแฮงค์ในบางส่วน, **%{servername}** จะรีสตาร์ทใน 1 นาที", + "schedule_reason": "รีสตาร์ทตามกำหนดเวลา %{time}", + "schedule_warn": "เซิร์ฟเวอร์ถูกกำหนดเวลาให้รีสตาร์ทใน %{smart_count} นาที โปรดยกเลิกการเชื่อมต่อทันที |||| เซิร์ฟเวอร์ถูกกำหนดเวลาให้รีสตาร์ทใน %{smart_count} นาที.", + "schedule_warn_discord": "**%{servername}** มีกำหนดการจะรีสตาร์ทใน %{smart_count} นาที |||| **%{servername}** มีกำหนดการจะรีสตาร์ทใน %{smart_count} นาที" + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) คุณถูกแบนจากเซิร์ฟเวอร์นี้เพราะ \"%{reason}\" การแบนของคุณจะหมดอายุใน: %{expiration}.", + "kick_permanent": "(%{author}) คุณถูกแบนจากเซิร์ฟเวอร์นี้อย่างถาวรเพราะ \"%{reason}\"", + "reject": { + "title_permanent": "You have been permanently banned from this server.", + "title_temporary": "You have been temporarily banned from this server.", + "label_expiration": "Your ban will expire in", + "label_date": "Ban Date", + "label_author": "Banned by", + "label_reason": "Ban Reason", + "label_id": "Ban ID", + "note_multiple_bans": "Note: you have more than one active ban on your identifiers.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "This server is in Admin-only mode.", + "insufficient_ids": "You do not have discord or fivem identifiers, and at least one of them is required to validate if you are a txAdmin administrator.", + "deny_message": "Your identifiers are not assigned to any txAdmin administrator." + }, + "guild_member": { + "mode_title": "This server is in Discord server Member Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "You are required to join our Discord server to connect.", + "deny_message": "Please join the guild %{guildname} then try again." + }, + "guild_roles": { + "mode_title": "This server is in Discord Role Whitelist mode.", + "insufficient_ids": "You do not have the discord identifier, which is required to validate if you have joined our Discord server. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "You are required to join our Discord server to connect.", + "deny_notmember_message": "Please join %{guildname}, get one of the required roles, then try again.", + "deny_noroles_title": "You do not have a whitelisted role required to join.", + "deny_noroles_message": "To join this server you are required to have at least one of the whitelisted roles on the guild %{guildname}." + }, + "approved_license": { + "mode_title": "This server is in License Whitelist mode.", + "insufficient_ids": "You do not have the license identifier, which means the server has sv_lan enabled. If you are the server owner, you can disable it in the server.cfg file.", + "deny_title": "You are not whitelisted to join this server.", + "request_id_label": "Request ID" + } + }, + "server_actions": { + "restarting": "เซิร์ฟเวอร์รีสตาร์ท (%{reason})", + "restarting_discord": "**%{servername}** กำลังรีสตาร์ท (%{reason})", + "stopping": "เซิร์ฟเวอร์หยุดทำงาน (%{reason})", + "stopping_discord": "**%{servername}** กำลังหยุดทำงาน (%{reason})", + "spawning_discord": "**%{servername}** กำลังเริ่มต้นระบบ" + }, + "nui_warning": { + "title": "WARNING", + "warned_by": "Warned by:", + "stale_message": "คำเตือนนี้ถูกออกก่อนที่คุณจะเชื่อมต่อกับเซิร์ฟเวอร์", + "dismiss_key": "SPACE", + "instruction": "กด %{key} ค้างไว้เป็นเวลา %{smart_count} วินาทีเพื่อปิดข้อความนี้ |||| กด %{key} ค้างไว้เป็นเวลา %{smart_count} วินาทีเพื่อปิดข้อความนี้" + }, + "nui_menu": { + "misc": { + "help_message": "เปิดใช้งานเมนู txAdmin แล้ว พิมพ์ /tx เพื่อเปิดมันขึ้น\nคุณยังสามารถกำหนดค่าปุ่ม keybind ได้ที่ [Game Settings > Key Bindings > FiveM > Menu: Open Main Page]", + "menu_not_admin": "ตัวตนของคุณไม่ตรงกับที่ผู้ดูแลระบบลงทะเบียนใน txAdmin\nหากคุณลงทะเบียนใน txAdmin แล้ว ให้ไปที่ Admin Manager และตรวจสอบให้แน่ใจว่าได้บันทึกตัวระบุของคุณแล้ว", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "คุณไม่ได้รับสิทธิ์นี้", + "unknown_error": "เกิดข้อผิดพลาดที่ไม่รู้จัก", + "not_enabled": "ไม่ได้เปิดใช้งานเมนู txAdmin คุณสามารถเปิดใช้งานได้ในหน้า การตั้งค่าของ txAdmin", + "announcement_title": "ประกาศจากเซิร์ฟเวอร์โดย %{author}:", + "dialog_empty_input": "คุณไม่สามารถป้อนข้อมูลว่างได้", + "directmessage_title": "DM from admin %{author}:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "คุณได้ถูกระงับผู้เล่นแล้ว", + "unfroze_player": "คุณได้ยกเลิกการถูกระงับผู้เล่นแล้ว", + "was_frozen": "คุณถูกระงับโดยผู้ดูแลเซิร์ฟเวอร์" + }, + "common": { + "cancel": "ยกเลิก", + "submit": "ยอมรับ", + "error": "เกิดข้อผิดพลาด", + "copied": "คัดลอกไปยังคลิปบอร์ดแล้ว" + }, + "page_main": { + "tooltips": { + "tooltip_1": "ใช้ปุ่ม %{key} เพื่อสลับหน้าและเป็นปุ่มลูกศรเพื่อนำทางรายการเมนู", + "tooltip_2": "รายการเมนูบางรายการมีตัวเลือกย่อยซึ่งสามารถเลือกได้โดยใช้ปุ่มลูกศรซ้ายและขวา" + }, + "player_mode": { + "title": "โหมดผู้เล่น", + "noclip": { + "title": "โหมดอิสระ", + "label": "บินไปรอบๆได้", + "success": "เปิดใช้งานโหมดอิสระ" + }, + "godmode": { + "title": "โหมดพระเจ้า", + "label": "อยู่ยงคงกระพัน", + "success": "เปิดใช้โหมดพระเจ้า" + }, + "superjump": { + "title": "Super Jump", + "label": "Toggle super jump mode, the player will also run faster", + "success": "Super Jump enabled" + }, + "normal": { + "title": "โหมดธรรมดา", + "label": "โหมดเริ่มต้น", + "success": "กลับไปสู่โหมดเริ่มต้นเป็นผู้เล่น" + } + }, + "teleport": { + "title": "เทเลพอร์ต", + "generic_success": "ส่งคุณเข้าไปในรูหนอนแล้วแล้ว", + "waypoint": { + "title": "จุดมาร์ค", + "label": "ไปยังจุดที่ทำเครื่องหมายไว้", + "error": "คุณยังไม่ได้ทำจุดมาร์คไว้" + }, + "coords": { + "title": "พิกัด", + "label": "ไปยังพิกัดจะระบุ", + "dialog_title": "เทเลพอร์ต", + "dialog_desc": "ระบุพิกัดในรูปแบบ x, y, z เพื่อผ่านโดยใช้รูหนอน", + "dialog_error": "พิกัดไม่ถูกต้อง ต้องอยู่ในรูปแบบ: 111, 222, 33" + }, + "back": { + "title": "กลับ", + "label": "กลับไปยังตำแหน่งล่าสุด", + "error": "คุณไม่มีสถานที่ล่าสุดที่จะกลับไป" + }, + "copy": { + "title": "คัดลอกพิกัด", + "label": "คัดลอกพิกัดไปยังคลิปบอร์ด" + } + }, + "vehicle": { + "title": "ยานพาหนะ", + "not_in_veh_error": "คุณไม่ได้อยู่ในยานพาหนะในขณะนี้", + "spawn": { + "title": "Spawn", + "label": "สร้างยานพาหนะตามชื่อโมเดล", + "dialog_title": "สร้างยานพาหนะ", + "dialog_desc": "ป้อนชื่อโมเดลของยานพาหนะที่คุณต้องการสร้างขึ้นมา", + "dialog_success": "สร้างยานพาหนะขึ้นมาแล้ว", + "dialog_error": "โมเดลยานพาหนะชื่อ '%{modelName}' ไม่ได้มีอยู่", + "dialog_info": "พยายามที่จะสร้าง %{modelName}" + }, + "fix": { + "title": "ซ่อม", + "label": "ซ่อมยานพาหนะปัจจุบัน", + "success": "ซ่อมยานพาหนะแล้ว" + }, + "delete": { + "title": "ลบ", + "label": "ลบยานพาหนะปัจจุบัน", + "success": "ลบยานพาหนะแล้ว" + }, + "boost": { + "title": "Boost", + "label": "Boost the car to achieve max fun (and maybe speed)", + "success": "Vehicle boosted!", + "already_boosted": "This vehicle was already boosted.", + "unsupported_class": "This vehicle class is not supported.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "รักษา", + "myself": { + "title": "ตัวเอง", + "label": "ฟื้นฟูสุขภาพของคุณ", + "success_0": "หายดีแล้ว", + "success_1": "คุณน่าจะรู้สึกดีขึ้นแล้วในขณะนี้", + "success_2": "ฟื้นคืนชีพเต็มพิกัด", + "success_3": "รักษาอาการเจ็บแล้ว" + }, + "everyone": { + "title": "ทุกคน", + "label": "รักษาและชุบชีวิตผู้เล่นทุกคน", + "success": "รักษาและชุบชีวิตผู้เล่นทุกคนแล้ว" + } + }, + "announcement": { + "title": "ส่งประกาศ", + "label": "ส่งประกาศถึงผู้เล่นออนไลน์ทุกคนแล้ว", + "dialog_desc": "ส่งประกาศถึงผู้เล่นออนไลน์ทุกคนแล้ว", + "dialog_placeholder": "ข้อความประกาศของคุณ...", + "dialog_success": "ส่งข้อความประกาศแล้ว" + }, + "clear_area": { + "title": "รีเซ็ตพื้นที่โลก", + "label": "รีเซ็ตพื้นที่โลกให้กลับไปเป็นสถานะเริ่มต้น", + "dialog_desc": "โปรดป้อนรัศมีที่คุณต้องการรีเซ็ตเอนทิตีในช่วง (0-300) สิ่งนี้จะไม่ล้างเอนทิตีที่เกิดจากฝั่งเซิร์ฟเวอร์ได้", + "dialog_success": "ลง้าพื้นที่ในรัศมีระยะ %{radius}เมตร แล้ว", + "dialog_error": "ป้อนข้อมูลรัศมีไม่ถูกต้อง ลองอีกครั้ง" + }, + "player_ids": { + "title": "สลับไอดีผู้เล่น", + "label": "สลับการแสดงไอดีผู้เล่น (และข้อมูลอื่นๆ) บริเวณส่วนเหนือหัวของผู้เล่นในระยะใกล้เคียงทั้งหมด", + "alert_show": "กำลังแสดงไอดีของผู้เล่นใกล้เคียง", + "alert_hide": "กำลังซ่อนไอดีของผู้เล่นใกล้เคียง" + } + }, + "page_players": { + "misc": { + "online_players": "ผู้เล่นที่ออนไลน์", + "players": "ผู้เล่น", + "search": "ค้นหา", + "zero_players": "ไม่พบผู้เล่น" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "กรอกผู้เล่นจาก", + "distance": "ระยะทาง", + "id": "ไอดี", + "joined_first": "เข้าร่วมก่อน", + "joined_last": "เข้าร่วมล่าสุด", + "closest": "ใกล้ที่สุด", + "farthest": "ไกลที่สุด" + }, + "card": { + "health": "สุขภาพ %{percentHealth}%" + } + }, + "player_modal": { + "misc": { + "error": "เกิดข้อผิดพลาดขณะดึงรายละเอียดผู้ใช้รายนี้ ข้อผิดพลาดแสดงอยู่ด้านล่าง:", + "target_not_found": "ไม่สามารถหาผู้เล่นที่ออนไลน์จากไอดีหรือชื่อผู้ใช้จาก %{target}" + }, + "tabs": { + "actions": "การกระทำ", + "info": "ข้อมูล", + "ids": "ไอดี", + "history": "ประวัติ", + "ban": "แบน" + }, + "actions": { + "title": "การกระทำของผู้เล่น", + "command_sent": "ส่งคำสั่งแล้ว", + "moderation": { + "title": "จัดการ", + "options": { + "dm": "DM", + "warn": "เตือน", + "kick": "เตะ", + "set_admin": "มอบผู้ดูแลระบบ" + }, + "dm_dialog": { + "title": "Direct Message", + "description": "เหตุผลที่จะส่งข้อความไปหาผู้เล่นรายนี้โดยตรง", + "placeholder": "เหตุผล...", + "success": "ส่ง DM ของคุณแล้ว" + }, + "warn_dialog": { + "title": "เตือน", + "description": "เหตุผลที่จะส่งข้อความเตือนไปหาผู้เล่นรายนี้", + "placeholder": "เหตุผล...", + "success": "ผู้เล่นถูกเตือนแล้ว" + }, + "kick_dialog": { + "title": "เตะ", + "description": "เหตุผลที่จะเตะผู้เล่นรายนี้", + "placeholder": "เหตุผล...", + "success": "ผู้เล่นถูกเตะแล้ว" + } + }, + "interaction": { + "title": "ปฏิสัมพันธ์", + "options": { + "heal": "รักษา", + "go_to": "ไปหา", + "bring": "ดึงมา", + "spectate": "รับชม", + "toggle_freeze": "ระงับ" + }, + "notifications": { + "heal_player": "รักษาผู้เล่น", + "tp_player": "เทเลพอร์ตไปหาผู้เล่น", + "bring_player": "เรียกผู้เล่นมา", + "spectate_failed": "ล้มเหลวในการไปหาเป้าหมาย ออกจากโหมดรับชม", + "spectate_yourself": "คุณไม่สามารถรับชมตัวเองได้", + "freeze_yourself": "คุณไม่สามารถระงับตัวเองได้", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "แผนร้าย", + "options": { + "drunk": "ทำให้เมา", + "fire": "ใส่ไฟ", + "wild_attack": "สัตว์ป่าโจมตี" + } + } + }, + "info": { + "title": "ข้อมูลผู้เล่น", + "session_time": "เวลาเซสชัน", + "play_time": "เวลาเล่น", + "joined": "เข้าร่วม", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "not yet", + "btn_wl_add": "ADD WL", + "btn_wl_remove": "REMOVE WL", + "btn_wl_success": "Whitelist status changed.", + "log_label": "Log", + "log_empty": "No bans/warns found.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} warn |||| %{smart_count} warns", + "log_btn": "DETAILS", + "notes_changed": "Player note changed.", + "notes_placeholder": "Notes about this player..." + }, + "history": { + "title": "ประวัติที่เกี่ยวข้อง", + "btn_revoke": "REVOKE", + "revoked_success": "Action revoked!", + "banned_by": "BANNED by %{author}", + "warned_by": "WARNED by %{author}", + "revoked_by": "Revoked by %{author}.", + "expired_at": "Expired at %{date}.", + "expires_at": "Expires at %{date}." + }, + "ban": { + "title": "แบนผู้เล่น", + "reason_placeholder": "เหตุผล", + "duration_placeholder": "ระยะเวลา", + "hours": "ชั่วโมง", + "days": "วัน", + "weeks": "สัปดาห์", + "months": "เดือน", + "permanent": "ถาวร", + "custom": "กำหนดเอง", + "helper_text": "กรุณาเลือกระยะเวลา", + "submit": "ยอมระบการแบน", + "reason_required": "The Reason field is required.", + "success": "Player banned!" + }, + "ids": { + "current_ids": "Current Identifiers", + "previous_ids": "Previously Used Identifiers", + "all_hwids": "All Hardware IDs" + } + } + } +} diff --git a/locale/tr.json b/locale/tr.json new file mode 100644 index 0000000..f70f3ae --- /dev/null +++ b/locale/tr.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Turkish", + "humanizer_language": "tr" + }, + "restarter": { + "server_unhealthy_kick_reason": "sunucu yeniden başlatılmalı, lütfen yeniden bağlan", + "partial_hang_warn": "Takılma nedeniyle bu sunucu 1 dakika içinde yeniden başlayacak. Lütfen şimdi bağlantıyı kesin.", + "partial_hang_warn_discord": "Takılma nedeniyle **%{servername}** 1 dakika içinde yeniden başlayacak.", + "schedule_reason": "Zamanlanmış yeniden başlatma %{time}", + "schedule_warn": "Sunucu %{smart_count} dakika içerisinde zamanlanmış şekilde yeniden başlatılacaktır. Lütfen çıkış yapın. |||| Sunucu %{smart_count} dakika içerisinde zamanlanmış şekilde yeniden başlatılacaktır.", + "schedule_warn_discord": "**%{servername}** sunucusu %{smart_count} dakika içerisinde yeniden başlatılmaya zamanlandı. |||| **%{servername}** sunucusu %{smart_count} dakika içerisinde yeniden başlatılmaya zamanlandı." + }, + "kick_messages": { + "everyone": "All players kicked: %{reason}.", + "player": "You have been kicked: %{reason}.", + "unknown_reason": "for unknown reason" + }, + "ban_messages": { + "kick_temporary": "(%{author}) \"%{reason}\" sebebi ile bu sunucudan uzaklaştırıldın! Cezanın bitmesine kalan süre: %{expiration}.", + "kick_permanent": "(%{author}) \"%{reason}\". sebebi ile bu sunucudan süresiz olarak uzaklaştırıldın!", + "reject": { + "title_permanent": "Bu sunucudan kalıcı olarak yasaklandınız.", + "title_temporary": "Bu sunucudan geçici olarak yasaklandınız.", + "label_expiration": "Yasak süreniz şu tarihte sona erecek:", + "label_date": "Yasaklanma Tarihi", + "label_author": "Yasaklayan", + "label_reason": "Yasaklanma Sebebi", + "label_id": "Ban ID", + "note_multiple_bans": "Not: Tanımlayıcılarınız üzerinde birden fazla aktif yasağınız var.", + "note_diff_license": "Note: the ban above was applied for another license, which means some of your IDs/HWIDs match the ones associated with that ban." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Bu sunucu Sadece-Yetkili modundunadır.", + "insufficient_ids": "discord veya fivem tanımlayıcısına sahip değilsiniz, en az bir tanesi sizin bir txAdmin yöneticisi olup olmadığınızı doğrulamak için gereklidir.", + "deny_message": "Tanımlayıcılarınız herhangi bir txAdmin yöneticisine ait değildir." + }, + "guild_member": { + "mode_title": "Bu sunucu Discord Üye Beyaz Listesi modundunadır.", + "insufficient_ids": "discord tanımlayıcısına sahip değilsiniz, Discord Sunucusuna katılıp katılmadığınızı doğrulamak için gereklidir. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_title": "Bağlanmak için Discord Sunucumuzda bulunmanız gerekiyor.", + "deny_message": "Lütfen %{guildname} sunucumuza katılın ve tekrar deneyin." + }, + "guild_roles": { + "mode_title": "Bu sunucu Discord Rol Beyaz Listesi modunda.", + "insufficient_ids": "discord tanımlayıcısına sahip değilsiniz, Discord Sunucusuna katılıp katılmadığınızı doğrulamak için gereklidir. Please open the Discord Desktop app and try again (the Web app won't work).", + "deny_notmember_title": "Bağlanmak için Discord Sunucumuzda bulunmanız gerekiyor.", + "deny_notmember_message": "Lütfen %{guildname} sunucumuzda bulunun, gerekli rollerden birini alın ve tekrar deneyin", + "deny_noroles_title": "Katılmak için beyaz listelenmiş bir role sahip değilsiniz.", + "deny_noroles_message": "Bu sunucuya katılmak için %{guildname} sunucumuzda beyaz listelenmiş rollerden en az birine sahip olmanız gerekiyor." + }, + "approved_license": { + "mode_title": "Bu sunucu Lisans Beyaz Listesi modunda.", + "insufficient_ids": "license tanımlayıcısına sahip değilsiniz, bu sunucunun sv_lan özelliği etkinleştirilmiş demektir. Sunucu sahibiyseniz, server.cfg dosyasında devre dışı bırakabilirsiniz.", + "deny_title": "Bu sunucuya katılmak için beyaz listeli değilsiniz.", + "request_id_label": "Talep ID" + } + }, + "server_actions": { + "restarting": "Sunucu yeniden başlatılıyor.(%{reason}).", + "restarting_discord": "**%{servername}** sunucusu yeniden başlatılıyor. (%{reason}).", + "stopping": "Sunucu kapatılıyor(%{reason}).", + "stopping_discord": "**%{servername}** sunucu kapatılıyor (%{reason}).", + "spawning_discord": "**%{servername}** sunucu başlatıldı." + }, + "nui_warning": { + "title": "UYARI", + "warned_by": "Uyarıldın:", + "stale_message": "Bu uyarı, sunucuya bağlanmadan önce verildi.", + "dismiss_key": "SPACE", + "instruction": "Bu mesajı geçmek için %{key} tuşuna %{smart_count} saniye boyunca basılı tut. |||| Bu mesajı geçmek için %{key} tuşuna %{smart_count} saniye boyunca basılı tut." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin Menüsü etkinleştirildi, açmak için /tx yazın.\nAyrıca [Oyun Ayarları > Tuş Bağlantıları > FiveM > Menü: Ana Sayfayı Aç] öğesinde bir tuş ataması yapılandırabilirsiniz.", + "menu_not_admin": "Tanımlayıcılarınız, txAdmin'de kayıtlı herhangi bir yöneticiyle eşleşmiyor.\ntxAdmin'de kayıtlıysanız, Admin Manager'e gidin ve tanımlayıcılarınızın kaydedildiğinden emin olun.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Bu izne sahip değilsiniz.", + "unknown_error": "Bilinmeyen bir hata oluştu.", + "not_enabled": "txAdmin Menüsü etkin değil! Bunu txAdmin ayarları sayfasından etkinleştirebilirsiniz.", + "announcement_title": "%{author} tarafından Sunucu Duyurusu:", + "dialog_empty_input": "Boş bir girdiniz olamaz.", + "directmessage_title": "%{author} yöneticisinden DM:", + "onesync_error": "This action requires OneSync to be enabled." + }, + "frozen": { + "froze_player": "Oyuncuyu dondurdunuz!", + "unfroze_player": "Oyuncuyu çözdünüz!", + "was_frozen": "Bir sunucu yöneticisi tarafından donduruldunuz!" + }, + "common": { + "cancel": "İptal", + "submit": "Gönder", + "error": "Bir hata oluştu", + "copied": "Panoya kopyalandı." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Sayfalar arasında geçiş yapmak için %{key} & menü öğelerinde gezinmek için ok tuşlarını kullanın", + "tooltip_2": "Belirli menü öğeleri, sol ve sağ ok tuşları kullanılarak seçilebilen alt seçeneklere sahiptir." + }, + "player_mode": { + "title": "Oyuncu Modu", + "noclip": { + "title": "NoClip", + "label": "Etrafta uç", + "success": "NoClip etkinleştirilmiş" + }, + "godmode": { + "title": "Tanrı modu", + "label": "Ölümsüzlük", + "success": "Tanrı modu etkinleştirilmiş" + }, + "superjump": { + "title": "Süper Zıplama", + "label": "Süper Zıplama modunu aç/kapat, oyuncu aynı zamanda daha hızlı koşacaktır", + "success": "Süper Zıplama etkinleştirilmiş" + }, + "normal": { + "title": "Normal", + "label": "Varsayılan mod", + "success": "Varsayılan oyuncu moduna döndü." + } + }, + "teleport": { + "title": "Işınlanma", + "generic_success": "Seni solucan deliğine gönderdi!", + "waypoint": { + "title": "Waypoint", + "label": "İşaretlediğin yere git", + "error": "Herhangi bir yer işaretlemedin." + }, + "coords": { + "title": "Koordinat", + "label": "Belirtilen koordinatlara git", + "dialog_title": "Işınlanma", + "dialog_desc": "Solucan deliğinden geçmek için koordinatları x, y, z formatında sağlayın.", + "dialog_error": "Geçersiz koordinatlar. Şu biçimde olmalıdır: 111, 222, 33" + }, + "back": { + "title": "Geri", + "label": "Son konuma geri dön", + "error": "Geri dönecek son konumun yok!" + }, + "copy": { + "title": "Koordinatları Kopyala", + "label": "Koordinatları panoya kopyalayın." + } + }, + "vehicle": { + "title": "Araç", + "not_in_veh_error": "Şu anda bir araçta değilsiniz!", + "spawn": { + "title": "Spawn", + "label": "Model adına göre araç spawnlama", + "dialog_title": "Aracı spawnla", + "dialog_desc": "Spawnlamak istediğiniz aracın model adını girin.", + "dialog_success": "Araç spawnlandı!", + "dialog_error": "'%{modelName}' araç modeli mevcut değil!", + "dialog_info": "%{modelName} spawnlanıyor." + }, + "fix": { + "title": "Tamir", + "label": "Mevcut aracı tamir et", + "success": "Araç tamir edildi!" + }, + "delete": { + "title": "Sil", + "label": "Mevcut aracı sil", + "success": "Araç silindi!" + }, + "boost": { + "title": "Güçlendir", + "label": "Maksimum eğlenceye (ve belki de hıza) ulaşmak için arabayı güçlendirin", + "success": "Araç güçlendirildi!", + "already_boosted": "This vehicle was already boosted.", + "unsupported_class": "This vehicle class is not supported.", + "redm_not_mounted": "You can only boost when mounted on a horse." + } + }, + "heal": { + "title": "Sağlık", + "myself": { + "title": "Kendim", + "label": "Sağlığınızı geri yükler", + "success_0": "Hepsi iyileşti!", + "success_1": "Şimdi iyi hissediyor olmalısın!", + "success_2": "Tamamen restore edildi!", + "success_3": "Hatalar düzeltildi!" + }, + "everyone": { + "title": "Herkes", + "label": "Tüm oyuncuları iyileştirecek ve canlandıracak", + "success": "Tüm oyuncuları iyileştirdi ve canlandırdı." + } + }, + "announcement": { + "title": "Duyuru Gönder", + "label": "Tüm çevrimiçi oyunculara bir duyuru gönderin.", + "dialog_desc": "Tüm çevrimiçi oyunculara bir duyuru gönderin.", + "dialog_placeholder": "Duyurunuz...", + "dialog_success": "Duyuru gönderildi." + }, + "clear_area": { + "title": "Dünya Alanını Sıfırla", + "label": "Belirtilen bir dünya alanını varsayılan durumuna sıfırlayın", + "dialog_desc": "Lütfen (0-300) içindeki varlıkları sıfırlamak istediğiniz yarıçapı girin. Bu, sunucu tarafında oluşturulan varlıkları temizlemez.", + "dialog_success": "%{radius}m yarıçaplı alanı temizleme", + "dialog_error": "Geçersiz yarıçap girişi. Tekrar deneyin." + }, + "player_ids": { + "title": "Oyuncu Kimliklerini Aç/Kapat", + "label": "Yakındaki tüm oyuncuların başının üstünde oyuncu kimliklerini (ve diğer bilgileri) göstermeyi aç/kapat", + "alert_show": "Yakındaki oyuncu NetID'leri gösteriliyor.", + "alert_hide": "Yakındaki oyuncu NetID'lerini gizleme." + } + }, + "page_players": { + "misc": { + "online_players": "Çevrimiçi Oyuncular", + "players": "Oyuncular", + "search": "Arama", + "zero_players": "Oyuncu bulunamadı" + }, + "filter": { + "label": "Filter by", + "no_filter": "No Filter", + "is_admin": "Is Admin", + "is_injured": "Is Injured / Dead", + "in_vehicle": "In Vehicle" + }, + "sort": { + "label": "Sırala", + "distance": "Mesafe", + "id": "ID", + "joined_first": "İlk katıldı", + "joined_last": "Son katıldı", + "closest": "En yakın", + "farthest": "En uzak" + }, + "card": { + "health": "%{percentHealth}% sağlık" + } + }, + "player_modal": { + "misc": { + "error": "Bu kullanıcıların ayrıntıları alınırken bir hata oluştu. Hata aşağıda gösterilmiştir:", + "target_not_found": "Kimliği veya kullanıcı adı %{target} olan bir çevrimiçi oyuncu bulunamadı" + }, + "tabs": { + "actions": "Hareketler", + "info": "Bilgi", + "ids": "ID'ler", + "history": "Geçmiş", + "ban": "Yasakla" + }, + "actions": { + "title": "Oyuncu Eylemleri", + "command_sent": "Komut gönderildi!", + "moderation": { + "title": "Moderasyon", + "options": { + "dm": "Direkt Mesaj", + "warn": "Uyarı", + "kick": "At", + "set_admin": "Adminlik Ver" + }, + "dm_dialog": { + "title": "Direkt mesaj", + "description": "Bu oyuncuya doğrudan mesaj göndermenin nedeni nedir?", + "placeholder": "Sebep...", + "success": "DM'niz gönderildi!" + }, + "warn_dialog": { + "title": "Uyarı", + "description": "Bu oyuncuyu doğrudan uyarmanın sebebi nedir?", + "placeholder": "Sebep...", + "success": "Oyuncu uyarıldı!" + }, + "kick_dialog": { + "title": "Kick", + "description": "Bu oyuncuyu atmanın sebebi nedir?", + "placeholder": "Sebep...", + "success": "Oyuncu atıldı!" + } + }, + "interaction": { + "title": "Etkileşim", + "options": { + "heal": "İyileştirmek", + "go_to": "Yanına Git", + "bring": "Yanına Çek", + "spectate": "İzle", + "toggle_freeze": "Dondurmayı Aç/Kapat" + }, + "notifications": { + "heal_player": "Oyuncu iyileştirildi", + "tp_player": "Oyuncuya ışınlanıldı", + "bring_player": "Oyuncu yanınıza ışınlandı", + "spectate_failed": "Hedef çözülemedi! İzlemeden çıkıldı.", + "spectate_yourself": "Kendini izleyemezsiniz.", + "freeze_yourself": "Kendini donduramazsın.", + "spectate_cycle_failed": "There are no players to cycle to." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Sarhoş Yap", + "fire": "Ateşe Ver", + "wild_attack": "Hayvan Saldırısı" + } + } + }, + "info": { + "title": "Oyuncu bilgisi", + "session_time": "Oturum süresi", + "play_time": "Oynama süresi", + "joined": "Katıldı", + "whitelisted_label": "Whitelist'e alındı", + "whitelisted_notyet": "henüz değil", + "btn_wl_add": "WL ekle", + "btn_wl_remove": "WL al", + "btn_wl_success": "Whitelist durumu değişti.", + "log_label": "Kayıt", + "log_empty": "Yasaklama/uyarı bulunamadı.", + "log_ban_count": "%{smart_count} yasak |||| %{smart_count} yasak", + "log_warn_count": "%{smart_count} uyarı |||| %{smart_count} uyarı", + "log_btn": "DETAYLAR", + "notes_changed": "Oyuncu notu değişti.", + "notes_placeholder": "Bu oyuncu hakkında notlar..." + }, + "history": { + "title": "İlgili geçmiş", + "btn_revoke": "İPTAL ET", + "revoked_success": "İşlem iptal edildi!", + "banned_by": "%{author} tarafından yasaklandı", + "warned_by": "%{author} tarafından uyarıldı", + "revoked_by": "%{author} tarafından iptal edildi.", + "expired_at": "%{date} tarihinde süresi doldu.", + "expires_at": "%{date} tarihinde sona eriyor." + }, + "ban": { + "title": "Oyuncuyu yasakla", + "reason_placeholder": "Sebep", + "duration_placeholder": "Süre", + "hours": "saat", + "days": "gün", + "weeks": "hafta", + "months": "ay", + "permanent": "Kalıcı", + "custom": "Özel", + "helper_text": "Lütfen bir süre seçin", + "submit": "Yasağı uygula", + "reason_required": "Neden alanı gereklidir.", + "success": "Oyuncu yasaklandı!" + }, + "ids": { + "current_ids": "Geçerli Tanımlayıcılar", + "previous_ids": "Daha Önce Kullanılan Tanımlayıcılar", + "all_hwids": "All Hardware IDs" + } + } + } +} diff --git a/locale/uk.json b/locale/uk.json new file mode 100644 index 0000000..4ddf383 --- /dev/null +++ b/locale/uk.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Ukrainian", + "humanizer_language": "uk" + }, + "restarter": { + "server_unhealthy_kick_reason": "сервер потрібно перезапустити, будь ласка, підключись знову", + "partial_hang_warn": "Через часткове зависання, сервер буде перезавантажений через 1 хвилину. Будь ласка, відключіться зараз.", + "partial_hang_warn_discord": "Через часткове зависання, **%{servername}** буде перезавантажений через 1 хвилину.", + "schedule_reason": "заплановане перезавантаження о %{time}", + "schedule_warn": "Цей сервер заплановано перезавантажити через %{smart_count} хвилин(у). Будь ласка, відключіться зараз. |||| Цей сервер заплановано перезавантажити через %{smart_count} хвилин(у).", + "schedule_warn_discord": "**%{servername}** заплановано перезавантажити через %{smart_count} хвилин(у). |||| **%{servername}** заплановано перезавантажити через %{smart_count} хвилин(у)." + }, + "kick_messages": { + "everyone": "Всіх гравців викинуто: %{reason}.", + "player": "Ви були викинуті: %{reason}.", + "unknown_reason": "з невідомої причини" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Ви були забанені на цьому сервері за \"%{reason}\". Ваш бан закінчиться через: %{expiration}.", + "kick_permanent": "(%{author}) Ви були назавжди забанені на цьому сервері за \"%{reason}\".", + "reject": { + "title_permanent": "Ви були назавжди забанені на цьому сервері.", + "title_temporary": "Ви були тимчасово забанені на цьому сервері.", + "label_expiration": "Ваш бан закінчиться через", + "label_date": "Дата бана", + "label_author": "Забанено", + "label_reason": "Причина бана", + "label_id": "ID бана", + "note_multiple_bans": "Примітка: у вас є кілька активних банів на ваших ідентифікаторах.", + "note_diff_license": "Примітка: бан вище був застосований до іншої license, що означає, що деякі з ваших ID/HWID збігаються з тими, що асоціюються з цим баном." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Цей сервер працює в режимі Тільки для Адміністраторів.", + "insufficient_ids": "У вас немає discord або fivem ідентифікаторів, і хоча б один з них необхідний для перевірки, чи є ви адміністратором txAdmin.", + "deny_message": "Ваші ідентифікатори не призначені жодному адміністратору txAdmin." + }, + "guild_member": { + "mode_title": "Цей сервер працює в режимі Тільки для учасників Discord.", + "insufficient_ids": "У вас немає discord ідентифікатора, який необхідний для перевірки, чи приєдналися ви до нашого Discord. Будь ласка, відкрийте десктопну версію Discord і спробуйте ще раз (веб-версія не працює).", + "deny_title": "Вам потрібно приєднатися до нашого Discord для підключення.", + "deny_message": "Будь ласка, приєднайтеся до %{guildname}, а потім спробуйте ще раз." + }, + "guild_roles": { + "mode_title": "Цей сервер працює в режимі Тільки за наявністю ролі в Discord.", + "insufficient_ids": "У вас немає discord ідентифікатора, який необхідний для перевірки, чи приєдналися ви до нашого Discord каналу. Будь ласка, відкрийте десктопну версію Discord і спробуйте ще раз (веб-версія не працює).", + "deny_notmember_title": "Вам потрібно приєднатися до нашого Discord каналу для підключення.", + "deny_notmember_message": "Будь ласка, приєднайтеся до %{guildname}, отримайте одну з необхідних ролей, а потім спробуйте ще раз.", + "deny_noroles_title": "У вас немає ролі, яка дозволена для підключення.", + "deny_noroles_message": "Для того, щоб підключитися до цього сервера, вам необхідно мати хоча б одну з дозволених ролей у нашому %{guildname}." + }, + "approved_license": { + "mode_title": "Цей сервер працює в режимі Тільки за наявністю Ліцензії.", + "insufficient_ids": "У вас немає license ідентифікатора, що означає, що сервер має включений sv_lan. Якщо ви є власником сервера, ви можете вимкнути його у файлі server.cfg.", + "deny_title": "Ви не потрапили в білий список для підключення до цього сервера.", + "request_id_label": "ID запиту" + } + }, + "server_actions": { + "restarting": "Перезавантаження сервера (%{reason}).", + "restarting_discord": "**%{servername}** перезавантажується (%{reason}).", + "stopping": "Зупинка сервера (%{reason}).", + "stopping_discord": "**%{servername}** зупиняється (%{reason}).", + "spawning_discord": "**%{servername}** запускається." + }, + "nui_warning": { + "title": "ПОПЕРЕДЖЕННЯ", + "warned_by": "Попередження за:", + "stale_message": "Це попередження було видано до вашого підключення до сервера.", + "dismiss_key": "ПРОБІЛ", + "instruction": "Утримуйте %{key} протягом %{smart_count} секунд(и), щоб відхилити це повідомлення. |||| Утримуйте %{key} протягом %{smart_count} секунд(и), щоб відхилити це повідомлення." + }, + "nui_menu": { + "misc": { + "help_message": "Меню txAdmin увімкнено, введіть /tx, щоб відкрити його.\nВи також можете налаштувати комбінацію клавіш у [Налаштування гри > Призначення клавіш > FiveM > Меню: Відкрити головну сторінку].", + "menu_not_admin": "Ваші ідентифікатори не співпадають з жодним адміністратором, зареєстрованим на txAdmin.\nЯкщо ви зареєстровані на txAdmin, перейдіть до Менеджера адміністраторів і переконайтеся, що ваші ідентифікатори збережено.", + "menu_auth_failed": "Аутентифікація меню txAdmin не вдалася з причини: %{reason}", + "no_perms": "У вас немає дозволу.", + "unknown_error": "Сталася невідома помилка.", + "not_enabled": "Меню txAdmin не увімкнено! Ви можете увімкнути його на сторінці налаштувань txAdmin.", + "announcement_title": "Оголошення сервера від %{author}:", + "directmessage_title": "Лист від адміністратора %{author}:", + "dialog_empty_input": "Не можна мати порожній ввід.", + "onesync_error": "Ця опція вимагає включеного OneSync." + }, + "frozen": { + "froze_player": "Ви заморозили гравця!", + "unfroze_player": "Ви розморозили гравця!", + "was_frozen": "Вас заморозив адміністратор сервера!" + }, + "common": { + "cancel": "Скасувати", + "submit": "Подати", + "error": "Сталася помилка", + "copied": "Скопійовано в буфер обміну." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Використовуйте %{key}, щоб перемикатися між сторінками, та стрілки для навігації по меню", + "tooltip_2": "Деякі елементи меню мають підпункти, які можна вибирати за допомогою стрілок вліво і вправо" + }, + "player_mode": { + "title": "Режим гравця", + "noclip": { + "title": "NoClip", + "label": "Увімкнути NoClip, щоб рухатися через стіни та інші об'єкти", + "success": "NoClip увімкнено" + }, + "godmode": { + "title": "Бог", + "label": "Увімкнути непереможність, щоб не отримувати пошкодження", + "success": "Режим Бога увімкнено" + }, + "superjump": { + "title": "Суперстрибок", + "label": "Увімкнути режим суперстрибка, також гравець буде бігти швидше", + "success": "Суперстрибок увімкнено" + }, + "normal": { + "title": "Нормальний", + "label": "Повернутися до стандартного/нормального режиму гравця", + "success": "Повернулися до стандартного режиму гравця." + } + }, + "teleport": { + "title": "Телепортація", + "generic_success": "Ви потрапили в червоточину!", + "waypoint": { + "title": "Точка маршруту", + "label": "Телепортуватися до встановленої на карті точки", + "error": "Ви не встановили точку маршруту." + }, + "coords": { + "title": "Координати", + "label": "Телепортуватися до заданих координат", + "dialog_title": "Телепортація", + "dialog_desc": "Введіть координати у форматі x, y, z для проходу через червоточину.", + "dialog_error": "Некоректні координати. Вони повинні бути у форматі: 111, 222, 33" + }, + "back": { + "title": "Назад", + "label": "Повернутися до попереднього місця телепортації", + "error": "У вас немає місця, щоб повернутися!" + }, + "copy": { + "title": "Копіювати координати", + "label": "Копіювати поточні координати світу в буфер обміну" + } + }, + "vehicle": { + "title": "Транспорт", + "not_in_veh_error": "Ви не знаходитеся в транспорті!", + "spawn": { + "title": "Створити", + "label": "Створити транспорт за його моделлю", + "dialog_title": "Створення транспорту", + "dialog_desc": "Введіть модель транспорту, яку ви хочете створити.", + "dialog_success": "Транспорт створено!", + "dialog_error": "Модель транспорту '%{modelName}' не існує!", + "dialog_info": "Спроба створити %{modelName}." + }, + "fix": { + "title": "Полагодити", + "label": "Відновить транспорт до максимального стану", + "success": "Транспорт полагоджено!" + }, + "delete": { + "title": "Видалити", + "label": "Видалить транспорт, в якому знаходиться гравець", + "success": "Транспорт видалено!" + }, + "boost": { + "title": "Прискорити", + "label": "Прискорюйте автомобіль, щоб отримати максимальне задоволення (і, можливо, швидкість)", + "success": "Транспорт прискорено!", + "already_boosted": "Цей транспорт вже прискорений.", + "unsupported_class": "Цей клас транспорту не підтримується.", + "redm_not_mounted": "Прискорення можна здійснити лише перебуваючи на коні." + } + }, + "heal": { + "title": "Лікування", + "myself": { + "title": "Себе", + "label": "Лікує себе до максимального здоров'я поточного персонажа", + "success_0": "Ви повністю вилікувані!", + "success_1": "Тепер ви повинні відчувати себе добре!", + "success_2": "Відновлено до повного здоров'я!", + "success_3": "Біль зникла!" + }, + "everyone": { + "title": "Всі", + "label": "Лікує і відновлює всіх підключених гравців", + "success": "Лікарська допомога та відновлення всіх гравців." + } + }, + "announcement": { + "title": "Надіслати оголошення", + "label": "Надіслати оголошення всім онлайн-гравцям.", + "dialog_desc": "Введіть повідомлення, яке ви хочете передати всім гравцям.", + "dialog_placeholder": "Ваше оголошення...", + "dialog_success": "Оголошення надіслано." + }, + "clear_area": { + "title": "Відновити ігровий світ", + "label": "Скинути область світу навколо до стандартного стану", + "dialog_desc": "Введіть радіус, в якому потрібно скинути об'єкти (0-300). Це не очистить об'єкти, що створені на сервері.", + "dialog_success": "Очищення зони з радіусом %{radius}м", + "dialog_error": "Некоректний ввід радіусу. Спробуйте ще раз." + }, + "player_ids": { + "title": "Перемикання ідентифікаторів гравців", + "label": "Перемикання показу ідентифікаторів гравців (та іншої інформації) над головами всіх гравців поблизу", + "alert_show": "Показано ідентифікатори гравців поблизу.", + "alert_hide": "Ідентифікатори гравців приховано." + } + }, + "page_players": { + "misc": { + "online_players": "Онлайн гравці", + "players": "Гравці", + "search": "Пошук", + "zero_players": "Гравців не знайдено." + }, + "filter": { + "label": "Фільтрувати по", + "no_filter": "Без фільтру", + "is_admin": "Адміністратор", + "is_injured": "Поранений / Мертвий", + "in_vehicle": "В транспорті" + }, + "sort": { + "label": "Сортувати за", + "distance": "Відстань", + "id": "ID", + "joined_first": "Перший приєднався", + "joined_last": "Останній приєднався", + "closest": "Найближчі", + "farthest": "Найвіддаленіші" + }, + "card": { + "health": "%{percentHealth}% здоров'я" + } + }, + "player_modal": { + "misc": { + "error": "Сталася помилка під час отримання деталей цього користувача. Ось помилка:", + "target_not_found": "Не вдалося знайти онлайн-гравця з ID або іменем користувача %{target}" + }, + "tabs": { + "actions": "Дії", + "info": "Інформація", + "ids": "Ідентифікатори", + "history": "Історія", + "ban": "Бан" + }, + "actions": { + "title": "Дії з гравцем", + "command_sent": "Команду надіслано!", + "moderation": { + "title": "Модерація", + "options": { + "dm": "Особисте повідомлення", + "warn": "Попередження", + "kick": "Викинути", + "set_admin": "Дати адміністратора" + }, + "dm_dialog": { + "title": "Особисте повідомлення", + "description": "Яка причина для особистого повідомлення цьому гравцеві?", + "placeholder": "Причина...", + "success": "Ваше повідомлення надіслано!" + }, + "warn_dialog": { + "title": "Попередження", + "description": "Яка причина для попередження цього гравця?", + "placeholder": "Причина...", + "success": "Гравця попереджено!" + }, + "kick_dialog": { + "title": "Викинути", + "description": "Яка причина для викиду цього гравця?", + "placeholder": "Причина...", + "success": "Гравця викинуто!" + } + }, + "interaction": { + "title": "Взаємодія", + "options": { + "heal": "Лікування", + "go_to": "Йти до", + "bring": "Перенести", + "spectate": "Спостерігати", + "toggle_freeze": "Перемикання заморожування" + }, + "notifications": { + "heal_player": "Лікування гравця", + "tp_player": "Телепортація до гравця", + "bring_player": "Перенесення гравця", + "spectate_failed": "Не вдалося визначити ціль! Завершення спостереження.", + "spectate_yourself": "Ви не можете спостерігати за собою.", + "freeze_yourself": "Ви не можете заморозити себе.", + "spectate_cycle_failed": "Немає гравців для циклу спостереження." + } + }, + "troll": { + "title": "Тролінг", + "options": { + "drunk": "Зробити п'яним", + "fire": "Підпалити", + "wild_attack": "Напад звірів" + } + } + }, + "info": { + "title": "Інформація про гравця", + "session_time": "Час сесії", + "play_time": "Час гри", + "joined": "Приєднався", + "whitelisted_label": "У білому списку", + "whitelisted_notyet": "ще не", + "btn_wl_add": "ДОДАТИ ДО БІЛОГО СПИСКУ", + "btn_wl_remove": "ВИДАЛИТИ З БІЛОГО СПИСКУ", + "btn_wl_success": "Статус у білому списку змінено.", + "log_label": "Журнал", + "log_empty": "Не знайдено банів/попереджень.", + "log_ban_count": "%{smart_count} бан |||| %{smart_count} банів", + "log_warn_count": "%{smart_count} попередження |||| %{smart_count} попереджень", + "log_btn": "ДЕТАЛІ", + "notes_placeholder": "Примітки про цього гравця...", + "notes_changed": "Примітка гравця змінена." + }, + "ids": { + "current_ids": "Поточні ідентифікатори", + "previous_ids": "Раніше використані ідентифікатори", + "all_hwids": "Усі апаратні ID" + }, + "history": { + "title": "Зв'язана історія", + "btn_revoke": "СКАСУВАТИ", + "revoked_success": "Дію скасовано!", + "banned_by": "ЗАБАНЕНО %{author}", + "warned_by": "ПОПЕРЕЖЕНО %{author}", + "revoked_by": "Скасовано %{author}.", + "expired_at": "Закінчився %{date}.", + "expires_at": "Термін дії до %{date}." + }, + "ban": { + "title": "Забанити гравця", + "reason_placeholder": "Причина", + "reason_required": "Поле 'Причина' обов'язкове.", + "duration_placeholder": "Тривалість", + "success": "Гравця забанено!", + "hours": "години", + "days": "дні", + "weeks": "тижні", + "months": "місяці", + "permanent": "Назавжди", + "custom": "Індивідуально", + "helper_text": "Будь ласка, виберіть тривалість", + "submit": "Застосувати бан" + } + } + } +} diff --git a/locale/vi.json b/locale/vi.json new file mode 100644 index 0000000..69c0c69 --- /dev/null +++ b/locale/vi.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Vietnamese", + "humanizer_language": "vi" + }, + "restarter": { + "server_unhealthy_kick_reason": "server cần được khởi động lại, vui lòng kết nối lại", + "partial_hang_warn": "Máy chủ gặp sự cố và sẽ khởi động lại sau 1 phút, vui lòng ngắt kết nối.", + "partial_hang_warn_discord": "Có sự cố, **%{servername}** sẽ khởi động lại sau 1 phút.", + "schedule_reason": "khởi động lại định kỳ vào lúc %{time}", + "schedule_warn": "Máy chủ sẽ khởi động lại sau %{smart_count} phút. Vui lòng ngắt kết nối. |||| Máy chủ sẽ khởi động lại sau %{smart_count} phút.", + "schedule_warn_discord": "**%{servername}** sẽ khởi động lại sau %{smart_count} phút. |||| **%{servername}** đã được lên lịch khởi động lại sau %{smart_count} phút." + }, + "kick_messages": { + "everyone": "Tất cả người chơi đã bị kick: %{reason}.", + "player": "Bạn đã bị kick: %{reason}.", + "unknown_reason": "không có lý do nào được đưa ra" + }, + "ban_messages": { + "kick_temporary": "(%{author}) Bạn đã bị cấm tham gia máy chủ vì lý do: \"%{reason}\". Thời hạn cấm sẽ hết hạn sau: %{expiration}.", + "kick_permanent": "(%{author}) Bạn đã bị cấm tham gia máy chủ vì lý do: \"%{reason}\".", + "reject": { + "title_permanent": "Bạn đã bị cấm vĩnh viễn tham gia máy chủ này.", + "title_temporary": "Bạn đã tạm thời bị cấm tham gia máy chủ này.", + "label_expiration": "Lệnh cấm sẽ hết hạn sau", + "label_date": "Ngày cấm", + "label_author": "Cấm bởi", + "label_reason": "Lý do cấm", + "label_id": "ID cấm", + "note_multiple_bans": "Lưu ý: bạn có nhiều hơn một lệnh cấm hoạt động đối với tài khoản của mình.", + "note_diff_license": "Lưu ý: lệnh cấm ở trên đã được áp dụng cho một giấy phép khác, có nghĩa là một số ID/HWID của bạn khớp với những ID/HWID được liên kết với lệnh cấm đó." + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "Máy chủ đang trong chế độ chỉ Admin.", + "insufficient_ids": "Bạn không có bất kì định danh discord hay fivem, bạn cần có ít nhất một định danh để xác thực bạn là Admin.", + "deny_message": "Mã định danh của bạn không được chỉ định cho bất kỳ quản trị viên txAdmin nào." + }, + "guild_member": { + "mode_title": "Máy chủ đang trong chế độ Whitelist Discord.", + "insufficient_ids": "Bạn không có định danh discord, bạn cần mở Discord Desktop để xác nhận bạn là thành viên trong Discord của máy chủ.", + "deny_title": "Bạn cần tham gia Discord của máy chủ để tham gia.", + "deny_message": "Vui lòng tham gia Discord %{guildname} và thử lại." + }, + "guild_roles": { + "mode_title": "Máy chủ đang trong chế độ Whitelist Discord Role.", + "insufficient_ids": "Bạn không có định danh discord, bạn cần mở Discord Desktop để xác nhận bạn là thành viên trong Discord của máy chủ.", + "deny_notmember_title": "Bạn cần tham gia Discord của máy chủ để tham gia.", + "deny_notmember_message": "Vui lòng tham gia Discord %{guildname} để xin cấp Roles sau đó thử lại.", + "deny_noroles_title": "Bạn chưa được cấp Role WhiteList để tham gia máy chủ.", + "deny_noroles_message": "Để tham gia máy chủ, bạn cần được cấp Role Whitelist tại Discord %{guildname}." + }, + "approved_license": { + "mode_title": "Máy chủ đang trong chế độ Whitelist License.", + "insufficient_ids": "Bạn không có định danh Rockstar License, có nghĩa máy chủ đang ở chế độ sv_lan. Nếu bạn là chủ máy chủ, bạn có thể sửa trong tập tin server.cfg.", + "deny_title": "Bạn chưa được cấp WhiteList.", + "request_id_label": "ID Yêu cầu" + } + }, + "server_actions": { + "restarting": "Máy chủ khởi động lại (%{reason}).", + "restarting_discord": "**%{servername}** đang khởi động lại (%{reason}).", + "stopping": "Máy chủ ngừng hoạt động (%{reason}).", + "stopping_discord": "**%{servername}** đã tạm ngừng hoạt động (%{reason}).", + "spawning_discord": "**%{servername}** đang khởi động." + }, + "nui_warning": { + "title": "CẢNH BÁO", + "warned_by": "Cảnh cáo bởi:", + "stale_message": "Cảnh báo này đã được phát hành trước khi bạn kết nối với máy chủ.", + "dismiss_key": "SPACE", + "instruction": "Giữ %{key} trong %{smart_count} giây để đóng cửa sổ này. |||| Giữ %{key} trong %{smart_count} giây để đóng cửa sổ này." + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin đang bật, dùng /tx để mở.\nBạn có thể cài đặt phím tắt bằng cách vào cài đặt game [Game Settings > Key Bindings > FiveM > Menu: Open Main Page].", + "menu_not_admin": "Thông tin tài khoản của bạn không có quyền thao tác.\nNếu bạn đã có tài khoản txAdmin, hãy chắc chắn rằng bạn có đủ các quyền.", + "menu_auth_failed": "txAdmin Menu authentication failed with reason: %{reason}", + "no_perms": "Bạn không có quyền!", + "unknown_error": "Có lỗi xảy ra.", + "not_enabled": "txAdmin Menu chưa được bật, bạn có thể bật tại mục cài đặt.", + "announcement_title": "Thông báo máy chủ bởi %{author}:", + "directmessage_title": "Tin nhắn từ Admin %{author}:", + "dialog_empty_input": "Bạn không thể để trống mục này.", + "onesync_error": "Hành động này yêu cầu bật OneSync." + }, + "frozen": { + "froze_player": "Bạn đã đóng băng người chơi!", + "unfroze_player": "Bạn đã bỏ đóng băng người chơi!", + "was_frozen": "Bạn đang bị khống chế bởi Admin!" + }, + "common": { + "cancel": "Hủy", + "submit": "Xác nhận", + "error": "Có lỗi xảy ra", + "copied": "Sao chép vào bộ nhớ tạm." + }, + "page_main": { + "tooltips": { + "tooltip_1": "Sử dụng phím %{key} để chuyển tab, nút lên xuống để di chuyển menu", + "tooltip_2": "Các mục menu nhất định có các tùy chọn phụ có thể được chọn bằng các phím mũi tên trái và phải" + }, + "player_mode": { + "title": "Admin Actions", + "noclip": { + "title": "NoClip", + "label": "Bay lượn", + "success": "Đã bật noclip" + }, + "godmode": { + "title": "God", + "label": "Bất khả chiến bại", + "success": "Đã bật God Mode" + }, + "superjump": { + "title": "Super Jump", + "label": "Bật chế độ nhảy siêu cao, đồng thời chạy nhanh hơn", + "success": "Đã bật siêu nhảy" + }, + "normal": { + "title": "Normal", + "label": "Chế độ mặc định", + "success": "Trở lại chế độ người chơi bình thường." + } + }, + "teleport": { + "title": "Dịch Chuyển", + "generic_success": "Dịch chuyển tức thời!", + "waypoint": { + "title": "Waypoint", + "label": "Đi đến vị trí đã chọn trên bản đồ", + "error": "Bạn chưa chọn vị trí." + }, + "coords": { + "title": "Tọa Độ", + "label": "Đi đến tọa độ nào đó", + "dialog_title": "Dịch Chuyển", + "dialog_desc": "Hãy nhập tọa độ x, y, z để đi tới.", + "dialog_error": "Tọa động không hợp lệ, ví dụ: 123, 234, 11" + }, + "back": { + "title": "Trở lại", + "label": "Trở lại vị trí trước đó", + "error": "Bạn không có lịch sử vị trí!" + }, + "copy": { + "title": "Sao Chép Vị Trí", + "label": "Sao chép vị trí vào clipboard." + } + }, + "vehicle": { + "title": "Phương tiện", + "not_in_veh_error": "Bạn đang ở trong xe!", + "spawn": { + "title": "Tạo ra", + "label": "Tạo ra xe", + "dialog_title": "Tạo ra xe", + "dialog_desc": "Nhập tên xe bạn muốn tạo ra.", + "dialog_success": "xe đã được tạo ra!", + "dialog_error": "xe với tên '%{modelName}' không tồn tại!", + "dialog_info": "Đang tạo ra xe %{modelName}." + }, + "fix": { + "title": "Sửa", + "label": "Sửa xe", + "success": "Xe được sửa chữa!" + }, + "delete": { + "title": "Xóa", + "label": "Xóa xe hiện tại", + "success": "Xe đã bị xóa!" + }, + "boost": { + "title": "Nâng cấp xe", + "label": "Nâng cấp xe để kiểm tra tốc độ tối đa.", + "success": "Xe đã được nâng cấp", + "already_boosted": "Xe này đã được độ.", + "unsupported_class": "Loại xe này không được hỗ trợ.", + "redm_not_mounted": "Bạn chỉ có thể tăng tốc khi được cưỡi trên ngựa." + } + }, + "heal": { + "title": "Máu", + "myself": { + "title": "Bản thân", + "label": "Hồi máu cho bản thân", + "success_0": "Đã hồi máu!", + "success_1": "Bạn đã được hồi máu!", + "success_2": "Đã đầy máu!", + "success_3": "Đã chữa các thương tật!" + }, + "everyone": { + "title": "Tất cả mọi người", + "label": "Hồi máu và hồi sinh tất cả người chơi", + "success": "Đã hồi máu và hồi sinh tất cả người chơi." + } + }, + "announcement": { + "title": "Gửi thông báo", + "label": "Gửi thông báo tới tất cả người chơi.", + "dialog_desc": "Gửi thông báo tới tất cả người chơi.", + "dialog_placeholder": "Nhập thông báo...", + "dialog_success": "Đang gửi thông báo." + }, + "clear_area": { + "title": "Dọn Dẹp Khu Vực", + "label": "Đặt lại một khu vực cụ thể về trạng thái mặc định", + "dialog_desc": "Vui lòng nhập radius mà bạn muốn dọn dẹp (0-300), các vật thể tạo ra từ script sẽ không bị xóa.", + "dialog_success": "Đang dọn dẹp khu vực bán kính %{radius}m", + "dialog_error": "Bán kính không hợp lệ." + }, + "player_ids": { + "title": "Hiện Tên & IDs", + "label": "Chuyển đổi hiển thị ID người chơi (và thông tin khác) phía trên đầu của tất cả người chơi gần đó", + "alert_show": "Đang hiển thị tên và ID người chơi gần đây.", + "alert_hide": "Đang ẩn tên và ID người chơi gần đây." + } + }, + "page_players": { + "misc": { + "online_players": "Người chơi đang online", + "players": "Người chơi", + "search": "Tìm", + "zero_players": "Không tìm thấy" + }, + "filter": { + "label": "Lọc theo", + "no_filter": "Không có bộ lọc", + "is_admin": "Is Admin", + "is_injured": "Bị thương / Chết", + "in_vehicle": "Trong xe" + }, + "sort": { + "label": "Sắp xếp theo", + "distance": "Khoảng Cách", + "id": "ID", + "joined_first": "Tham Gia Trước", + "joined_last": "Tham Gia Sau", + "closest": "Gần Nhất", + "farthest": "Xa Nhất" + }, + "card": { + "health": "%{percentHealth}% máu" + } + }, + "player_modal": { + "misc": { + "error": "Có lỗi xảy ra, thông tin chi tiết bên dưới:", + "target_not_found": "Không tìm thấy người chơi đang online với ID hoặc tên %{target}" + }, + "tabs": { + "actions": "Thao Tác", + "info": "Thông Tin", + "ids": "IDs", + "history": "Lịch Sử", + "ban": "Cấm" + }, + "actions": { + "title": "Hành Động Tới Người Chơi", + "command_sent": "Đã thực hiện!", + "moderation": { + "title": "Quản Trị", + "options": { + "dm": "Nhắn Tin Riêng", + "warn": "Cảnh Cáo", + "kick": "Đá Ra", + "set_admin": "Cấp Quyền Admin" + }, + "dm_dialog": { + "title": "Tin nhắn trực tiếp", + "description": "Lý do cho việc gửi tin nhắn riêng trực tiếp?", + "placeholder": "Lý do...", + "success": "Tin nhắn đã được gửi!" + }, + "warn_dialog": { + "title": "Cảnh Cáo", + "description": "Lý do cho việc cảnh cáo người chơi?", + "placeholder": "Lý do...", + "success": "Người chơi đã bị cảnh cáo!" + }, + "kick_dialog": { + "title": "Kick", + "description": "Vui lòng nhập lý do kick người chơi?", + "placeholder": "Lý do...", + "success": "Người chơi đã bị đá khỏi máy chủ!" + } + }, + "interaction": { + "title": "Tương Tác", + "options": { + "heal": "Hồi Máu", + "go_to": "Đi Tới", + "bring": "Mang Tới", + "spectate": "Theo Dõi", + "toggle_freeze": "Đóng Băng" + }, + "notifications": { + "heal_player": "Hồi máu người chơi", + "tp_player": "Dịch chuyển tới người chơi", + "bring_player": "Đưa người chơi tới chỗ bạn", + "spectate_failed": "Có lỗi khi thực hiện theo dõi, đang thoát chế độ theo dõi.", + "spectate_yourself": "Bạn không thể tự theo dõi chính mình.", + "freeze_yourself": "Bạn không thể tự đóng băng bản thân!", + "spectate_cycle_failed": "Không có người chơi để đạp xe đến." + } + }, + "troll": { + "title": "Troll", + "options": { + "drunk": "Làm say xỉn", + "fire": "Làm bốc cháy", + "wild_attack": "Động vật tấn công" + } + } + }, + "info": { + "title": "Thông tin người chơi", + "session_time": "Thời gian phiên", + "play_time": "Thời gian chơi", + "joined": "Đã tham gia", + "whitelisted_label": "Whitelisted", + "whitelisted_notyet": "chưa", + "btn_wl_add": "THÊM WL", + "btn_wl_remove": "XÓA WL", + "btn_wl_success": "Đã cập nhật whitelist.", + "log_label": "Nhật ký", + "log_empty": "Không tìm thấy lệnh cấm/cảnh cáo nào.", + "log_ban_count": "%{smart_count} ban |||| %{smart_count} bans", + "log_warn_count": "%{smart_count} warn |||| %{smart_count} warns", + "log_btn": "CHI TIẾT", + "notes_placeholder": "Ghi chú về người chơi này...", + "notes_changed": "Đã cập nhật ghi chú của người chơi." + }, + "ids": { + "current_ids": "Định danh hiện tại", + "previous_ids": "Định danh được sử dụng trước đây", + "all_hwids": "All Hardware IDs" + }, + "history": { + "title": "Lịch sử liên quan", + "btn_revoke": "GỠ BỎ", + "revoked_success": "Hành động bị thu hồi!", + "banned_by": "Bị cấm bởi %{author}", + "warned_by": "Cảnh cáo bởi %{author}", + "revoked_by": "Gỡ bỏ bởi %{author}.", + "expired_at": "Hết hạn lúc %{date}.", + "expires_at": "Hết hạn lúc %{date}." + }, + "ban": { + "title": "Cấm người chơi", + "reason_placeholder": "Lý do...", + "reason_required": "Lý do cấm là bắt buộc.", + "duration_placeholder": "Thời gian.", + "success": "Người chơi đã bị cấm.", + "hours": "giờ", + "days": "ngày", + "weeks": "tuần", + "months": "tháng", + "permanent": "Vĩnh viễn", + "custom": "Tùy chỉnh", + "helper_text": "Vui lòng nhập thời gian cấm người chơi.", + "submit": "Xác nhận" + } + } + } +} diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..8185370 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1,367 @@ +{ + "$meta": { + "label": "Chinese", + "humanizer_language": "zh_CN" + }, + "restarter": { + "server_unhealthy_kick_reason": "服务器需要重启, 请重新连接", + "partial_hang_warn": "由于部分挂起,本服务器将在 1 分钟后重新启动。请立即断开连接。", + "partial_hang_warn_discord": "由于部分挂起, **%{servername}** 将在1分钟后重启", + "schedule_reason": "预计 %{time}后重启", + "schedule_warn": "本服务器计划 %{smart_count} 分钟后重新启动, 请立即断开连接。 |||| 服务器计划 %{smart_count} 分钟后重新启动。", + "schedule_warn_discord": "**%{servername}** 预计 %{smart_count} 分钟后重新启动。 |||| **%{servername}** 预计 %{smart_count} 分钟后重新启动。" + }, + "kick_messages": { + "everyone": "已踢出所有玩家。原因: %{reason}.", + "player": "您已被踢出。原因: %{reason}.", + "unknown_reason": "未知原因" + }, + "ban_messages": { + "kick_temporary": "您因 \"%{reason}\" 而被封禁。您可在 %{expiration} 后再次登录服务器。操作人:%{author}", + "kick_permanent": "您因 \"%{reason}\" 而被永久封禁。操作人:%{author}", + "reject": { + "title_permanent": "您已被本服务器永久封禁。", + "title_temporary": "您已被本服务器暂时封禁。", + "label_expiration": "封禁解除时间:", + "label_date": "封禁日期", + "label_author": "操作人", + "label_reason": "封禁原因", + "label_id": "封禁ID", + "note_multiple_bans": "提示:您还有其他激活的封禁。", + "note_diff_license": "提示:上述封禁适用于另一个license,这意味着您的一些ID/HWID与该禁令相关的ID/HWID匹配。" + } + }, + "whitelist_messages": { + "admin_only": { + "mode_title": "本服务器处于 仅限管理员 模式", + "insufficient_ids": "您没有 discordfivem 标识符,如果您是管理员,则需要其中一个标识符来验证。", + "deny_message": "您不是本服务器的管理员。" + }, + "guild_member": { + "mode_title": "本服务器处于 Discord服务器白名单 模式", + "insufficient_ids": "您没有 discord 标识符,这是为了验证您是否加入了我们的Discord服务器,请打开Discord桌面版再试。", + "deny_title": "您必须加入我们的Discord服务器才能连接。", + "deny_message": "请加入我们的Discord服务器 %{guildname} 再试。" + }, + "guild_roles": { + "mode_title": "本服务器处于 Discord规则白名单 模式.", + "insufficient_ids": "您没有 discord 标识符,这是为了验证您是否加入了我们的Discord服务器,请打开Discord桌面版再试。", + "deny_notmember_title": "您必须加入我们的Discord服务器才能连接。", + "deny_notmember_message": "请加入 %{guildname} 并获取需要的角色再试。", + "deny_noroles_title": "您没有加入所需的白名单角色。", + "deny_noroles_message": "要加入此服务器,您需要在Discord服务器中有至少一个白名单角色 %{guildname}。" + }, + "approved_license": { + "mode_title": "本服务器处于 机器码白名单 模式", + "insufficient_ids": "您没有 license 标识符,这意味着服务器已启用 sv_lan 如果您是服务器所有者,可以在 server.cfg 中禁用。", + "deny_title": "您没有加入本服务器的白名单。", + "request_id_label": "请求ID" + } + }, + "server_actions": { + "restarting": "服务器正在重启 (%{reason})。", + "restarting_discord": "**%{servername}** 正在重新启动 (%{reason})。", + "stopping": "服务器正在关闭 (%{reason})。", + "stopping_discord": "**%{servername}** 正在关闭 (%{reason})。", + "spawning_discord": "**%{servername}** 正在重启。" + }, + "nui_warning": { + "title": "警告", + "warned_by": "操作人:", + "stale_message": "此警告是在您连接服务器之前发出的。", + "dismiss_key": "空格", + "instruction": "按住 %{key} %{smart_count}秒来关闭这个消息。 |||| 按住 %{key} %{smart_count}秒来关闭这个消息。" + }, + "nui_menu": { + "misc": { + "help_message": "txAdmin 菜单已启用,键入 /tx 以打开它。\n您也可以在 [游戏设置 > 按键绑定 > FiveM > 菜单:打开主页] 配置按键绑定。", + "menu_not_admin": "您的标识符与在 txAdmin 上注册的任何管理员都不匹配。\n如果您在 txAdmin 上注册,请转到管理员管理器并确保您的标识符已保存", + "menu_auth_failed": "txAdmin菜单身份认证失败,原因:%{reason}", + "no_perms": "您没有权限", + "unknown_error": "出现未知错误。", + "not_enabled": "txAdmin 菜单未启用!您可以在 txAdmin 设置页面中启用它。", + "announcement_title": "公告%{author}:", + "dialog_empty_input": "提交内客不能为空,请重新输入。", + "directmessage_title": "来自管理员 %{author} 的消息:", + "onesync_error": "此操作需要启用OneSync。" + }, + "frozen": { + "froze_player": "您冻结了玩家!", + "unfroze_player": "您已将玩家解冻!", + "was_frozen": "您已被管理员冻结!" + }, + "common": { + "cancel": "取消", + "submit": "提交", + "error": "出现了一个错误", + "copied": "复制到剪贴板" + }, + "page_main": { + "tooltips": { + "tooltip_1": "按 %{key} 切换页面 & 使用 方向上键/方向下键 切换选项", + "tooltip_2": "一些选项包含子选项,可用 方向左键/方向右键 选择" + }, + "player_mode": { + "title": "玩家模式", + "noclip": { + "title": "穿墙", + "label": "自由视角", + "success": "穿墙模式 启用" + }, + "godmode": { + "title": "上帝", + "label": "无敌", + "success": "上帝模式 启用" + }, + "superjump": { + "title": "超人", + "label": "超级跳跃和快速奔跑", + "success": "超人模式 启用" + }, + "normal": { + "title": "正常", + "label": "默认模式", + "success": "返回正常模式" + } + }, + "teleport": { + "title": "传送选项", + "generic_success": "传送成功!", + "waypoint": { + "title": "传送到导航点", + "label": "传送到地图上的导航点", + "error": "您没有设置导航点" + }, + "coords": { + "title": "传送到坐标", + "label": "传送到您输入的xyz坐标", + "dialog_title": "输入坐标", + "dialog_desc": "输入 x, y, z 格式的坐标", + "dialog_error": "您输入的格式有误,正确格式 111, 222, 33" + }, + "back": { + "title": "返回上一个位置", + "label": "传送到上一次使用传送的位置", + "error": "您没有可以返回的地方!" + }, + "copy": { + "title": "复制坐标代码", + "label": "将当前位置的xyz坐标复制到剪贴板" + } + }, + "vehicle": { + "title": "载具选项", + "not_in_veh_error": "您不在载具内!", + "spawn": { + "title": "生成", + "label": "输入模型名称来生成载具", + "dialog_title": "输入载具的模型名称", + "dialog_desc": "输入您想要生成载具的模型名称", + "dialog_success": "载具已生成!", + "dialog_error": "未找到模型名称为 '%{modelName}' 的载具!", + "dialog_info": "正在尝试生成 %{modelName}." + }, + "fix": { + "title": "修理", + "label": "修理当前载具", + "success": "载具已修理!" + }, + "delete": { + "title": "删除", + "label": "删除当前载具", + "success": "载具已删除!" + }, + "boost": { + "title": "加速", + "label": "加速载具", + "success": "载具已加速!", + "already_boosted": "载具已经被加速了", + "unsupported_class": "这辆载具不支持加速", + "redm_not_mounted": "只能在骑马时使用加速。" + } + }, + "heal": { + "title": "恢复状态", + "myself": { + "title": "自己", + "label": "恢复自己的状态", + "success_0": "状态已恢复!", + "success_1": "想飞上天和太阳肩并肩~", + "success_2": "飞起来!", + "success_3": "我要飞的更高~飞的更高~" + }, + "everyone": { + "title": "所有人", + "label": "复活所有玩家并恢复他们的状态", + "success": "已复活所有玩家并恢复他们的状态" + } + }, + "announcement": { + "title": "输入公告内容", + "label": "向所有在线玩家发送公告。", + "dialog_desc": "向所有在线玩家发送公告。", + "dialog_placeholder": "公告内容...", + "dialog_success": "公告已发送!" + }, + "clear_area": { + "title": "重置世界状态", + "label": "将指定的世界区域重置为默认状态", + "dialog_desc": "请输入要清除的半径(0-300)。这不会清除服务器端生成的实体。", + "dialog_success": "已重置半径 %{radius}m 内的世界", + "dialog_error": "您输入的半径无效,请重新输入。" + }, + "player_ids": { + "title": "显示玩家ID", + "label": "在附近玩家的头上显示ID和其他信息", + "alert_show": "显示玩家ID 开启", + "alert_hide": "显示玩家ID 关闭" + } + }, + "page_players": { + "misc": { + "online_players": "在线玩家", + "players": "玩家", + "search": "搜索", + "zero_players": "未找到玩家" + }, + "filter": { + "label": "过滤条件", + "no_filter": "无过滤", + "is_admin": "是管理员", + "is_injured": "受伤 / 死亡", + "in_vehicle": "在车内" + }, + "sort": { + "label": "排序方式", + "distance": "距离", + "id": "ID", + "joined_first": "最先加入", + "joined_last": "最后加入", + "closest": "最近的", + "farthest": "最远的" + }, + "card": { + "health": "%{percentHealth}% 健康" + } + }, + "player_modal": { + "misc": { + "error": "获取此用户的详细信息时出错。错误信息:", + "target_not_found": "找不到ID为或用户名为 %{target} 的在线玩家" + }, + "tabs": { + "actions": "操作", + "info": "信息", + "ids": "ID", + "history": "历史", + "ban": "封禁" + }, + "actions": { + "title": "玩家选项", + "command_sent": "命令已发送!", + "moderation": { + "title": "管理选项", + "options": { + "dm": "私聊", + "warn": "警告", + "kick": "踢出游戏", + "set_admin": "给管理员" + }, + "dm_dialog": { + "title": "私信", + "description": "请输入输入要发送的私信内容", + "placeholder": "私信内容...", + "success": "私信已发送!" + }, + "warn_dialog": { + "title": "警告", + "description": "请输入警告此玩家的原因", + "placeholder": "原因...", + "success": "警告已发送!" + }, + "kick_dialog": { + "title": "踢出玩家", + "description": "请输入踢出此玩家的原因", + "placeholder": "原因...", + "success": "玩家已被踢出!" + } + }, + "interaction": { + "title": "互动选项", + "options": { + "heal": "恢复状态", + "go_to": "传送过去", + "bring": "召唤过来", + "spectate": "观看", + "toggle_freeze": "冻结" + }, + "notifications": { + "heal_player": "恢复玩家的状态", + "tp_player": "将您传送到玩家", + "bring_player": "将玩家召唤过来", + "spectate_failed": "无法解析目标!正在退出观看,", + "spectate_yourself": "您不能观看您自己", + "freeze_yourself": "您不能冻结您自己", + "spectate_cycle_failed": "没有可循环的玩家" + } + }, + "troll": { + "title": "恶搞选项", + "options": { + "drunk": "醉酒", + "fire": "着火", + "wild_attack": "动物攻击" + } + } + }, + "info": { + "title": "玩家信息", + "session_time": "游玩时长", + "play_time": "游玩总时常", + "joined": "加入时间", + "whitelisted_label": "白名单状态", + "whitelisted_notyet": "未通过", + "btn_wl_add": "通过白名单", + "btn_wl_remove": "撤销白名单", + "btn_wl_success": "白名单状态已更改。", + "log_label": "记录", + "log_empty": "未找到封禁/警告。", + "log_ban_count": "%{smart_count} 封禁 |||| %{smart_count} 封禁", + "log_warn_count": "%{smart_count} 警告 |||| %{smart_count} 警告", + "log_btn": "详细信息", + "notes_changed": "玩家备注已更改。", + "notes_placeholder": "关于此玩家的备注..." + }, + "history": { + "title": "历史信息", + "btn_revoke": "撤销", + "revoked_success": "操作已撤销!", + "banned_by": "被 %{author} 封禁", + "warned_by": "被 %{author} 警告", + "revoked_by": "被 %{author} 撤销", + "expired_at": "将于 %{date} 过期。", + "expires_at": "将于 %{date} 过期。" + }, + "ban": { + "title": "封禁玩家", + "reason_placeholder": "原因", + "duration_placeholder": "时间", + "hours": "小时", + "days": "天", + "weeks": "星期", + "months": "月", + "permanent": "永久", + "custom": "自定义", + "helper_text": "请选择封禁时间", + "submit": "提交", + "reason_required": "您必须输入封禁原因!", + "success": "已封禁该玩家!" + }, + "ids": { + "current_ids": "当前标识符", + "previous_ids": "曾使用过的标识符", + "all_hwids": "所有硬件ID(HWID)" + } + } + } +} diff --git a/nui/index.html b/nui/index.html new file mode 100644 index 0000000..8ca0a0f --- /dev/null +++ b/nui/index.html @@ -0,0 +1,19 @@ + + + + + + txAdmin NUI + + + +
+ + + diff --git a/nui/package.json b/nui/package.json new file mode 100644 index 0000000..7b4860e --- /dev/null +++ b/nui/package.json @@ -0,0 +1,37 @@ +{ + "name": "txadmin-nui", + "version": "1.0.0", + "description": "The NUI package contains all the in-game interface (menu and warn page) of txAdmin.", + "main": "index.js", + "type": "module", + "scripts": { + "test": "echo \"No tests for this workspace. Skipping...\"", + "build": "vite build --mode production", + "dev": "vite build --watch --mode development", + "browser": "vite dev --port 40121 --strictPort --mode devNuiBrowser", + "typecheck": "tsc -p tsconfig.json --noEmit", + "license:report": "npx license-report > ../.reports/license/nui.html" + }, + "keywords": [], + "author": "André Tabarra", + "license": "MIT", + "dependencies": { + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@mui/icons-material": "^6.1.2", + "@mui/material": "6.1.2", + "notistack": "^3.0.1", + "react-polyglot": "^0.7.2", + "recoil": "^0.7.7" + }, + "devDependencies": { + "@types/node-polyglot": "^2.5.0", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.7.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "vite": "^5.4.8", + "vite-tsconfig-paths": "^5.0.1" + } +} diff --git a/nui/public/images/txadmin-redm.png b/nui/public/images/txadmin-redm.png new file mode 100644 index 0000000..6c5dea1 Binary files /dev/null and b/nui/public/images/txadmin-redm.png differ diff --git a/nui/public/images/txadmin.png b/nui/public/images/txadmin.png new file mode 100644 index 0000000..7d696e0 Binary files /dev/null and b/nui/public/images/txadmin.png differ diff --git a/nui/public/sounds/announcement.mp3 b/nui/public/sounds/announcement.mp3 new file mode 100644 index 0000000..73cbe7f Binary files /dev/null and b/nui/public/sounds/announcement.mp3 differ diff --git a/nui/public/sounds/message.mp3 b/nui/public/sounds/message.mp3 new file mode 100644 index 0000000..dfe7582 Binary files /dev/null and b/nui/public/sounds/message.mp3 differ diff --git a/nui/public/sounds/warning_open.mp3 b/nui/public/sounds/warning_open.mp3 new file mode 100644 index 0000000..97d4440 Binary files /dev/null and b/nui/public/sounds/warning_open.mp3 differ diff --git a/nui/public/sounds/warning_pulse.mp3 b/nui/public/sounds/warning_pulse.mp3 new file mode 100644 index 0000000..966ac4b Binary files /dev/null and b/nui/public/sounds/warning_pulse.mp3 differ diff --git a/nui/src/App.css b/nui/src/App.css new file mode 100644 index 0000000..8cb390c --- /dev/null +++ b/nui/src/App.css @@ -0,0 +1,22 @@ +.App { + height: 100%; + display: flex; + flex-direction: column; + transition: opacity 0.15s linear; +} + +/* width */ +::-webkit-scrollbar { + width: 7px; +} + +/* Track */ +::-webkit-scrollbar-track { + border-radius: 20px; +} + +/* Handle */ +::-webkit-scrollbar-thumb { + background: #24282b; + border-radius: 20px; +} diff --git a/nui/src/MenuWrapper.tsx b/nui/src/MenuWrapper.tsx new file mode 100644 index 0000000..baddcf8 --- /dev/null +++ b/nui/src/MenuWrapper.tsx @@ -0,0 +1,116 @@ +import React, { useEffect } from "react"; +import "./App.css"; +import { useIsMenuVisibleValue } from "./state/visibility.state"; +import MenuRoot from "./components/MenuRoot"; +import { DialogProvider } from "./provider/DialogProvider"; +import { useExitListener } from "./hooks/useExitListener"; +import { useNuiListenerService } from "./hooks/useNuiListenersService"; +import { TopLevelErrorBoundary } from "./components/misc/TopLevelErrorBoundary"; +import { debugData } from "./utils/debugData"; +import { I18n } from "react-polyglot"; +import { useServerCtxValue } from "./state/server.state"; +import { WarnPage } from "./components/WarnPage/WarnPage"; +import { IFrameProvider } from "./provider/IFrameProvider"; +import { PlayerModalProvider } from "./provider/PlayerModalProvider"; +import { txAdminMenuPage, useSetPage } from "./state/page.state"; +import { useListenerForSomething } from "./hooks/useListenerForSomething"; +import { + usePlayersFilterIsTemp, + useSetPlayerFilter, +} from "./state/players.state"; +import { Box } from "@mui/material"; +import { fetchNui } from "./utils/fetchNui"; +import { useLocale } from "./hooks/useLocale"; +import { TooltipProvider } from "./provider/TooltipProvider"; + +//Mock events for browser development +debugData( + [ + { + action: "setPermissions", + data: ["all_permissions"], + // data: ['players.heal', 'announcement'], + // data: [], + }, + { + action: "setVisible", + data: true, + }, + { + action: "setGameName", + data: 'fivem', + // data: 'redm', + }, + ], + 150 +); + +const MenuWrapper: React.FC = () => { + const visible = useIsMenuVisibleValue(); + const serverCtx = useServerCtxValue(); + const [playersFilterIsTemp, setPlayersFilterIsTemp] = + usePlayersFilterIsTemp(); + const setPlayerFilter = useSetPlayerFilter(); + + const setPage = useSetPage(); + // These hooks don't ever unmount + useExitListener(); + useNuiListenerService(); + + //Change page back to Main when closed + useEffect(() => { + if (visible) return; + + const changeTimer = setTimeout(() => { + setPage(txAdminMenuPage.Main); + }, 750); + + if (playersFilterIsTemp) { + setPlayerFilter(""); + setPlayersFilterIsTemp(false); + } + + return () => clearInterval(changeTimer); + }, [visible, playersFilterIsTemp]); + + const localeSelected = useLocale(); + //Inform Lua that we are ready to get all variables (server ctx, permissions, debug, etc) + useEffect(() => { + fetchNui("reactLoaded").catch(() => { }); + }, []); + + useListenerForSomething(); + + return ( + + + <> + + + + + + + + + + + + + + + + ); +}; + +export default MenuWrapper; diff --git a/nui/src/components/IFramePage/IFramePage.tsx b/nui/src/components/IFramePage/IFramePage.tsx new file mode 100644 index 0000000..89e2d44 --- /dev/null +++ b/nui/src/components/IFramePage/IFramePage.tsx @@ -0,0 +1,51 @@ +import React, { useEffect } from "react"; +import { Box, styled } from "@mui/material"; +import { IFramePostData, useIFrameCtx } from "../../provider/IFrameProvider"; +import { debugLog } from "../../utils/debugLog"; +import { usePermissionsValue } from "../../state/permissions.state"; + +const StyledIFrame = styled("iframe")({ + border: "0px", + borderRadius: 15, + height: "100%", + width: "100%", +}); + +const StyledRoot = styled(Box)({ + backgroundColor: "#171718", + height: "100%", + borderRadius: 15, +}); + +export const IFramePage: React.FC<{ visible: boolean }> = ({ visible }) => { + const { fullFrameSrc, handleChildPost } = useIFrameCtx(); + const userPerms = usePermissionsValue(); + + // We will only use the provider's src value if the permissions + // have been successfully fetched + const trueFrameSource = Boolean(userPerms) ? fullFrameSrc : "about:blank"; + + // Handles listening for postMessage requests from iFrame + useEffect(() => { + const handler = (event: MessageEvent) => { + const data: IFramePostData = + typeof event.data === "string" ? JSON.parse(event.data) : event.data; + + if (!data?.__isFromChild) return; + + debugLog("Post from iFrame", data); + + handleChildPost(data); + }; + + window.addEventListener("message", handler); + + return () => window.removeEventListener("message", handler); + }, [handleChildPost]); + + return ( + + {visible && } + + ); +}; diff --git a/nui/src/components/MainPage/MainPageList.tsx b/nui/src/components/MainPage/MainPageList.tsx new file mode 100644 index 0000000..54ebcc8 --- /dev/null +++ b/nui/src/components/MainPage/MainPageList.tsx @@ -0,0 +1,562 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Box, List, styled } from "@mui/material"; +import { MenuListItem, MenuListItemMulti } from "./MenuListItem"; +import { + AccessibilityNew, + Announcement, + Build, + CenterFocusWeak, + ControlCamera, + DirectionsCar, + ExpandMore, + Favorite, + FileCopy, + GpsFixed, + LocalHospital, + PersonPinCircle, + Groups, + Restore, + Security, + DeleteForever, + RocketLaunch, + AirlineStops, + // Stream //Spawn Weapon action +} from "@mui/icons-material"; +import { useKeyboardNavigation } from "../../hooks/useKeyboardNavigation"; +import { useDialogContext } from "../../provider/DialogProvider"; +import { fetchNui } from "../../utils/fetchNui"; +import { useTranslate } from "react-polyglot"; +import { useSnackbar } from "notistack"; +import { PlayerMode, usePlayerMode } from "../../state/playermode.state"; +import { useIsMenuVisibleValue } from "../../state/visibility.state"; +import { TeleportMode, useTeleportMode } from "../../state/teleportmode.state"; +import { HealMode, useHealMode } from "../../state/healmode.state"; +import { copyToClipboard } from "../../utils/copyToClipboard"; +import { useServerCtxValue } from "../../state/server.state"; +import { VehicleMode, useVehicleMode } from "../../state/vehiclemode.state"; +import { useIsRedmValue } from "@nui/src/state/isRedm.state"; +import { getVehicleSpawnDialogData, vehiclePlaceholderReplacer } from "@nui/src/utils/vehicleSpawnDialogHelper"; +import { useNuiEvent } from "@nui/src/hooks/useNuiEvent"; +import { usePlayerModalContext } from "@nui/src/provider/PlayerModalProvider"; + +const fadeHeight = 20; +const listHeight = 402; + +const BoxFadeTop = styled(Box)(({ theme }) => ({ + backgroundImage: `linear-gradient(to top, transparent, ${theme.palette.background.default})`, + position: "relative", + bottom: listHeight + fadeHeight - 4, + height: fadeHeight, +})); + +const BoxFadeBottom = styled(Box)(({ theme }) => ({ + backgroundImage: `linear-gradient(to bottom, transparent, ${theme.palette.background.default})`, + position: "relative", + height: fadeHeight, + bottom: fadeHeight * 2, +})); + +const BoxIcon = styled(Box)(({ theme }) => ({ + color: theme.palette.text.secondary, + marginTop: -(fadeHeight * 2), + display: "flex", + justifyContent: "center", +})); + +const StyledList = styled(List)({ + maxHeight: listHeight, + overflow: "auto", + "&::-webkit-scrollbar": { + display: "none", + }, +}); + +// TODO: This component is kinda getting out of hand, might want to split it somehow +export const MainPageList: React.FC = () => { + const { openDialog } = useDialogContext(); + const [curSelected, setCurSelected] = useState(0); + const t = useTranslate(); + const { enqueueSnackbar } = useSnackbar(); + const [playerMode, setPlayerMode] = usePlayerMode(); + const [teleportMode, setTeleportMode] = useTeleportMode(); + const [vehicleMode, setVehicleMode] = useVehicleMode(); + const [healMode, setHealMode] = useHealMode(); + const serverCtx = useServerCtxValue(); + const menuVisible = useIsMenuVisibleValue(); + const isRedm = useIsRedmValue() + const { closeMenu } = usePlayerModalContext(); + + //FIXME: this is so the menu resets multi selectors when we close it + // but it is not working, and when I do this the first time we press + // noclip it will actually think we are changing back to normal. + // We need to review handlePlayermodeToggle() + useEffect(() => { + if (menuVisible) return; + setCurSelected(0); + // setPlayerMode(PlayerMode.NOCLIP); + // setTeleportMode(TeleportMode.WAYPOINT); + // setVehicleMode(VehicleMode.SPAWN); + // setHealMode(HealMode.SELF); + }, [menuVisible]); + + //============================================= + const handleArrowDown = useCallback(() => { + const next = curSelected + 1; + fetchNui("playSound", "move").catch(); + setCurSelected(next >= menuListItems.length ? 0 : next); + }, [curSelected]); + + const handleArrowUp = useCallback(() => { + const next = curSelected - 1; + fetchNui("playSound", "move").catch(); + setCurSelected(next < 0 ? menuListItems.length - 1 : next); + }, [curSelected]); + + useKeyboardNavigation({ + onDownDown: handleArrowDown, + onUpDown: handleArrowUp, + disableOnFocused: true, + }); + + //============================================= + const handlePlayermodeToggle = (targetMode: PlayerMode) => { + if (targetMode === playerMode || targetMode === PlayerMode.DEFAULT) { + setPlayerMode(PlayerMode.DEFAULT); + fetchNui("playerModeChanged", PlayerMode.DEFAULT); + enqueueSnackbar(t("nui_menu.page_main.player_mode.normal.success"), { + variant: "success", + }); + } else { + setPlayerMode(targetMode); + fetchNui("playerModeChanged", targetMode); + } + }; + + //============================================= + const handleTeleportCoords = (autoClose = false) => { + openDialog({ + title: t("nui_menu.page_main.teleport.coords.dialog_title"), + description: t("nui_menu.page_main.teleport.coords.dialog_desc"), + placeholder: "340, 480, 12", + onSubmit: (coords: string) => { + // Testing examples: + // {x: -1; y: 2; z:3} + // {x = -1.01; y= 2.02; z=3.03} + // -1, 2, 3 + // 474.08966064453, -1718.7073974609, 29.329517364502 + let [x, y, z] = Array.from( + coords.matchAll(/-?\d{1,4}(?:\.\d+)?/g), + (m) => parseFloat(m[0]) + ); + if (typeof x !== 'number' || typeof y !== 'number') { + return enqueueSnackbar( + t("nui_menu.page_main.teleport.coords.dialog_error"), + { variant: "error" } + ); + } + if (typeof z !== 'number') { + z = 0; + } + + enqueueSnackbar( + t("nui_menu.page_main.teleport.generic_success"), + { variant: "success" } + ); + fetchNui("tpToCoords", { x, y, z }); + if (autoClose) { + closeMenu() + } + }, + }); + }; + useNuiEvent("openTeleportCoordsDialog", () => { + handleTeleportCoords(true); + }); + + const handleTeleportBack = () => { + fetchNui("tpBack"); + }; + + const handleCopyCoords = () => { + fetchNui<{ coords: string }>("copyCurrentCoords").then(({ coords }) => { + copyToClipboard(coords); + enqueueSnackbar(t("nui_menu.common.copied"), { variant: "success" }); + }); + }; + + //============================================= + const handleSpawnVehicle = (autoClose = false) => { + // Requires onesync because the vehicle is spawned on the server + if (!serverCtx.oneSync.status) { + return enqueueSnackbar(t("nui_menu.misc.onesync_error"), { + variant: "error", + }); + } + + const dialogData = getVehicleSpawnDialogData(isRedm); + openDialog({ + title: t("nui_menu.page_main.vehicle.spawn.dialog_title"), + description: t("nui_menu.page_main.vehicle.spawn.dialog_desc"), + placeholder: 'any vehicle model or ' + dialogData.shortcuts.join(', '), + suggestions: dialogData.shortcuts, + onSubmit: (modelName: string) => { + modelName = vehiclePlaceholderReplacer(modelName, dialogData.shortcutsData); + fetchNui("spawnVehicle", { model: modelName }); + if (autoClose) { + closeMenu() + } + }, + }); + }; + useNuiEvent("openSpawnVehicleDialog", () => { + handleSpawnVehicle(true); + }); + + const handleFixVehicle = () => { + fetchNui("fixVehicle"); + }; + + const handleDeleteVehicle = () => { + // If onesync is disabled, show an error due to server side entity handling + if (!serverCtx.oneSync.status) { + return enqueueSnackbar(t("nui_menu.misc.onesync_error"), { + variant: "error", + }); + } + fetchNui("deleteVehicle").then(({ success }) => { + //NOTE: since there is no client action for deleting the car, the success message is triggered here + if (success) { + return enqueueSnackbar(t("nui_menu.page_main.vehicle.delete.success"), { + variant: "info", + }); + } + }); + }; + + const handleBoostVehicle = () => { + fetchNui("boostVehicle"); + }; + + //============================================= + const handleHealMyself = () => { + fetchNui("healMyself"); + }; + + const handleHealAllPlayers = () => { + fetchNui("healAllPlayers"); + enqueueSnackbar(t("nui_menu.page_main.heal.everyone.success"), { + variant: "info", + }); + }; + + //============================================= + const handleAnnounceMessage = () => { + openDialog({ + title: t("nui_menu.page_main.announcement.title"), + description: t("nui_menu.page_main.announcement.dialog_desc"), + placeholder: t("nui_menu.page_main.announcement.dialog_placeholder"), + onSubmit: (message: string) => { + enqueueSnackbar(t("nui_menu.page_main.announcement.dialog_success"), { + variant: "success", + }); + fetchNui("sendAnnouncement", { message }); + }, + }); + }; + + const handleClearArea = (autoClose = false) => { + if (isRedm) { + return enqueueSnackbar( + 'This option is not yet available for RedM.', + { variant: "error" } + ); + } + openDialog({ + title: t("nui_menu.page_main.clear_area.title"), + description: t("nui_menu.page_main.clear_area.dialog_desc"), + placeholder: "300", + suggestions: ['50', '150', '300'], + onSubmit: (msg) => { + const parsedRadius = parseInt(msg); + + if (isNaN(parsedRadius) || parsedRadius > 300 || parsedRadius < 0) { + return enqueueSnackbar( + t("nui_menu.page_main.clear_area.dialog_error"), + { variant: "error" } + ); + } + + fetchNui("clearArea", parsedRadius); + if (autoClose) { + closeMenu() + } + }, + }); + }; + useNuiEvent("openClearAreaDialog", () => { + handleClearArea(true); + }); + + const handleTogglePlayerIds = () => { + fetchNui("togglePlayerIDs"); + }; + + // This is here for when I am bored developing + // const handleSpawnWeapon = () => { + // openDialog({ + // title: "Spawn Weapon", + // placeholder: "WEAPON_ASSAULTRIFLE", + // description: "Type in the model name for the weapon you want to spawn.", + // onSubmit: (inputValue) => { + // fetchNui("spawnWeapon", inputValue); + // }, + // }); + // }; + + // This is where we keep a memoized list of all actions, can be dynamically + // set in the future for third party resource integration. For now here for + // simplicity + const menuListItems = useMemo( + () => [ + //PLAYER MODE + { + title: t("nui_menu.page_main.player_mode.title"), + requiredPermission: "players.playermode", + isMultiAction: true, + initialValue: playerMode, + actions: [ + { + name: t("nui_menu.page_main.player_mode.noclip.title"), + label: t("nui_menu.page_main.player_mode.noclip.label"), + value: PlayerMode.NOCLIP, + icon: , + onSelect: () => { + handlePlayermodeToggle(PlayerMode.NOCLIP); + }, + }, + { + name: t("nui_menu.page_main.player_mode.godmode.title"), + label: t("nui_menu.page_main.player_mode.godmode.label"), + value: PlayerMode.GOD_MODE, + icon: , + onSelect: () => { + handlePlayermodeToggle(PlayerMode.GOD_MODE); + }, + }, + { + name: t("nui_menu.page_main.player_mode.superjump.title"), + label: t("nui_menu.page_main.player_mode.superjump.label"), + value: PlayerMode.SUPER_JUMP, + icon: , + onSelect: () => { + handlePlayermodeToggle(PlayerMode.SUPER_JUMP); + }, + }, + { + name: t("nui_menu.page_main.player_mode.normal.title"), + label: t("nui_menu.page_main.player_mode.normal.label"), + value: PlayerMode.DEFAULT, + icon: , + onSelect: () => { + handlePlayermodeToggle(PlayerMode.DEFAULT); + }, + }, + ], + }, + + //TELEPORT + { + title: t("nui_menu.page_main.teleport.title"), + requiredPermission: "players.teleport", + isMultiAction: true, + initialValue: teleportMode, + actions: [ + { + name: t("nui_menu.page_main.teleport.waypoint.title"), + label: t("nui_menu.page_main.teleport.waypoint.label"), + value: TeleportMode.WAYPOINT, + icon: , + onSelect: () => { + setTeleportMode(TeleportMode.WAYPOINT); + fetchNui("tpToWaypoint", {}); + }, + }, + { + name: t("nui_menu.page_main.teleport.coords.title"), + label: t("nui_menu.page_main.teleport.coords.label"), + value: TeleportMode.COORDINATES, + icon: , + onSelect: () => { + setTeleportMode(TeleportMode.COORDINATES); + handleTeleportCoords(); + }, + }, + { + name: t("nui_menu.page_main.teleport.back.title"), + label: t("nui_menu.page_main.teleport.back.label"), + value: TeleportMode.PREVIOUS, + icon: , + onSelect: handleTeleportBack, + }, + { + name: t("nui_menu.page_main.teleport.copy.title"), + label: t("nui_menu.page_main.teleport.copy.label"), + value: TeleportMode.COPY, + icon: , + onSelect: handleCopyCoords, + }, + ], + }, + + //VEHICLE + { + title: t("nui_menu.page_main.vehicle.title"), + requiredPermission: "menu.vehicle", + isMultiAction: true, + initialValue: vehicleMode, + actions: [ + { + name: t("nui_menu.page_main.vehicle.spawn.title"), + label: t("nui_menu.page_main.vehicle.spawn.label"), + value: VehicleMode.SPAWN, + icon: , + onSelect: () => { + setVehicleMode(VehicleMode.SPAWN); + handleSpawnVehicle(); + }, + }, + { + name: t("nui_menu.page_main.vehicle.fix.title"), + label: t("nui_menu.page_main.vehicle.fix.label"), + value: VehicleMode.FIX, + icon: , + onSelect: () => { + setVehicleMode(VehicleMode.FIX); + handleFixVehicle(); + }, + }, + { + name: t("nui_menu.page_main.vehicle.delete.title"), + label: t("nui_menu.page_main.vehicle.delete.label"), + value: VehicleMode.DELETE, + icon: , + onSelect: () => { + setVehicleMode(VehicleMode.DELETE); + handleDeleteVehicle(); + }, + }, + { + name: t("nui_menu.page_main.vehicle.boost.title"), + label: t("nui_menu.page_main.vehicle.boost.label"), + value: VehicleMode.BOOST, + icon: , + onSelect: () => { + setVehicleMode(VehicleMode.BOOST); + handleBoostVehicle(); + }, + }, + ], + }, + + //HEAL + { + title: t("nui_menu.page_main.heal.title"), + requiredPermission: "players.heal", + isMultiAction: true, + initialValue: healMode, + actions: [ + { + name: t("nui_menu.page_main.heal.myself.title"), + label: t("nui_menu.page_main.heal.myself.label"), + value: HealMode.SELF, + icon: , + onSelect: () => { + setHealMode(HealMode.SELF); + handleHealMyself(); + }, + }, + { + name: t("nui_menu.page_main.heal.everyone.title"), + label: t("nui_menu.page_main.heal.everyone.label"), + value: HealMode.ALL, + icon: , + onSelect: () => { + setHealMode(HealMode.ALL); + handleHealAllPlayers(); + }, + }, + ], + }, + + //MISC + { + title: t("nui_menu.page_main.announcement.title"), + label: t("nui_menu.page_main.announcement.label"), + requiredPermission: "announcement", + icon: , + onSelect: handleAnnounceMessage, + }, + { + title: t("nui_menu.page_main.clear_area.title"), + label: t("nui_menu.page_main.clear_area.label"), + requiredPermission: "menu.clear_area", + icon: , + onSelect: handleClearArea, + }, + { + title: t("nui_menu.page_main.player_ids.title"), + label: t("nui_menu.page_main.player_ids.label"), + requiredPermission: "menu.viewids", + icon: , + onSelect: handleTogglePlayerIds, + }, + // { + // title: "Spawn Weapon", + // icon: , + // onSelect: handleSpawnWeapon, + // }, + ], + [playerMode, teleportMode, vehicleMode, healMode, serverCtx, isRedm] + ); + + return ( + // add pb={2} if we don't have that arrow at the bottom + ( + + {menuListItems.map((item, index) => + item.isMultiAction ? ( + // @ts-ignore + () + ) : ( + // @ts-ignore + () + ) + )} + + + = 6 ? 0 : 1 }} /> + + + + {/* + v{serverCtx.txAdminVersion} + */} + ) + ); +}; diff --git a/nui/src/components/MainPage/MenuListItem.tsx b/nui/src/components/MainPage/MenuListItem.tsx new file mode 100644 index 0000000..ec1b349 --- /dev/null +++ b/nui/src/components/MainPage/MenuListItem.tsx @@ -0,0 +1,272 @@ +import React, { memo, useEffect, useRef, useState } from "react"; +import { styled } from '@mui/material/styles'; +import { + Box, + BoxProps, + ListItem, + ListItemButton, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + Typography, +} from "@mui/material"; +import { useKeyboardNavigation } from "../../hooks/useKeyboardNavigation"; +import { Code } from "@mui/icons-material"; +import { fetchNui } from "../../utils/fetchNui"; +import { useTranslate } from "react-polyglot"; +import { + ResolvablePermission, + usePermissionsValue, +} from "../../state/permissions.state"; +import { userHasPerm } from "../../utils/miscUtils"; +import { useSnackbar } from "notistack"; +import { useTooltip } from "../../provider/TooltipProvider"; + +const PREFIX = 'MenuListItem'; + +const classes = { + root: `${PREFIX}-root`, + rootDisabled: `${PREFIX}-rootDisabled`, + icon: `${PREFIX}-icon`, + overrideText: `${PREFIX}-overrideText` +}; + +const Root = styled('div')(({ theme }) => ({ + [`& .${classes.root}`]: { + borderRadius: 10, + }, + + [`& .${classes.rootDisabled}`]: { + borderRadius: 10, + opacity: 0.35, + }, + + [`& .${classes.icon}`]: { + color: theme.palette.text.secondary, + }, + + [`& .${classes.overrideText}`]: { + color: theme.palette.text.primary, + fontSize: 16, + } +})); + +export interface MenuListItemProps { + title: string; + label: string; + requiredPermission?: ResolvablePermission; + icon: JSX.Element; + selected: boolean; + onSelect: () => void; +} + + +export const MenuListItem: React.FC = memo( + ({ title, label, requiredPermission, icon, selected, onSelect }) => { + + const t = useTranslate(); + const divRef = useRef(null); + const userPerms = usePermissionsValue(); + const isUserAllowed = requiredPermission + ? userHasPerm(requiredPermission, userPerms) + : true; + const { enqueueSnackbar } = useSnackbar(); + const { setTooltipText } = useTooltip(); + + const handleEnter = (): void => { + if (!selected) return; + + if (!isUserAllowed) { + enqueueSnackbar(t("nui_menu.misc.no_perms"), { + variant: "error", + anchorOrigin: { + horizontal: "center", + vertical: "bottom", + }, + }); + return; + } + + fetchNui("playSound", "enter"); + onSelect(); + }; + + useEffect(() => { + if (selected && divRef) { + divRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "start", + }); + } + }, [selected]); + + useEffect(() => { + if (selected) { + setTooltipText(label); + } + }, [selected]); + + useKeyboardNavigation({ + onEnterDown: handleEnter, + disableOnFocused: true, + }); + + return ( + + onSelect()} + className={isUserAllowed ? classes.root : classes.rootDisabled} + dense + selected={selected} + > + {icon} + + + + ); + } +); + +interface MenuListItemMultiAction { + name?: string | JSX.Element; + label: string; + value: string | number | boolean; + icon?: JSX.Element; + onSelect: () => void; +} + +export interface MenuListItemMultiProps { + title: string; + requiredPermission?: ResolvablePermission; + initialValue?: MenuListItemMultiAction; + selected: boolean; + icon: JSX.Element; + actions: MenuListItemMultiAction[]; +} + +export const MenuListItemMulti: React.FC = memo( + ({ selected, title, actions, icon, initialValue, requiredPermission }) => { + + const t = useTranslate(); + const [curState, setCurState] = useState(0); + const userPerms = usePermissionsValue(); + const { enqueueSnackbar } = useSnackbar(); + const { setTooltipText } = useTooltip(); + + const isUserAllowed = requiredPermission && userHasPerm(requiredPermission, userPerms); + + const compMounted = useRef(false); + + const divRef = useRef(null); + + const showNotAllowedAlert = () => { + enqueueSnackbar(t("nui_menu.misc.no_perms"), { + variant: "error", + anchorOrigin: { + horizontal: "center", + vertical: "bottom", + }, + }); + }; + + useEffect(() => { + if (selected && divRef) { + divRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "start", + }); + } + }, [selected]); + + // Mount/unmount detection + // We will only run this hook after initial mount + // and not on unmount. + // NOTE: This hook does not work if actions prop are dynamic + useEffect(() => { + if (!compMounted.current) { + compMounted.current = true; + // We will set the initial value of the item based on the passed initial value + const index = actions.findIndex((a) => a.value === initialValue?.value); + setCurState(index > -1 ? index : 0); + } + }, [curState]); + + useEffect(() => { + if (actions[curState]?.label && selected) { + setTooltipText(actions[curState]?.label); + } + }, [curState, selected]); + + const handleLeftArrow = () => { + if (!selected) return; + + fetchNui("playSound", "move").catch(); + const nextEstimatedItem = curState - 1; + const nextItem = + nextEstimatedItem < 0 ? actions.length - 1 : nextEstimatedItem; + setCurState(nextItem); + }; + + const handleRightArrow = () => { + if (!selected) return; + + fetchNui("playSound", "move"); + const nextEstimatedItem = curState + 1; + const nextItem = + nextEstimatedItem >= actions.length ? 0 : nextEstimatedItem; + setCurState(nextItem); + }; + + const handleEnter = () => { + if (!selected) return; + if (!isUserAllowed) return showNotAllowedAlert(); + + fetchNui("playSound", "enter").catch(); + actions[curState].onSelect(); + }; + + useKeyboardNavigation({ + onRightDown: handleRightArrow, + onLeftDown: handleLeftArrow, + onEnterDown: handleEnter, + disableOnFocused: true, + }); + + return ( + + + + {actions[curState]?.icon ?? icon} + + + {title}:  + + {actions[curState]?.name ?? "???"} + + + } + classes={{ + primary: classes.overrideText, + }} + /> + + + + + + ); + } +); diff --git a/nui/src/components/MenuRoot.tsx b/nui/src/components/MenuRoot.tsx new file mode 100644 index 0000000..8676d5d --- /dev/null +++ b/nui/src/components/MenuRoot.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Box } from "@mui/material"; +import { PlayersPage } from "./PlayersPage/PlayersPage"; +import { IFramePage } from "./IFramePage/IFramePage"; +import { txAdminMenuPage, usePageValue } from "../state/page.state"; +import { useHudListenersService } from "../hooks/useHudListenersService"; +import { HelpTooltip } from "./misc/HelpTooltip"; +import { useServerCtxValue } from "../state/server.state"; +import { MenuRootContent } from "@nui/src/components/MenuRootContent"; + + +const MenuRoot: React.FC = () => { + // We need to mount this here so we can get access to + // the translation context + useHudListenersService(); + const curPage = usePageValue(); + const serverCtx = useServerCtxValue() + + if (curPage === txAdminMenuPage.PlayerModalOnly) return null; + return ( + <> + + + + + + + + + ); +}; + +export default MenuRoot; diff --git a/nui/src/components/MenuRootContent.tsx b/nui/src/components/MenuRootContent.tsx new file mode 100644 index 0000000..4b40a0c --- /dev/null +++ b/nui/src/components/MenuRootContent.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { Box, Collapse, styled, Typography, useTheme } from "@mui/material"; +import { PageTabs} from "@nui/src/components/misc/PageTabs"; +import { txAdminMenuPage, usePageValue } from "@nui/src/state/page.state"; +import { MainPageList } from "@nui/src/components/MainPage/MainPageList"; +import { useServerCtxValue } from "@nui/src/state/server.state"; +import { useDebounce } from "@nui/src/hooks/useDebouce"; + +interface TxAdminLogoProps { + themeName: string; +} + +const TxAdminLogo: React.FC = ({ themeName }) => { + const imgName = themeName === 'fivem' ? 'txadmin.png' : 'txadmin-redm.png'; + return ( + + txAdmin logo + + ) +}; + +const StyledRoot = styled(Box)(({ theme }) => ({ + height: "fit-content", + background: theme.palette.background.default, + width: 325, + borderRadius: 15, + display: "flex", + flexDirection: "column", + userSelect: "none", +})); + +export const MenuRootContent: React.FC = React.memo(() => { + const theme = useTheme(); + const serverCtx = useServerCtxValue(); + const curPage = usePageValue() + const padSize = Math.max(0, 9 - serverCtx.txAdminVersion.length); + const versionPad = "\u0020\u205F".repeat(padSize); + + // Hack to prevent collapse transition from breaking + // In some cases, i.e, when setting target player from playerModal + // Collapse transition can break due to multiple page updates within a short + // time frame + const debouncedCurPage = useDebounce(curPage, 50) + + return ( + + + + v{serverCtx.txAdminVersion} + {versionPad} + + + + + + ) +}); diff --git a/nui/src/components/PlayerModal/ErrorHandling/PlayerModalErrorBoundary.tsx b/nui/src/components/PlayerModal/ErrorHandling/PlayerModalErrorBoundary.tsx new file mode 100644 index 0000000..f24a339 --- /dev/null +++ b/nui/src/components/PlayerModal/ErrorHandling/PlayerModalErrorBoundary.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { PlayerModalHasError } from "./PlayerModalHasError"; + +interface PlayerErrorBoundaryState { + hasError: boolean; + errorMessage: string; +} + +export class PlayerModalErrorBoundary extends React.Component< + any, + PlayerErrorBoundaryState +> { + public state = { + hasError: false, + errorMessage: "Unknown Error Occurred", + }; + + public constructor(props) { + super(props); + } + + static getDerivedStateFromError(error) { + return { hasError: true, errorMessage: error.message }; + } + + render() { + if (this.state.hasError) { + return ; + } + + return this.props.children; + } +} diff --git a/nui/src/components/PlayerModal/ErrorHandling/PlayerModalHasError.tsx b/nui/src/components/PlayerModal/ErrorHandling/PlayerModalHasError.tsx new file mode 100644 index 0000000..2274d58 --- /dev/null +++ b/nui/src/components/PlayerModal/ErrorHandling/PlayerModalHasError.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { useTranslate } from "react-polyglot"; +import { Box, styled, Typography } from "@mui/material"; +import { Error } from "@mui/icons-material"; + +const BoxRoot = styled(Box)(({ theme }) => ({ + color: theme.palette.text.secondary, + fontWeight: 300, +})); + +const ErrorIcon = styled(Error)(({ theme }) => ({ + paddingRight: theme.spacing(2), +})); + +interface PlayerModalHasErrorProps { + msg: string; +} + +export const PlayerModalHasError: React.FC = ({ + msg, +}) => { + const t = useTranslate(); + + return ( + + + + + {t("nui_menu.player_modal.misc.error")} + + +
+ {msg} +
+ ); +}; diff --git a/nui/src/components/PlayerModal/PlayerModal.tsx b/nui/src/components/PlayerModal/PlayerModal.tsx new file mode 100644 index 0000000..e3486e2 --- /dev/null +++ b/nui/src/components/PlayerModal/PlayerModal.tsx @@ -0,0 +1,214 @@ +import { + Box, + CircularProgress, + DialogTitle, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { + Block, + Close, + FlashOn, + FormatListBulleted, + MenuBook, + Person, +} from "@mui/icons-material"; +import { + useAssociatedPlayerValue, + usePlayerDetailsValue, +} from "../../state/playerDetails.state"; +import { useTranslate } from "react-polyglot"; +import { DialogBaseView } from "./Tabs/DialogBaseView"; +import { PlayerModalErrorBoundary } from "./ErrorHandling/PlayerModalErrorBoundary"; +import { usePermissionsValue } from "../../state/permissions.state"; +import { userHasPerm } from "../../utils/miscUtils"; +import React from "react"; +import { + PlayerModalTabs, + usePlayerModalTabValue, + useSetPlayerModalTab, + useSetPlayerModalVisibility, +} from "@nui/src/state/playerModal.state"; + +const classes = { + listItem: `PlayerModal-listItem`, + listItemBan: `PlayerModal-listItemBan`, +}; + +const StyledList = styled(List)(({ theme }) => ({ + [`& .${classes.listItem}`]: { + borderRadius: 8, + "&.Mui-selected:hover": { + backgroundColor: "rgba(255, 255, 255, 0.08)", + }, + }, + + [`& .${classes.listItemBan}`]: { + borderRadius: 8, + "&:hover, &.Mui-selected": { + background: theme.palette.error.main, + }, + "&.Mui-selected:hover": { + backgroundColor: "rgba(194,13,37, 0.8)", + }, + }, +})); + +const LoadingModal: React.FC = () => ( + + + +); + +const StyledCloseButton = styled(IconButton)(({ theme }) => ({ + position: "absolute", + top: theme.spacing(1), + right: theme.spacing(2), +})); + +type PlayerModalProps = { + onClose: () => void +}; +const PlayerModal: React.FC = ({onClose}) => { + const setModalOpen = useSetPlayerModalVisibility(); + const playerDetails = usePlayerDetailsValue(); + const assocPlayer = useAssociatedPlayerValue(); + + if (!assocPlayer) return null; + + const error = (playerDetails as any).error; + + return ( + <> + + [{assocPlayer.id}]{" "} + {playerDetails?.player?.displayName ?? assocPlayer.displayName} + + + + + + + {error ? ( + <> +

+ {error} +

+ + ) : ( + <> + + + + }> + + + + )} +
+
+ + ); +}; + +interface DialogTabProps { + title: string; + tab: PlayerModalTabs; + curTab: PlayerModalTabs; + icon: JSX.Element; + isDisabled?: boolean; +} + +const DialogTab: React.FC = ({ + isDisabled, + curTab, + tab, + icon, + title, +}) => { + const setTab = useSetPlayerModalTab(); + + const stylingClass = + tab === PlayerModalTabs.BAN ? classes.listItemBan : classes.listItem; + + const isSelected = curTab === tab; + + return ( + setTab(tab)} + disabled={isDisabled} + > + {icon} + + + ); +}; + +const DialogList: React.FC = () => { + const curTab = usePlayerModalTabValue(); + const t = useTranslate(); + const playerPerms = usePermissionsValue(); + + return ( + + } + /> + } + /> + } + /> + } + /> + } + isDisabled={!userHasPerm("players.ban", playerPerms)} + /> + + ); +}; + +export default PlayerModal; diff --git a/nui/src/components/PlayerModal/Tabs/DialogActionView.tsx b/nui/src/components/PlayerModal/Tabs/DialogActionView.tsx new file mode 100644 index 0000000..5d72d94 --- /dev/null +++ b/nui/src/components/PlayerModal/Tabs/DialogActionView.tsx @@ -0,0 +1,420 @@ +import React from "react"; +import { styled } from "@mui/material/styles"; +import { + Box, + Button, + DialogContent, + Tooltip, + TooltipProps, + Typography, +} from "@mui/material"; +import { + useAssociatedPlayerValue, + usePlayerDetailsValue, +} from "../../../state/playerDetails.state"; +import { fetchWebPipe } from "../../../utils/fetchWebPipe"; +import { fetchNui } from "../../../utils/fetchNui"; +import { useDialogContext } from "../../../provider/DialogProvider"; +import { useSnackbar } from "notistack"; +import { useIFrameCtx } from "../../../provider/IFrameProvider"; +import { usePlayerModalContext } from "../../../provider/PlayerModalProvider"; +import { userHasPerm } from "../../../utils/miscUtils"; +import { useTranslate } from "react-polyglot"; +import { usePermissionsValue } from "../../../state/permissions.state"; +import { DialogLoadError } from "./DialogLoadError"; +import { useServerCtxValue } from "../../../state/server.state"; +import { GenericApiErrorResp, GenericApiResp } from "@shared/genericApiTypes"; +import { useSetPlayerModalVisibility } from "@nui/src/state/playerModal.state"; + +const PREFIX = "DialogActionView"; + +const classes = { + actionGrid: `${PREFIX}-actionGrid`, + tooltipOverride: `${PREFIX}-tooltipOverride`, + sectionTitle: `${PREFIX}-sectionTitle`, +}; + +const StyledDialogContent = styled(DialogContent)({ + [`& .${classes.actionGrid}`]: { + display: "flex", + columnGap: 10, + rowGap: 10, + paddingBottom: 15, + }, + [`& .${classes.tooltipOverride}`]: { + fontSize: 12, + }, + [`& .${classes.sectionTitle}`]: { + paddingBottom: 5, + }, +}); + +export type TxAdminActionRespType = "success" | "warning" | "danger"; + +export interface TxAdminAPIResp { + type: TxAdminActionRespType; + message: string; +} + +const DialogActionView: React.FC = () => { + const { openDialog } = useDialogContext(); + const playerDetails = usePlayerDetailsValue(); + const assocPlayer = useAssociatedPlayerValue(); + const { enqueueSnackbar } = useSnackbar(); + const t = useTranslate(); + const { goToFramePage } = useIFrameCtx(); + const serverCtx = useServerCtxValue(); + const playerPerms = usePermissionsValue(); + const setModalOpen = useSetPlayerModalVisibility(); + const { closeMenu, showNoPerms } = usePlayerModalContext(); + if ("error" in playerDetails) return ; + + //Helper + const handleGenericApiResponse = ( + result: GenericApiResp, + successMessageKey: string + ) => { + if ("success" in result && result.success === true) { + enqueueSnackbar(t(`nui_menu.player_modal.actions.${successMessageKey}`), { + variant: "success", + }); + } else { + enqueueSnackbar( + (result as GenericApiErrorResp).error ?? t("nui_menu.misc.unknown_error"), + { variant: "error" } + ); + } + }; + + //Moderation + const handleDM = () => { + if (!userHasPerm("players.direct_message", playerPerms)) + return showNoPerms("Direct Message"); + + openDialog({ + title: `${t( + "nui_menu.player_modal.actions.moderation.dm_dialog.title" + )} ${assocPlayer.displayName}`, + description: t( + "nui_menu.player_modal.actions.moderation.dm_dialog.description" + ), + placeholder: t( + "nui_menu.player_modal.actions.moderation.dm_dialog.placeholder" + ), + onSubmit: async (message: string) => { + try { + const result = await fetchWebPipe( + `/player/message?mutex=current&netid=${assocPlayer.id}`, + { + method: "POST", + data: { message: message.trim() }, + } + ); + handleGenericApiResponse(result, "moderation.dm_dialog.success"); + } catch (error) { + enqueueSnackbar((error as Error).message, { variant: "error" }); + } + }, + }); + }; + + const handleWarn = () => { + if (!userHasPerm("players.warn", playerPerms)) return showNoPerms("Warn"); + + openDialog({ + title: `${t( + "nui_menu.player_modal.actions.moderation.warn_dialog.title" + )} ${assocPlayer.displayName}`, + description: t( + "nui_menu.player_modal.actions.moderation.warn_dialog.description" + ), + placeholder: t( + "nui_menu.player_modal.actions.moderation.warn_dialog.placeholder" + ), + onSubmit: async (reason: string) => { + try { + const result = await fetchWebPipe( + `/player/warn?mutex=current&netid=${assocPlayer.id}`, + { + method: "POST", + data: { reason: reason.trim() }, + } + ); + handleGenericApiResponse(result, "moderation.warn_dialog.success"); + } catch (error) { + enqueueSnackbar((error as Error).message, { variant: "error" }); + } + }, + }); + }; + + const handleKick = () => { + if (!userHasPerm("players.kick", playerPerms)) return showNoPerms("Kick"); + + openDialog({ + title: `${t( + "nui_menu.player_modal.actions.moderation.kick_dialog.title" + )} ${assocPlayer.displayName}`, + description: t( + "nui_menu.player_modal.actions.moderation.kick_dialog.description" + ), + placeholder: t( + "nui_menu.player_modal.actions.moderation.kick_dialog.placeholder" + ), + onSubmit: async (reason: string) => { + try { + const result = await fetchWebPipe( + `/player/kick?mutex=current&netid=${assocPlayer.id}`, + { + method: "POST", + data: { reason: reason.trim() }, + } + ); + handleGenericApiResponse(result, "moderation.kick_dialog.success"); + } catch (error) { + enqueueSnackbar((error as Error).message, { variant: "error" }); + } + }, + }); + }; + + const handleSetAdmin = () => { + if (!userHasPerm("manage.admins", playerPerms)) { + return showNoPerms("Manage Admins"); + } + //If the playerDetails is available + const params = new URLSearchParams(); + if (typeof playerDetails.player.netid === "number") { + params.set("autofill", "true"); + params.set("name", playerDetails.player.pureName); + + for (const id of playerDetails.player.ids) { + if (id.startsWith("discord:")) { + params.set("discord", id); + } else if (id.startsWith("fivem:")) { + params.set("citizenfx", id); + } + } + } + + // TODO: Change iFrame Src through Provider? + goToFramePage(`/admins?${params}`); + setModalOpen(false); + }; + + //Interaction + const handleHeal = () => { + if (!userHasPerm("players.heal", playerPerms)) return showNoPerms("Heal"); + + fetchNui("healPlayer", { id: assocPlayer.id }); + enqueueSnackbar( + t("nui_menu.player_modal.actions.interaction.notifications.heal_player"), + { variant: "success" } + ); + }; + + const handleGoTo = () => { + if (!userHasPerm("players.teleport", playerPerms)) + return showNoPerms("Teleport"); + + // Only works with onesync because server needs to know the player's coords + if (!serverCtx.oneSync.status) { + return enqueueSnackbar(t("nui_menu.misc.onesync_error"), { + variant: "error", + }); + } + + closeMenu(); + fetchNui("tpToPlayer", { id: assocPlayer.id }); + enqueueSnackbar( + t("nui_menu.player_modal.actions.interaction.notifications.tp_player"), + { variant: "success" } + ); + }; + + const handleBring = () => { + if (!userHasPerm("players.teleport", playerPerms)) + return showNoPerms("Teleport"); + + // Only works with onesync because server needs to know the player's coords + if (!serverCtx.oneSync.status) { + return enqueueSnackbar(t("nui_menu.misc.onesync_error"), { + variant: "error", + }); + } + + closeMenu(); + fetchNui("summonPlayer", { id: assocPlayer.id }); + enqueueSnackbar( + t("nui_menu.player_modal.actions.interaction.notifications.bring_player"), + { variant: "success" } + ); + }; + + const handleSpectate = () => { + if (!userHasPerm("players.spectate", playerPerms)) + return showNoPerms("Spectate"); + + closeMenu(); + fetchNui("spectatePlayer", { id: assocPlayer.id }); + }; + + const handleFreeze = () => { + if (!userHasPerm("players.freeze", playerPerms)) + return showNoPerms("Freeze"); + fetchNui("togglePlayerFreeze", { id: assocPlayer.id }); + }; + + //Troll + const handleDrunk = () => { + if (!userHasPerm("players.troll", playerPerms)) return showNoPerms("Troll"); + fetchNui("drunkEffectPlayer", { id: assocPlayer.id }); + enqueueSnackbar(t("nui_menu.player_modal.actions.command_sent")); + }; + + const handleSetOnFire = () => { + if (!userHasPerm("players.troll", playerPerms)) return showNoPerms("Troll"); + fetchNui("setOnFire", { id: assocPlayer.id }); + enqueueSnackbar(t("nui_menu.player_modal.actions.command_sent")); + }; + + const handleWildAttack = () => { + if (!userHasPerm("players.troll", playerPerms)) return showNoPerms("Troll"); + fetchNui("wildAttack", { id: assocPlayer.id }); + enqueueSnackbar(t("nui_menu.player_modal.actions.command_sent")); + }; + + const TooltipOverride: React.FC = (props) => ( + + {props.children} + + ); + + return ( + + + + {t("nui_menu.player_modal.actions.title")} + + + + {t("nui_menu.player_modal.actions.moderation.title")} + + + + + + + + + {t("nui_menu.player_modal.actions.interaction.title")} + + + + + + + + + + {t("nui_menu.player_modal.actions.troll.title")} + + + + + + + + ); +}; + +export default DialogActionView; diff --git a/nui/src/components/PlayerModal/Tabs/DialogBanView.tsx b/nui/src/components/PlayerModal/Tabs/DialogBanView.tsx new file mode 100644 index 0000000..612f90a --- /dev/null +++ b/nui/src/components/PlayerModal/Tabs/DialogBanView.tsx @@ -0,0 +1,291 @@ +import React, { useMemo, useState } from "react"; +import { + Autocomplete, + Box, + Button, + DialogContent, + MenuItem, + TextField, + Typography, +} from "@mui/material"; +import { + useAssociatedPlayerValue, + usePlayerDetailsValue, +} from "../../../state/playerDetails.state"; +import { fetchWebPipe } from "../../../utils/fetchWebPipe"; +import { useSnackbar } from "notistack"; +import { useTranslate } from "react-polyglot"; +import { usePlayerModalContext } from "../../../provider/PlayerModalProvider"; +import { userHasPerm } from "../../../utils/miscUtils"; +import { usePermissionsValue } from "../../../state/permissions.state"; +import { DialogLoadError } from "./DialogLoadError"; +import { GenericApiErrorResp, GenericApiResp } from "@shared/genericApiTypes"; +import { useSetPlayerModalVisibility } from "@nui/src/state/playerModal.state"; +import type { BanTemplatesDataType, BanDurationType } from "@shared/otherTypes"; + +//Helpers - yoinked from the web ui code +const maxReasonSize = 128; +const defaultDurations = ['permanent', '2 hours', '8 hours', '1 day', '2 days', '1 week', '2 weeks']; +const banDurationToString = (duration: BanDurationType) => { + if (duration === 'permanent') return 'permanent'; + if (typeof duration === 'string') return duration; + const pluralizedString = duration.value === 1 ? duration.unit.slice(0, -1) : duration.unit; + return `${duration.value} ${pluralizedString}`; +} +const banDurationToShortString = (duration: BanDurationType) => { + if (typeof duration === 'string') { + return duration === 'permanent' ? 'PERM' : duration; + } + + let suffix: string; + if (duration.unit === 'hours') { + suffix = 'h'; + } else if (duration.unit === 'days') { + suffix = 'd'; + } else if (duration.unit === 'weeks') { + suffix = 'w'; + } else if (duration.unit === 'months') { + suffix = 'mo'; + } else { + suffix = duration.unit; + } + return `${duration.value}${suffix}`; +} + +const DialogBanView: React.FC = () => { + const assocPlayer = useAssociatedPlayerValue(); + const playerDetails = usePlayerDetailsValue(); + const [reason, setReason] = useState(""); + const [duration, setDuration] = useState("2 hours"); + const [customDuration, setCustomDuration] = useState("hours"); + const [customDurLength, setCustomDurLength] = useState("1"); + const t = useTranslate(); + const setModalOpen = useSetPlayerModalVisibility(); + const { enqueueSnackbar } = useSnackbar(); + const { showNoPerms } = usePlayerModalContext(); + const playerPerms = usePermissionsValue(); + + if (typeof assocPlayer !== "object") { + return ; + } + + const handleBan = (e: React.FormEvent) => { + e.preventDefault(); + if (!userHasPerm("players.ban", playerPerms)) return showNoPerms("Ban"); + + const trimmedReason = reason.trim(); + if (!trimmedReason.length) { + enqueueSnackbar(t("nui_menu.player_modal.ban.reason_required"), { + variant: "error", + }); + return; + } + + const actualDuration = duration === "custom" + ? `${customDurLength} ${customDuration}` + : duration; + + fetchWebPipe( + `/player/ban?mutex=current&netid=${assocPlayer.id}`, + { + method: "POST", + data: { + reason: trimmedReason, + duration: actualDuration, + }, + } + ) + .then((result) => { + if ("success" in result && result.success) { + setModalOpen(false); + enqueueSnackbar(t(`nui_menu.player_modal.ban.success`), { + variant: "success", + }); + } else { + enqueueSnackbar( + (result as GenericApiErrorResp).error ?? t("nui_menu.misc.unknown_error"), + { variant: "error" } + ); + } + }) + .catch((error) => { + enqueueSnackbar((error as Error).message, { variant: "error" }); + }); + }; + + const banDurations = [ + { + value: "2 hours", + label: `2 ${t("nui_menu.player_modal.ban.hours")}`, + }, + { + value: "8 hours", + label: `8 ${t("nui_menu.player_modal.ban.hours")}`, + }, + { + value: "1 day", + label: `1 ${t("nui_menu.player_modal.ban.days")}`, + }, + { + value: "2 days", + label: `2 ${t("nui_menu.player_modal.ban.days")}`, + }, + { + value: "1 week", + label: `1 ${t("nui_menu.player_modal.ban.weeks")}`, + }, + { + value: "2 weeks", + label: `2 ${t("nui_menu.player_modal.ban.weeks")}`, + }, + { + value: "permanent", + label: t("nui_menu.player_modal.ban.permanent"), + }, + { + value: "custom", + label: t("nui_menu.player_modal.ban.custom"), + }, + ]; + + const customBanLength = [ + { + value: "hours", + label: t("nui_menu.player_modal.ban.hours"), + }, + { + value: "days", + label: t("nui_menu.player_modal.ban.days"), + }, + { + value: "weeks", + label: t("nui_menu.player_modal.ban.weeks"), + }, + { + value: "months", + label: t("nui_menu.player_modal.ban.months"), + }, + ]; + + //Handling ban templates + const banTemplates: BanTemplatesDataType[] = (playerDetails as any)?.banTemplates ?? []; + const processedTemplates = useMemo(() => { + return banTemplates.map((template, index) => ({ + id: template.id, + label: template.reason, + })); + }, [banTemplates]); + + const handleTemplateChange = (event: React.SyntheticEvent, value: any, reason: string, details?: any) => { + //reason = One of "createOption", "selectOption", "removeOption", "blur" or "clear". + if (reason !== 'selectOption' || value === null) return; + const template = banTemplates.find(template => template.id === value.id); + if (!template) return; + + const processedDuration = banDurationToString(template.duration); + if (defaultDurations.includes(processedDuration)) { + setDuration(processedDuration); + } else if (typeof template.duration === 'object') { + setDuration('custom'); + setCustomDurLength(template.duration.value.toString()); + setCustomDuration(template.duration.unit); + } + } + + const handleReasonInputChange = (event: React.SyntheticEvent, value: string, reason: string) => { + setReason(value); + } + + return ( + + + {t("nui_menu.player_modal.ban.title")} + +
+ { + const duration = banTemplates.find((t) => t.id === option.id)?.duration ?? '????'; + const reason = option.label.length > maxReasonSize + ? option.label.slice(0, maxReasonSize - 3) + '...' + : option.label; + return
  • + {banDurationToShortString(duration as any)} {reason} +
  • + }} + renderInput={(params) => } + /> + setDuration(e.target.value)} + helperText={t("nui_menu.player_modal.ban.helper_text")} + fullWidth + > + {banDurations.map((option) => ( + + {option.label} + + ))} + + {duration === "custom" && ( + + setCustomDurLength(e.target.value)} + /> + setCustomDuration(e.target.value)} + > + {customBanLength.map((option) => ( + + {option.label} + + ))} + + + )} + + +
    + ); +}; + +export default DialogBanView; diff --git a/nui/src/components/PlayerModal/Tabs/DialogBaseView.tsx b/nui/src/components/PlayerModal/Tabs/DialogBaseView.tsx new file mode 100644 index 0000000..c4785ba --- /dev/null +++ b/nui/src/components/PlayerModal/Tabs/DialogBaseView.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import DialogActionView from "./DialogActionView"; +import DialogInfoView from "./DialogInfoView"; +import DialogIdView from "./DialogIdView"; +import DialogHistoryView from "./DialogHistoryView"; +import DialogBanView from "./DialogBanView"; +import {Box} from "@mui/material"; +import {PlayerModalTabs, usePlayerModalTabValue} from "@nui/src/state/playerModal.state"; + +const tabToRender = (tab: PlayerModalTabs) => { + switch (tab) { + case PlayerModalTabs.ACTIONS: + return + case PlayerModalTabs.INFO: + return + case PlayerModalTabs.IDENTIFIERS: + return + case PlayerModalTabs.HISTORY: + return + case PlayerModalTabs.BAN: + return + } +} + +export const DialogBaseView: React.FC = () => { + const curTab = usePlayerModalTabValue() + + return ( + + {tabToRender(curTab)} + + ); +}; diff --git a/nui/src/components/PlayerModal/Tabs/DialogHistoryView.tsx b/nui/src/components/PlayerModal/Tabs/DialogHistoryView.tsx new file mode 100644 index 0000000..cedaa2d --- /dev/null +++ b/nui/src/components/PlayerModal/Tabs/DialogHistoryView.tsx @@ -0,0 +1,202 @@ +import React from "react"; +import { Box, Typography, useTheme } from "@mui/material"; +import { + useForcePlayerRefresh, + usePlayerDetailsValue, +} from "../../../state/playerDetails.state"; +import { useTranslate } from "react-polyglot"; +import { DialogLoadError } from "./DialogLoadError"; +import { PlayerHistoryItem } from "@shared/playerApiTypes"; +import { useSnackbar } from "notistack"; +import { fetchWebPipe } from "@nui/src/utils/fetchWebPipe"; +import { GenericApiErrorResp, GenericApiResp } from "@shared/genericApiTypes"; +import { ButtonXS } from "../../misc/ButtonXS"; +import { tsToLocaleDateTime, userHasPerm } from "@nui/src/utils/miscUtils"; +import { usePermissionsValue } from "@nui/src/state/permissions.state"; + +// TODO: Make the styling on this nicer +const NoHistoryBox = () => ( + + + No history found for this player. + + +); + +const colors = { + danger: "#c2293e", + warning: "#f1c40f", + dark: "gray", +}; + +type ActionCardProps = { + action: PlayerHistoryItem; + permsDisableWarn: boolean; + permsDisableBan: boolean; + serverTime: number; + btnAction: Function; +}; +const ActionCard: React.FC = ({ + action, + permsDisableWarn, + permsDisableBan, + serverTime, + btnAction, +}) => { + const theme = useTheme(); + const t = useTranslate(); + + const revokeButonDisabled = + action.revokedBy !== undefined || + (action.type == "warn" && permsDisableWarn) || + (action.type == "ban" && permsDisableBan); + + let footerNote, actionColor, actionMessage; + if (action.type == "ban") { + actionColor = colors.danger; + actionMessage = t("nui_menu.player_modal.history.banned_by", { + author: action.author, + }); + } else if (action.type == "warn") { + actionColor = colors.warning; + actionMessage = t("nui_menu.player_modal.history.warned_by", { + author: action.author, + }); + } + if (action.revokedBy) { + actionColor = colors.dark; + footerNote = t("nui_menu.player_modal.history.revoked_by", { + author: action.revokedBy, + }); + } + if (typeof action.exp == "number") { + const expirationDate = tsToLocaleDateTime(action.exp, "medium"); + footerNote = + action.exp < serverTime + ? t("nui_menu.player_modal.history.expired_at", { + date: expirationDate, + }) + : t("nui_menu.player_modal.history.expires_at", { + date: expirationDate, + }); + } + + return ( + + + {actionMessage} + + ({action.id})   + {tsToLocaleDateTime(action.ts, "medium")} +   + + {t("nui_menu.player_modal.history.btn_revoke")} + + + + + {action.reason} + + {footerNote && ( + + {footerNote} + + )} + + ); +}; + +const DialogHistoryView: React.FC = () => { + const { enqueueSnackbar } = useSnackbar(); + const playerDetails = usePlayerDetailsValue(); + const forceRefresh = useForcePlayerRefresh(); + const userPerms = usePermissionsValue(); + const t = useTranslate(); + if ("error" in playerDetails) return ; + + //slice is required to clone the array + const playerActionHistory = playerDetails.player.actionHistory + .slice() + .reverse(); + + const handleRevoke = async (actionId: string) => { + try { + const result = await fetchWebPipe( + `/history/revokeAction`, + { + method: "POST", + data: { actionId }, + } + ); + if ("success" in result && result.success === true) { + forceRefresh((val) => val + 1); + enqueueSnackbar(t(`nui_menu.player_modal.history.revoked_success`), { + variant: "success", + }); + } else { + enqueueSnackbar( + (result as GenericApiErrorResp).error ?? t("nui_menu.misc.unknown_error"), + { variant: "error" } + ); + } + } catch (error) { + enqueueSnackbar((error as Error).message, { variant: "error" }); + } + }; + + const hasWarnPerm = userHasPerm('players.warn', userPerms); + const hasBanPerm = userHasPerm('players.ban', userPerms); + + return ( + + + Related History: + + + {!playerActionHistory?.length ? ( + + ) : ( + playerActionHistory.map((action) => ( + { + handleRevoke(action.id); + }} + /> + )) + )} + + + ); +}; + +export default DialogHistoryView; diff --git a/nui/src/components/PlayerModal/Tabs/DialogIdView.tsx b/nui/src/components/PlayerModal/Tabs/DialogIdView.tsx new file mode 100644 index 0000000..1025ef0 --- /dev/null +++ b/nui/src/components/PlayerModal/Tabs/DialogIdView.tsx @@ -0,0 +1,121 @@ +import React from "react"; +import { styled } from '@mui/material/styles'; +import { Box, IconButton, Typography } from "@mui/material"; +import { usePlayerDetailsValue } from "../../../state/playerDetails.state"; +import { FileCopy } from "@mui/icons-material"; +import { copyToClipboard } from "../../../utils/copyToClipboard"; +import { useSnackbar } from "notistack"; +import { useTranslate } from "react-polyglot"; +import { DialogLoadError } from "./DialogLoadError"; + +const PREFIX = 'DialogIdView'; + +const classes = { + codeBlock: `${PREFIX}-codeBlock`, + codeBlockText: `${PREFIX}-codeBlockText`, + codeBlockHwids: `${PREFIX}-codeBlockHwids` +}; + +const StyledBox = styled(Box)(({ theme }) => ({ + [`& .${classes.codeBlock}`]: { + background: theme.palette.background.paper, + borderRadius: 8, + padding: "0px 15px", + marginBottom: 7, + display: "flex", + alignItems: "center", + }, + + [`& .${classes.codeBlockText}`]: { + flexGrow: 1, + fontFamily: "monospace", + }, + + [`& .${classes.codeBlockHwids}`]: { + flexGrow: 1, + fontFamily: "monospace", + padding: '15px 0px', + fontSize: '0.95rem', + opacity: '0.75' + } +})); + +const DialogIdView: React.FC = () => { + const playerDetails = usePlayerDetailsValue(); + const { enqueueSnackbar } = useSnackbar(); + const t = useTranslate(); + if ('error' in playerDetails) return (); + + const handleCopyToClipboard = (value: string) => { + copyToClipboard(value, true); + enqueueSnackbar(t("nui_menu.common.copied"), { variant: "info" }); + }; + + const getCurrentIds = () => { + if (!Array.isArray(playerDetails.player.ids) || !playerDetails.player.ids.length) { + return No identifiers saved. + } else { + return playerDetails.player.ids.map((ident) => ( + + {ident} + handleCopyToClipboard(ident)} size="large"> + + + + )) + } + } + + const getOldIds = () => { + if (!Array.isArray(playerDetails.player.oldIds) || !playerDetails.player.oldIds.length) { + return No identifiers saved. + } else { + const filtered = playerDetails.player.oldIds.filter(id => !playerDetails.player.ids.includes(id)); + if (!filtered.length) { + return No identifiers saved. + } else { + return playerDetails.player.oldIds.map((ident) => ( + + {ident} + handleCopyToClipboard(ident)} size="large"> + + + + )); + } + } + } + + const getAllHwids = () => { + if (!Array.isArray(playerDetails.player.oldHwids) || !playerDetails.player.oldHwids.length) { + return No HWIDs saved. + } else { + return + + {playerDetails.player.oldHwids.join('\n')} + + + } + } + + return ( + + {t("nui_menu.player_modal.ids.current_ids")}: + + {getCurrentIds()} + + + {t("nui_menu.player_modal.ids.previous_ids")}: + + {getOldIds()} + + + {t("nui_menu.player_modal.ids.all_hwids")}: + + {getAllHwids()} + + + ); +}; + +export default DialogIdView; diff --git a/nui/src/components/PlayerModal/Tabs/DialogInfoView.tsx b/nui/src/components/PlayerModal/Tabs/DialogInfoView.tsx new file mode 100644 index 0000000..018c55e --- /dev/null +++ b/nui/src/components/PlayerModal/Tabs/DialogInfoView.tsx @@ -0,0 +1,216 @@ +import React, { FormEventHandler, useEffect, useState } from "react"; +import { + Box, + Button, + DialogContent, + TextField, + Typography, + useTheme, +} from "@mui/material"; +import { + useForcePlayerRefresh, + usePlayerDetailsValue, +} from "../../../state/playerDetails.state"; +import { fetchWebPipe } from "../../../utils/fetchWebPipe"; +import { useSnackbar } from "notistack"; +import { useTranslate } from "react-polyglot"; +import { DialogLoadError } from "./DialogLoadError"; +import { GenericApiErrorResp, GenericApiResp } from "@shared/genericApiTypes"; +import humanizeDuration, { Unit } from "humanize-duration"; +import { ButtonXS } from "../../misc/ButtonXS"; +import { tsToLocaleDate, userHasPerm } from "@nui/src/utils/miscUtils"; +import { + PlayerModalTabs, + useSetPlayerModalTab, +} from "@nui/src/state/playerModal.state"; +import { usePermissionsValue } from "@nui/src/state/permissions.state"; + +const DialogInfoView: React.FC = () => { + const [note, setNote] = useState(""); + const { enqueueSnackbar } = useSnackbar(); + const playerDetails = usePlayerDetailsValue(); + const forceRefresh = useForcePlayerRefresh(); + const setTab = useSetPlayerModalTab(); + const t = useTranslate(); + const theme = useTheme(); + const userPerms = usePermissionsValue(); + if ("error" in playerDetails) return ; + const player = playerDetails.player; + + //Prepare vars + const language = t("$meta.humanizer_language"); + function minsToDuration(seconds: number) { + return humanizeDuration(seconds * 60_000, { + language, + round: true, + units: ["d", "h", "m"] as Unit[], + fallbacks: ["en"], + }); + } + + const handleSaveNote: FormEventHandler = async (e) => { + e.preventDefault(); + try { + const result = await fetchWebPipe( + `/player/save_note?mutex=current&netid=${player.netid}`, + { + method: "POST", + data: { note: note.trim() }, + } + ); + if ("success" in result && result.success === true) { + forceRefresh((val) => val + 1); + enqueueSnackbar(t(`nui_menu.player_modal.info.notes_changed`), { + variant: "success", + }); + } else { + enqueueSnackbar( + (result as GenericApiErrorResp).error ?? t("nui_menu.misc.unknown_error"), + { variant: "error" } + ); + } + } catch (e) { + enqueueSnackbar(t("nui_menu.misc.unknown_error"), { variant: "error" }); + } + }; + + useEffect(() => { + setNote(player.notes ?? ""); + }, [playerDetails]); + + //Whitelist button + const btnChangeWhitelistStatus = async () => { + try { + const result = await fetchWebPipe( + `/player/whitelist?mutex=current&netid=${player.netid}`, + { + method: "POST", + data: { status: !player.tsWhitelisted }, + } + ); + if ("success" in result && result.success === true) { + forceRefresh((val) => val + 1); + enqueueSnackbar(t(`nui_menu.player_modal.info.btn_wl_success`), { + variant: "success", + }); + } else { + enqueueSnackbar( + (result as GenericApiErrorResp).error ?? t("nui_menu.misc.unknown_error"), + { variant: "error" } + ); + } + } catch (error) { + enqueueSnackbar((error as Error).message, { variant: "error" }); + } + }; + + //Log stuff + const counts = { ban: 0, warn: 0 }; + for (const action of player.actionHistory) { + counts[action.type]++; + } + const btnLogDetails = () => { + setTab(PlayerModalTabs.HISTORY); + }; + + return ( + + + {t("nui_menu.player_modal.info.title")} + + + {t("nui_menu.player_modal.info.session_time")}:{" "} + + {player.sessionTime ? minsToDuration(player.sessionTime) : "--"} + + + + {t("nui_menu.player_modal.info.play_time")}:{" "} + + {player.playTime ? minsToDuration(player.playTime) : "--"} + + + + {t("nui_menu.player_modal.info.joined")}:{" "} + + {player.tsJoined ? tsToLocaleDate(player.tsJoined) : "--"} + + + + {t("nui_menu.player_modal.info.whitelisted_label")}:{" "} + + {player.tsWhitelisted + ? tsToLocaleDate(player.tsWhitelisted) + : t("nui_menu.player_modal.info.whitelisted_notyet")} + {" "} + + {player.tsWhitelisted + ? t("nui_menu.player_modal.info.btn_wl_remove") + : t("nui_menu.player_modal.info.btn_wl_add")} + + + + {t("nui_menu.player_modal.info.log_label")}:{" "} + + {!counts.ban && !counts.warn ? ( + t("nui_menu.player_modal.info.log_empty") + ) : ( + <> + + {t("nui_menu.player_modal.info.log_ban_count", { + smart_count: counts.ban, + })} + + ,  + + {t("nui_menu.player_modal.info.log_warn_count", { + smart_count: counts.warn, + })} + + + )} + {" "} + + {t("nui_menu.player_modal.info.log_btn")} + + +
    + + setNote(e.currentTarget.value)} + onKeyPress={(e) => e.key === "Enter" && handleSaveNote} + variant="outlined" + multiline + rows={4} + fullWidth + /> + + +
    +
    + ); +}; + +export default DialogInfoView; diff --git a/nui/src/components/PlayerModal/Tabs/DialogLoadError.tsx b/nui/src/components/PlayerModal/Tabs/DialogLoadError.tsx new file mode 100644 index 0000000..e079c53 --- /dev/null +++ b/nui/src/components/PlayerModal/Tabs/DialogLoadError.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { Box, styled, Typography } from "@mui/material"; + +const StyledTypography = styled(Typography)({ + alignSelf: "center", + marginTop: "auto", + marginBottom: "auto", + opacity: 0.5, +}); + +export const DialogLoadError: React.FC = () => { + return ( + + + Failed to load player data :( + + + ); +}; diff --git a/nui/src/components/PlayersPage/PlayerCard.tsx b/nui/src/components/PlayersPage/PlayerCard.tsx new file mode 100644 index 0000000..5433f7e --- /dev/null +++ b/nui/src/components/PlayersPage/PlayerCard.tsx @@ -0,0 +1,185 @@ +import React, { memo } from "react"; +import { styled } from "@mui/material/styles"; +import { Box, Paper, Theme, Tooltip, Typography } from "@mui/material"; +import { + DirectionsBoat, + DirectionsWalk, + DriveEta, + LiveHelp, + TwoWheeler, + Flight, +} from "@mui/icons-material"; +import { useSetAssociatedPlayer } from "../../state/playerDetails.state"; +import { formatDistance } from "../../utils/miscUtils"; +import { useTranslate } from "react-polyglot"; +import { PlayerData, VehicleStatus } from "../../hooks/usePlayerListListener"; +import { useSetPlayerModalVisibility } from "@nui/src/state/playerModal.state"; + +const PREFIX = "PlayerCard"; + +const classes = { + paper: `${PREFIX}-paper`, + barBackground: `${PREFIX}-barBackground`, + barInner: `${PREFIX}-barInner`, + icon: `${PREFIX}-icon`, + tooltipOverride: `${PREFIX}-tooltipOverride`, +}; + +const StyledBox = styled(Box)(({ theme }) => ({ + [`& .${classes.paper}`]: { + padding: 20, + borderRadius: 10, + cursor: "pointer", + "&:hover": { + backgroundColor: theme.palette.action.selected, + }, + }, + + [`& .${classes.barBackground}`]: { + background: theme.palette.primary.dark, + height: 5, + borderRadius: 10, + overflow: "hidden", + }, + + [`& .${classes.barInner}`]: { + height: "100%", + background: theme.palette.primary.main, + }, + + [`& .${classes.icon}`]: { + paddingRight: 7, + color: theme.palette.primary.main, + }, + + [`& .${classes.tooltipOverride}`]: { + fontSize: 12, + }, +})); + +const determineHealthBGColor = (val: number) => { + if (val === -1) return "#4A4243"; + else if (val <= 20) return "#4a151b"; + else if (val <= 60) return "#624d18"; + else return "#097052"; +}; + +const determineHealthColor = (val: number, theme: Theme) => { + if (val === -1) return "#4A4243"; + else if (val <= 20) return theme.palette.error.light; + else if (val <= 60) return theme.palette.warning.light; + else return theme.palette.success.light; +}; + +const HealthBarBackground = styled(Box, { + shouldForwardProp: (prop) => prop !== "healthVal", +})<{ healthVal: number }>(({ healthVal }) => ({ + background: determineHealthBGColor(healthVal), + height: 5, + borderRadius: 10, + overflow: "hidden", +})); + +const HealthBar = styled(Box, { + shouldForwardProp: (prop) => prop !== "healthVal", +})<{ healthVal: number }>(({ theme, healthVal }) => ({ + background: determineHealthColor(healthVal, theme), + height: 5, + borderRadius: 10, + overflow: "hidden", +})); + +const PlayerCard: React.FC<{ playerData: PlayerData }> = ({ playerData }) => { + const setModalOpen = useSetPlayerModalVisibility(); + const setAssociatedPlayer = useSetAssociatedPlayer(); + const t = useTranslate(); + + const statusIcon: { [K in VehicleStatus]: JSX.Element } = { + unknown: , + walking: , + driving: , + boating: , + biking: , + flying: , + }; + + const handlePlayerClick = () => { + setAssociatedPlayer(playerData); + setModalOpen(true); + }; + + const upperCaseStatus = playerData.vType.charAt(0).toUpperCase() + playerData.vType.slice(1); + const healthBarSize = Math.max(0, playerData.health); + + return ( + +
    + + + + + + {statusIcon[playerData.vType]} + + + + {playerData.id} + + + | + + + {playerData.admin && "🛡️"} {playerData.displayName} + + + {playerData.dist < 0 ? `?? m` : formatDistance(playerData.dist)} + + + +
    + + + + + +
    +
    +
    +
    + ); +}; + +export default memo(PlayerCard); diff --git a/nui/src/components/PlayersPage/PlayerPageHeader.tsx b/nui/src/components/PlayersPage/PlayerPageHeader.tsx new file mode 100644 index 0000000..3c591c4 --- /dev/null +++ b/nui/src/components/PlayersPage/PlayerPageHeader.tsx @@ -0,0 +1,168 @@ +import React, { useEffect, useState } from "react"; +import { + Box, + InputAdornment, + MenuItem, + styled, + Typography, +} from "@mui/material"; +import { FilterAlt, Search, SwapVert } from "@mui/icons-material"; +import { + PlayerDataFilter, + PlayerDataSort, + usePlayersFilterBy, + usePlayersSortBy, + usePlayersState, + usePlayersSearch, + useSetPlayersFilterIsTemp, +} from "../../state/players.state"; +import { useServerCtxValue } from "../../state/server.state"; +import { useTranslate } from "react-polyglot"; +import { TextField } from "../misc/TextField"; +import { useDebounce } from "@nui/src/hooks/useDebouce"; + +const TypographyTitle = styled(Typography)(({ theme }) => ({ + fontWeight: 600, +})); + +const TypographyPlayerCount = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.secondary, + fontWeight: 500, +})); + +const InputAdornmentIcon = styled(InputAdornment)(({ theme }) => ({ + color: theme.palette.text.secondary, +})); + +const TextFieldInputs = styled(TextField)({ + minWidth: 150, +}); + +export const PlayerPageHeader: React.FC = () => { + const [filterType, setFilterType] = usePlayersFilterBy(); + const [sortType, setSortType] = usePlayersSortBy(); + const [playerSearch, setPlayerSearch] = usePlayersSearch(); + const allPlayers = usePlayersState(); + const [searchVal, setSearchVal] = useState(""); + const setPlayersFilterIsTemp = useSetPlayersFilterIsTemp(); + const serverCtx = useServerCtxValue(); + const t = useTranslate(); + + const debouncedInput = useDebounce(searchVal, 500); + + // We might need to debounce this in the future + const onFilterChange = (e: React.ChangeEvent) => { + setFilterType(e.target.value as PlayerDataFilter); + }; + const onSortChange = (e: React.ChangeEvent) => { + setSortType(e.target.value as PlayerDataSort); + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchVal(e.target.value); + setPlayersFilterIsTemp(false); + }; + + useEffect(() => { + setPlayerSearch(debouncedInput); + }, [debouncedInput]); + + // Synchronize filter from player state, used for optional args in /tx + useEffect(() => { + setSearchVal(playerSearch); + }, [playerSearch]); + + + const playerTranslation = t("nui_menu.page_players.misc.players"); + const oneSyncStatus = serverCtx.oneSync.status + ? `OneSync (${serverCtx.oneSync.type})` + : `OneSync Off`; + const playerCountText = `${allPlayers.length}/${serverCtx.maxClients} ${playerTranslation} - ${oneSyncStatus}`; + + return ( + + + + {t("nui_menu.page_players.misc.online_players")} + + + {playerCountText} + + + + + + + ), + }} + /> + + + + ), + }} + > + + {t("nui_menu.page_players.filter.no_filter")} + + + {t("nui_menu.page_players.filter.is_admin")} + + + {t("nui_menu.page_players.filter.is_injured")} + + + {t("nui_menu.page_players.filter.in_vehicle")} + + + + + + ), + }} + > + + {`${t("nui_menu.page_players.sort.id")} (${t( + "nui_menu.page_players.sort.joined_first" + )})`} + + + {`${t("nui_menu.page_players.sort.id")} (${t( + "nui_menu.page_players.sort.joined_last" + )})`} + + + {`${t("nui_menu.page_players.sort.distance")} (${t( + "nui_menu.page_players.sort.closest" + )})`} + + + {`${t("nui_menu.page_players.sort.distance")} (${t( + "nui_menu.page_players.sort.farthest" + )})`} + + + + + ); +}; diff --git a/nui/src/components/PlayersPage/PlayersListEmpty.tsx b/nui/src/components/PlayersPage/PlayersListEmpty.tsx new file mode 100644 index 0000000..20dc913 --- /dev/null +++ b/nui/src/components/PlayersPage/PlayersListEmpty.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { Box, styled, Typography } from "@mui/material"; +import { Error } from "@mui/icons-material"; +import { useTranslate } from "react-polyglot"; + +const BoxRoot = styled(Box)(({ theme }) => ({ + color: theme.palette.text.secondary, + fontWeight: 300, +})); + +const ErrorIcon = styled(Error)(({ theme }) => ({ + paddingRight: theme.spacing(2), +})); + +export const PlayersListEmpty: React.FC = () => { + const t = useTranslate(); + + return ( + + + + {t("nui_menu.page_players.misc.zero_players")} + + + ); +}; diff --git a/nui/src/components/PlayersPage/PlayersListGrid.tsx b/nui/src/components/PlayersPage/PlayersListGrid.tsx new file mode 100644 index 0000000..3782b15 --- /dev/null +++ b/nui/src/components/PlayersPage/PlayersListGrid.tsx @@ -0,0 +1,109 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useFilteredSortedPlayers } from "../../state/players.state"; +import PlayerCard from "./PlayerCard"; +import { Box, CircularProgress, styled } from "@mui/material"; +import { useIsMenuVisibleValue } from "@nui/src/state/visibility.state"; + +const MAX_PER_BUCKET = 60; +const FAKE_LOAD_TIME = 250; + +const DivWrapper = styled("div")({ + overflow: "auto", +}); + +const DivLoadTrigger = styled("div")({ + height: 50, +}); + +const BoxLoadingSpinner = styled(Box)({ + display: "flex", + justifyContent: "center", +}); + +export const PlayersListGrid: React.FC = () => { + const filteredPlayers = useFilteredSortedPlayers(); + const [bucket, setBucket] = useState(1); + const [fakeLoading, setFakeLoading] = useState(false); + const containerRef = useRef(null); + const isMenuVisible = useIsMenuVisibleValue(); + + useEffect(() => { + // we want to ideally keep the same bucket as previous update cycle, if possible. preventing + // scroll reset. if the new number of players does not reach the current bucket size, set bucket + // to highest possible value, to limit scroll jump. + setBucket((prevBucketState) => { + const highestPotentialBucket = Math.ceil( + filteredPlayers.length / MAX_PER_BUCKET + ); + // if our greatest possible bucket point in the new updated list, + if (highestPotentialBucket < prevBucketState) + return highestPotentialBucket; + else return prevBucketState; + }); + }, [filteredPlayers]); + + const slicedPlayers = useMemo( + () => filteredPlayers.slice(0, MAX_PER_BUCKET * bucket), + [filteredPlayers, bucket] + ); + + const handleObserver = useCallback( + (entities: IntersectionObserverEntry[]) => { + const lastEntry = entities[0]; + + if (!isMenuVisible) return setBucket(1) + + if ( + lastEntry.isIntersecting && + filteredPlayers.length > slicedPlayers.length && + !fakeLoading + ) { + setFakeLoading(true); + setTimeout(() => { + setBucket((prevState) => prevState + 1); + setFakeLoading(false); + }, FAKE_LOAD_TIME); + } + }, + [filteredPlayers, slicedPlayers, fakeLoading, isMenuVisible] + ); + + useEffect(() => { + const observer = new IntersectionObserver(handleObserver, { + root: null, + rootMargin: "10px", + threshold: 0.9, + }); + + if (containerRef.current) observer.observe(containerRef.current); + + return () => { + if (containerRef.current) observer.unobserve(containerRef.current); + }; + }, [handleObserver]); + + return ( + + + {slicedPlayers.map((player) => ( + + ))} + + + {fakeLoading && ( + + + + )} + + ); +}; diff --git a/nui/src/components/PlayersPage/PlayersPage.tsx b/nui/src/components/PlayersPage/PlayersPage.tsx new file mode 100644 index 0000000..90e1185 --- /dev/null +++ b/nui/src/components/PlayersPage/PlayersPage.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Box, styled } from "@mui/material"; +import { PlayerPageHeader } from "./PlayerPageHeader"; +import { useFilteredSortedPlayers } from "../../state/players.state"; +import { PlayersListEmpty } from "./PlayersListEmpty"; +import { PlayersListGrid } from "./PlayersListGrid"; +import { usePlayerListListener } from "../../hooks/usePlayerListListener"; + +const RootStyled = styled(Box)(({ theme }) => ({ + backgroundColor: theme.palette.background.default, + height: "50vh", + borderRadius: 15, + flex: 1, +})); + +const GridStyled = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + height: "85%", +})); + +export const PlayersPage: React.FC<{ visible: boolean }> = ({ visible }) => { + const players = useFilteredSortedPlayers(); + + usePlayerListListener(); + + return ( + + + + {players.length ? : } + + + ); +}; diff --git a/nui/src/components/WarnPage/WarnPage.tsx b/nui/src/components/WarnPage/WarnPage.tsx new file mode 100644 index 0000000..84cf908 --- /dev/null +++ b/nui/src/components/WarnPage/WarnPage.tsx @@ -0,0 +1,299 @@ +import React, { useState } from "react"; +import { styled } from '@mui/material/styles'; +import { Box, Fade, Typography } from "@mui/material"; +import { useNuiEvent } from "../../hooks/useNuiEvent"; +import { useTranslate } from "react-polyglot"; +import { debugData } from "../../utils/debugData"; +import { ReportProblemOutlined } from "@mui/icons-material"; + + +/** + * Warn box + */ +const boxClasses = { + root: `WarnBox-root`, + inner: `WarnBox-inner`, + title: `WarnBox-title`, + message: `WarnBox-message`, + author: `WarnBox-author`, + instruction: `WarnBox-instruction` +}; + +const WarnInnerStyles = styled('div')({ + color: "whitesmoke", + transition: "transform 300ms ease-in-out", + maxWidth: "700px", + + [`& .${boxClasses.inner}`]: { + padding: 32, + border: "3px dashed whitesmoke", + borderRadius: 12, + }, + [`& .${boxClasses.title}`]: { + display: "flex", + margin: "-20px auto 18px auto", + width: "max-content", + borderBottom: "2px solid whitesmoke", + paddingBottom: 5, + fontWeight: 700, + }, + [`& .${boxClasses.message}`]: { + fontSize: "1.5em", + }, + [`& .${boxClasses.author}`]: { + textAlign: "right", + fontSize: "0.8em", + marginTop: 15, + marginBottom: -15, + }, + [`& .${boxClasses.instruction}`]: { + marginTop: "1em", + textAlign: "center", + opacity: 0.85, + }, +}); + +interface WarnInnerComp { + message: string; + warnedBy: string; + isWarningNew: boolean; + secsRemaining: number; + resetCounter: number; +} + +const WarningIcon = () => ( + +); + +const WarnInnerComp: React.FC = ({ + message, + warnedBy, + isWarningNew, + secsRemaining, + resetCounter, +}) => { + const t = useTranslate(); + const instructionFontSize = Math.min(1.5, 0.9 + resetCounter * 0.15); + + const [iHead, iTail] = t("nui_warning.instruction", { + key: "%R%", + smart_count: secsRemaining, + }).split("%R%", 2); + + const iKey = + {t("nui_warning.dismiss_key")} + + + return (<> + + + + + + {t("nui_warning.title")} + + + + + {message} + + + {t("nui_warning.warned_by")} {warnedBy} + + + {!isWarningNew ? ( + + {t("nui_warning.stale_message")} + + ) : null} + + + + + {iHead} {iKey} {iTail} + + + ); +}; + + +/** + * Main warn container (whole page) + */ +const mainClasses = { + root: `MainWarn-root`, + miniBounce: `MainWarn-miniBounce`, +} + +const MainPageStyles = styled('div')(({ + [`& .${mainClasses.root}`]: { + top: 0, + left: 0, + transition: "background-color 750ms ease-in-out", + position: "absolute", + height: "100vh", + width: "100vw", + display: "flex", + flexDirection: "column", + gap: "1em", + justifyContent: "center", + alignItems: "center", + backgroundColor: "rgba(133, 3, 3, 0.95)", + }, + "@keyframes miniBounce": { + "0%": { + backgroundColor: "rgba(133, 3, 3, 0.95)", + }, + "30%": { + backgroundColor: "rgba(133, 3, 3, 0.60)", + }, + "60%": { + backgroundColor: "rgba(133, 3, 3, 0.30)", + }, + "70%": { + backgroundColor: "rgba(133, 3, 3, 0.60)", + }, + "100%": { + backgroundColor: "rgba(133, 3, 3, 0.95)", + }, + }, + [`& .${mainClasses.miniBounce}`]: { + animation: "miniBounce 500ms ease-in-out", + }, +})); + +export interface SetWarnOpenData { + reason: string; + warnedBy: string; + isWarningNew: boolean; +} + +const pulseSound = new Audio("sounds/warning_pulse.mp3"); +const openSound = new Audio("sounds/warning_open.mp3"); + +export const WarnPage: React.FC = ({ }) => { + const [isMiniBounce, setIsMiniBounce] = useState(false); + + const [isOpen, setIsOpen] = useState(false); + const [warnData, setWarnData] = useState(null); + const [secsRemaining, setSecsRemaining] = useState(10); + const [resetCounter, setResetCounter] = useState(0); + + useNuiEvent("setWarnOpen", (warnData) => { + setWarnData(warnData); + setSecsRemaining(10); + setResetCounter(0); + setIsOpen(true); + openSound.play(); + }); + + useNuiEvent("pulseWarning", (secsRemaining) => { + setSecsRemaining(secsRemaining); + setIsMiniBounce(true); + pulseSound.play(); + setTimeout(() => { + setIsMiniBounce(false); + }, 500); + }); + + useNuiEvent("resetWarning", () => { + setSecsRemaining(10); + setResetCounter((prev) => prev + 1); + pulseSound.pause(); + pulseSound.currentTime = 0; + openSound.pause(); + openSound.currentTime = 0; + openSound.play(); + }); + + useNuiEvent("closeWarning", () => { + setIsOpen(false); + }); + + const exitHandler = () => { + pulseSound.play(); + }; + + return ( + + + + + + + + ); +}; + +/** + * Browser mock + */ +// debugData([ +// { +// action: 'setWarnOpen', +// data: { +// reason: 'Stop doing bad things 😠', +// warnedBy: 'Tabby', +// isWarningNew: false, +// } +// } +// ], 500) +// setInterval(() => { +// debugData([ +// { +// action: 'pulseWarning', +// data: {} +// } +// ]); +// }, 1000); +// debugData([ +// { +// action: 'closeWarning', +// data: {} +// } +// ], 2_000); diff --git a/nui/src/components/misc/ButtonXS.tsx b/nui/src/components/misc/ButtonXS.tsx new file mode 100644 index 0000000..c408937 --- /dev/null +++ b/nui/src/components/misc/ButtonXS.tsx @@ -0,0 +1,19 @@ +import { ButtonProps, Button } from "@mui/material"; +import React from "react"; + +export const ButtonXS: React.FC = (props) => { + return ( + + ); +}; diff --git a/nui/src/components/misc/HelpTooltip.tsx b/nui/src/components/misc/HelpTooltip.tsx new file mode 100644 index 0000000..edf9dfb --- /dev/null +++ b/nui/src/components/misc/HelpTooltip.tsx @@ -0,0 +1,56 @@ +import React, { ReactNode } from "react"; +import { Fade, styled, Typography } from "@mui/material"; +import Tooltip, { TooltipProps, tooltipClasses } from '@mui/material/Tooltip'; + +import { useTooltip } from "../../provider/TooltipProvider"; + +const StyledTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, + borderRadius: 10, + padding: 10, + transformOrigin: "bottom", + }, + [`& .${tooltipClasses.arrow}`]: { + color: theme.palette.background.default, + }, +})); + +interface HelpTooltipProps { + children: ReactNode; +} + +export const HelpTooltip: React.FC = ({ children }) => { + const { tooltipText, tooltipOpen } = useTooltip(); + + return ( + + {tooltipText} + + } + //FIXME: is it needed? it was there in Taso's version + // PopperProps={{ + // container: () => document.getElementById("#root"), + // }} + sx={{ + zIndex: -1, + }} + TransitionComponent={Fade} + TransitionProps={{ + timeout: { + enter: 500, + appear: 500, + exit: 500, + }, + }} + > +
    {children}
    +
    + ); +}; diff --git a/nui/src/components/misc/PageTabs.tsx b/nui/src/components/misc/PageTabs.tsx new file mode 100644 index 0000000..12ac5f7 --- /dev/null +++ b/nui/src/components/misc/PageTabs.tsx @@ -0,0 +1,42 @@ +import React, { useCallback } from "react"; +import { Box, styled, Tab, Tabs } from "@mui/material"; +import { usePage } from "../../state/page.state"; +import { useKey } from "../../hooks/useKey"; +import { useTabDisabledValue } from "../../state/keys.state"; +import { useIsMenuVisibleValue } from "../../state/visibility.state"; +import { useServerCtxValue } from "../../state/server.state"; + +const StyledTab = styled(Tab)({ + letterSpacing: '0.1em', + minWidth: 100, +}); + +export const PageTabs: React.FC = () => { + const [page, setPage] = usePage(); + const tabDisabled = useTabDisabledValue(); + const visible = useIsMenuVisibleValue(); + const serverCtx = useServerCtxValue(); + + const handleTabPress = useCallback(() => { + if (tabDisabled || !visible) return; + setPage((prevState) => (prevState + 1 > 2 ? 0 : prevState + 1)); + }, [tabDisabled, visible]); + + useKey(serverCtx.switchPageKey, handleTabPress); + + return ( + + setPage(newVal)} + > + + + + + + ); +}; diff --git a/nui/src/components/misc/TextField.tsx b/nui/src/components/misc/TextField.tsx new file mode 100644 index 0000000..5ac79d1 --- /dev/null +++ b/nui/src/components/misc/TextField.tsx @@ -0,0 +1,26 @@ +import { TextFieldProps, TextField as MuiTextField } from "@mui/material"; +import React, { FocusEventHandler } from "react"; +import { useSetListenForExit } from "../../state/keys.state"; + +export const TextField: React.FC = (props) => { + const setListenForExit = useSetListenForExit(); + + const handleOnFocusExit: FocusEventHandler = () => { + // Forward if they exist on props + setListenForExit(true); + }; + + const handleOnFocusEnter: FocusEventHandler = () => { + // Forward if they exist on props + setListenForExit(false); + }; + + return ( + + ); +}; diff --git a/nui/src/components/misc/TopLevelErrorBoundary.tsx b/nui/src/components/misc/TopLevelErrorBoundary.tsx new file mode 100644 index 0000000..253db59 --- /dev/null +++ b/nui/src/components/misc/TopLevelErrorBoundary.tsx @@ -0,0 +1,67 @@ +import React, { Component } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; +import { fetchNui } from "../../utils/fetchNui"; + +interface ErrorCompState { + hasError: boolean; + error: Error | null; +} + +export class TopLevelErrorBoundary extends Component { + state = { + hasError: false, + error: null, + }; + + constructor(props) { + super(props); + this.handleReloadClick.bind(this); + } + + componentDidUpdate() { + if (this.state.hasError) fetchNui("focusInputs", true); + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + handleReloadClick() { + fetchNui("focusInputs", false); + window.location.reload(); + } + + render() { + if (this.state.hasError) { + return ( + + Fatal Error Encountered + + + The txAdmin menu has an encountered an error it was unable to + recover from, the NUI frame will need to be reloaded. The error + message is shown below for developer reference. +
    +
    + {this.state.error.message} +
    +
    + + + +
    + ); + } + + return this.props.children; + } +} diff --git a/nui/src/hooks/useDebouce.tsx b/nui/src/hooks/useDebouce.tsx new file mode 100644 index 0000000..1b0e225 --- /dev/null +++ b/nui/src/hooks/useDebouce.tsx @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay = 100) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect( + () => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => { + clearTimeout(handler); + }; + }, + [value, delay] + ); + + return debouncedValue; +} \ No newline at end of file diff --git a/nui/src/hooks/useExitListener.tsx b/nui/src/hooks/useExitListener.tsx new file mode 100644 index 0000000..bcd95a5 --- /dev/null +++ b/nui/src/hooks/useExitListener.tsx @@ -0,0 +1,29 @@ +import { useEffect } from "react"; +import { fetchNui } from "../utils/fetchNui"; +import { useSetIsMenuVisible } from "../state/visibility.state"; +import { useListenForExitValue } from "../state/keys.state"; + +/** + * Attach a keyboard listener for escape & backspace, which will close the menu + * + */ +export const useExitListener = () => { + const setVisible = useSetIsMenuVisible(); + + const shouldListen = useListenForExitValue(); + + useEffect(() => { + const keyHandler = (e: KeyboardEvent) => { + if (!shouldListen) return; + if (["Escape", "Backspace"].includes(e.code)) { + setVisible(false); + fetchNui("closeMenu"); + fetchNui("playSound", "enter"); + } + }; + + window.addEventListener("keydown", keyHandler); + + return () => window.removeEventListener("keydown", keyHandler); + }, [setVisible, shouldListen]); +}; diff --git a/nui/src/hooks/useHudListenersService.tsx b/nui/src/hooks/useHudListenersService.tsx new file mode 100644 index 0000000..a7f8098 --- /dev/null +++ b/nui/src/hooks/useHudListenersService.tsx @@ -0,0 +1,218 @@ +import React from "react"; +import { SnackbarKey, useSnackbar } from "notistack"; +import { useNuiEvent } from "./useNuiEvent"; +import { Box, Typography } from "@mui/material"; +import { useTranslate } from "react-polyglot"; +import { shouldHelpAlertShow } from "../utils/shouldHelpAlertShow"; +import { debugData } from "../utils/debugData"; +import { getNotiDuration } from "../utils/getNotiDuration"; +import { + usePlayersState, + useSetPlayerFilter, + useSetPlayersFilterIsTemp, +} from "../state/players.state"; +import { useSetAssociatedPlayer } from "../state/playerDetails.state"; +import { txAdminMenuPage, useSetPage } from "../state/page.state"; +import { useAnnounceNotiPosValue } from "../state/server.state"; +import { useSetPlayerModalVisibility } from "@nui/src/state/playerModal.state"; +import cleanPlayerName from "@shared/cleanPlayerName"; +import { usePlayerModalContext } from "../provider/PlayerModalProvider"; +import { fetchNui } from "../utils/fetchNui"; + +type SnackbarAlertSeverities = "success" | "error" | "warning" | "info"; + +interface SnackbarAlert { + level: SnackbarAlertSeverities; + message: string; + isTranslationKey?: boolean; + tOptions?: object; +} + +interface SnackbarPersistentAlert extends SnackbarAlert { + key: string; +} + +interface AnnounceMessageProps { + title: string; + message: string; +} + +export interface AddAnnounceData { + message: string; + author: string; + isDirectMessage: boolean; +} + +const AnnounceMessage: React.FC = ({ + title, + message, +}) => ( + + {title} + {message} + +); + +const alertMap = new Map(); + +debugData( + [ + { + action: "showMenuHelpInfo", + data: {}, + }, + ], + 5000 +); + +const announcementSound = new Audio("sounds/announcement.mp3"); +const messageSound = new Audio("sounds/message.mp3"); + +export const useHudListenersService = () => { + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const t = useTranslate(); + const onlinePlayers = usePlayersState(); + const setAssocPlayer = useSetAssociatedPlayer(); + const setModalOpen = useSetPlayerModalVisibility(); + const setPlayerFilter = useSetPlayerFilter(); + const setPlayersFilterIsTemp = useSetPlayersFilterIsTemp(); + const setPage = useSetPage(); + const notiPos = useAnnounceNotiPosValue(); + const { closeMenu } = usePlayerModalContext(); + + const snackFormat = (m: string) => ( + {m} + ); + + useNuiEvent( + "setSnackbarAlert", + ({ level, message, isTranslationKey, tOptions }) => { + if (isTranslationKey) { + message = t(message, tOptions); + } + enqueueSnackbar( + snackFormat(message), + { variant: level } + ); + } + ); + + useNuiEvent("showMenuHelpInfo", () => { + const showAlert = shouldHelpAlertShow(); + if (showAlert) { + enqueueSnackbar(snackFormat(t("nui_menu.misc.help_message")), { + variant: "info", + anchorOrigin: { + horizontal: "center", + vertical: "bottom", + }, + autoHideDuration: 10000, + }); + } + }); + + useNuiEvent( + "setPersistentAlert", + ({ level, message, key, isTranslationKey }) => { + if (alertMap.has(key)) return; + const snackbarItem = enqueueSnackbar( + isTranslationKey ? t(message) : message, + { + variant: level, + persist: true, + anchorOrigin: { + horizontal: "center", + vertical: "top", + }, + } + ); + alertMap.set(key, snackbarItem); + } + ); + + useNuiEvent("clearPersistentAlert", ({ key }) => { + const snackbarItem = alertMap.get(key); + if (!snackbarItem) return; + closeSnackbar(snackbarItem); + alertMap.delete(key); + }); + + // Handler for dynamically opening the player page & player modal with target + useNuiEvent("openPlayerModal", (target) => { + let targetPlayer; + + //Search by ID + const targetId = parseInt(target); + if (!isNaN(targetId)) { + targetPlayer = onlinePlayers.find( + (playerData) => playerData.id === targetId + ); + } + + //Search by pure name + if (!targetPlayer && typeof target === "string") { + const searchInput = cleanPlayerName(target).pureName; + const foundPlayers = onlinePlayers.filter((playerData) => + playerData.pureName?.includes(searchInput) + ); + + if (foundPlayers.length === 1) { + targetPlayer = foundPlayers[0]; + } else if (foundPlayers.length > 1) { + setPlayerFilter(target); + setPage(txAdminMenuPage.Players); + setPlayersFilterIsTemp(true); + return; + } + } + + if (targetPlayer) { + setPage(txAdminMenuPage.PlayerModalOnly); + setAssocPlayer(targetPlayer); + setModalOpen(true); + } else { + closeMenu(); + setModalOpen(false); + enqueueSnackbar( + t("nui_menu.player_modal.misc.target_not_found", { target }), + { variant: "error" } + ); + } + }); + + useNuiEvent("addAnnounceMessage", ({ message, author }) => { + announcementSound.play(); + enqueueSnackbar( + , + { + variant: "warning", + autoHideDuration: getNotiDuration(message) * 1000, + anchorOrigin: { + horizontal: notiPos.horizontal, + vertical: notiPos.vertical, + }, + } + ); + }); + + useNuiEvent("addDirectMessage", ({ message, author }) => { + messageSound.play(); + enqueueSnackbar( + , + { + variant: "info", + autoHideDuration: getNotiDuration(message) * 1000 * 2, //*2 to slow things down + anchorOrigin: { + horizontal: notiPos.horizontal, + vertical: notiPos.vertical, + }, + } + ); + }); +}; diff --git a/nui/src/hooks/useKey.ts b/nui/src/hooks/useKey.ts new file mode 100644 index 0000000..dff0698 --- /dev/null +++ b/nui/src/hooks/useKey.ts @@ -0,0 +1,15 @@ +import { useEffect } from "react"; + +export const useKey = (key: string, handler: () => void) => { + useEffect(() => { + const keyListener = (e: KeyboardEvent) => { + if (e.code === key) { + e.preventDefault(); + handler(); + } + }; + + window.addEventListener("keydown", keyListener); + return () => window.removeEventListener("keydown", keyListener); + }, [handler, key]); +}; diff --git a/nui/src/hooks/useKeyboardNavigation.tsx b/nui/src/hooks/useKeyboardNavigation.tsx new file mode 100644 index 0000000..ca947ff --- /dev/null +++ b/nui/src/hooks/useKeyboardNavigation.tsx @@ -0,0 +1,86 @@ +import { useEffect } from "react"; +import { useKeyboardNavContext } from "../provider/KeyboardNavProvider"; +import { useIsMenuVisibleValue } from "../state/visibility.state"; + +interface KeyCallbacks { + onLeftDown?: () => void; + onRightDown?: () => void; + onUpDown?: () => void; + onDownDown?: () => void; + onEnterDown?: () => void; + disableOnFocused?: boolean; +} + +/** + * A simple hook for listening to arrow key down events + enter + * + * Note: Might change this up a little bit so its more of a global event listener + * + * @param onLeftDown - Left arrow handler function + * @param onRightDown - Right arrow handler function + * @param onUpDown - Up arrow handler function + * @param onDownDown - Down arrow handler function + * @param onEnterDown - Enter handler function + * @param disableOnFocused - Whether to disable these inputs if the NUI currently has both keyboard and mouse focus + */ + +export const useKeyboardNavigation = ({ + onLeftDown, + onRightDown, + onUpDown, + onDownDown, + onEnterDown, + disableOnFocused = false, +}: KeyCallbacks) => { + const { disabledKeyNav } = useKeyboardNavContext(); + const isMenuVisible = useIsMenuVisibleValue(); + + useEffect(() => { + // Our basic handler function for keydown events + const keyHandler = (e: KeyboardEvent) => { + if (disableOnFocused && disabledKeyNav) return; + + // Fix for menu still having focus when it shouldn't + if (!isMenuVisible) return; + + switch (e.code) { + case "ArrowLeft": + e.preventDefault(); + onLeftDown && onLeftDown(); + break; + case "ArrowRight": + e.preventDefault(); + onRightDown && onRightDown(); + break; + case "ArrowUp": + e.preventDefault(); + onUpDown && onUpDown(); + break; + case "ArrowDown": + e.preventDefault(); + onDownDown && onDownDown(); + break; + case "Enter": + if(e.repeat) return; + e.preventDefault(); + onEnterDown && onEnterDown(); + break; + } + }; + + // Add that boi + window.addEventListener("keydown", keyHandler); + + // Remove on cleanup + return () => window.removeEventListener("keydown", keyHandler); + }, [ + onLeftDown, + onRightDown, + onUpDown, + onDownDown, + onEnterDown, + disabledKeyNav, + disableOnFocused, + isMenuVisible, + ]); +}; diff --git a/nui/src/hooks/useListenerForSomething.ts b/nui/src/hooks/useListenerForSomething.ts new file mode 100644 index 0000000..1b638c1 --- /dev/null +++ b/nui/src/hooks/useListenerForSomething.ts @@ -0,0 +1,40 @@ +import { useEffect } from "react"; +import { useIsMenuVisible } from "../state/visibility.state"; + +const seq = [ + "ArrowUp", + "ArrowUp", + "ArrowDown", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowLeft", + "ArrowRight", + "b", + "a", +]; + +let currentIdx = 0; + +export const useListenerForSomething = () => { + const isMenuVisible = useIsMenuVisible(); + + useEffect(() => { + if (!isMenuVisible) return; + + const handler = (e: KeyboardEvent) => { + if (e.key === seq[currentIdx]) { + currentIdx++; + if (currentIdx >= seq.length) { + // ee after seq? + console.log("seq entered successfully"); + } + } else { + currentIdx = 0; + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [isMenuVisible]); +}; diff --git a/nui/src/hooks/useLocale.ts b/nui/src/hooks/useLocale.ts new file mode 100644 index 0000000..f47ef5e --- /dev/null +++ b/nui/src/hooks/useLocale.ts @@ -0,0 +1,23 @@ +import { useServerCtxValue } from "../state/server.state"; +import { useMemo } from "react"; +import localeMap from '@shared/localeMap'; + +export const useLocale = () => { + const serverCtx = useServerCtxValue(); + + return useMemo(() => { + if ( + serverCtx.locale === "custom" && + typeof serverCtx.localeData === "object" + ) { + return serverCtx.localeData; + } else { + if (localeMap[serverCtx.locale]) { + return localeMap[serverCtx.locale]; + } else { + console.log(`Unable to find a locale with code ${serverCtx.locale} in cache, using English`); + return localeMap.en; + } + } + }, [serverCtx.locale, serverCtx.localeData]); +}; diff --git a/nui/src/hooks/useNuiEvent.ts b/nui/src/hooks/useNuiEvent.ts new file mode 100644 index 0000000..5eef54f --- /dev/null +++ b/nui/src/hooks/useNuiEvent.ts @@ -0,0 +1,51 @@ +import { MutableRefObject, useEffect, useRef } from "react"; +import { debugLog } from "../utils/debugLog"; + +interface NuiMessageData { + action: string; + data: T; +} + +type NuiHandlerSignature = (data: T) => void; + +/** + * A hook that manage events listeners for receiving data from the client scripts + * @param action The specific `action` that should be listened for. + * @param handler The callback function that will handle data relayed by this hook + * + * @example + * useNuiEvent<{visibility: true, wasVisible: 'something'}>('setVisible', (data) => { + * // whatever logic you want + * }) + * + **/ + +export const useNuiEvent = ( + action: string, + handler: (data: T) => void +) => { + const savedHandler: MutableRefObject> = useRef(); + + // When handler value changes set mutable ref to handler val + useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + useEffect(() => { + const eventListener = (event: MessageEvent>) => { + const { action: eventAction, data } = event.data; + + if (savedHandler.current && savedHandler.current.call) { + if (eventAction === action) { + //omg this is so annoying!!! + // debugLog(action, data, "NuiMessageReceived"); + savedHandler.current(data); + } + } + }; + + window.addEventListener("message", eventListener); + // Remove Event Listener on component cleanup + return () => window.removeEventListener("message", eventListener); + }, [action]); +}; diff --git a/nui/src/hooks/useNuiListenersService.tsx b/nui/src/hooks/useNuiListenersService.tsx new file mode 100644 index 0000000..00cf6de --- /dev/null +++ b/nui/src/hooks/useNuiListenersService.tsx @@ -0,0 +1,28 @@ +import { useSetIsMenuVisible } from "../state/visibility.state"; +import { txAdminMenuPage, useSetPage } from "../state/page.state"; +import { useNuiEvent } from "./useNuiEvent"; +import { + ResolvablePermission, + useSetPermissions, +} from "../state/permissions.state"; + +import { + ServerCtx, + useSetServerCtx, +} from "../state/server.state"; + +// Passive Message Event Listeners & Handlers for global state +export const useNuiListenerService = () => { + const setVisible = useSetIsMenuVisible(); + const setMenuPage = useSetPage(); + const setPermsState = useSetPermissions(); + const setServerCtxState = useSetServerCtx(); + + useNuiEvent("setDebugMode", (debugMode) => { + (window as any).__MenuDebugMode = debugMode; + }); + useNuiEvent("setVisible", setVisible); + useNuiEvent("setPermissions", setPermsState); + useNuiEvent("setServerCtx", setServerCtxState); + useNuiEvent("setMenuPage", setMenuPage); +}; diff --git a/nui/src/hooks/usePlayerListListener.ts b/nui/src/hooks/usePlayerListListener.ts new file mode 100644 index 0000000..a8fe187 --- /dev/null +++ b/nui/src/hooks/usePlayerListListener.ts @@ -0,0 +1,98 @@ +import { useEffect } from "react"; +import { txAdminMenuPage, usePageValue } from "../state/page.state"; +import { useNuiEvent } from "./useNuiEvent"; +import { useSetPlayersState } from "../state/players.state"; +import { fetchNui } from "../utils/fetchNui"; +import cleanPlayerName from "@shared/cleanPlayerName"; +import { debugLog } from "../utils/debugLog"; + +export enum VehicleStatus { + Unknown = "unknown", + Walking = "walking", + Driving = "driving", + Flying = "flying", //planes or heli + Boat = "boating", + Biking = "biking", +} + +export interface PlayerData { + /** + * Players server ID + **/ + id: number; + /** + * Player's display name + **/ + displayName: string; + /** + * Player's name in its pure form, used for searching + */ + pureName: string; + /** + * Player's vehicle status + **/ + vType: VehicleStatus; + /** + * Distance in units between admin and player + * Unknown distance due to client culling scope is passed as -1 + **/ + dist: number; + /** + * A non-normalized player health value + * Integer between 0-100 or -1 if information is not available + **/ + health: number; + /** + * If this player is an admin + **/ + admin: boolean; +} + +export type LuaPlayerData = Omit & { name: string }; + +export const usePlayerListListener = () => { + const curPage = usePageValue(); + const setPlayerList = useSetPlayersState(); + + useNuiEvent("setPlayerList", (playerList) => { + const newPlayerList = playerList.map((player) => { + let displayName = 'Unknown'; + let pureName = 'unknown'; + try { + const res = cleanPlayerName(player.name); + displayName = res.displayName; + pureName = res.pureName; + } catch (error) { + debugLog('cleanPlayerName', error); + } + return { + id: player.id, + displayName, + pureName, + vType: player.vType, + dist: player.dist, + health: player.health, + admin: player.admin, + } satisfies PlayerData; + }); + setPlayerList(newPlayerList); + }); + + useEffect(() => { + // Since our player list is never technically unmounted, + // we target page changes as our interval entrance technique + if (curPage !== txAdminMenuPage.Players) return; + + // Getting detailed playerlist + fetchNui("signalPlayersPageOpen", {}, { mockResp: {} }).catch(); + + // Getting detailed playerlist every 5 seconds + const updaterInterval = window.setInterval(() => { + fetchNui("signalPlayersPageOpen", {}, { mockResp: {} }).catch(); + }, 5000); + + return () => { + window.clearInterval(updaterInterval); + }; + }, [curPage]); +}; diff --git a/nui/src/index.css b/nui/src/index.css new file mode 100644 index 0000000..4f86fb6 --- /dev/null +++ b/nui/src/index.css @@ -0,0 +1,15 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + padding: 20px 40px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + height: 100vh; + overflow: hidden; +} + +#root { + height: 100% +} \ No newline at end of file diff --git a/nui/src/index.tsx b/nui/src/index.tsx new file mode 100644 index 0000000..04839b7 --- /dev/null +++ b/nui/src/index.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import MenuWrapper from "./MenuWrapper"; +import "./index.css"; +import { ThemeProvider, StyledEngineProvider, createTheme } from "@mui/material"; +import { RecoilRoot } from "recoil"; +import { KeyboardNavProvider } from "./provider/KeyboardNavProvider"; +import { MaterialDesignContent, SnackbarProvider } from "notistack"; +import { registerDebugFunctions } from "./utils/registerDebugFunctions"; +import { useNuiEvent } from "./hooks/useNuiEvent"; +import styled from "@emotion/styled"; +import rawMenuTheme from "./styles/theme"; +import rawMenuRedmTheme from "./styles/theme-redm"; +import { useIsRedm } from "./state/isRedm.state"; + +registerDebugFunctions(); + +//Instantiating the two themes +declare module '@mui/material/styles' { + interface Theme { + name: string; + logo: string; + } + + // allow configuration using `createTheme` + interface ThemeOptions { + name?: string; + logo?: string; + } +} +const menuRedmTheme = createTheme(rawMenuRedmTheme); +const menuTheme = createTheme(rawMenuTheme); + +//Overwriting the notistack colors +//Actually using the colors from the RedM theme, but could start using `theme` if needed +const StyledMaterialDesignContent = styled(MaterialDesignContent)(({ theme }) => ({ + '&.notistack-MuiContent-default': { + color: menuRedmTheme.palette.text.primary, + backgroundColor: menuRedmTheme.palette.background.default, + }, + '&.notistack-MuiContent-info': { + backgroundColor: menuRedmTheme.palette.info.main, + color: menuRedmTheme.palette.info.contrastText, + }, + '&.notistack-MuiContent-success': { + backgroundColor: menuRedmTheme.palette.success.main, + color: menuRedmTheme.palette.success.contrastText, + }, + '&.notistack-MuiContent-warning': { + backgroundColor: menuRedmTheme.palette.warning.main, + color: menuRedmTheme.palette.warning.contrastText, + }, + '&.notistack-MuiContent-error': { + backgroundColor: menuRedmTheme.palette.error.main, + color: menuRedmTheme.palette.error.contrastText, + }, +})); + + +const App = () => { + const [isRedm, setIsRedm] = useIsRedm(); + + useNuiEvent("setGameName", (gameName: string) => { + setIsRedm(gameName === 'redm') + }); + + return ( + + + + + }> + + + + + + + ) +} + + +const rootContainer = document.getElementById("root"); +const root = createRoot(rootContainer); +root.render( + + + +); diff --git a/nui/src/provider/DialogProvider.tsx b/nui/src/provider/DialogProvider.tsx new file mode 100644 index 0000000..e3283e3 --- /dev/null +++ b/nui/src/provider/DialogProvider.tsx @@ -0,0 +1,222 @@ +import React, { + ChangeEvent, + createContext, + ReactEventHandler, + ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +import { styled } from '@mui/material/styles'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + InputAdornment, + TextField, + Theme, + useTheme, +} from "@mui/material"; +import { Create } from "@mui/icons-material"; +import { useKeyboardNavContext } from "./KeyboardNavProvider"; +import { useSnackbar } from "notistack"; +import { useTranslate } from "react-polyglot"; +import { useSetDisableTab, useSetListenForExit } from "../state/keys.state"; +import { txAdminMenuPage, usePageValue } from "../state/page.state"; +import { Box } from "@mui/system"; + +const StyledDialogTitle = styled(DialogTitle)(({theme}) => ({ + color: theme.palette.primary.main, +})) +const StyledCreate = styled(Create)(({theme}) => ({ + color: theme.palette.text.secondary, +})) + +interface InputDialogProps { + title: string; + description: string; + placeholder: string; + onSubmit: (inputValue: string) => void; + isMultiline?: boolean; + suggestions?: string[] +} + +interface DialogProviderContext { + openDialog: (dialogProps: InputDialogProps) => void; + closeDialog: () => void; + isDialogOpen: boolean; +} + +const DialogContext = createContext(null); + +const defaultDialogState = { + description: "This is the default description for whatever", + placeholder: "This is the default placeholder...", + onSubmit: () => {}, + title: "Dialog Title", +}; + +interface DialogProviderProps { + children: ReactNode; +} + +export const DialogProvider: React.FC = ({ children }) => { + const theme = useTheme(); + const [canSubmit, setCanSubmit] = useState(true); + + const setDisableTabs = useSetDisableTab(); + const { setDisabledKeyNav } = useKeyboardNavContext(); + const setListenForExit = useSetListenForExit(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogProps, setDialogProps] = + useState(defaultDialogState); + const [dialogInputVal, setDialogInputVal] = useState(""); + const { enqueueSnackbar } = useSnackbar(); + const curPage = usePageValue(); + const t = useTranslate(); + + useEffect(() => { + if (curPage === txAdminMenuPage.Main) { + setDisabledKeyNav(dialogOpen); + setDisableTabs(dialogOpen); + } + }, [dialogOpen, setDisabledKeyNav, setDisableTabs]); + + const handleDialogSubmit = (suggested?: string) => { + if (!canSubmit) return; + + const input = suggested ?? dialogInputVal; + if (!input.trim()) { + return enqueueSnackbar(t("nui_menu.misc.dialog_empty_input"), { + variant: "error", + }); + } + + dialogProps.onSubmit(input); + setCanSubmit(false); + + setListenForExit(true); + setDialogOpen(false); + }; + + const handleChange = (e: ChangeEvent) => { + setDialogInputVal(e.target.value); + }; + + const openDialog = useCallback((dialogProps: InputDialogProps) => { + setDialogProps(dialogProps); + setDialogOpen(true); + setListenForExit(false); + }, []); + + const handleDialogClose: ReactEventHandler<{}> = useCallback((e) => { + e.stopPropagation(); + setDialogOpen(false); + setListenForExit(true); + }, []); + + // We reset default state after the animation is complete + const handleOnExited = () => { + setDialogProps(defaultDialogState); + setCanSubmit(true); + setDialogInputVal(""); + }; + + return ( + + +
    { + e.preventDefault(); + handleDialogSubmit(); + }} + > + + {dialogProps.title} + + + {dialogProps.description} + + + + ), + }} + onChange={handleChange} + /> + + + + + {Array.isArray(dialogProps.suggestions) && dialogProps.suggestions.map(suggestion => ( + + ))} + + + + + + + + +
    +
    + {children} +
    + ); +}; + +export const useDialogContext = () => + useContext(DialogContext); diff --git a/nui/src/provider/IFrameProvider.tsx b/nui/src/provider/IFrameProvider.tsx new file mode 100644 index 0000000..34444de --- /dev/null +++ b/nui/src/provider/IFrameProvider.tsx @@ -0,0 +1,94 @@ +import React, { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { txAdminMenuPage, usePage } from "../state/page.state"; +import { useIsMenuVisibleValue } from "../state/visibility.state"; + +const iFrameCtx = createContext(null); + +type ValidPath = `/${string}`; + +interface iFrameContextValue { + goToFramePage: (path: ValidPath) => void; + setFramePage: (path: ValidPath) => void; + currentFramePg: string; + fullFrameSrc: string; + handleChildPost: (data: IFramePostData) => string; +} + +export interface IFramePostData { + action: string; + data: unknown; + __isFromChild: true; +} + +export const BASE_IFRAME_PATH = "https://monitor/WebPipe"; + +export const useIFrameCtx = () => useContext(iFrameCtx); + +interface IFrameProviderProps { + children: ReactNode; +} + +// This allows for global control of the iFrame from other components +export const IFrameProvider: React.FC = ({ children }) => { + const [curFramePg, setCurFramePg] = useState(null); + const [menuPage, setMenuPage] = usePage(); + const isMenuVisible = useIsMenuVisibleValue(); + + // Will reset the iFrame page to server logs everytime + useEffect(() => { + if (isMenuVisible) { + const refreshBuster = Math.random().toString().padStart(8, "0").slice(-8); + setCurFramePg(`/server/server-log?refresh${refreshBuster}`); + } + }, [isMenuVisible]); + + // Call if you need to both navigate to iFrame page & set the iFrame path + const goToFramePage = useCallback( + (path: ValidPath) => { + if (menuPage !== txAdminMenuPage.IFrame) { + setMenuPage(txAdminMenuPage.IFrame); + } + + setCurFramePg(path); + }, + [menuPage] + ); + + // Call if you only need to set the iFrame path for background use, and + // do not require for the menu to change page + const setFramePage = useCallback((path: ValidPath) => { + setCurFramePg(path); + }, []); + + const handleChildPost = useCallback((data: IFramePostData) => { + // Probably should have a reducer here or smth, for now lets just log the data + console.log("Data received from child:", data); + }, []); + + const fullFrameSrc = useMemo( + () => BASE_IFRAME_PATH + curFramePg, + [curFramePg] + ); + + return ( + + {children} + + ); +}; diff --git a/nui/src/provider/KeyboardNavProvider.tsx b/nui/src/provider/KeyboardNavProvider.tsx new file mode 100644 index 0000000..942dc09 --- /dev/null +++ b/nui/src/provider/KeyboardNavProvider.tsx @@ -0,0 +1,69 @@ +import React, { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { fetchNui } from "../utils/fetchNui"; +import { useIsMenuVisibleValue } from "../state/visibility.state"; +import { txAdminMenuPage, usePageValue } from "../state/page.state"; + +const KeyboardNavContext = createContext(null); + +interface KeyboardNavProviderProps { + children: ReactNode; +} + +export const KeyboardNavProvider: React.FC = ({ + children, +}) => { + const [disabledKeyNav, setDisabledKeyNav] = useState(false); + const isMenuVisible = useIsMenuVisibleValue(); + const curPage = usePageValue(); + + const handleSetDisabledInputs = useCallback((bool: boolean) => { + setDisabledKeyNav(bool); + }, []); + + useEffect(() => { + if (!isMenuVisible) return; + + if ( + curPage === txAdminMenuPage.IFrame + || curPage === txAdminMenuPage.Players + || curPage === txAdminMenuPage.PlayerModalOnly + ) { + return setDisabledKeyNav(true); + } + + if (curPage === txAdminMenuPage.Main) { + return setDisabledKeyNav(false); + } + }, [curPage, isMenuVisible]); + + useEffect(() => { + if (!isMenuVisible) return; + fetchNui("focusInputs", disabledKeyNav, { mockResp: {} }); + }, [disabledKeyNav, isMenuVisible]); + + return ( + + {children} + + ); +}; + +interface KeyboardNavProviderValue { + disabledKeyNav: boolean; + setDisabledKeyNav: (bool: boolean) => void; +} + +export const useKeyboardNavContext = () => + useContext(KeyboardNavContext); diff --git a/nui/src/provider/PlayerModalProvider.tsx b/nui/src/provider/PlayerModalProvider.tsx new file mode 100644 index 0000000..27c58da --- /dev/null +++ b/nui/src/provider/PlayerModalProvider.tsx @@ -0,0 +1,121 @@ +import React, { + useContext, + createContext, + useCallback, + useEffect, + ReactNode, +} from "react"; +import PlayerModal from "../components/PlayerModal/PlayerModal"; +import { useSetDisableTab, useSetListenForExit } from "../state/keys.state"; +import { useIsMenuVisible } from "../state/visibility.state"; +import { fetchNui } from "../utils/fetchNui"; +import { useSnackbar } from "notistack"; +import { Box, CircularProgress, Dialog, useTheme } from "@mui/material"; +import { + usePlayerModalVisibility, + useSetPlayerModalTab, +} from "@nui/src/state/playerModal.state"; +import { txAdminMenuPage, usePageValue } from "../state/page.state"; + +const PlayerContext = createContext({} as PlayerProviderCtx); + +interface PlayerProviderCtx { + closeMenu: () => void; + showNoPerms: (opt: string) => void; +} + +interface PlayerModalProviderProps { + children: ReactNode; +} + +const LoadingModal: React.FC = () => ( + + + +); + +export const PlayerModalProvider: React.FC = ({ + children, +}) => { + const [modalOpen, setModalOpen] = usePlayerModalVisibility(); + const setDisableTabNav = useSetDisableTab(); + const setListenForExit = useSetListenForExit(); + const { enqueueSnackbar } = useSnackbar(); + const [menuVisible, setMenuVisible] = useIsMenuVisible(); + const setTab = useSetPlayerModalTab(); + const theme = useTheme(); + const curPage = usePageValue(); + + useEffect(() => { + setDisableTabNav(modalOpen); + setListenForExit(!modalOpen); + setTimeout(() => { + if (!modalOpen) setTab(0); + }, 500); + }, [modalOpen]); + + // In case the modal is open when menu visibility is toggled + // we need to close the modal as a result + useEffect(() => { + if (!menuVisible && modalOpen) setModalOpen(false); + }, [menuVisible]); + + // Will close both the modal and set the menu to invisible + const closeMenu = useCallback(() => { + setModalOpen(false); + setMenuVisible(false); + fetchNui("closeMenu"); + }, []); + + const showNoPerms = useCallback((opt: string) => { + enqueueSnackbar(`You do not have permissions for "${opt}"`, { + variant: "error", + }); + }, []); + + const handleClose = () => { + if (curPage === txAdminMenuPage.PlayerModalOnly) { + closeMenu(); + } else { + setModalOpen(false); + } + } + + return ( + + + }> + + + + {children} + + ); +}; + +export const usePlayerModalContext = () => useContext(PlayerContext); diff --git a/nui/src/provider/TooltipProvider.tsx b/nui/src/provider/TooltipProvider.tsx new file mode 100644 index 0000000..4ba55fa --- /dev/null +++ b/nui/src/provider/TooltipProvider.tsx @@ -0,0 +1,104 @@ +import React, { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { txAdminMenuPage, usePageValue } from "../state/page.state"; +import { useIsMenuVisibleValue } from "../state/visibility.state"; +import { useDialogContext } from "./DialogProvider"; + +interface TooltipContextValue { + setTooltipText: (text: string) => void; + tooltipText: string; + setTooltipOpen: (open: boolean) => void; + tooltipOpen: boolean; +} + +const TooltipCtx = createContext(null); + +export const useTooltip = () => useContext(TooltipCtx); + +const HIDE_TOOLTIP_AFTER_MS = 7000; + +interface TooltipProviderProps { + children: ReactNode; +} + +export const TooltipProvider: React.FC = ({ + children, +}) => { + const [tooltipText, _setTooltipText] = useState(""); + const [_tooltipOpen, setTooltipOpen] = useState(false); + const isMenuVisible = useIsMenuVisibleValue(); + const curPage = usePageValue(); + const { isDialogOpen } = useDialogContext(); + const hideTooltipTimerRef = useRef(null); + const firstDisplayFinished = useRef(false); + + // Make sure we hide tooltip with these conditions + const tooltipOpen = useMemo(() => { + if (tooltipText === "") return false; + if (isDialogOpen) return false; + if (!isMenuVisible) return false; + if (curPage !== txAdminMenuPage.Main) return false; + + return _tooltipOpen; + }, [tooltipText, isDialogOpen, isMenuVisible, curPage, _tooltipOpen]); + + useLayoutEffect(() => { + if (curPage !== txAdminMenuPage.Main) { + firstDisplayFinished.current = false; + return setTooltipOpen(false); + } + + const pageTimeout = setTimeout(() => { + setTooltipOpen(true); + firstDisplayFinished.current = true; + }, 2000); + + return () => { + clearTimeout(pageTimeout); + }; + }, [curPage]); + + const setTooltipText = useCallback( + (text: string) => { + _setTooltipText(text); + + if (firstDisplayFinished.current) { + setTooltipOpen(true); + } + + if (hideTooltipTimerRef.current) { + clearTimeout(hideTooltipTimerRef.current); + } + + hideTooltipTimerRef.current = setTimeout(() => { + setTooltipOpen(false); + hideTooltipTimerRef.current = null; + }, HIDE_TOOLTIP_AFTER_MS); + }, + [tooltipOpen] + ); + + useEffect(() => { + return () => { + if (hideTooltipTimerRef.current) + clearTimeout(hideTooltipTimerRef.current); + }; + }, []); + + return ( + + {children} + + ); +}; diff --git a/nui/src/state/healmode.state.ts b/nui/src/state/healmode.state.ts new file mode 100644 index 0000000..438718d --- /dev/null +++ b/nui/src/state/healmode.state.ts @@ -0,0 +1,13 @@ +import { atom, useRecoilState } from "recoil"; + +export enum HealMode { + SELF, + ALL, +} + +const healMode = atom({ + key: "healModeState", + default: HealMode.SELF, +}); + +export const useHealMode = () => useRecoilState(healMode); diff --git a/nui/src/state/isRedm.state.ts b/nui/src/state/isRedm.state.ts new file mode 100644 index 0000000..d46c6a4 --- /dev/null +++ b/nui/src/state/isRedm.state.ts @@ -0,0 +1,8 @@ +import { atom, useRecoilState, useRecoilValue } from "recoil"; + +const isRedmState = atom({ + key: 'isRedm', + default: false, +}); +export const useIsRedmValue = () => useRecoilValue(isRedmState); +export const useIsRedm = () => useRecoilState(isRedmState); diff --git a/nui/src/state/keys.state.ts b/nui/src/state/keys.state.ts new file mode 100644 index 0000000..5e2a685 --- /dev/null +++ b/nui/src/state/keys.state.ts @@ -0,0 +1,21 @@ +import { atom, useRecoilValue, useSetRecoilState } from "recoil"; + +const tabState = atom({ + key: "tabKeysDisabledState", + default: false, +}); + +// setter - Disable tab nav +export const useSetDisableTab = () => useSetRecoilState(tabState); +// value - disable tab nav +export const useTabDisabledValue = () => useRecoilValue(tabState); + +const listenForExitState = atom({ + key: "listenForExitState", + default: true, +}); +// setter - Listen for ESC/Delete keys +export const useSetListenForExit = () => useSetRecoilState(listenForExitState); + +// value - Listen for ESC/Delete keys +export const useListenForExitValue = () => useRecoilValue(listenForExitState); diff --git a/nui/src/state/page.state.ts b/nui/src/state/page.state.ts new file mode 100644 index 0000000..e75bfdb --- /dev/null +++ b/nui/src/state/page.state.ts @@ -0,0 +1,24 @@ +import { + atom, + useRecoilState, + useRecoilValue, + useSetRecoilState, +} from "recoil"; + +export enum txAdminMenuPage { + Main, + Players, + IFrame, + PlayerModalOnly, +} + +const pageState = atom({ + default: 0, + key: "menuPage", +}); + +export const usePage = () => useRecoilState(pageState); + +export const useSetPage = () => useSetRecoilState(pageState); + +export const usePageValue = () => useRecoilValue(pageState); diff --git a/nui/src/state/permissions.state.ts b/nui/src/state/permissions.state.ts new file mode 100644 index 0000000..ff7c113 --- /dev/null +++ b/nui/src/state/permissions.state.ts @@ -0,0 +1,39 @@ +import { atom, useRecoilValue, useSetRecoilState } from "recoil"; + +// TODO: Make an enum +export type ResolvablePermission = + | "all_permissions" + | "announcement" + | "manage.admins" + | "commands.resources" + | "players.playermode" + | "players.teleport" + | "players.heal" + | "players.ban" + | "players.kick" + | "players.direct_message" + | "players.warn" + | "players.whitelist" + | "console.view" + | "console.write" + | "control.server" + | "server.cfg.editor" + | "settings.view" + | "settings.write" + | "txadmin.log.view" + | "server.log.view" + | "menu.vehicle" + | "menu.clear_area" + | "menu.viewids" + | "players.spectate" + | "players.troll" + | "players.freeze"; + +const permissionState = atom({ + key: "permissionsState", + default: [], +}); + +export const usePermissionsValue = () => useRecoilValue(permissionState); + +export const useSetPermissions = () => useSetRecoilState(permissionState); diff --git a/nui/src/state/playerDetails.state.ts b/nui/src/state/playerDetails.state.ts new file mode 100644 index 0000000..f138fa4 --- /dev/null +++ b/nui/src/state/playerDetails.state.ts @@ -0,0 +1,74 @@ +import { + atom, + selector, + useRecoilState, + useRecoilValue, + useSetRecoilState, +} from "recoil"; +import { fetchWebPipe } from "../utils/fetchWebPipe"; +import { debugLog } from "../utils/debugLog"; +import { MockedPlayerDetails } from "../utils/constants"; +import { PlayerData } from "../hooks/usePlayerListListener"; +import { PlayerModalResp, PlayerModalSuccess } from "@shared/playerApiTypes"; +import { GenericApiErrorResp } from "@shared/genericApiTypes"; + +const playerDetails = { + selectedPlayerData: selector({ + key: "selectedPlayerDetails", + get: async ({ get }) => { + get(playerDetails.forcePlayerRefresh); + const assocPlayer = get(playerDetails.associatedPlayer); + if (!assocPlayer) return; + const assocPlayerId = assocPlayer.id; + + const res: any = await fetchWebPipe( + `/player?mutex=current&netid=${assocPlayerId}`, + { mockData: MockedPlayerDetails } + ); + debugLog("FetchWebPipe", res, "PlayerFetch"); + + if (res.error) { + return { error: (res as GenericApiErrorResp).error }; + } else if (res.player) { + const player = (res as PlayerModalSuccess).player; + if (player.isConnected) { + return res; + } else { + return { error: 'This player is no longer connected to the server.' }; + } + }else{ + return { error: 'Unknown error :(' }; + } + }, + }), + forcePlayerRefresh: atom({ + key: "forcePlayerRefresh", + default: 0, + }), + associatedPlayer: atom({ + key: "associatedPlayerDetails", + default: null, + }), +}; + +export const usePlayerDetailsValue = () => { + const value = useRecoilValue(playerDetails.selectedPlayerData); + if (!value) { + throw new Error('Player details are undefined'); // !NC Handle undefined case + } + return value; +}; + +export const useForcePlayerRefresh = () => + useSetRecoilState(playerDetails.forcePlayerRefresh); + +export const useAssociatedPlayerValue = () => { + const value = useRecoilValue(playerDetails.associatedPlayer); + if (!value) { + throw new Error('Associated player is null'); // !NC Handle null case + } + return value; +}; + +export const useSetAssociatedPlayer = () => + useSetRecoilState(playerDetails.associatedPlayer); diff --git a/nui/src/state/playerModal.state.ts b/nui/src/state/playerModal.state.ts new file mode 100644 index 0000000..0532212 --- /dev/null +++ b/nui/src/state/playerModal.state.ts @@ -0,0 +1,35 @@ +import { + atom, + useRecoilState, + useRecoilValue, + useSetRecoilState, +} from "recoil"; + +export enum PlayerModalTabs { + ACTIONS, + INFO, + IDENTIFIERS, + HISTORY, + BAN, +} + +const playerModalTabAtom = atom({ + key: "playerModalTab", + default: PlayerModalTabs.ACTIONS, +}); + +export const usePlayerModalTabValue = () => useRecoilValue(playerModalTabAtom); +export const useSetPlayerModalTab = () => useSetRecoilState(playerModalTabAtom); +export const usePlayerModalTab = () => useRecoilState(playerModalTabAtom); + +const modalVisibilityAtom = atom({ + key: "playerModalVisibility", + default: false, +}); + +export const usePlayerModalVisbilityValue = () => + useRecoilValue(modalVisibilityAtom); +export const usePlayerModalVisibility = () => + useRecoilState(modalVisibilityAtom); +export const useSetPlayerModalVisibility = () => + useSetRecoilState(modalVisibilityAtom); diff --git a/nui/src/state/playermode.state.ts b/nui/src/state/playermode.state.ts new file mode 100644 index 0000000..20b308f --- /dev/null +++ b/nui/src/state/playermode.state.ts @@ -0,0 +1,15 @@ +import { atom, useRecoilState } from "recoil"; + +export enum PlayerMode { + DEFAULT = "none", + NOCLIP = "noclip", + GOD_MODE = "godmode", + SUPER_JUMP = "superjump", +} + +const playermodeState = atom({ + key: "playerModeState", + default: PlayerMode.DEFAULT, +}); + +export const usePlayerMode = () => useRecoilState(playermodeState); diff --git a/nui/src/state/players.state.ts b/nui/src/state/players.state.ts new file mode 100644 index 0000000..2f3cba5 --- /dev/null +++ b/nui/src/state/players.state.ts @@ -0,0 +1,163 @@ +import { + atom, + selector, + useRecoilState, + useRecoilValue, + useSetRecoilState, +} from "recoil"; +import { VehicleStatus, PlayerData, LuaPlayerData } from "../hooks/usePlayerListListener"; +import { debugData } from "../utils/debugData"; +import cleanPlayerName from "@shared/cleanPlayerName"; + +export enum PlayerDataFilter { + NoFilter = "noFilter", + IsAdmin = "isAdmin", + IsInjured = "isInjured", + InVehicle = "inVehicle", +} +export enum PlayerDataSort { + IdJoinedFirst = "idJoinedFirst", + IdJoinedLast = "idJoinedLast", + DistanceClosest = "distanceClosest", + DistanceFarthest = "distanceFarthest", +} + +const playersState = { + playerData: atom({ + default: [], + key: "playerStates", + }), + playerFilterType: atom({ + default: PlayerDataFilter.NoFilter, + key: "playerFilterType", + }), + playerSortType: atom({ + default: PlayerDataSort.IdJoinedFirst, + key: "playerSortType", + }), + sortedAndFilteredPlayerData: selector({ + key: "sortedAndFilteredPlayerStates", + get: ({ get }) => { + const filterType: PlayerDataFilter = get(playersState.playerFilterType) ?? PlayerDataFilter.NoFilter; + const sortType: PlayerDataSort = get(playersState.playerSortType) ?? PlayerDataSort.IdJoinedFirst; + const filteredValueInput = get(playersState.filterPlayerDataInput); + const unfilteredPlayerStates = get(playersState.playerData) as PlayerData[]; + + let searchFilter = (p: PlayerData) => true; + const formattedInput = filteredValueInput.trim(); + if (formattedInput) { + const searchInput = cleanPlayerName(formattedInput).pureName; + searchFilter = (p) => { + return p.pureName.includes(searchInput) + || p.id.toString().includes(formattedInput) + }; + } + + let playerFilter = (p: PlayerData) => true; + if (filterType === PlayerDataFilter.IsAdmin) { + playerFilter = (p) => p.admin; + } else if (filterType === PlayerDataFilter.IsInjured) { + playerFilter = (p) => p.health <= 20; + } else if (filterType === PlayerDataFilter.InVehicle) { + playerFilter = (p) => p.vType !== VehicleStatus.Walking; + } + + const playerStates = unfilteredPlayerStates.filter((p) => { + return searchFilter(p) && playerFilter(p); + }); + + switch (sortType) { + case PlayerDataSort.DistanceClosest: + // Since our distance can come back as -1 when unknown, we need to explicitly + // move to the end of the sorted array. + return [...playerStates].sort((a, b) => { + if (b.dist < 0) return -1; + if (a.dist < 0) return 1; + + return a.dist > b.dist ? 1 : -1; + }); + case PlayerDataSort.DistanceFarthest: + return [...playerStates].sort((a, b) => (a.dist < b.dist ? 1 : -1)); + case PlayerDataSort.IdJoinedFirst: + return [...playerStates].sort((a, b) => (a.id > b.id ? 1 : -1)); + case PlayerDataSort.IdJoinedLast: + return [...playerStates].sort((a, b) => (a.id < b.id ? 1 : -1)); + default: + return playerStates; + } + }, + }), + filterPlayerDataInput: atom({ + key: "filterPlayerDataInput", + default: "", + }), + // If true, player data filter will reset on page switch + filterPlayerDataIsTemp: atom({ + key: "filterPlayerDataIsTemp", + default: false, + }), +}; + +export const usePlayersState = () => useRecoilValue(playersState.playerData); + +export const useSetPlayersState = () => + useSetRecoilState(playersState.playerData); + +export const useSetPlayerFilter = () => + useSetRecoilState(playersState.filterPlayerDataInput); + +export const useSetPlayersFilterIsTemp = () => + useSetRecoilState(playersState.filterPlayerDataIsTemp); + +export const usePlayersSortedValue = () => + useRecoilValue(playersState.sortedAndFilteredPlayerData); + +export const usePlayersFilterBy = () => + useRecoilState(playersState.playerFilterType); + +export const usePlayersSortBy = () => + useRecoilState(playersState.playerSortType); + +export const usePlayersSearch = () => + useRecoilState(playersState.filterPlayerDataInput); + +export const usePlayersFilterIsTemp = () => + useRecoilState(playersState.filterPlayerDataIsTemp); + +export const useFilteredSortedPlayers = (): PlayerData[] => + useRecoilValue(playersState.sortedAndFilteredPlayerData); + +debugData( + [ + { + action: "setPlayerList", + data: [ + { + vType: VehicleStatus.Walking, + name: "example", + id: 1, + dist: 0, + health: 80, + admin: false, + }, + { + vType: VehicleStatus.Driving, + name: "example2", + id: 2, + dist: 20, + health: 50, + admin: true, + }, + { + vType: VehicleStatus.Boat, + name: "example3", + id: 3, + dist: 700, + health: 10, + admin: true, + }, + ], + }, + ], + 750 +); diff --git a/nui/src/state/server.state.ts b/nui/src/state/server.state.ts new file mode 100644 index 0000000..7248606 --- /dev/null +++ b/nui/src/state/server.state.ts @@ -0,0 +1,73 @@ +import { LocaleType } from "@shared/localeMap"; +import { atom, selector, useRecoilValue, useSetRecoilState } from "recoil"; +import config from "../utils/config.json"; + +interface OneSyncCtx { + type: null | string; + status: boolean; +} + + +export interface ServerCtx { + oneSync: OneSyncCtx; + projectName: null | string; + maxClients: number; + locale: string; + localeData: LocaleType | boolean; + switchPageKey: string; + announceNotiPos: string; + txAdminVersion: string; + alignRight: boolean; +} + +const serverCtx = atom({ + key: "serverCtx", + default: config.serverCtx, +}); + +export const useServerCtxValue = () => useRecoilValue(serverCtx); + +export const useSetServerCtx = () => useSetRecoilState(serverCtx); + +interface AnnounceNotiLocation { + vertical: "top" | "bottom"; + horizontal: "left" | "right" | "center"; +} + +const verifyNotiLocation = (pos: { vertical: string, horizontal: string }) => { + if (pos.vertical !== "top" && pos.vertical !== "bottom") { + throw new Error( + `Notification vertical position must be "top" or "bottom", but got ${pos.vertical}` + ); + } + + if ( + pos.horizontal !== "left" && + pos.horizontal !== "right" && + pos.horizontal !== "center" + ) { + throw new Error( + `Notification horizontal position must be "left", "right" or "center", but got ${pos.horizontal}` + ); + } + + return pos as AnnounceNotiLocation; +}; + +const notiLocationSelector = selector({ + key: "notiLocation", + get: ({ get }) => { + const notiTgtRaw = get(serverCtx).announceNotiPos; + const [vertical, horizontal] = notiTgtRaw.split("-"); + + try { + return verifyNotiLocation({ vertical, horizontal }); + } catch (e) { + console.error(e); + return { vertical: "top", horizontal: "center" } satisfies AnnounceNotiLocation; + } + }, +}); + +export const useAnnounceNotiPosValue = () => + useRecoilValue(notiLocationSelector); diff --git a/nui/src/state/teleportmode.state.ts b/nui/src/state/teleportmode.state.ts new file mode 100644 index 0000000..a9bba59 --- /dev/null +++ b/nui/src/state/teleportmode.state.ts @@ -0,0 +1,15 @@ +import { atom, useRecoilState } from "recoil"; + +export enum TeleportMode { + WAYPOINT = "waypoint", + COORDINATES = "coords", + PREVIOUS = "previous", + COPY = "copy", +} + +const teleportMode = atom({ + key: "teleportModeState", + default: TeleportMode.WAYPOINT, +}); + +export const useTeleportMode = () => useRecoilState(teleportMode); diff --git a/nui/src/state/vehiclemode.state.ts b/nui/src/state/vehiclemode.state.ts new file mode 100644 index 0000000..919e026 --- /dev/null +++ b/nui/src/state/vehiclemode.state.ts @@ -0,0 +1,15 @@ +import { atom, useRecoilState } from "recoil"; + +export enum VehicleMode { + SPAWN = "spawn", + FIX = "fix", + DELETE = "delete", + BOOST = "boost", +} + +const vehicleMode = atom({ + key: "vehicleModeState", + default: VehicleMode.SPAWN, +}); + +export const useVehicleMode = () => useRecoilState(vehicleMode); diff --git a/nui/src/state/visibility.state.ts b/nui/src/state/visibility.state.ts new file mode 100644 index 0000000..ece0b83 --- /dev/null +++ b/nui/src/state/visibility.state.ts @@ -0,0 +1,17 @@ +import { + atom, + useRecoilState, + useRecoilValue, + useSetRecoilState, +} from "recoil"; + +const visibilityState = atom({ + default: false, + key: "menuVisibility", +}); + +export const useIsMenuVisibleValue = () => useRecoilValue(visibilityState); + +export const useSetIsMenuVisible = () => useSetRecoilState(visibilityState); + +export const useIsMenuVisible = () => useRecoilState(visibilityState); diff --git a/nui/src/styles/module-augmentation.d.ts b/nui/src/styles/module-augmentation.d.ts new file mode 100644 index 0000000..5fae6bd --- /dev/null +++ b/nui/src/styles/module-augmentation.d.ts @@ -0,0 +1,12 @@ +declare module '@mui/material/styles' { + interface Theme { + name: string; + logo: string; + } + + // allow configuration using `createTheme` + interface ThemeOptions { + name?: string; + logo?: string; + } +} diff --git a/nui/src/styles/theme-redm.tsx b/nui/src/styles/theme-redm.tsx new file mode 100644 index 0000000..73bd369 --- /dev/null +++ b/nui/src/styles/theme-redm.tsx @@ -0,0 +1,75 @@ +export default { + name: 'redm', + logo: 'images/txadmin-redm.png', + palette: { + mode: "dark", + primary: { + // main: "#F7DC6F", + // main: "#F4D03E", //darker + main: "#F4DF88", //desaturated + }, + success: { + // main: "#82E0AA", + main: "#57D58D", //darker + }, + warning: { + main: "#F5B041", + // main: "#F39C12", //darker + }, + error: { + // main: "#E74C3C", + main: "#D52C1A", //darker + }, + info: { + // main: "#85C1E9", + main: "#5BACE1", //darker + }, + background: { + default: "#332E27", + paper: "#4B3B2E", + }, + action: { + selected: "rgba(255, 255, 255, 0.1)", + }, + secondary: { + // main: "#D6A2E8", + // main: "#BB64D9", //darker + main: "#C68ED9", //desaturated + }, + text: { + primary: "#E8E1DC", + secondary: "#E6D5C9", + }, + }, + components: { + MuiListItem: { + styleOverrides: { + root: { + border: "1px solid transparent", + "&.Mui-selected": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + border: "1px solid rgba(255, 255, 255, 0.15)", + }, + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + border: "1px solid transparent", + "&.Mui-selected": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + border: "1px solid rgba(255, 255, 255, 0.15)", + }, + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: "unset" + } + } + }, + }, +} as const; diff --git a/nui/src/styles/theme.tsx b/nui/src/styles/theme.tsx new file mode 100644 index 0000000..641c1ea --- /dev/null +++ b/nui/src/styles/theme.tsx @@ -0,0 +1,67 @@ +export default { + name: 'fivem', + logo: 'images/txadmin.png', + palette: { + mode: "dark", + primary: { + main: "rgba(0,197,140,0.87)", + }, + success: { + main: "rgba(0,149,108,0.87)", + }, + warning: { + main: "rgb(255,189,22)", + }, + error: { + main: "rgb(194,13,37)", + }, + info: { + main: "rgb(9,96,186)", + }, + background: { + default: "#151a1f", + paper: "#24282B", + }, + action: { + selected: "rgba(255, 255, 255, 0.1)", + }, + secondary: { + main: "#fff", + }, + text: { + primary: "#fff", + secondary: "rgba(221,221,221,0.54)", + }, + }, + components: { + MuiListItem: { + styleOverrides: { + root: { + border: "1px solid transparent", + "&.Mui-selected": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + border: "1px solid rgba(255, 255, 255, 0.15)", + }, + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + border: "1px solid transparent", + "&.Mui-selected": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + border: "1px solid rgba(255, 255, 255, 0.15)", + }, + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: "unset" + } + } + }, + }, +} as const; diff --git a/nui/src/utils/config.json b/nui/src/utils/config.json new file mode 100644 index 0000000..b0420d1 --- /dev/null +++ b/nui/src/utils/config.json @@ -0,0 +1,16 @@ +{ + "serverCtx": { + "locale": "en", + "localeData": false, + "oneSync": { + "type": null, + "status": false + }, + "announceNotiPos": "top-center", + "projectName": "Context Loading...", + "maxClients": 48, + "switchPageKey": "Tab", + "txAdminVersion": "9.9.9", + "alignRight": false + } +} diff --git a/nui/src/utils/constants.ts b/nui/src/utils/constants.ts new file mode 100644 index 0000000..79e91ef --- /dev/null +++ b/nui/src/utils/constants.ts @@ -0,0 +1,353 @@ +import { PlayerModalSuccess } from "@shared/playerApiTypes"; + +export const MockedPlayerDetails: PlayerModalSuccess = { + "serverTime": Math.floor(Date.now() / 1000), + "banTemplates": [ + { + "id": "vocszy2sd1a", + "reason": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean condimentum, mi quis laoreet placerat, diam neque scelerisque tellus, at venenatis sem nunc vel enim. Etiam in erat posuere, blandit diam sollicitudin, tincidunt urna. In dolor lectus, facilisis in enim ac, varius ultrices libero. Vestibulum semper lobortis aliquam. Donec pharetra commodo tellus, non eleifend lacus dictum ut. Nunc rhoncus sem in lacinia sagittis. Mauris ut nisi ut dolor pretium finibus. Pellentesque eu tincidunt mauris.", + "duration": { + "value": 2, + "unit": "weeks" + } + }, + { + "id": "q3u6jof67pd", + "reason": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "duration": { + "value": 1, + "unit": "days" + } + }, + { + "id": "vcmorv3c11n", + "reason": "I don't know, some random reason example.", + "duration": "permanent" + }, + { + "id": "fhtva57e4tq", + "reason": "Aiuhgi aiuHIHA huifhuidhsiuhIUHAUsh hUHAIUhs ihufdisahf", + "duration": "permanent" + }, + { + "id": "kdmte4kjwg", + "reason": "Test long months", + "duration": { + "value": 99, + "unit": "months" + } + }, + ], + "player": { + "displayName": "tabby", + "pureName": "tabby", + "isRegistered": true, + "isConnected": true, + "license": "9b9fc300cc65d22ad3b536175a4d15c0e4933753", + "ids": [ + "license:9b9fc300cc65d22ad3b536175a4d15c0e4933753" + ], + "hwids": [ + "9:0000000000000000000000000000000000000000000000000000000000000005", + "9:0000000000000000000000000000000000000000000000000000000000000006" + ], + "actionHistory": [ + { + "id": "BKA3-ZDA9", + "type": "ban", + "reason": "sdfsdfsdf", + "author": "tabarra", + "ts": 1590812890, + "exp": 1590985690, + "revokedBy": "tabarra" + }, + { + "id": "BBGC-ZQDW", + "type": "ban", + "reason": "efrdgdfdf", + "author": "tabarra", + "ts": 1590817677, + "exp": 1590990477, + "revokedBy": "tabarra" + }, + { + "id": "WU9K-EQAK", + "type": "warn", + "reason": "warningggggggggggg", + "author": "tabarra", + "ts": 1590915097 + }, + { + "id": "B6YB-36WP", + "type": "ban", + "reason": "ban test", + "author": "tabarra", + "ts": 1590915223, + "exp": 1591088023, + "revokedBy": "tabarra" + }, + { + "id": "BUMF-RF4K", + "type": "ban", + "reason": "aaaaaaaaaaaaaaaaaa", + "author": "tabarra", + "ts": 1590915327, + "revokedBy": "tabarra" + }, + { + "id": "BZWX-7T92", + "type": "ban", + "reason": "ban ids", + "author": "tabarra", + "ts": 1590916449, + "exp": 123456 + }, + { + "id": "WD8A-TF29", + "type": "warn", + "reason": "🆃🆇🅰🅳🅼🅸🅽", + "author": "tabarra", + "ts": 1592112016 + }, + { + "id": "WFBP-RPKA", + "type": "warn", + "reason": "zxc", + "author": "tabarra", + "ts": 1593506835 + }, + { + "id": "B28W-5Z9Z", + "type": "ban", + "reason": "dsfdfs", + "author": "tabarra", + "ts": 1593507597, + "exp": 1593680397, + "revokedBy": "tabarra" + }, + { + "id": "BE1V-WM8R", + "type": "ban", + "reason": "sadf sadf asdf asd", + "author": "tabarra", + "ts": 1593507790, + "exp": 1593680590, + "revokedBy": "tabarra" + }, + { + "id": "BNLA-1WRQ", + "type": "ban", + "reason": "msgmsgmsgmsg msg", + "author": "tabarra", + "ts": 1593507940, + "exp": 1593680740, + "revokedBy": "tabarra" + }, + { + "id": "B58M-ZG5T", + "type": "ban", + "reason": "sdf sdf dsf ds", + "author": "tabarra", + "ts": 1593508091, + "exp": 1593680891, + "revokedBy": "tabarra" + }, + { + "id": "BDLY-YVUH", + "type": "ban", + "reason": "asdasd", + "author": "tabarra", + "ts": 1593508612, + "exp": 1593681412, + "revokedBy": "tabarra" + }, + { + "id": "BT6X-HE7M", + "type": "ban", + "reason": "sadf", + "author": "tabarra", + "ts": 1593508757, + "exp": 1593681557, + "revokedBy": "tabarra" + }, + { + "id": "B6KL-YHHQ", + "type": "ban", + "reason": "zdfgdfg dfg dfg dfdfg", + "author": "tabarra", + "ts": 1593508806, + "revokedBy": "tabarra" + }, + { + "id": "WBR1-H1RJ", + "type": "warn", + "reason": "dfsg", + "author": "tabarra", + "ts": 1594133299 + }, + { + "id": "W57D-4GAE", + "type": "warn", + "reason": "fds", + "author": "tabarra", + "ts": 1594133352 + }, + { + "id": "W2XM-6Q32", + "type": "warn", + "reason": "sdf", + "author": "tabarra", + "ts": 1594133385 + }, + { + "id": "WJG5-65BJ", + "type": "warn", + "reason": "yuiyiu", + "author": "tabarra", + "ts": 1594133717, + "revokedBy": "tabarra" + }, + { + "id": "BQHS-2ZS1", + "type": "ban", + "reason": "sdfsdffsd", + "author": "tabarra", + "ts": 1624492869, + "revokedBy": "tabarra" + }, + { + "id": "WBYN-8U51", + "type": "warn", + "reason": "sdf", + "author": "tabarra", + "ts": 1632547265 + }, + { + "id": "BMNG-DPFF", + "type": "ban", + "reason": "test ban reason", + "author": "tabarra", + "ts": 1666739975, + "exp": 1666912775, + "revokedBy": "tabarra" + }, + { + "id": "BZYG-TPN7", + "type": "ban", + "reason": "whatever new ban", + "author": "tabarra", + "ts": 1666740120, + "exp": 1666912920, + "revokedBy": "tabarra" + }, + { + "id": "B2PY-NFJ2", + "type": "ban", + "reason": "test ban whoop whoop", + "author": "tabarra", + "ts": 1666830531, + "exp": 1667003331, + "revokedBy": "tabarra" + }, + { + "id": "BFR2-KCX7", + "type": "ban", + "reason": "test ban reason", + "author": "tabarra", + "ts": 1667268168, + "exp": 1667440968, + "revokedBy": "tabarra" + }, + { + "id": "BFAH-96ZV", + "type": "ban", + "reason": "222222", + "author": "tabarra", + "ts": 1667268176, + "exp": 1667440976, + "revokedBy": "tabarra" + }, + { + "id": "B3NN-13WE", + "type": "ban", + "reason": "test ban reason", + "author": "tabarra", + "ts": 1667268617, + "exp": 1667441417, + "revokedBy": "tabarra" + }, + { + "id": "WFTC-ZRMG", + "type": "warn", + "reason": "yabadabadu", + "author": "tabarra", + "ts": 1667312038, + "revokedBy": "tabarra" + }, + { + "id": "WVM2-VBND", + "type": "warn", + "reason": "whatevs", + "author": "tabarra", + "ts": 1667694161, + "revokedBy": "tabarra" + }, + { + "id": "WZL4-PMW1", + "type": "warn", + "reason": "lipsum", + "author": "tabarra", + "ts": 1667695747, + "revokedBy": "tabarra" + }, + { + "id": "W82M-6B2B", + "type": "warn", + "reason": "bbbbbbbbbb", + "author": "tabarra", + "ts": 1667695778 + }, + { + "id": "BDJA-L9KB", + "type": "ban", + "reason": "test ban reason", + "author": "tabarra", + "ts": 1667710213, + "exp": 1667883013 + } + ], + "netid": 53, + "sessionTime": 53, + "tsJoined": 1590812869, + "playTime": 4706, + "tsWhitelisted": 1667314521, + "oldIds": [ + "license:9b9fc300cc65d22ad3b536175a4d15c0e4933753", + "fivem:271816", + "discord:272800190639898628" + ], + "oldHwids": [ + "9:0000000000000000000000000000000000000000000000000000000000000001", + "9:0000000000000000000000000000000000000000000000000000000000000002", + "9:0000000000000000000000000000000000000000000000000000000000000003", + "9:0000000000000000000000000000000000000000000000000000000000000004", + "9:0000000000000000000000000000000000000000000000000000000000000005", + "9:0000000000000000000000000000000000000000000000000000000000000006", + + "8:0000000000000000000000000000000000000000000000000000000000000001", + "8:0000000000000000000000000000000000000000000000000000000000000002", + "8:0000000000000000000000000000000000000000000000000000000000000003", + "8:0000000000000000000000000000000000000000000000000000000000000004", + "8:0000000000000000000000000000000000000000000000000000000000000005", + "8:0000000000000000000000000000000000000000000000000000000000000006", + + "7:0000000000000000000000000000000000000000000000000000000000000001", + "7:0000000000000000000000000000000000000000000000000000000000000002", + "7:0000000000000000000000000000000000000000000000000000000000000003", + "7:0000000000000000000000000000000000000000000000000000000000000004", + "7:0000000000000000000000000000000000000000000000000000000000000005", + "7:0000000000000000000000000000000000000000000000000000000000000006" + ], + "tsLastConnection": 1667708940 + } +}; diff --git a/nui/src/utils/copyToClipboard.ts b/nui/src/utils/copyToClipboard.ts new file mode 100644 index 0000000..055e9c0 --- /dev/null +++ b/nui/src/utils/copyToClipboard.ts @@ -0,0 +1,15 @@ +// Because we don't have access to Clipboard API in FiveM's CEF, +// we need to use the old school method. +// NOTE: Since the only place we use this is in the player-modal. This is +// currently targeting the wrapper element for where it appends + +export const copyToClipboard = (value: string, isPlayerModal?: boolean): void => { + const targetElement = isPlayerModal ? document.getElementById('player-modal-container') : document.body; + if(!targetElement) return; + const clipElem = document.createElement("input"); + clipElem.value = value; + targetElement.appendChild(clipElem); + clipElem.select(); + document.execCommand("copy"); + targetElement.removeChild(clipElem); +}; diff --git a/nui/src/utils/debugData.ts b/nui/src/utils/debugData.ts new file mode 100644 index 0000000..f646cce --- /dev/null +++ b/nui/src/utils/debugData.ts @@ -0,0 +1,28 @@ +import { isBrowserEnv } from "./miscUtils"; + +interface DebugEvent { + action: string; + data: T; +} + +/** + * Emulates data we'll have in production. + * @param events - The event you want to cover + * @param timer - How long until it should trigger (ms) + */ +export const debugData =

    (events: DebugEvent

    [], timer = 1000): void => { + if(!isBrowserEnv()) return; + + for (const event of events) { + setTimeout(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + action: event.action, + data: event.data, + }, + }) + ); + }, timer); + } +}; diff --git a/nui/src/utils/debugLog.ts b/nui/src/utils/debugLog.ts new file mode 100644 index 0000000..5839150 --- /dev/null +++ b/nui/src/utils/debugLog.ts @@ -0,0 +1,19 @@ +/** + * Simple debug logger for development use. + * Used in `fetchNui` and `useNuiEvent`. + * + * @param action - The action of this debug log + * @param data - Data you wish to debug + * @param context - Optional context + */ +export const debugLog = ( + action: string, + data: unknown, + context = "Unknown" +): void => { + if ((window as any).__MenuDebugMode) { + console.group(`${context} | Action: ${action}`); + console.dir(data); + console.groupEnd(); + } +}; diff --git a/nui/src/utils/fetchNui.ts b/nui/src/utils/fetchNui.ts new file mode 100644 index 0000000..a5cdb04 --- /dev/null +++ b/nui/src/utils/fetchNui.ts @@ -0,0 +1,43 @@ +import { debugLog } from "./debugLog"; +import { isBrowserEnv } from "./miscUtils"; + +type OptsWithMockData = Partial; + +/** + * Simple wrapper around fetch API tailored for CEF/NUI use. + * @param eventName - The endpoint eventname to target + * @param data - Data you wish to send in the NUI Callback + * @param opts - Request init opts to pass to fetch API + * @return returnData - A promise for the data sent back by the NuiCallbacks CB argument + */ +export async function fetchNui( + eventName: string, + data: unknown = {}, + opts?: OptsWithMockData +): Promise { + const options = { + ...opts, + method: "post", + headers: { + "Content-Type": "application/json; charset=UTF-8", + }, + body: JSON.stringify(data), + }; + + // debugLog(eventName, data, "PostToScripts"); + + // If we are in browser and mockResp option is defined, we can + // bail out of having to make failing HTTP reqs, speeding up data dispatching. + if (isBrowserEnv() && opts?.mockResp) return opts.mockResp; + + try { + const resp = await fetch(`https://monitor/${eventName}`, options); + return await resp.json(); + } catch (error) { + if (error.name === 'SyntaxError') { + throw new Error(`JSON error. Maybe the NUI Callback \'${eventName}\' is not registered. This can be caused if the file that registers it has a lua syntax error.`); + } else { + throw error; + } + } +} diff --git a/nui/src/utils/fetchWebPipe.ts b/nui/src/utils/fetchWebPipe.ts new file mode 100644 index 0000000..2e2fda3 --- /dev/null +++ b/nui/src/utils/fetchWebPipe.ts @@ -0,0 +1,56 @@ +import { isBrowserEnv } from "./miscUtils"; + +const WEBPIPE_PATH = "https://monitor/WebPipe"; + +type ValidPath = `/${string}`; + +enum PipeTimeout { + SHORT = 1500, + MEDIUM = 5000, + LONG = 9000, +} + +interface fetchWebPipeOpts { + method?: "GET" | "POST"; + timeout?: PipeTimeout; + data?: unknown; + mockData?: T; +} +/** + * A wrapper around fetch for HTTP reqs to the txAdminPipe + * @param path The path to send the req to + * @param options Additional options to control the fetch event's behavior + **/ +export const fetchWebPipe = async ( + path: ValidPath, + options?: fetchWebPipeOpts +): Promise => { + const reqPath = WEBPIPE_PATH + path; + const timeout = options?.timeout || PipeTimeout.MEDIUM; + + const abortionController = new AbortController(); + + const fetchOpts: RequestInit = { + headers: { + "Content-Type": "application/json; charset=UTF-8", + }, + method: options?.method, + body: JSON.stringify(options?.data), + signal: abortionController.signal, + }; + // Bail out of request if possible when browser + if (isBrowserEnv() && options?.mockData) { + return options.mockData as unknown as T; + } + + // Timeout logic for fetch request + const timeoutId = setTimeout(() => abortionController.abort(), timeout); + const resp = await fetch(reqPath, fetchOpts); + clearTimeout(timeoutId); + + if (resp.status === 404) { + return false; + } + + return await resp.json(); +}; diff --git a/nui/src/utils/generateMockPlayerData.ts b/nui/src/utils/generateMockPlayerData.ts new file mode 100644 index 0000000..35d647c --- /dev/null +++ b/nui/src/utils/generateMockPlayerData.ts @@ -0,0 +1,45 @@ +import { PlayerData, VehicleStatus } from "../hooks/usePlayerListListener"; +import { arrayRandom } from "./miscUtils"; + +export function mockPlayerData(players = 500) { + const randomUsernames = [ + 'Lion', + 'Tiger', + 'Horse', + 'Donkey', + 'Dog', + 'Cat', + 'Pig', + ]; + + const playerData: PlayerData[] = []; + const statuses: VehicleStatus[] = [ + VehicleStatus.Biking, + VehicleStatus.Boat, + VehicleStatus.Unknown, + VehicleStatus.Flying, + VehicleStatus.Walking, + ]; + + for (let i = 0; i < players; i++) { + const randomDist = Math.random() * 5000; + const randomUsername = arrayRandom(randomUsernames); + const randomStatusIdx = Math.floor(Math.random() * 5); + const randomStatus = statuses[randomStatusIdx]; + const isAdmin = Math.floor(Math.random() * 5) === 1 + + playerData.push({ + admin: isAdmin, + id: i + 1, + dist: randomDist, + health: Math.floor(Math.random() * 100), + // health: -1, + name: randomUsername, + vType: randomStatus, + }); + } + + console.log(playerData); + + return playerData; +} diff --git a/nui/src/utils/getNotiDuration.ts b/nui/src/utils/getNotiDuration.ts new file mode 100644 index 0000000..fe1159f --- /dev/null +++ b/nui/src/utils/getNotiDuration.ts @@ -0,0 +1,17 @@ +const minDuration = 7.5; +const maxDuration = 30; +const avgWordSize = 5.5; //We could count words, but this is probably better. +const avgWordReadingTime = 60 / 150; //Assuming slow-reading speeds + +/** + * Will return the ideal notification duration (in ms) given the length of the + * passed string + * @param text - Text to display + **/ +export const getNotiDuration = (text: string): number => { + const idealSeconds = (text.length / avgWordSize) * avgWordReadingTime; + if (idealSeconds < minDuration) return minDuration; + if (idealSeconds > maxDuration) return maxDuration; + + return idealSeconds; +}; diff --git a/nui/src/utils/miscUtils.ts b/nui/src/utils/miscUtils.ts new file mode 100644 index 0000000..fe1e362 --- /dev/null +++ b/nui/src/utils/miscUtils.ts @@ -0,0 +1,69 @@ +import { ResolvablePermission } from "../state/permissions.state"; +import { TxAdminActionRespType } from "../components/PlayerModal/Tabs/DialogActionView"; +import { VariantType } from "notistack"; + +export const userHasPerm = ( + perm: ResolvablePermission, + permsState: ResolvablePermission[] +): boolean => { + const userPerms = permsState ?? []; + return userPerms.includes(perm) || userPerms.includes("all_permissions"); +}; + +export const formatDistance = (distance: number): string => { + let unit = "m"; + let roundedDistance = Math.round(distance); + if (roundedDistance >= 1000) { + roundedDistance = +(roundedDistance / 1000).toFixed(1); + unit = "km"; + } + return `${roundedDistance.toLocaleString()} ${unit}`; +}; + +export const arrayRandom = (arr: T[]): T => { + return arr[Math.round(Math.random() * (arr.length - 1))]; +}; + +const lookupTable: Record = { + success: "success", + danger: "error", + warning: "warning", +}; +export const translateAlertType = ( + txAdminType: TxAdminActionRespType +): VariantType => lookupTable[txAdminType]; + +/** + * Returns whether we are in browser or in NUI + **/ +export const isBrowserEnv = (): boolean => !(window as any).invokeNative + + +/** + * Translates a timestamp into a localized date string + */ +export const tsToLocaleDate = ( + ts: number, + dateStyle: any = 'long', +) => { + return new Date(ts * 1000) + .toLocaleDateString( + (window as any).nuiSystemLanguages, + { dateStyle } + ); +} + +/** + * Translates a timestamp into a localized date time string + */ +export const tsToLocaleDateTime = ( + ts: number, + dateStyle: any = 'long', + timeStyle: any = 'medium', +) => { + return new Date(ts * 1000) + .toLocaleString( + (window as any).nuiSystemLanguages, + { dateStyle, timeStyle } + ); +} diff --git a/nui/src/utils/registerDebugFunctions.ts b/nui/src/utils/registerDebugFunctions.ts new file mode 100644 index 0000000..0d61038 --- /dev/null +++ b/nui/src/utils/registerDebugFunctions.ts @@ -0,0 +1,143 @@ +import { isBrowserEnv } from "./miscUtils"; +import { debugData } from "./debugData"; +import { PlayerData } from "../hooks/usePlayerListListener"; +import { LocaleType } from "@shared/localeMap"; +import { ServerCtx } from "../state/server.state"; +import { SetWarnOpenData } from "../components/WarnPage/WarnPage"; +import { AddAnnounceData } from "../hooks/useHudListenersService"; +import { mockPlayerData } from "./generateMockPlayerData"; + +let playerUpdateInterval: ReturnType | null = null; + +const MenuObject = { + warnSelf: (reason: string) => { + debugData([ + { + action: "setWarnOpen", + data: { + reason: reason, + warnedBy: "Taso", + isWarningNew: true, + }, + }, + ]); + }, + setPlayerModalTarget: (target: string) => { + debugData([ + { + action: "openPlayerModal", + data: target + } + ]) + }, + startPlayerUpdateLoop: (ms = 30000) => { + if (playerUpdateInterval) { + clearTimeout(playerUpdateInterval); + playerUpdateInterval = null; + } + + console.log("Started player update loop"); + + playerUpdateInterval = setInterval(() => { + const mockPlayers = mockPlayerData(200); + + debugData( + [ + { + action: "setPlayerList", + data: mockPlayers, + }, + ], + 3000 + ); + }, ms); + }, + clearPlayerUpdateLoop: () => { + if (!playerUpdateInterval) return console.error("No interval to clear"); + + clearTimeout(playerUpdateInterval); + playerUpdateInterval = null; + }, + warnPulse: () => { + debugData([ + { + action: "pulseWarning", + data: {}, + }, + ]); + }, + closeWarn: () => { + debugData([ + { + action: "closeWarning", + data: {}, + }, + ]); + }, + announceMsg: ({ message, author }: AddAnnounceData) => { + debugData([ + { + action: "addAnnounceMessage", + data: { + message, + author, + }, + }, + ]); + }, + setCustomLocale: (localeObj: LocaleType) => { + debugData([ + { + action: "setServerCtx", + data: { + announceNotiPos: "top-right", + projectName: "", + locale: "custom", + localeData: localeObj, + alignRight: false, + maxClients: 32, + oneSync: { + status: true, + type: "Infinity", + }, + switchPageKey: "Tab", + txAdminVersion: "9.9.9", + }, + }, + ]); + }, + setVisible: (bool: boolean = true) => { + debugData( + [ + { + action: "setVisible", + data: bool, + }, + ], + 0 + ); + }, + useMockPlayerList: () => { + debugData([ + { + action: "setPlayerList", + data: mockPlayerData(400), + }, + ]); + }, +}; + +export const registerDebugFunctions = () => { + if (isBrowserEnv()) { + (window as any).menuDebug = MenuObject; + + console.log( + "%ctxAdmin Menu Development", + "font-weight: bold; font-size: 25px; color: red;" + ); + console.log( + "%cDebug Utilities have been injected for browser use. Inspect `window.menuDebug` object for further details.", + "font-size: 15px; color: green;" + ); + } +}; diff --git a/nui/src/utils/shouldHelpAlertShow.ts b/nui/src/utils/shouldHelpAlertShow.ts new file mode 100644 index 0000000..d00d83d --- /dev/null +++ b/nui/src/utils/shouldHelpAlertShow.ts @@ -0,0 +1,29 @@ +interface TxAdminHelpData { + date: Date; +} + +const TXADMIN_HELP_DATA_KEY = "txAdminHelpData"; + +const dayInMs = 24 * 60 * 60 * 1000; + +export const shouldHelpAlertShow = (): boolean => { + const rawLocalStorageStr = localStorage.getItem(TXADMIN_HELP_DATA_KEY); + + const setNewItemDate = JSON.stringify({ date: new Date() }); + + if (rawLocalStorageStr) { + const data: TxAdminHelpData = JSON.parse(rawLocalStorageStr); + const oneDayAgo = new Date(Date.now() - dayInMs); + + // If the last time message was shown was over a day ago + if (data.date > oneDayAgo) { + localStorage.setItem(TXADMIN_HELP_DATA_KEY, setNewItemDate); + return true; + } + + return false; + } + + localStorage.setItem(TXADMIN_HELP_DATA_KEY, setNewItemDate); + return true; +}; diff --git a/nui/src/utils/vehicleSpawnDialogHelper.ts b/nui/src/utils/vehicleSpawnDialogHelper.ts new file mode 100644 index 0000000..e2bdc86 --- /dev/null +++ b/nui/src/utils/vehicleSpawnDialogHelper.ts @@ -0,0 +1,70 @@ +import { arrayRandom } from './miscUtils'; + +type ShortcutDataType = { + easterEgg: string | false; + default: string[] +} +type ShortcutsDataType = Record + +const fivemShortcuts: ShortcutsDataType = { + car: { + easterEgg: 'caddy', + default: ['comet2', 'coquette', 'trophytruck', 'issi5', 'f620', 'nero', 'sc1', 'toros', 'tyrant'], + }, + bike: { + easterEgg: 'bmx', + default: ['esskey', 'nemesis', 'sanchez'], + }, + heli: { + easterEgg: 'havok', + default: ['buzzard2', 'volatus'], + }, + boat: { + easterEgg: 'seashark', + default: ['dinghy', 'toro2'], + }, +}; + +const redmShortcuts: ShortcutsDataType = { + horse: { + easterEgg: 'a_c_horsemulepainted_01', + default: ['a_c_horse_arabian_redchestnut', 'a_c_horse_turkoman_perlino', 'a_c_horse_missourifoxtrotter_buckskinbrindle'], + }, + buggy: { + easterEgg: false, + default: ['buggy01', 'buggy02', 'buggy03'], + }, + coach: { + easterEgg: false, + default: ['coach2', 'coach3', 'coach4', 'coach5', 'coach6'], + }, + canoe: { + easterEgg: 'rowboat', + default: ['canoe', 'pirogue', 'pirogue2'], + }, +}; + +/** + * Returns the input string or replaces it with a random vehicle shortcut + */ +export const vehiclePlaceholderReplacer = (vehInput: string, shortcutsData: ShortcutsDataType) => { + vehInput = vehInput.trim().toLowerCase(); + if (vehInput in shortcutsData) { + const shortcut = shortcutsData[vehInput as keyof typeof shortcutsData]; + if (shortcut.easterEgg && Math.random() < 0.05) { + vehInput = shortcut.easterEgg; + } else { + vehInput = arrayRandom(shortcut.default); + } + } + + return vehInput; +} + +/** + * Returns the appropriate vehicle shortcut data for a given game + */ +export const getVehicleSpawnDialogData = (isRedm: boolean) => ({ + shortcuts: isRedm ? Object.keys(redmShortcuts) : Object.keys(fivemShortcuts), + shortcutsData: isRedm ? redmShortcuts : fivemShortcuts, +}) diff --git a/nui/tsconfig.json b/nui/tsconfig.json new file mode 100644 index 0000000..31c1894 --- /dev/null +++ b/nui/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES6", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES6"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ES6", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* mod resolution. */ + "paths": { + "@shared/*": ["../shared/*"], + "@nui/*": ["./*"] + }, + }, + "include": ["src"], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "../shared" } + ] +} diff --git a/nui/tsconfig.node.json b/nui/tsconfig.node.json new file mode 100644 index 0000000..defda65 --- /dev/null +++ b/nui/tsconfig.node.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": [ + "vite.config.ts", + "../scripts/build/config.ts", + "../scripts/build/utils.ts", + "../shared/txDevEnv.ts", + ] +} diff --git a/nui/vite.config.ts b/nui/vite.config.ts new file mode 100644 index 0000000..ca88ba2 --- /dev/null +++ b/nui/vite.config.ts @@ -0,0 +1,80 @@ +import path from 'node:path'; +import { visualizer } from "rollup-plugin-visualizer"; +import { PluginOption, UserConfig, defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import { getFxsPaths, licenseBanner } from '../scripts/build/utils'; +import { parseTxDevEnv } from '../shared/txDevEnv'; +process.loadEnvFile('../.env'); + +//Check if TXDEV_FXSERVER_PATH is set +const txDevEnv = parseTxDevEnv(); +if (!txDevEnv.FXSERVER_PATH) { + console.error('Missing TXDEV_FXSERVER_PATH env variable.'); + process.exit(1); +} + +const baseConfig = { + build: { + emptyOutDir: true, + reportCompressedSize: false, + outDir: '../dist/nui', + minify: true as boolean, + target: 'chrome103', + sourcemap: false, + + rollupOptions: { + output: { + banner: licenseBanner('..', true), + //Doing this because fxserver's cicd doesn't wipe the dist folder + entryFileNames: `[name].js`, + chunkFileNames: `[name].js`, + assetFileNames: '[name].[ext]', + } + }, + }, + base: '/nui/', + clearScreen: false, + plugins: [ + tsconfigPaths({ + projects: ['./', '../shared'] + }), + react(), + visualizer({ + // template: 'flamegraph', + // template: 'sunburst', + gzipSize: true, + filename: '../.reports/nui_bundle.html', + }), + ] as PluginOption[], //i gave up +} satisfies UserConfig; + +// https://vitejs.dev/config/ +export default defineConfig(({ command, mode }) => { + if (mode === 'devNuiBrowser') { + console.log('Launching NUI in browser mode') + return baseConfig + } + + if (mode === 'development') { + let devDeplyPath: string; + try { + //Extract paths and validate them + const fxsPaths = getFxsPaths(txDevEnv.FXSERVER_PATH); + devDeplyPath = path.join(fxsPaths.monitor, 'nui'); + } catch (error) { + console.error('Could not extract/validate the fxserver and monitor paths.'); + console.error(error); + process.exit(1); + } + + baseConfig.build.outDir = devDeplyPath; + baseConfig.build.minify = false; + + //DEBUG sourcemap is super slow + // baseConfig.build.sourcemap = true; + return baseConfig; + } else { + return baseConfig; + } +}) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a7cae8e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,16490 @@ +{ + "name": "txadmin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "txadmin", + "version": "1.0.0", + "license": "MIT", + "workspaces": [ + "core", + "nui", + "panel", + "shared" + ], + "dependencies": { + "chalk": "^5.3.0", + "dequal": "^2.0.3", + "humanize-duration": "^3.32.1", + "jsonrepair": "^3.11.2", + "lodash-es": "^4.17.21", + "semver": "^7.6.3" + }, + "devDependencies": { + "@commitlint/cli": "^19.5.0", + "@commitlint/config-conventional": "^19.5.0", + "@types/humanize-duration": "^3.27.4", + "@types/lodash-es": "^4.17.12", + "@types/node": "^22.7.5", + "esbuild": "~0.24.0", + "eslint": "^9.12.0", + "generate-license-file": "^3.5.1", + "husky": "^8.0.3", + "rimraf": "^6.0.1", + "rollup-plugin-visualizer": "^5.12.0", + "typescript": "^5.6.3", + "utility-types": "^3.11.0", + "vitest": "^2.1.2" + } + }, + "core": { + "name": "txadmin-core", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@koa/cors": "^5.0.0", + "@koa/router": "^13.1.0", + "boxen": "^7.1.1", + "bytes": "^3.1.2", + "cookie": "^0.7.0", + "d3-array": "^3.2.4", + "dateformat": "^5.0.3", + "discord.js": "14.11.0", + "ejs": "^3.1.10", + "error-stack-parser": "^2.1.4", + "execa": "^5.1.1", + "fs-extra": "^9.1.0", + "fuse.js": "^7.0.0", + "got": "^13.0.0", + "is-localhost-ip": "^2.0.0", + "jose": "^4.15.4", + "js-yaml": "^4.1.0", + "koa": "^2.15.4", + "koa-bodyparser": "^4.4.1", + "koa-ratelimit": "^5.1.0", + "lodash": "^4.17.21", + "lowdb": "^6.1.0", + "mnemonist": "^0.39.8", + "mysql2": "^3.11.3", + "nanoid": "^4.0.2", + "nanoid-dictionary": "^4.3.0", + "node-polyglot": "^2.6.0", + "node-stream-zip": "^1.15.0", + "open": "7.1.0", + "openid-client": "^5.7.0", + "pidtree": "^0.6.0", + "pidusage": "^3.0.2", + "rotating-file-stream": "^3.2.5", + "slash": "^5.1.0", + "slug": "^8.2.3", + "socket.io": "^4.8.0", + "source-map-support": "^0.5.21", + "stream-json": "^1.9.1", + "string-argv": "^0.3.2", + "systeminformation": "^5.23.5", + "throttle-debounce": "^5.0.2", + "unicode-emoji-json": "^0.8.0", + "xss": "^1.0.15", + "zod": "^3.23.8", + "zod-validation-error": "^3.4.0" + }, + "devDependencies": { + "@types/bytes": "^3.1.4", + "@types/d3-array": "^3.2.1", + "@types/dateformat": "^5.0.2", + "@types/ejs": "^3.1.5", + "@types/fs-extra": "^11.0.4", + "@types/js-yaml": "^4.0.9", + "@types/koa": "^2.15.0", + "@types/koa__cors": "^5.0.0", + "@types/koa__router": "^12.0.4", + "@types/koa-bodyparser": "^4.3.12", + "@types/koa-ratelimit": "^5.0.5", + "@types/nanoid-dictionary": "^4.2.3", + "@types/node": "^16.9.1", + "@types/pidusage": "^2.0.5", + "@types/semver": "^7.5.8", + "@types/slug": "^5.0.9", + "@types/source-map-support": "^0.5.10", + "@types/stream-json": "^1.7.7", + "windows-release": "^4.0.0" + } + }, + "core/node_modules/@types/node": { + "version": "16.18.123", + "dev": true, + "license": "MIT" + }, + "core/node_modules/open": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-7.1.0.tgz", + "integrity": "sha512-lLPI5KgOwEYCDKXf4np7y1PBEkj7HYIyP2DY8mVDRnx0VIIu6bNrRB0R66TuO7Mack6EnTNLm4uvcl1UoklTpA==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", + "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.3", + "@babel/types": "^7.26.3", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", + "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", + "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.3", + "@babel/parser": "^7.26.3", + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.3", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", + "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@commander-js/extra-typings": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-12.1.0.tgz", + "integrity": "sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "commander": "~12.1.0" + } + }, + "node_modules/@commitlint/cli": { + "version": "19.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.6.1.tgz", + "integrity": "sha512-8hcyA6ZoHwWXC76BoC8qVOSr8xHy00LZhZpauiD0iO0VYbVhMnED0da85lTfIULxl7Lj4c6vZgF0Wu/ed1+jlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/format": "^19.5.0", + "@commitlint/lint": "^19.6.0", + "@commitlint/load": "^19.6.1", + "@commitlint/read": "^19.5.0", + "@commitlint/types": "^19.5.0", + "tinyexec": "^0.3.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "19.6.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.6.0.tgz", + "integrity": "sha512-DJT40iMnTYtBtUfw9ApbsLZFke1zKh6llITVJ+x9mtpHD08gsNXaIRqHTmwTZL3dNX5+WoyK7pCN/5zswvkBCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.5.0", + "conventional-changelog-conventionalcommits": "^7.0.2" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.5.0.tgz", + "integrity": "sha512-CHtj92H5rdhKt17RmgALhfQt95VayrUo2tSqY9g2w+laAXyk7K/Ef6uPm9tn5qSIwSmrLjKaXK9eiNuxmQrDBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.5.0", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.5.0.tgz", + "integrity": "sha512-Kv0pYZeMrdg48bHFEU5KKcccRfKmISSm9MvgIgkpI6m+ohFTB55qZlBW6eYqh/XDfRuIO0x4zSmvBjmOwWTwkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.5.0", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.5.0.tgz", + "integrity": "sha512-aqyGgytXhl2ejlk+/rfgtwpPexYyri4t8/n4ku6rRJoRhGZpLFMqrZ+YaubeGysCP6oz4mMA34YSTaSOKEeNrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.5.0.tgz", + "integrity": "sha512-yNy088miE52stCI3dhG/vvxFo9e4jFkU1Mj3xECfzp/bIS/JUay4491huAlVcffOoMK1cd296q0W92NlER6r3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.5.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "19.6.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.6.0.tgz", + "integrity": "sha512-Ov6iBgxJQFR9koOupDPHvcHU9keFupDgtB3lObdEZDroiG4jj1rzky60fbQozFKVYRTUdrBGICHG0YVmRuAJmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.5.0", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint": { + "version": "19.6.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.6.0.tgz", + "integrity": "sha512-LRo7zDkXtcIrpco9RnfhOKeg8PAnE3oDDoalnrVU/EVaKHYBWYL1DlRR7+3AWn0JiBqD8yKOfetVxJGdEtZ0tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/is-ignored": "^19.6.0", + "@commitlint/parse": "^19.5.0", + "@commitlint/rules": "^19.6.0", + "@commitlint/types": "^19.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load": { + "version": "19.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.6.1.tgz", + "integrity": "sha512-kE4mRKWWNju2QpsCWt428XBvUH55OET2N4QKQ0bF85qS/XbsRGG1MiTByDNlEVpEPceMkDr46LNH95DtRwcsfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^19.5.0", + "@commitlint/execute-rule": "^19.5.0", + "@commitlint/resolve-extends": "^19.5.0", + "@commitlint/types": "^19.5.0", + "chalk": "^5.3.0", + "cosmiconfig": "^9.0.0", + "cosmiconfig-typescript-loader": "^6.1.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/message": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.5.0.tgz", + "integrity": "sha512-R7AM4YnbxN1Joj1tMfCyBryOC5aNJBdxadTZkuqtWi3Xj0kMdutq16XQwuoGbIzL2Pk62TALV1fZDCv36+JhTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.5.0.tgz", + "integrity": "sha512-cZ/IxfAlfWYhAQV0TwcbdR1Oc0/r0Ik1GEessDJ3Lbuma/MRO8FRQX76eurcXtmhJC//rj52ZSZuXUg0oIX0Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.5.0", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/read": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.5.0.tgz", + "integrity": "sha512-TjS3HLPsLsxFPQj6jou8/CZFAmOP2y+6V4PGYt3ihbQKTY1Jnv0QG28WRKl/d1ha6zLODPZqsxLEov52dhR9BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^19.5.0", + "@commitlint/types": "^19.5.0", + "git-raw-commits": "^4.0.0", + "minimist": "^1.2.8", + "tinyexec": "^0.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.5.0.tgz", + "integrity": "sha512-CU/GscZhCUsJwcKTJS9Ndh3AKGZTNFIOoQB2n8CmFnizE0VnEuJoum+COW+C1lNABEeqk6ssfc1Kkalm4bDklA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^19.5.0", + "@commitlint/types": "^19.5.0", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules": { + "version": "19.6.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.6.0.tgz", + "integrity": "sha512-1f2reW7lbrI0X0ozZMesS/WZxgPa4/wi56vFuJENBmed6mWq5KsheN/nxqnl/C23ioxpPO/PL6tXpiiFy5Bhjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^19.5.0", + "@commitlint/message": "^19.5.0", + "@commitlint/to-lines": "^19.5.0", + "@commitlint/types": "^19.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.5.0.tgz", + "integrity": "sha512-R772oj3NHPkodOSRZ9bBVNq224DOxQtNef5Pl8l2M8ZnkkzQfeSTr4uxawV2Sd3ui05dUVzvLNnzenDBO1KBeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.5.0.tgz", + "integrity": "sha512-IP1YLmGAk0yWrImPRRc578I3dDUI5A2UBJx9FbSOjxe9sTlzFiwVJ+zeMLgAtHMtGZsC8LUnzmW1qRemkFU4ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^7.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.5.0.tgz", + "integrity": "sha512-DSHae2obMSMkAtTBSOulg5X7/z+rGLxcXQIkg3OmWvY6wifojge5uVMydfhUvs7yQj+V7jNmRZ2Xzl8GJyqRgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.10.0.tgz", + "integrity": "sha512-ikVZsZP+3shmVJ5S1oM+7SveUCK3L9fTyfA8aJ7uD9cNQlTqF+3Irbk2Y22KXTb3C3RNUahRkSInClJMkHrINg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.0", + "@discordjs/util": "^1.1.1", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.37.114", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/builders/node_modules/@discordjs/formatters": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.0.tgz", + "integrity": "sha512-YIruKw4UILt/ivO4uISmrGq2GdMY6EkoTtD0oS0GvkJFRZbTSdPhzYiUILbJ/QslsvC9H9nTgGgnarnIl4jMfw==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.37.114" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/builders/node_modules/@discordjs/util": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", + "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.3.3.tgz", + "integrity": "sha512-wTcI1Q5cps1eSGhl6+6AzzZkBBlVrBdc9IUhJbijRgVjCNIIIZPgqnUj3ntFODsHrdbGU8BEG9XmDQmgEEYn3w==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "0.37.61" + }, + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters/node_modules/discord-api-types": { + "version": "0.37.61", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.61.tgz", + "integrity": "sha512-o/dXNFfhBpYHpQFdT6FWzeO7pKc838QeeZ9d91CfVAtpr5XLK4B/zYxQbYgPdoMiTDvJfzcsLW5naXgmHGDNXw==", + "license": "MIT" + }, + "node_modules/@discordjs/rest": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-1.7.1.tgz", + "integrity": "sha512-Ofa9UqT0U45G/eX86cURQnX7gzOJLG2oC28VhIk/G6IliYgQF7jFByBJEykPSHE4MxPhqCleYvmsrtfKh1nYmQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^1.5.1", + "@discordjs/util": "^0.3.0", + "@sapphire/async-queue": "^1.5.0", + "@sapphire/snowflake": "^3.4.2", + "discord-api-types": "^0.37.41", + "file-type": "^18.3.0", + "tslib": "^2.5.0", + "undici": "^5.22.0" + }, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@discordjs/util": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.3.1.tgz", + "integrity": "sha512-HxXKYKg7vohx2/OupUN/4Sd02Ev3PBJ5q0gtjdcvXb0ErCva8jNHWfe/v5sU3UKjIB/uxOhc+TDOnhqffj9pRA==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@discordjs/ws": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-0.8.3.tgz", + "integrity": "sha512-hcYtppanjHecbdNyCKQNH2I4RP9UrphDgmRgLYrATEQF1oo4sYSve7ZmGsBEXSzH72MO2tBPdWSThunbxUVk0g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^1.5.1", + "@discordjs/rest": "^1.7.1", + "@discordjs/util": "^0.3.1", + "@sapphire/async-queue": "^1.5.0", + "@types/ws": "^8.5.4", + "@vladfrangu/async_event_emitter": "^2.2.1", + "discord-api-types": "^0.37.41", + "tslib": "^2.5.0", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", + "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", + "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", + "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.5", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz", + "integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", + "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", + "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", + "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz", + "integrity": "sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "license": "MIT" + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.1.1.tgz", + "integrity": "sha512-OpXFTmiH6tHkYijMvQTycFKBLK4X+SRV6tet1m4YOUH7SzIIlMqDja+ocDtiCA72UthBH/vF+3ZtlMr2rN/wIw==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.1.2.tgz", + "integrity": "sha512-syGEJ/N4FFisAXegxtbe1etYpo2T630dqCbYufPf7Dli/hMKSoSUIm3wK4q5RsGFRxWno1WecfnsgZA1jRK0EQ==", + "license": "OFL-1.1" + }, + "node_modules/@formkit/auto-animate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.2.tgz", + "integrity": "sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==", + "license": "MIT" + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "license": "BSD-3-Clause" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", + "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz", + "integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@koa/cors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@koa/cors/-/cors-5.0.0.tgz", + "integrity": "sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==", + "license": "MIT", + "dependencies": { + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@koa/router": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-13.1.0.tgz", + "integrity": "sha512-mNVu1nvkpSd8Q8gMebGbCkDWJ51ODetrFvLKYusej+V0ByD4btqHYnPIzTBLXnQMVUlm/oxVwqmWBY3zQfZilw==", + "license": "MIT", + "dependencies": { + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "path-to-regexp": "^6.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@monaco-editor/loader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", + "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.1.tgz", + "integrity": "sha512-2OmnEyoHpj5//dJJpMuxOeLItCCHdf99pjMFfUFdBteCunAK9jW+PwEo4mtdGcLs7P+IgZ+85ypd52eY4AigoQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.3.1.tgz", + "integrity": "sha512-nJmWj1PBlwS3t1PnoqcixIsftE+7xrW3Su7f0yrjPw4tVjYrgkhU0hrRp+OlURfZ3ptdSkoBkalee9Bhf1Erfw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^6.3.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.1.tgz", + "integrity": "sha512-ynG9ayhxgCsHJ/dtDcT1v78/r2GwQyP3E0hPz3GdPRl0uFJz/uUTtI5KFYwadXmbC+Uv3bfB8laZ6+Cpzh03gA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.3.1", + "@mui/system": "^6.3.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.3.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.3.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT", + "peer": true + }, + "node_modules/@mui/private-theming": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.1.tgz", + "integrity": "sha512-g0u7hIUkmXmmrmmf5gdDYv9zdAig0KoxhIQn1JN8IVqApzf/AyRhH3uDGx5mSvs8+a1zb4+0W6LC260SyTTtdQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.3.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.1.tgz", + "integrity": "sha512-/7CC0d2fIeiUxN5kCCwYu4AWUDd9cCTxWCyo0v/Rnv6s8uk6hWgJC3VLZBoDENBHf/KjqDZuYJ2CR+7hD6QYww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.1.tgz", + "integrity": "sha512-AwqQ3EAIT2np85ki+N15fF0lFXX1iFPqenCzVOSl3QXKy2eifZeGd9dGtt7pGMoFw5dzW4dRGGzRpLAq9rkl7A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.3.1", + "@mui/styled-engine": "^6.3.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.3.1", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.21", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", + "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.1.tgz", + "integrity": "sha512-sjGjXAngoio6lniQZKJ5zGfjm+LD2wvLwco7FbKe1fu8A7VIFmz2SwkLb+MDPLNX1lE7IscvNNyh1pobtZg2tw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.21", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT" + }, + "node_modules/@nivo/annotations": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@nivo/annotations/-/annotations-0.87.0.tgz", + "integrity": "sha512-4Xk/soEmi706iOKszjX1EcGLBNIvhMifCYXOuLIFlMAXqhw1x2YS7PxickVSskdSzJCwJX4NgQ/R/9u6nxc5OA==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.87.0", + "@nivo/core": "0.87.0", + "@react-spring/web": "9.4.5 || ^9.7.2", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, + "node_modules/@nivo/arcs": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@nivo/arcs/-/arcs-0.87.0.tgz", + "integrity": "sha512-YWmIm0el0hgVbPI3C5AX6R59WNnuKjh2GdocaVDP5zupqAMhfqyoMx+IM+A+Cg+UzE4xakrL0mSzL+rpMUK90Q==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.87.0", + "@nivo/core": "0.87.0", + "@react-spring/web": "9.4.5 || ^9.7.2", + "@types/d3-shape": "^3.1.6", + "d3-shape": "^3.2.0" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, + "node_modules/@nivo/axes": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@nivo/axes/-/axes-0.87.0.tgz", + "integrity": "sha512-zCRBfiRKJi+xOxwxH5Pxq/8+yv3fAYDl4a1F2Ssnp5gMIobwzVsdearvsm5B04e9bfy3ZXTL7KgbkEkSAwu6SA==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.87.0", + "@nivo/scales": "0.87.0", + "@react-spring/web": "9.4.5 || ^9.7.2", + "@types/d3-format": "^1.4.1", + "@types/d3-time-format": "^2.3.1", + "d3-format": "^1.4.4", + "d3-time-format": "^3.0.0" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, + "node_modules/@nivo/bar": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@nivo/bar/-/bar-0.87.0.tgz", + "integrity": "sha512-r/MEVCNAHKfmsy1Fb+JztVczOhIEtAx4VFs2XUbn9YpEDgxydavUJyfoy5/nGq6h5jG1/t47cfB4nZle7c0fyQ==", + "license": "MIT", + "dependencies": { + "@nivo/annotations": "0.87.0", + "@nivo/axes": "0.87.0", + "@nivo/colors": "0.87.0", + "@nivo/core": "0.87.0", + "@nivo/legends": "0.87.0", + "@nivo/scales": "0.87.0", + "@nivo/tooltip": "0.87.0", + "@react-spring/web": "9.4.5 || ^9.7.2", + "@types/d3-scale": "^4.0.8", + "@types/d3-shape": "^3.1.6", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, + "node_modules/@nivo/colors": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.87.0.tgz", + "integrity": "sha512-S4pZzRGKK23t8XAjQMhML6wwsfKO9nH03xuyN4SvCodNA/Dmdys9xV+9Dg/VILTzvzsBTBGTX0dFBg65WoKfVg==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.87.0", + "@types/d3-color": "^3.0.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-scale-chromatic": "^3.0.0", + "@types/prop-types": "^15.7.2", + "d3-color": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "lodash": "^4.17.21", + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, + "node_modules/@nivo/core": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.87.0.tgz", + "integrity": "sha512-yEQWJn7QjWnbmCZccBCo4dligNyNyz3kgyV9vEtcaB1iGeKhg55RJEAlCOul+IDgSCSPFci2SxTmipE6LZEZCg==", + "license": "MIT", + "dependencies": { + "@nivo/tooltip": "0.87.0", + "@react-spring/web": "9.4.5 || ^9.7.2", + "@types/d3-shape": "^3.1.6", + "d3-color": "^3.1.0", + "d3-format": "^1.4.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21", + "prop-types": "^15.7.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nivo/donate" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, + "node_modules/@nivo/legends": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.87.0.tgz", + "integrity": "sha512-bVJCeqEmK4qHrxNaPU/+hXUd/yaKlcQ0yrsR18ewoknVX+pgvbe/+tRKJ+835JXlvRijYIuqwK1sUJQIxyB7oA==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.87.0", + "@nivo/core": "0.87.0", + "@types/d3-scale": "^4.0.8", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, + "node_modules/@nivo/pie": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@nivo/pie/-/pie-0.87.0.tgz", + "integrity": "sha512-kY6LAQhOITwg8waFoDYLPkwUj/5XavSm61c7dXXJgCtqoj6c5u9AgwOTnZqS6IhMVEc5KV7ZNxSEHlHLQinmrg==", + "license": "MIT", + "dependencies": { + "@nivo/arcs": "0.87.0", + "@nivo/colors": "0.87.0", + "@nivo/core": "0.87.0", + "@nivo/legends": "0.87.0", + "@nivo/tooltip": "0.87.0", + "@types/d3-shape": "^3.1.6", + "d3-shape": "^3.2.0" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, + "node_modules/@nivo/scales": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@nivo/scales/-/scales-0.87.0.tgz", + "integrity": "sha512-IHdY9w2em/xpWurcbhUR3cUA1dgbY06rU8gmA/skFCwf3C4Da3Rqwr0XqvxmkDC+EdT/iFljMbLst7VYiCnSdw==", + "license": "MIT", + "dependencies": { + "@types/d3-scale": "^4.0.8", + "@types/d3-time": "^1.1.1", + "@types/d3-time-format": "^3.0.0", + "d3-scale": "^4.0.2", + "d3-time": "^1.0.11", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21" + } + }, + "node_modules/@nivo/scales/node_modules/@types/d3-time-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.4.tgz", + "integrity": "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg==", + "license": "MIT" + }, + "node_modules/@nivo/tooltip": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.87.0.tgz", + "integrity": "sha512-nZJWyRIt/45V/JBdJ9ksmNm1LFfj59G1Dy9wB63Icf2YwyBT+J+zCzOGXaY7gxCxgF1mnSL3dC7fttcEdXyN/g==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.87.0", + "@react-spring/web": "9.4.5 || ^9.7.2" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/arborist": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/arborist/-/arborist-8.0.0.tgz", + "integrity": "sha512-APDXxtXGSftyXibl0dZ3CuZYmmVnkiN3+gkqwXshY4GKC2rof2+Lg0sGuj6H1p2YfBAKd7PRwuMVhu6Pf/nQ/A==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/metavuln-calculator": "^8.0.0", + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.1", + "@npmcli/query": "^4.0.0", + "@npmcli/redact": "^3.0.0", + "@npmcli/run-script": "^9.0.1", + "bin-links": "^5.0.0", + "cacache": "^19.0.1", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^8.0.0", + "npm-install-checks": "^7.1.0", + "npm-package-arg": "^12.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.1", + "pacote": "^19.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "proggy": "^3.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "ssri": "^12.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^3.0.1" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/arborist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@npmcli/arborist/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/git": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.1.tgz", + "integrity": "sha512-BBWMMxeQzalmKadyimwb2/VVQyJB01PH0HhVSNLHNBDZN/M/h/02P6f8fxedIiFhpMj11SO9Ep5tKTBE7zL2nw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", + "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/map-workspaces": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/map-workspaces/-/map-workspaces-4.0.2.tgz", + "integrity": "sha512-mnuMuibEbkaBTYj9HQ3dMe6L0ylYW+s/gfz7tBDMFY/la0w9Kf44P9aLn4/+/t3aTR3YUHKoT6XQL9rlicIe3Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@npmcli/map-workspaces/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/metavuln-calculator": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/metavuln-calculator/-/metavuln-calculator-8.0.1.tgz", + "integrity": "sha512-WXlJx9cz3CfHSt9W9Opi1PTFc4WZLFomm5O8wekxQZmkyljrBRwATwDxfC9iOXJwYVmfiW1C1dUe0W2aN0UrSg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cacache": "^19.0.0", + "json-parse-even-better-errors": "^4.0.0", + "pacote": "^20.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/pacote": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-20.0.0.tgz", + "integrity": "sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/name-from-folder": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/name-from-folder/-/name-from-folder-3.0.0.tgz", + "integrity": "sha512-61cDL8LUc9y80fXn+lir+iVt8IS0xHqEKwPu/5jCjxQTVoSCmkXvw4vbMrzAMtmghz3/AkiBjhHkDKUH+kf7kA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", + "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.1.0.tgz", + "integrity": "sha512-t6G+6ZInT4X+tqj2i+wlLIeCKnKOTuz9/VFYDtj+TGTur5q7sp/OYrQA19LdBbWfXDOi0Y4jtedV6xtB8zQ9ug==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "normalize-package-data": "^7.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.2.tgz", + "integrity": "sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/query": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-4.0.0.tgz", + "integrity": "sha512-3pPbese0fbCiFJ/7/X1GBgxAKYFE8sxBddA7GtuRmOgNseH4YbGsXJ807Ig3AEwNITjDUISHglvy89cyDJnAwA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.0.0.tgz", + "integrity": "sha512-/1uFzjVcfzqrgCeGW7+SZ4hv0qLWmKXVzFahZGJ6QuJBj6Myt9s17+JL86i76NV9YSnJRcGXJYQbAU0rn1YTCQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.0.2.tgz", + "integrity": "sha512-cJXiUlycdizQwvqE1iaAb4VRUM3RX09/8q46zjvy+ct9GhfZRWd7jXYVc1tn/CfRlGPVkX/u4sstRlepsm7hfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/run-script/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/run-script/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.4.tgz", + "integrity": "sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.4", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", + "integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz", + "integrity": "sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.3.tgz", + "integrity": "sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", + "integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz", + "integrity": "sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz", + "integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.4.tgz", + "integrity": "sha512-iXU1Ab5ecM+yEepGAWK8ZhMyKX4ubFdCNtol4sT9D0OVErG9PNElfx3TQhjw7n7BC5nFVz68/5//clWy+8TXzA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.4", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", + "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.1.tgz", + "integrity": "sha512-UUw5E4e/2+4kFMH7+YxORXGWggtY6sM8WIwh5RZchhLuUg2H1hc98Py+pr8HMz6rdaYrK2t296ZEjYLOCO5uUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.4.tgz", + "integrity": "sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.3.tgz", + "integrity": "sha512-IQWAsQ7dsLIYDrn0WqPU+cdM7MONTv9nqrLVYoie3BPiabSfUVDe6Fr+oEt0Cofsr9ONDcDe9xhmJbL1Uq1yKg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", + "integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz", + "integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.2.tgz", + "integrity": "sha512-E0MLLGfOP0l8P/NxgVzfXJ8w3Ch8cdO6UDzJfDChu4EJDy+/WdO5LqpdY8PYnCErkmZH3gZhDL1K7kQ41fAHuQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz", + "integrity": "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz", + "integrity": "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz", + "integrity": "sha512-pOkb2u8KgO47j/h7AylCj7dJsm69BXcjkrvTqMptFqsE2i0p8lHkfgneXKjAgPzBMivnoMyt8o4KiV4wYzDdyQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.1.tgz", + "integrity": "sha512-RRiNRSrD8iUiXriq/Y5n4/3iE8HzqgLHsusUSg5jVpU2+3tqcUFPJXHDymwEypunc2sWxDUS3UC+rkZRlHedsw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz", + "integrity": "sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz", + "integrity": "sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz", + "integrity": "sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz", + "integrity": "sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, + "node_modules/@react-spring/animated": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz", + "integrity": "sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.29.2.tgz", + "integrity": "sha512-s/8RiF4bdmGnc/J0N7lHAr5ZFJj+NdJqJ/Hj29K+c4lEdoVlukzvWXB9XpWZCdakVT0YAw8iyIqUP2iFRz5/jA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.29.2.tgz", + "integrity": "sha512-mKRlVj1KsKWyEOwR6nwpmzakq6SgZXW4NUHNWlYSiyncJpuXk7wdLzuKdWsRoR1WLbWsZBKvsUCdCTIAqRn9cA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.29.2.tgz", + "integrity": "sha512-vJX+vennGwygmutk7N333lvQ/yKVAHnGoBS2xMRQgXWW8tvn46YWuTDOpKroSPR9BEW0Gqdga2DHqz8Pwk6X5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.29.2.tgz", + "integrity": "sha512-e2rW9ng5O6+Mt3ht8fH0ljfjgSCC6ffmOipiLUgAnlK86CHIaiCdHCzHzmTkMj6vEkqAiRJ7ss6Ibn56B+RE5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.29.2.tgz", + "integrity": "sha512-/xdNwZe+KesG6XJCK043EjEDZTacCtL4yurMZRLESIgHQdvtNyul3iz2Ab03ZJG0pQKbFTu681i+4ETMF9uE/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.29.2.tgz", + "integrity": "sha512-eXKvpThGzREuAbc6qxnArHh8l8W4AyTcL8IfEnmx+bcnmaSGgjyAHbzZvHZI2csJ+e0MYddl7DX0X7g3sAuXDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.29.2.tgz", + "integrity": "sha512-h4VgxxmzmtXLLYNDaUcQevCmPYX6zSj4SwKuzY7SR5YlnCBYsmvfYORXgiU8axhkFCDtQF3RW5LIXT8B14Qykg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.29.2.tgz", + "integrity": "sha512-EObwZ45eMmWZQ1w4N7qy4+G1lKHm6mcOwDa+P2+61qxWu1PtQJ/lz2CNJ7W3CkfgN0FQ7cBUy2tk6D5yR4KeXw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.29.2.tgz", + "integrity": "sha512-Z7zXVHEXg1elbbYiP/29pPwlJtLeXzjrj4241/kCcECds8Zg9fDfURWbZHRIKrEriAPS8wnVtdl4ZJBvZr325w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.29.2.tgz", + "integrity": "sha512-TF4kxkPq+SudS/r4zGPf0G08Bl7+NZcFrUSR3484WwsHgGgJyPQRLCNrQ/R5J6VzxfEeQR9XRpc8m2t7lD6SEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.29.2.tgz", + "integrity": "sha512-kO9Fv5zZuyj2zB2af4KA29QF6t7YSxKrY7sxZXfw8koDQj9bx5Tk5RjH+kWKFKok0wLGTi4bG117h31N+TIBEg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.29.2.tgz", + "integrity": "sha512-gIh776X7UCBaetVJGdjXPFurGsdWwHHinwRnC5JlLADU8Yk0EdS/Y+dMO264OjJFo7MXQ5PX4xVFbxrwK8zLqA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.29.2.tgz", + "integrity": "sha512-YgikssQ5UNq1GoFKZydMEkhKbjlUq7G3h8j6yWXLBF24KyoA5BcMtaOUAXq5sydPmOPEqB6kCyJpyifSpCfQ0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.29.2.tgz", + "integrity": "sha512-9ouIR2vFWCyL0Z50dfnon5nOrpDdkTG9lNDs7MRaienQKlTyHcDxplmk3IbhFlutpifBSBr2H4rVILwmMLcaMA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.29.2.tgz", + "integrity": "sha512-ckBBNRN/F+NoSUDENDIJ2U9UWmIODgwDB/vEXCPOMcsco1niTkxTXa6D2Y/pvCnpzaidvY2qVxGzLilNs9BSzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.29.2.tgz", + "integrity": "sha512-jycl1wL4AgM2aBFJFlpll/kGvAjhK8GSbEmFT5v3KC3rP/b5xZ1KQmv0vQQ8Bzb2ieFQ0kZFPRMbre/l3Bu9JA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.29.2.tgz", + "integrity": "sha512-S2V0LlcOiYkNGlRAWZwwUdNgdZBfvsDHW0wYosYFV3c7aKgEVcbonetZXsHv7jRTTX+oY5nDYT4W6B1oUpMNOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.29.2.tgz", + "integrity": "sha512-pW8kioj9H5f/UujdoX2atFlXNQ9aCfAxFRaa+mhczwcsusm6gGrSo4z0SLvqLF5LwFqFTjiLCCzGkNK/LE0utQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.29.2.tgz", + "integrity": "sha512-p6fTArexECPf6KnOHvJXRpAEq0ON1CBtzG/EY4zw08kCHk/kivBc5vUEtnCFNCHOpJZ2ne77fxwRLIKD4wuW2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sigstore/bundle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.0.0.tgz", + "integrity": "sha512-XDUYX56iMPAn/cdgh/DTJxz5RWmqKV4pwvUAEKEWJl+HzKdCd/24wUa9JYNMlDSCb7SUHAdtksxYX779Nne/Zg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", + "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz", + "integrity": "sha512-c6B0ehIWxMI8wiS/bj6rHMPqeFvngFV7cDU/MY+B16P9Z3Mp9k8L93eYZ7BYzSickzuqAQqAq0V956b3Ju6mLw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.0.0.tgz", + "integrity": "sha512-UjhDMQOkyDoktpXoc5YPJpJK6IooF2gayAr5LvXI4EL7O0vd58okgfRcxuaH+YTdhvb5aa1Q9f+WJ0c2sVuYIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.0.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "make-fetch-happen": "^14.0.1", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.0.0.tgz", + "integrity": "sha512-9Xxy/8U5OFJu7s+OsHzI96IX/OzjF/zj0BSSaWhgJgTqtlBhQIV2xdrQI5qxLD7+CWWDepadnXAxzaZ3u9cvRw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2", + "tuf-js": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.0.0.tgz", + "integrity": "sha512-Ggtq2GsJuxFNUvQzLoXqRwS4ceRfLAJnrIHUDrzAD0GgnOhwujJkKkxM/s5Bako07c3WtAs/sZo5PJq7VHjeDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.0.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@swc/core": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.4.tgz", + "integrity": "sha512-ut3zfiTLORMxhr6y/GBxkHmzcGuVpwJYX4qyXWuBKkpw/0g0S5iO1/wW7RnLnZbAi8wS/n0atRZoaZlXWBkeJg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.17" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.10.4", + "@swc/core-darwin-x64": "1.10.4", + "@swc/core-linux-arm-gnueabihf": "1.10.4", + "@swc/core-linux-arm64-gnu": "1.10.4", + "@swc/core-linux-arm64-musl": "1.10.4", + "@swc/core-linux-x64-gnu": "1.10.4", + "@swc/core-linux-x64-musl": "1.10.4", + "@swc/core-win32-arm64-msvc": "1.10.4", + "@swc/core-win32-ia32-msvc": "1.10.4", + "@swc/core-win32-x64-msvc": "1.10.4" + }, + "peerDependencies": { + "@swc/helpers": "*" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.4.tgz", + "integrity": "sha512-sV/eurLhkjn/197y48bxKP19oqcLydSel42Qsy2zepBltqUx+/zZ8+/IS0Bi7kaWVFxerbW1IPB09uq8Zuvm3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.4.tgz", + "integrity": "sha512-gjYNU6vrAUO4+FuovEo9ofnVosTFXkF0VDuo1MKPItz6e2pxc2ale4FGzLw0Nf7JB1sX4a8h06CN16/pLJ8Q2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.4.tgz", + "integrity": "sha512-zd7fXH5w8s+Sfvn2oO464KDWl+ZX1MJiVmE4Pdk46N3PEaNwE0koTfgx2vQRqRG4vBBobzVvzICC3618WcefOA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.4.tgz", + "integrity": "sha512-+UGfoHDxsMZgFD3tABKLeEZHqLNOkxStu+qCG7atGBhS4Slri6h6zijVvf4yI5X3kbXdvc44XV/hrP/Klnui2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.4.tgz", + "integrity": "sha512-cDDj2/uYsOH0pgAnDkovLZvKJpFmBMyXkxEG6Q4yw99HbzO6QzZ5HDGWGWVq/6dLgYKlnnmpjZCPPQIu01mXEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.4.tgz", + "integrity": "sha512-qJXh9D6Kf5xSdGWPINpLGixAbB5JX8JcbEJpRamhlDBoOcQC79dYfOMEIxWPhTS1DGLyFakAx2FX/b2VmQmj0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.4.tgz", + "integrity": "sha512-A76lIAeyQnHCVt0RL/pG+0er8Qk9+acGJqSZOZm67Ve3B0oqMd871kPtaHBM0BW3OZAhoILgfHW3Op9Q3mx3Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.4.tgz", + "integrity": "sha512-e6j5kBu4fIY7fFxFxnZI0MlEovRvp50Lg59Fw+DVbtqHk3C85dckcy5xKP+UoXeuEmFceauQDczUcGs19SRGSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.4.tgz", + "integrity": "sha512-RSYHfdKgNXV/amY5Tqk1EWVsyQnhlsM//jeqMLw5Fy9rfxP592W9UTumNikNRPdjI8wKKzNMXDb1U29tQjN0dg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.4.tgz", + "integrity": "sha512-1ujYpaqfqNPYdwKBlvJnOqcl+Syn3UrQ4XE0Txz6zMYgyh6cdU6a3pxqLqIUSJ12MtXRA9ZUhEz1ekU3LfLWXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.17.tgz", + "integrity": "sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz", + "integrity": "sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==", + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz", + "integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.11.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz", + "integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", + "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@tufjs/models/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@tufjs/models/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bytes": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/bytes/-/bytes-3.1.5.tgz", + "integrity": "sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/content-disposition": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.8.tgz", + "integrity": "sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.1.tgz", + "integrity": "sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "license": "MIT" + }, + "node_modules/@types/cookies": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.0.tgz", + "integrity": "sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.4.tgz", + "integrity": "sha512-JIvy2HjRInE+TXOmIGN5LCmeO0hkFZx5f9FZ7kiN+D+YTcc8pptsiLiuHsvwxwC7VVKmJ2ExHUgNlAiV7vQM9g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.3.4.tgz", + "integrity": "sha512-xdDXbpVO74EvadI3UDxjxTdR6QIxm1FKzEA/+F8tL4GWWUg/hgvBqf6chql64U5A9ZUGWo7pEu4eNlyLwbKdhg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dateformat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/dateformat/-/dateformat-5.0.2.tgz", + "integrity": "sha512-M95hNBMa/hnwErH+a+VOD/sYgTmo15OTYTM2Hr52/e0OdOuY+Crag+kd3/ioZrhg0WGbl9Sm3hR7UU+MH6rfOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", + "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.3.tgz", + "integrity": "sha512-JEhMNwUJt7bw728CydvYzntD0XJeTmDnvwLlbfbAhE7Tbslm/ax6bdIiUwTgeVlZTsJQPwZwKpAkyDtIjsvx3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.15", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.15.tgz", + "integrity": "sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", + "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/humanize-duration": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.4.tgz", + "integrity": "sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/koa": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz", + "integrity": "sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa__cors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/koa__cors/-/koa__cors-5.0.0.tgz", + "integrity": "sha512-LCk/n25Obq5qlernGOK/2LUwa/2YJb2lxHUkkvYFDOpLXlVI6tKcdfCHRBQnOY4LwH6el5WOLs6PD/a8Uzau6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/koa__router": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@types/koa__router/-/koa__router-12.0.4.tgz", + "integrity": "sha512-Y7YBbSmfXZpa/m5UGGzb7XadJIRBRnwNY9cdAojZGp65Cpe5MAP3mOZE7e3bImt8dfKS4UFcR16SLH8L/z7PBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/koa-bodyparser": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@types/koa-bodyparser/-/koa-bodyparser-4.3.12.tgz", + "integrity": "sha512-hKMmRMVP889gPIdLZmmtou/BijaU1tHPyMNmcK7FAHAdATnRcGQQy78EqTTxLH1D4FTsrxIzklAQCso9oGoebQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.8.tgz", + "integrity": "sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/koa-ratelimit": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/koa-ratelimit/-/koa-ratelimit-5.0.5.tgz", + "integrity": "sha512-BMxiDvFJ9XdLpzqGZqc6TWTg4xKr9pP3EHo/nzJPhIZwZ0e7vZ4D/4MveVvpyHfxnsHkFo8GrI/uUDv8khLgzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ioredis": "^4.28.10", + "@types/koa": "*", + "@types/node": "*" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", + "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "license": "MIT" + }, + "node_modules/@types/nanoid-dictionary": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@types/nanoid-dictionary/-/nanoid-dictionary-4.2.3.tgz", + "integrity": "sha512-T98Halw1f2aJ802GtCNMHZgqX2q1LX85eMP+K7N+OXSLagdmAzooAEhB565FQXTiCxdF4wNijOBroAQJat4uig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.10.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", + "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/node-polyglot": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@types/node-polyglot/-/node-polyglot-2.5.0.tgz", + "integrity": "sha512-GY0UiBTWM3qclfdCs2BM1mwW9Hs5fSksNXeoTkeHHZ90pHXTtjvQ+fe9GR9NdqFhALBiD7CEAGIIqnQ4eQ5VEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/pidusage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/pidusage/-/pidusage-2.0.5.tgz", + "integrity": "sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.9.17", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", + "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", + "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", + "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/slug": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@types/slug/-/slug-5.0.9.tgz", + "integrity": "sha512-6Yp8BSplP35Esa/wOG1wLNKiqXevpQTEF/RcL/NV6BBQaMmZh4YlDwCgrrFSoUE4xAGvnKd5c+lkQJmPrBAzfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/source-map-support": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.5.10.tgz", + "integrity": "sha512-tgVP2H469x9zq34Z0m/fgPewGhg/MLClalNOiPIzQlXrSS2YrKu/xCdSCKnEDwkFha51VKEKB6A9wW26/ZNwzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "^0.6.0" + } + }, + "node_modules/@types/source-map-support/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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@types/stream-chain": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/stream-chain/-/stream-chain-2.1.0.tgz", + "integrity": "sha512-guDyAl6s/CAzXUOWpGK2bHvdiopLIwpGu8v10+lb9hnQOyo4oj/ZUQFOvqFjKGsE3wJP1fpIesCcMvbXuWsqOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stream-json": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@types/stream-json/-/stream-json-1.7.8.tgz", + "integrity": "sha512-MU1OB1eFLcYWd1LjwKXrxdoPtXSRzRmAnnxs4Js/ayB5O/NvHraWwuOaqMWIebpYwM6khFlsJOHEhI9xK/ab4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/stream-chain": "*" + } + }, + "node_modules/@types/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.0.tgz", + "integrity": "sha512-NggSaEZCdSrFddbctrVjkVZvFC6KGfKfNK0CU7mNK/iKHGKbzT4Wmgm08dKpcZECBu9f5FypndoMyRHkdqfT1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.19.0", + "@typescript-eslint/type-utils": "8.19.0", + "@typescript-eslint/utils": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.0.tgz", + "integrity": "sha512-6M8taKyOETY1TKHp0x8ndycipTVgmp4xtg5QpEZzXxDhNvvHOJi5rLRkLr8SK3jTgD5l4fTlvBiRdfsuWydxBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.19.0", + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/typescript-estree": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.0.tgz", + "integrity": "sha512-hkoJiKQS3GQ13TSMEiuNmSCvhz7ujyqD1x3ShbaETATHrck+9RaDdUbt+osXaUuns9OFwrDTTrjtwsU8gJyyRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.0.tgz", + "integrity": "sha512-TZs0I0OSbd5Aza4qAMpp1cdCYVnER94IziudE3JU328YUHgWu9gwiwhag+fuLeJ2LkWLXI+F/182TbG+JaBdTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.19.0", + "@typescript-eslint/utils": "8.19.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.0.tgz", + "integrity": "sha512-8XQ4Ss7G9WX8oaYvD4OOLCjIQYgRQxO+qCiR2V2s2GxI9AUpo7riNwo6jDhKtTcaJjT8PY54j2Yb33kWtSJsmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.0.tgz", + "integrity": "sha512-WW9PpDaLIFW9LCbucMSdYUuGeFUz1OkWYS/5fwZwTA+l2RwlWFdJvReQqMUMBw4yJWJOfqd7An9uwut2Oj8sLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.0.tgz", + "integrity": "sha512-PTBG+0oEMPH9jCZlfg07LCB2nYI0I317yyvXGfxnvGvw4SHIOuRnQ3kadyyXY6tGdChusIHIbM5zfIbp4M6tCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.19.0", + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/typescript-estree": "8.19.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.0.tgz", + "integrity": "sha512-mCFtBbFBJDCNCWUl5y6sZSCHXw1DEFEk3c/M3nRK2a4XUB8StGFtmcEMizdjKuBzB6e/smJAAWYug3VrdLMr1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz", + "integrity": "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.2.tgz", + "integrity": "sha512-y0byko2b2tSVVf5Gpng1eEhX1OvPC7x8yns1Fx8jDzlJp4LS6CMkCPfLw47cjyoMrshQDoQw4qcgjsU9VvlCew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@swc/core": "^1.7.26" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.8.tgz", + "integrity": "sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.8", + "@vitest/utils": "2.1.8", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.8.tgz", + "integrity": "sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", + "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.8.tgz", + "integrity": "sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.8", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.8.tgz", + "integrity": "sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.8", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.8.tgz", + "integrity": "sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.8.tgz", + "integrity": "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.8", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz", + "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@xterm/addon-canvas": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz", + "integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-search": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz", + "integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", + "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/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/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/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/ansi-align/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/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "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==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/async-ratelimiter": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/async-ratelimiter/-/async-ratelimiter-1.3.13.tgz", + "integrity": "sha512-eZDuN5XrlFwJdK0xxVS03Xj+LW9DkBi34RTVUhnbHoO+jFyI8I026y6x0KeRqTKay90wzl7eUC/HuKPm0U2ZFA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "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/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-macros/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "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/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/bin-links": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-5.0.0.tgz", + "integrity": "sha512-sdleLVfCjBtgO5cNjA2HVRvWBJAHs4zwenaCPMNJAJU0yNxpzj80IpjOIimkpkr+mhlA+how5poQtt53PygbHA==", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boxen": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", + "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.1", + "chalk": "^5.2.0", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", + "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "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/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/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "license": "MIT", + "dependencies": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001690", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", + "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", + "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "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/chokidar/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==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/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==", + "dev": true, + "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/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmd-shim": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-7.0.0.tgz", + "integrity": "sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/co-body": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.2.0.tgz", + "integrity": "sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==", + "license": "MIT", + "dependencies": { + "@hapi/bourne": "^3.0.0", + "inflation": "^2.0.0", + "qs": "^6.5.2", + "raw-body": "^2.3.3", + "type-is": "^1.6.16" + }, + "engines": { + "node": ">=8.0.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/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", + "dev": true, + "license": "ISC" + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "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/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "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/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-to": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/copy-to/-/copy-to-2.0.1.tgz", + "integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.1.0.tgz", + "integrity": "sha512-tJ1w35ZRUiM5FeTzT7DtYWAFFv37ZLqSRkGi2oeCK1gPhvaWjkAtfXvLmvE1pRfxxp9aQo6ba/Pvg1dKj05D4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "^2.4.1" + }, + "engines": { + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale/node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3/node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3/node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3/node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dargs": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", + "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dateformat": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-5.0.3.tgz", + "integrity": "sha512-Kvr6HmPXUMerlLcLF+Pwq3K7apHpYmGDVqrxcDasBg86UcKeTSNWbEzU8bwdXnxnR44FtMhJAxI4Bov6Y/KUfA==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/discord-api-types": { + "version": "0.37.115", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.115.tgz", + "integrity": "sha512-ivPnJotSMrXW8HLjFu+0iCVs8zP6KSliMelhr7HgcB2ki1QzpORkb26m71l1pzSnnGfm7gb5n/VtRTtpw8kXFA==", + "license": "MIT" + }, + "node_modules/discord.js": { + "version": "14.11.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.11.0.tgz", + "integrity": "sha512-CkueWYFQ28U38YPR8HgsBR/QT35oPpMbEsTNM30Fs8loBIhnA4s70AwQEoy6JvLcpWWJO7GY0y2BUzZmuBMepQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.6.3", + "@discordjs/collection": "^1.5.1", + "@discordjs/formatters": "^0.3.1", + "@discordjs/rest": "^1.7.1", + "@discordjs/util": "^0.3.1", + "@discordjs/ws": "^0.8.3", + "@sapphire/snowflake": "^3.4.2", + "@types/ws": "^8.5.4", + "discord-api-types": "^0.37.41", + "fast-deep-equal": "^3.1.3", + "lodash.snakecase": "^4.1.1", + "tslib": "^2.5.0", + "undici": "^5.22.0", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "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/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.76", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz", + "integrity": "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/engine.io": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz", + "integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==", + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.2.tgz", + "integrity": "sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "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/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "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/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "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-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", + "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.9.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.17.0", + "@eslint/plugin-kit": "^0.2.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz", + "integrity": "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.16.tgz", + "integrity": "sha512-slterMlxAhov/DZO8NScf6mEeMBBXodFUolijDvrtTxyezyLoTQaa73FyYus/VbTdftd8wBgBxPMRk3poleXNQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", + "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/expect-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", + "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/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==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.4.tgz", + "integrity": "sha512-G3iTQw1DizJQ5eEqj1CbFCWhq+pzum7qepkxU7rS1FGZDqjYKcrguo9XDRbV7EgPnn8CgaPigTq+NEjyioeYZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", + "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-type": { + "version": "18.7.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.7.0.tgz", + "integrity": "sha512-ihHtXRzXEziMrQ56VSgU7wkxh55iNchFkosu7Y9/S+tXHdKyrGjVK0ujbqNnsxzea+78MaLhN6PGmfYSAv1ACw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.2", + "strtok3": "^7.0.0", + "token-types": "^5.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "license": "MIT", + "engines": { + "node": ">= 14.17" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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/fuse.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", + "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "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/generate-license-file": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/generate-license-file/-/generate-license-file-3.6.0.tgz", + "integrity": "sha512-BzUqym85l+NtOgoHtPqRoOvXx/2K/2FeXBBOYM5YTv4SfdGzVJTYqOYNXxDST5qwwQC4XtdxVzGl/rXMLOgupw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@commander-js/extra-typings": "^12.0.0", + "@npmcli/arborist": "^8.0.0", + "cli-spinners": "^2.6.0", + "commander": "^12.0.0", + "cosmiconfig": "^9.0.0", + "enquirer": "^2.3.6", + "glob": "^10.3.0", + "json5": "^2.2.3", + "ora": "^5.4.1", + "tslib": "^2.3.0", + "zod": "^3.21.4" + }, + "bin": { + "generate-license-file": "bin/generate-license-file" + } + }, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "get-proto": "^1.0.0", + "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-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/git-raw-commits": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", + "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dargs": "^8.0.0", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "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/got": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", + "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/hamt_plus": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz", + "integrity": "sha512-t2JXKaehnMb9paaYA7J0BX8QQAY8lwfQ9Gjf4pg/mk4krt+cmwmU652HOoWonf+7+EQV97ARPMhhVgU1ra2GhA==", + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz", + "integrity": "sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-object": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hosted-git-info": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.0.2.tgz", + "integrity": "sha512-sYKnA7eGln5ov8T8gnYlkSOxFJvywzEx9BueN6xo/GKO8PGiI6uK6xx+DIGe45T3bdVjLAQDQW1aicT8z8JwQg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/humanize-duration": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.32.1.tgz", + "integrity": "sha512-inh5wue5XdfObhu/IGEMiA1nUXigSGcaKNemcbLRKa7jXYGDZXr3LoT9pTIzq2hPEbld7w/qv9h+ikWGz8fL1g==", + "license": "Unlicense" + }, + "node_modules/husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz", + "integrity": "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/ignore-walk/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/ignore-walk/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflation": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.1.0.tgz", + "integrity": "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "license": "MIT" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "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-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-localhost-ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-localhost-ip/-/is-localhost-ip-2.0.0.tgz", + "integrity": "sha512-vlgs2cSgMOfnKU8c1ewgKPyum9rVrjjLLW2HBdL5i0iAJjOs8NY55ZBd/hqUTaYR0EO9CKZd3hVSC2HlIbygTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-mobile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-4.0.0.tgz", + "integrity": "sha512-mlcHZA84t1qLSuWkt2v0I2l61PYdyQDt4aG1mLIXF5FDMm4+haBCxCPYSr/uwqQNRk1MiTizn0ypEuRAOLRAew==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jake/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/jake/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jotai": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", + "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/jotai-effect": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/jotai-effect/-/jotai-effect-1.0.7.tgz", + "integrity": "sha512-FBuECs1g8ajTXoAvBI2r9BwGCoaoRwKRa99QiybhjaupbOjoqtxlU8gIpeBmB4DPcybxi1oI7od9Wsrlgva7Qg==", + "license": "MIT", + "peerDependencies": { + "jotai": ">=2.5.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-nice": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", + "integrity": "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/jsonrepair": { + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.11.2.tgz", + "integrity": "sha512-ejydGcTq0qKk1r0NUBwjtvswbPFhs19+QEfwSeGwB8KJZ59W7/AOFmQh04c68mkJ+2hGk+OkOmkr2bKG4tGlLQ==", + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/just-diff": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/just-diff/-/just-diff-6.0.2.tgz", + "integrity": "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/just-diff-apply": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/just-diff-apply/-/just-diff-apply-5.5.0.tgz", + "integrity": "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/koa": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.4.tgz", + "integrity": "sha512-7fNBIdrU2PEgLljXoPWoyY4r1e+ToWCmzS/wwMPbUNs7X+5MMET1ObhJBlUkF5uZG9B6QhM2zS1TsH6adegkiQ==", + "license": "MIT", + "dependencies": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.9.0", + "debug": "^4.3.2", + "delegates": "^1.0.0", + "depd": "^2.0.0", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^2.0.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + }, + "engines": { + "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" + } + }, + "node_modules/koa-bodyparser": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/koa-bodyparser/-/koa-bodyparser-4.4.1.tgz", + "integrity": "sha512-kBH3IYPMb+iAXnrxIhXnW+gXV8OTzCu8VPDqvcDHW9SQrbkHmqPQtiZwrltNmSq6/lpipHnT7k7PsjlVD7kK0w==", + "license": "MIT", + "dependencies": { + "co-body": "^6.0.0", + "copy-to": "^2.0.1", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "license": "MIT" + }, + "node_modules/koa-convert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", + "license": "MIT", + "dependencies": { + "co": "^4.6.0", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/koa-ratelimit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/koa-ratelimit/-/koa-ratelimit-5.1.0.tgz", + "integrity": "sha512-bXz1UkF9l0PTaYfj5bDCX85NFWA1W3/miBMpZjS+BEKajNPHQaqgaMDwZMM2RdbLGTYRi+mHIOjNTUjTdWjhKA==", + "license": "MIT", + "dependencies": { + "async-ratelimiter": "^1.3.0", + "debug": "^4.1.1", + "ms": "^2.1.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/koa/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/http-errors/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "license": "Apache-2.0" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowdb": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-6.1.1.tgz", + "integrity": "sha512-HO13FCxI8SCwfj2JRXOKgXggxnmfSc+l0aJsZ5I34X3pwzG/DPBSKyKu3Zkgg/pNmx854SVgE2la0oUeh6wzNw==", + "license": "MIT", + "dependencies": { + "steno": "^3.1.1" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/lru.min": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.1.tgz", + "integrity": "sha512-FbAj6lXil6t8z4z3j0E5mfRlPzxkySotzUHwRXjlpRh10vc6AI6WN62ehZj82VG7M20rqogJ0GLwar2Xa05a8Q==", + "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/lucide-react": { + "version": "0.447.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.447.0.tgz", + "integrity": "sha512-SZ//hQmvi+kDKrNepArVkYK7/jfeZ5uFNEnYmd45RKZcbGD78KLnrcNXmgeg6m+xNHFvTG+CblszXCy4n6DN4w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "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/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.1.3.tgz", + "integrity": "sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.1.tgz", + "integrity": "sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.2.tgz", + "integrity": "sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.3.tgz", + "integrity": "sha512-VXJJuNxYWSoYL6AJ6OQECCFGhIU2GGHMw8tahogePBrjkG8aCCas3ibkp7RnVOSTClg2is05/R7maAhF1XyQMg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.1.tgz", + "integrity": "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.0.tgz", + "integrity": "sha512-2v6aXUXwLP1Epd/gc32HAMIWoczx+fZwEPRHm/VwtrJzRGwR1qGZXEYV3Zp8ZjjbwaZhMrM6uHV4KVkk+XCc2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", + "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/minizlib/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT", + "peer": true + }, + "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/mysql2": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz", + "integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/nanoid-dictionary": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/nanoid-dictionary/-/nanoid-dictionary-4.3.0.tgz", + "integrity": "sha512-Xw1+/QnRGWO1KJ0rLfU1xR85qXmAHyLbE3TUkklu9gOIDburP6CsUnLmTaNECGpBh5SHb2uPFmx0VT8UPyoeyw==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-gyp": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.0.0.tgz", + "integrity": "sha512-zQS+9MTTeCMgY0F3cWPyJyRFAkVltQ1uXm+xXu/ES6KFgC6Czo1Seb9vQW2wNxSX2OrDTiqL0ojtkFxBQ0ypIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-polyglot": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-polyglot/-/node-polyglot-2.6.0.tgz", + "integrity": "sha512-ZZFkaYzIfGfBvSM6QhA9dM8EEaUJOVewzGSRcXWbJELXDj0lajAtKaENCYxvF5yE+TgHg6NQb0CmgYMsMdcNJQ==", + "license": "BSD-2-Clause", + "dependencies": { + "hasown": "^2.0.2", + "object.entries": "^1.1.8", + "warning": "^4.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, + "node_modules/nopt": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.0.0.tgz", + "integrity": "sha512-1L/fTJ4UmV/lUxT2Uf006pfZKTvAgCF+chz+0OgBHO8u2Z67pE7AaAUUj7CJy0lXqHmymUvGFt6NE9R3HER0yw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-package-data": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-7.0.0.tgz", + "integrity": "sha512-k6U0gKRIuNCTkwHGZqblCfLfBRh+w1vI6tBo+IeJwq2M8FUiOqhX7GH+GArQGScA7azd1WfyRCvxoXDO3hQDIA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^8.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", + "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/notistack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.1.tgz", + "integrity": "sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.0", + "goober": "^2.0.33" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/notistack" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/notistack/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm-bundled": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", + "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-install-checks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.1.tgz", + "integrity": "sha512-u6DCwbow5ynAX5BdiHQ9qvexme4U3qHW3MWe5NqH+NeBm0LbiH6zvGjNNew1fY+AZZUtVHbOPF3j7mJxbUzpXg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-package-arg": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.1.tgz", + "integrity": "sha512-aDxjFfPV3Liw0WOBWlyZLMBqtbgbg03rmGvHDJa2Ttv7tIz+1oB5qWec4psCDFZcZi9b5XdGkPdQiJxOPzvQRQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-packlist": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-9.0.0.tgz", + "integrity": "sha512-8qSayfmHJQTx3nJWYbbUmflpyarbLMBc6LCAjYsiGtXxDB68HaZpb8re6zeaLGxZzDuMdhsg70jryJe+RrItVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^7.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", + "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", + "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.entries": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/obliterator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", + "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==", + "license": "MIT" + }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "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/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/pacote": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-19.0.1.tgz", + "integrity": "sha512-zIpxWAsr/BvhrkSruspG8aqCQUUrWtpwx0GjiRZQhEM/pZXrigA32ElN3vTcCPUDOFmHr6SFxwYrvVUs5NTEUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/pacote/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/pacote/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/pacote/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pacote/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pacote/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pacote/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/pacote/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pacote/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/pacote/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-conflict-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-4.0.0.tgz", + "integrity": "sha512-37CN2VtcuvKgHUs8+0b1uJeEsbGn61GRHz469C94P5xiOoqpDYJYwjg4RY9Vmz39WyZAVkR5++nbJwLMIgOCnQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "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": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/peek-readable": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.3.1.tgz", + "integrity": "sha512-GVlENSDW6KHaXcd9zkZltB7tCLosKB/4Hg0fqBJkAoBgYG2Tn1xtMgXtSUuMU9AK/gCm/tTdT8mgAeF4YNeeqw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pidusage": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-3.0.2.tgz", + "integrity": "sha512-g0VU+y08pKw5M8EZ2rIGiEBaB8wrQMjYGFfW2QVIfyT8V+fq8YFLkvlz4bz5ljvFDJYNFCWT3PWqcRr2FKO81w==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/proggy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/proggy/-/proggy-3.0.0.tgz", + "integrity": "sha512-QE8RApCM3IaRRxVzxrjbgNMpQEX6Wu0p0KBeoSiSEw5/bsGwZHsshF4LCxH2jp/r6BU+bqA3LrMDEYNfJnpD8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/promise-all-reject-late": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz", + "integrity": "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-call-limit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/promise-call-limit/-/promise-call-limit-3.0.2.tgz", + "integrity": "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", + "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "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/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/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/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-error-boundary": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz", + "integrity": "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, + "node_modules/react-hot-toast": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.1.tgz", + "integrity": "sha512-54Gq1ZD1JbmAb4psp9bvFHjS7lje+8ubboUmvKZkCsQBLH6AOpZ9JemfRvIdHcfb9AZXRaFLrb3qUobGYDJhFQ==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-icons": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", + "integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.1.tgz", + "integrity": "sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-polyglot": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/react-polyglot/-/react-polyglot-0.7.2.tgz", + "integrity": "sha512-d/075aofJ4of9wOSBewl+ViFkkM0L1DgE3RVDOXrHZ92w4o2643sTQJ6lSPw8wsJWFmlB/3Pvwm0UbGNvLfPBw==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.8" + }, + "peerDependencies": { + "node-polyglot": "^2.0.0", + "react": ">=16.8.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz", + "integrity": "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cmd-shim": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-5.0.0.tgz", + "integrity": "sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/read-package-json-fast": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", + "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", + "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/recoil": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.7.7.tgz", + "integrity": "sha512-8Og5KPQW9LwC577Vc7Ug2P0vQshkv1y3zG3tSSkWMqkWSwHmE+by06L8JtnGocjW6gcCvfwB3YtrJG6/tWivNQ==", + "license": "MIT", + "dependencies": { + "hamt_plus": "1.0.2" + }, + "peerDependencies": { + "react": ">=16.13.1" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.1.tgz", + "integrity": "sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz", + "integrity": "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/jackspeak": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.2.tgz", + "integrity": "sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", + "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.29.2.tgz", + "integrity": "sha512-tJXpsEkzsEzyAKIaB3qv3IuvTVcTN7qBw1jL4SPPXM3vzDrJgiLGFY6+HodgFaUHAJ2RYJ94zV5MKRJCoQzQeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.29.2", + "@rollup/rollup-android-arm64": "4.29.2", + "@rollup/rollup-darwin-arm64": "4.29.2", + "@rollup/rollup-darwin-x64": "4.29.2", + "@rollup/rollup-freebsd-arm64": "4.29.2", + "@rollup/rollup-freebsd-x64": "4.29.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.29.2", + "@rollup/rollup-linux-arm-musleabihf": "4.29.2", + "@rollup/rollup-linux-arm64-gnu": "4.29.2", + "@rollup/rollup-linux-arm64-musl": "4.29.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.29.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.29.2", + "@rollup/rollup-linux-riscv64-gnu": "4.29.2", + "@rollup/rollup-linux-s390x-gnu": "4.29.2", + "@rollup/rollup-linux-x64-gnu": "4.29.2", + "@rollup/rollup-linux-x64-musl": "4.29.2", + "@rollup/rollup-win32-arm64-msvc": "4.29.2", + "@rollup/rollup-win32-ia32-msvc": "4.29.2", + "@rollup/rollup-win32-x64-msvc": "4.29.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-visualizer": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.13.1.tgz", + "integrity": "sha512-vMg8i6BprL8aFm9DKvL2c8AwS8324EgymYQo9o6E26wgVvwMhsJxS37aNL6ZsU7X9iAcMYwdME7gItLfG5fwJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^8.4.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^17.5.1" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "rolldown": "1.x", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rotating-file-stream": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/rotating-file-stream/-/rotating-file-stream-3.2.5.tgz", + "integrity": "sha512-T8iBxUA4SookMTU97cIHUPck7beLOvN4g+y4db9E2eLn54OFsdp4qMnxuqmmJ05lcQHzueEVnPRykxfnPG948g==", + "license": "MIT", + "engines": { + "node": ">=14.0" + }, + "funding": { + "url": "https://www.blockchain.com/btc/address/12p1p5q7sK75tPyuesZmssiMYr4TKzpSCN" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "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", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.0.0.tgz", + "integrity": "sha512-PHMifhh3EN4loMcHCz6l3v/luzgT3za+9f8subGgeMNjbJjzH4Ij/YoX3Gvu+kaouJRIlVdTHHCREADYf+ZteA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.0.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "@sigstore/sign": "^3.0.0", + "@sigstore/tuf": "^3.0.0", + "@sigstore/verify": "^2.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slug": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/slug/-/slug-8.2.3.tgz", + "integrity": "sha512-fXjhAZszNecz855GUNIwW0+sFPi9WV4bMiEKDOCA4wcq1ts1UnUVNy/F78B0Aat7/W3rA+se//33ILKNMrbeYQ==", + "license": "MIT", + "bin": { + "slug": "cli.js" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "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/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/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/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", + "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ssri": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", + "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/steno": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/steno/-/steno-3.2.0.tgz", + "integrity": "sha512-zPKkv+LqoYffxrtD0GIVA08DvF6v1dW02qpP5XnERoobq9g3MKcTSBTi08gbGNFMNRo3TQV/6kBw811T1LUhKg==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "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/string-width-cjs/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/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/strip-ansi-cjs": { + "name": "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/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strtok3": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.1.1.tgz", + "integrity": "sha512-mKX8HA/cdBqMKUr0MMZAFssCkIGoZeSCMXgnt79yKxNFguMLVFgRe6wB+fsL0NmoHDbeyZXczy7vEPSoo3rkzg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^5.1.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swr": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.0.tgz", + "integrity": "sha512-NyZ76wA4yElZWBHzSgEJc28a0u6QZvhb6w0azeL2k7+Q1gAzVK+IqQYXhVOC/mzi+HZIozrZvBVeSeOZNR2bqA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/systeminformation": { + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.25.3.tgz", + "integrity": "sha512-ZmNFPqnYeTdVLQ+KWFnprIzyGnKXnGrwYeX5NV62ns4j9bp/D8SaPRjY9KNC2V5vtLDLgTYiuBvj00pkglMnow==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/tailwindcss/node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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==", + "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/token-types": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz", + "integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/treeverse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/treeverse/-/treeverse-3.0.0.tgz", + "integrity": "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tsconfck": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.4.tgz", + "integrity": "sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/tuf-js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.0.1.tgz", + "integrity": "sha512-+68OP1ZzSF84rTckf3FA95vJ1Zlx/uaXyiiKyPd1pA4rZNkpEvDAKmsu1xUSmbF/chCRYgZ6UZkDwC7PmzmAyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "3.0.1", + "debug": "^4.3.6", + "make-fetch-happen": "^14.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/txadmin-core": { + "resolved": "core", + "link": true + }, + "node_modules/txadmin-nui": { + "resolved": "nui", + "link": true + }, + "node_modules/txadmin-panel": { + "resolved": "panel", + "link": true + }, + "node_modules/txadmin-shared": { + "resolved": "shared", + "link": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT" + }, + "node_modules/unicode-emoji-json": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-json/-/unicode-emoji-json-0.8.0.tgz", + "integrity": "sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==", + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unique-filename": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "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/update-browserslist-db": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/usehooks-ts": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz", + "integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, + "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/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.0.tgz", + "integrity": "sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.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/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.8.tgz", + "integrity": "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", + "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.8.tgz", + "integrity": "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.8", + "@vitest/mocker": "2.1.8", + "@vitest/pretty-format": "^2.1.8", + "@vitest/runner": "2.1.8", + "@vitest/snapshot": "2.1.8", + "@vitest/spy": "2.1.8", + "@vitest/utils": "2.1.8", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.8", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.8", + "@vitest/ui": "2.1.8", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/walk-up-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz", + "integrity": "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/windows-release": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-4.0.0.tgz", + "integrity": "sha512-OxmV4wzDKB1x7AZaZgXMVsdJ1qER1ed83ZrTYd5Bwq2HfJVg3DJS8nqlAG4sMoJ7mu8cuRmLEYyU13BKwctRAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^4.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/windows-release/node_modules/execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/windows-release/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/windows-release/node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.12.0" + } + }, + "node_modules/windows-release/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wouter": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.3.5.tgz", + "integrity": "sha512-bx3fLQAMn+EhYbBdY3W1gw9ZfO/uchudxYMwOIBzF3HVgqNEEIT199vEoh7FLTC0Vz5+rpMO6NdFsOkGX1QQCw==", + "license": "Unlicense", + "dependencies": { + "mitt": "^3.0.1", + "regexparam": "^3.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/xss": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", + "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", + "license": "MIT", + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/xss/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/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==", + "dev": true, + "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/ylru": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", + "integrity": "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/yocto-queue": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz", + "integrity": "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.18.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "nui": { + "name": "txadmin-nui", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@mui/icons-material": "^6.1.2", + "@mui/material": "6.1.2", + "notistack": "^3.0.1", + "react-polyglot": "^0.7.2", + "recoil": "^0.7.7" + }, + "devDependencies": { + "@types/node-polyglot": "^2.5.0", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.7.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "vite": "^5.4.8", + "vite-tsconfig-paths": "^5.0.1" + } + }, + "nui/node_modules/@mui/material": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.2.tgz", + "integrity": "sha512-5TtHeAVX9D5d2LYfB1GAUn29BcVETVsrQ76Dwb2SpAfQGW3JVy4deJCAd0RrIkI3eEUrsl0E4xuBdreszxdTTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "@mui/core-downloads-tracker": "^6.1.2", + "@mui/system": "^6.1.2", + "@mui/types": "^7.2.17", + "@mui/utils": "^6.1.2", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^18.3.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.1.2", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "nui/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "panel": { + "name": "txadmin-panel", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@fontsource-variable/inter": "^5.1.0", + "@fontsource-variable/jetbrains-mono": "^5.1.0", + "@formkit/auto-animate": "^0.8.2", + "@monaco-editor/react": "^4.6.0", + "@nivo/bar": "^0.87.0", + "@nivo/pie": "^0.87.0", + "@radix-ui/react-alert-dialog": "^1.1.4", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-navigation-menu": "^1.2.1", + "@radix-ui/react-radio-group": "^1.2.2", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.3", + "@tailwindcss/typography": "^0.5.15", + "@tanstack/react-virtual": "^3.10.8", + "@types/d3": "^7.4.3", + "@types/throttle-debounce": "^5.0.2", + "@xterm/addon-canvas": "0.7.0", + "@xterm/addon-fit": "0.10.0", + "@xterm/addon-search": "0.15.0", + "@xterm/addon-web-links": "0.11.0", + "@xterm/xterm": "5.5.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "d3": "^7.9.0", + "is-mobile": "^4.0.0", + "jotai": "^2.10.0", + "jotai-effect": "^1.0.3", + "lucide-react": "^0.447.0", + "nanoid": "^5.0.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13", + "react-hot-toast": "^2.4.1", + "react-icons": "^5.3.0", + "react-markdown": "^9.0.1", + "socket.io-client": "^4.8.0", + "swr": "^2.2.5", + "tailwind-merge": "^2.5.3", + "tailwindcss-animate": "^1.0.7", + "throttle-debounce": "^5.0.2", + "usehooks-ts": "^3.1.0", + "wouter": "^3.3.5" + }, + "devDependencies": { + "@types/node": "^22.7.5", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.8.0", + "@typescript-eslint/parser": "^8.8.0", + "@vitejs/plugin-react-swc": "^3.7.1", + "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "panel/node_modules/nanoid": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "shared": { + "name": "txadmin-shared", + "version": "1.0.0", + "license": "MIT", + "devDependencies": {} + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..544aa56 --- /dev/null +++ b/package.json @@ -0,0 +1,58 @@ +{ + "name": "txadmin", + "version": "1.0.0", + "description": "The official FiveM/RedM server management platform used by tens of thousands of servers.", + "module": "./core/index.js", + "type": "module", + "workspaces": [ + "core", + "nui", + "panel", + "shared" + ], + "scripts": { + "build": "rimraf dist tmp_core_tsc && npm run build -w nui && npm run build -w panel && npm run build -w core && npm run license:distfile", + "prepare": "husky", + "test": "npm run test --workspaces", + "typecheck": "npm run typecheck --workspaces", + "locale:rebase": "node scripts/locale-utils.js rebase", + "locale:check": "node scripts/locale-utils.js check --color", + "license:report": "rimraf .reports/license && mkdir \".reports/license\" && npm run license:report --workspaces", + "license:list": "node scripts/list-licenses.js", + "license:distfile": "generate-license-file --ci --no-spinner --input package.json --output dist/THIRD-PARTY-LICENSES.txt" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tabarra/txAdmin.git" + }, + "author": "André Tabarra", + "license": "MIT", + "bugs": { + "url": "https://github.com/tabarra/txAdmin/issues" + }, + "homepage": "https://txadmin.gg/", + "dependencies": { + "chalk": "^5.3.0", + "dequal": "^2.0.3", + "humanize-duration": "^3.32.1", + "jsonrepair": "^3.11.2", + "lodash-es": "^4.17.21", + "semver": "^7.6.3" + }, + "devDependencies": { + "@commitlint/cli": "^19.5.0", + "@commitlint/config-conventional": "^19.5.0", + "@types/humanize-duration": "^3.27.4", + "@types/lodash-es": "^4.17.12", + "@types/node": "^22.7.5", + "esbuild": "~0.24.0", + "eslint": "^9.12.0", + "generate-license-file": "^3.5.1", + "husky": "^8.0.3", + "rimraf": "^6.0.1", + "rollup-plugin-visualizer": "^5.12.0", + "typescript": "^5.6.3", + "utility-types": "^3.11.0", + "vitest": "^2.1.2" + } +} diff --git a/panel/.eslintrc.cjs b/panel/.eslintrc.cjs new file mode 100644 index 0000000..a1a70a8 --- /dev/null +++ b/panel/.eslintrc.cjs @@ -0,0 +1,22 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'no-unused-vars': ['warn', { args: 'none' }], + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/ban-ts-comment': 'off' + }, +}; diff --git a/panel/components.json b/panel/components.json new file mode 100644 index 0000000..2678421 --- /dev/null +++ b/panel/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.cjs", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/panel/index.html b/panel/index.html new file mode 100644 index 0000000..1a1736d --- /dev/null +++ b/panel/index.html @@ -0,0 +1,29 @@ + + + + + + + + txAdmin + + + + + + + + + + + + + +

    + + + + + diff --git a/panel/package.json b/panel/package.json new file mode 100644 index 0000000..6fc68b2 --- /dev/null +++ b/panel/package.json @@ -0,0 +1,88 @@ +{ + "name": "txadmin-panel", + "version": "1.0.0", + "description": "The package responsible for the web panel.", + "author": "André Tabarra", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite --port 40122 --strictPort --host", + "build": "vite build", + "test": "vitest", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview", + "license:report": "npx license-report > ../.reports/license/panel.html" + }, + "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@fontsource-variable/inter": "^5.1.0", + "@fontsource-variable/jetbrains-mono": "^5.1.0", + "@formkit/auto-animate": "^0.8.2", + "@monaco-editor/react": "^4.6.0", + "@nivo/bar": "^0.87.0", + "@nivo/pie": "^0.87.0", + "@radix-ui/react-alert-dialog": "^1.1.4", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-navigation-menu": "^1.2.1", + "@radix-ui/react-radio-group": "^1.2.2", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.3", + "@tailwindcss/typography": "^0.5.15", + "@tanstack/react-virtual": "^3.10.8", + "@types/d3": "^7.4.3", + "@types/throttle-debounce": "^5.0.2", + "@xterm/addon-canvas": "0.7.0", + "@xterm/addon-fit": "0.10.0", + "@xterm/addon-search": "0.15.0", + "@xterm/addon-web-links": "0.11.0", + "@xterm/xterm": "5.5.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "d3": "^7.9.0", + "is-mobile": "^4.0.0", + "jotai": "^2.10.0", + "jotai-effect": "^1.0.3", + "lucide-react": "^0.447.0", + "nanoid": "^5.0.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13", + "react-hot-toast": "^2.4.1", + "react-icons": "^5.3.0", + "react-markdown": "^9.0.1", + "socket.io-client": "^4.8.0", + "swr": "^2.2.5", + "tailwind-merge": "^2.5.3", + "tailwindcss-animate": "^1.0.7", + "throttle-debounce": "^5.0.2", + "usehooks-ts": "^3.1.0", + "wouter": "^3.3.5" + }, + "devDependencies": { + "@types/node": "^22.7.5", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.8.0", + "@typescript-eslint/parser": "^8.8.0", + "@vitejs/plugin-react-swc": "^3.7.1", + "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/panel/postcss.config.js b/panel/postcss.config.js new file mode 100644 index 0000000..1d7d3e4 --- /dev/null +++ b/panel/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + 'tailwindcss/nesting': {}, + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/panel/public/favicon_default.svg b/panel/public/favicon_default.svg new file mode 100644 index 0000000..df72399 --- /dev/null +++ b/panel/public/favicon_default.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/panel/public/favicon_offline.svg b/panel/public/favicon_offline.svg new file mode 100644 index 0000000..157b9e3 --- /dev/null +++ b/panel/public/favicon_offline.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/panel/public/favicon_online.svg b/panel/public/favicon_online.svg new file mode 100644 index 0000000..94c5bcf --- /dev/null +++ b/panel/public/favicon_online.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/panel/public/favicon_partial.svg b/panel/public/favicon_partial.svg new file mode 100644 index 0000000..b2033cf --- /dev/null +++ b/panel/public/favicon_partial.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/panel/public/img/discord.png b/panel/public/img/discord.png new file mode 100644 index 0000000..5a0daae Binary files /dev/null and b/panel/public/img/discord.png differ diff --git a/panel/public/img/zap_login.png b/panel/public/img/zap_login.png new file mode 100644 index 0000000..932c415 Binary files /dev/null and b/panel/public/img/zap_login.png differ diff --git a/panel/public/img/zap_main.png b/panel/public/img/zap_main.png new file mode 100644 index 0000000..fedad60 Binary files /dev/null and b/panel/public/img/zap_main.png differ diff --git a/panel/src/components/AccountDialog.tsx b/panel/src/components/AccountDialog.tsx new file mode 100644 index 0000000..a73a5a0 --- /dev/null +++ b/panel/src/components/AccountDialog.tsx @@ -0,0 +1,370 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useAuth } from "@/hooks/auth"; +import { memo, useEffect, useState } from "react"; +import { TabsTrigger, TabsList, TabsContent, Tabs } from "@/components/ui/tabs"; +import { ApiChangeIdentifiersReq, ApiChangePasswordReq } from "@shared/authApiTypes"; +import { useAccountModal, useCloseAccountModal } from "@/hooks/dialogs"; +import { GenericApiOkResp } from "@shared/genericApiTypes"; +import { ApiTimeout, fetchWithTimeout, useAuthedFetcher, useBackendApi } from "@/hooks/fetch"; +import consts from "@shared/consts"; +import { txToast } from "./TxToaster"; +import useSWR from 'swr'; +import TxAnchor from "./TxAnchor"; + + +/** + * Change Password tab + */ +const ChangePasswordTab = memo(function () { + const { authData, setAuthData } = useAuth(); + const { setAccountModalTab } = useAccountModal(); + const closeAccountModal = useCloseAccountModal(); + const changePasswordApi = useBackendApi({ + method: 'POST', + path: '/auth/changePassword' + }); + + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [newPasswordConfirm, setNewPasswordConfirm] = useState(''); + const [error, setError] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + const handleSubmit = (event?: React.FormEvent) => { + event?.preventDefault(); + if (!authData) return; + setError(''); + + if (newPassword.length < consts.adminPasswordMinLength || newPassword.length > consts.adminPasswordMaxLength) { + setError(`The password must be between ${consts.adminPasswordMinLength} and ${consts.adminPasswordMaxLength} digits long.`); + return; + } else if (newPassword !== newPasswordConfirm) { + setError('The passwords do not match.'); + return; + } + + setIsSaving(true); + changePasswordApi({ + data: { + newPassword, + oldPassword: authData.isTempPassword ? undefined : oldPassword, + }, + error: (error) => { + setIsSaving(false); + setError(error); + }, + success: (data) => { + setIsSaving(false); + if ('success' in data) { + if (authData.isTempPassword) { + setAccountModalTab('identifiers'); + setAuthData({ + ...authData, + isTempPassword: false, + }); + } else { + txToast.success('Password changed successfully!'); + closeAccountModal(); + } + } else { + setError(data.error) + } + } + }); + }; + + if (!authData) return; + return ( + +
    + {authData.isTempPassword ? (

    + Your account has a temporary password that needs to be changed before you can use this web panel.
    + Make sure to take note of your new password before saving. +

    ) : (

    + You can use your password to login to the txAdmin inferface even without using the Cfx.re login button. +

    )} +
    + {!authData.isTempPassword && ( +
    + + { + setOldPassword(e.target.value); + setError(''); + }} + /> +
    + )} +
    + + { + setNewPassword(e.target.value); + setError(''); + }} + /> +
    +
    + + { + setNewPasswordConfirm(e.target.value); + setError(''); + }} + /> +
    +
    + + {error &&

    {error}

    } + +
    +
    + ); +}) + + +/** + * Change Identifiers tab + */ +function ChangeIdentifiersTab() { + const authedFetcher = useAuthedFetcher(); + const [cfxreId, setCfxreId] = useState(''); + const [discordId, setDiscordId] = useState(''); + const [error, setError] = useState(''); + const [isConvertingFivemId, setIsConvertingFivemId] = useState(false); + const closeAccountModal = useCloseAccountModal(); + const [isSaving, setIsSaving] = useState(false); + + const currIdsResp = useSWR( + '/auth/getIdentifiers', + () => authedFetcher('/auth/getIdentifiers'), + { + //the data min interval is 5 mins, so we can safely cache for 1 min + revalidateOnMount: true, + revalidateOnFocus: false, + } + ); + + useEffect(() => { + if (!currIdsResp.data) return; + setCfxreId(currIdsResp.data.cfxreId); + setDiscordId(currIdsResp.data.discordId); + }, [currIdsResp.data]); + + useEffect(() => { + setError(currIdsResp.error?.message ?? ''); + }, [currIdsResp.error]); + + const changeIdentifiersApi = useBackendApi({ + method: 'POST', + path: '/auth/changeIdentifiers' + }); + + const handleSubmit = (event?: React.FormEvent) => { + event?.preventDefault(); + setError(''); + setIsSaving(true); + changeIdentifiersApi({ + data: { cfxreId, discordId }, + error: (error) => { + setError(error); + }, + success: (data) => { + setIsSaving(false); + if ('success' in data) { + txToast.success('Identifiers changed successfully!'); + closeAccountModal(); + } else { + setError(data.error) + } + } + }); + }; + + const handleCfxreIdBlur = async () => { + if (!cfxreId) return; + const trimmed = cfxreId.trim(); + if (/^\d+$/.test(trimmed)) { + setCfxreId(`fivem:${trimmed}`); + } else if (!trimmed.startsWith('fivem:')) { + try { + setIsConvertingFivemId(true); + const forumData = await fetchWithTimeout(`https://forum.cfx.re/u/${trimmed}.json`); + if (forumData.user && typeof forumData.user.id === 'number') { + setCfxreId(`fivem:${forumData.user.id}`); + } else { + setError('Could not find the user in the forum. Make sure you typed the username correctly.'); + } + } catch (error) { + setError('Failed to check the identifiers on the forum API.'); + } + setIsConvertingFivemId(false); + } else if (cfxreId !== trimmed) { + setCfxreId(trimmed); + } + } + + const handleDiscordIdBlur = () => { + if (!discordId) return; + const trimmed = discordId.trim(); + if (/^\d+$/.test(trimmed)) { + setDiscordId(`discord:${trimmed}`); + } else if (discordId !== trimmed) { + setDiscordId(trimmed); + } + } + + return ( + +
    +

    + The identifiers are optional for accessing the Web Panel but required for you to be able to use the In Game Menu and the Discord Bot.
    + It is recommended that you configure at least one. +

    +
    +
    + + { + setCfxreId(e.target.value); + setError(''); + }} + /> +

    + Your identifier can be found by clicking in your name in the playerlist and going to the IDs page.
    + You can also type in your forum.cfx.re username and it will be converted automatically.
    + This is required if you want to login using the Cfx.re button. +

    +
    +
    + + { + setDiscordId(e.target.value); + setError(''); + }} + /> +

    + You can get your Discord User ID by following this guide.
    + This is required if you want to use the Discord Bot slash commands. +

    +
    +
    + + {error &&

    {error}

    } + +
    +
    + ); +} + + +/** + * Account Dialog + */ +export default function AccountDialog() { + const { authData } = useAuth(); + const { + isAccountModalOpen, setAccountModalOpen, + accountModalTab, setAccountModalTab + } = useAccountModal(); + + useEffect(() => { + if (!authData) return; + if (authData.isTempPassword) { + setAccountModalOpen(true); + setAccountModalTab('password'); + } + }, []); + + const dialogSetIsClose = (newState: boolean) => { + if (!newState && authData && !authData.isTempPassword) { + setAccountModalOpen(false); + setTimeout(() => { + setAccountModalTab('password'); + }, 500); + } + } + + if (!authData) return; + return ( + + + + + {authData.isTempPassword ? 'Welcome to txAdmin!' : `Your Account - ${authData.name}`} + + + + + Password + Identifiers + + + + + + + ); +} diff --git a/panel/src/components/Avatar.tsx b/panel/src/components/Avatar.tsx new file mode 100644 index 0000000..22a3cca --- /dev/null +++ b/panel/src/components/Avatar.tsx @@ -0,0 +1,75 @@ +import { cn } from "@/lib/utils"; +import { AvatarFallback, AvatarImage, Avatar as ShadcnAvatar } from "./ui/avatar"; + +// "Optimally distinct colors" from http://medialab.github.io/iwanthue/ +// Params: H 0~360, C 25~90, L 35~80, 128 colors +// NOTE: same app used to generate discourse's colors, but diff parameters (and seed) +const colors = ["#de87ff", "#4ad63b", "#8625c2", "#81da23", "#7135ce", "#b1d408", "#3953eb", "#5ab000", "#ad30ce", "#01a91e", "#e533cd", "#00ad3f", "#ff2ebe", "#00d577", "#d800ab", "#71dd65", "#5836c0", "#97d947", "#0044cd", "#c3cf23", "#6369ff", "#c8c200", "#015fe8", "#fcbc01", "#ba69ff", "#afd440", "#d669ff", "#1c7a00", "#ff5ee8", "#5a9300", "#a0009e", "#70dc85", "#f301a0", "#00a75d", "#f4006b", "#69dba1", "#b20084", "#b8a900", "#0178f1", "#e4b100", "#0266d2", "#e49700", "#0277d8", "#f27b00", "#0091e0", "#dd2b00", "#00d1db", "#d80029", "#50dbc0", "#ff409c", "#00702b", "#ff8ff6", "#386500", "#bd88ff", "#617700", "#948eff", "#db7d00", "#3f49a4", "#ff7c29", "#24509c", "#ffb14a", "#6b3b9a", "#e2c45f", "#872b84", "#019967", "#ce0073", "#007144", "#ff76d0", "#4d5e00", "#82a3ff", "#ff6434", "#5aceff", "#b93600", "#81b5ff", "#9c3700", "#00a7b6", "#ff4a61", "#01a79e", "#ff4b77", "#007f6b", "#b1003c", "#b4d086", "#92266c", "#887b00", "#d2b0ff", "#ac6500", "#005f9e", "#ff794e", "#007db0", "#ff695a", "#017574", "#a6111d", "#abc6ff", "#975100", "#b0bcff", "#966800", "#ffabed", "#265e33", "#ff77af", "#375b3a", "#a7004d", "#cbc98f", "#972063", "#f7bb73", "#005a83", "#ff915a", "#5f487e", "#5e5800", "#e1b9e9", "#6b4e0b", "#ff9cc5", "#4a582a", "#ff8792", "#83986d", "#873464", "#d0b58b", "#6f4274", "#feb595", "#893650", "#dba98f", "#952f28", "#b093bc", "#ff8d79", "#a06a80", "#7e432c", "#e8b0cc", "#9c6e57", "#ff9fa8"]; + +//Modified code from from @wowjeeez +//It's impossible to be inclusive to different alphabets and also have meaningful initials, but I tried my best +const getInitials = (username: string) => { + username = username.normalize('NFKD').replace(/[\u0300-\u036F]/g, ''); + let letters = ''; + if (/^[A-Z][a-z]*((?:[A-Z][a-z]*)+)$/.test(username)) { + // pascal + const upperCaseLetters = username.match(/[A-Z]/g); + letters = upperCaseLetters ? upperCaseLetters.slice(0, 2).join('') : '??'; + } else if (/^[a-z]+([_\-.][a-z]+)+$/.test(username)) { + // snake, kebab, dot + const words = username.split(/[_\-.]/); + letters = words[0][0] + words[1][0]; + } else { + // default + const justAlphanumerics = username.replace(/[^a-zA-Z0-9]/g, ''); + if (justAlphanumerics.length === 0) { + letters = username.length ? username[0] : '??'; + } else if (justAlphanumerics.length <= 2) { + letters = justAlphanumerics; + } else { + letters = justAlphanumerics[0] + justAlphanumerics[justAlphanumerics.length - 1]; + } + } + return letters.toLocaleUpperCase(); +}; + +//Apparently based on the hash DJB2, the distribution is relatively even +const getUsernameColor = (username: string) => { + let hash = 0; + for (let i = 0; i < username.length; i++) { + const char = username.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return colors[Math.abs(hash) % colors.length]; +}; + + +type Props = { + username: string; + profilePicture?: string; + className?: string; +}; +export default function Avatar({ username, profilePicture, className }: Props) { + return + { + profilePicture && + + } + + {getInitials(username)} + + ; +} diff --git a/panel/src/components/BanForm.tsx b/panel/src/components/BanForm.tsx new file mode 100644 index 0000000..7dcdcf8 --- /dev/null +++ b/panel/src/components/BanForm.tsx @@ -0,0 +1,243 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useClosePlayerModal } from "@/hooks/playerModal"; +import { ClipboardPasteIcon, ExternalLinkIcon, Loader2Icon } from "lucide-react"; +import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { DropDownSelect, DropDownSelectContent, DropDownSelectItem, DropDownSelectTrigger } from "@/components/dropDownSelect"; +import { banDurationToShortString, banDurationToString, cn } from "@/lib/utils"; +import { Link, useLocation } from "wouter"; +import type { BanTemplatesDataType } from "@shared/otherTypes"; + +// Consts +const reasonTruncateLength = 150; +const ADD_NEW_SELECT_OPTION = '!add-new'; +const defaultDurations = ['permanent', '2 hours', '8 hours', '1 day', '2 days', '1 week', '2 weeks']; + +// Types +type BanFormRespType = { + reason: string; + duration: string; +} +export type BanFormType = HTMLDivElement & { + focusReason: () => void; + clearData: () => void; + getData: () => BanFormRespType; +} +type BanFormProps = { + banTemplates?: BanTemplatesDataType[]; //undefined = loading + disabled?: boolean; + onNavigateAway?: () => void; +}; + +/** + * A form to set ban reason and duration. + */ +export default forwardRef(function BanForm({ banTemplates, disabled, onNavigateAway }: BanFormProps, ref) { + const reasonRef = useRef(null); + const customMultiplierRef = useRef(null); + const setLocation = useLocation()[1]; + const [currentDuration, setCurrentDuration] = useState('2 days'); + const [customUnits, setCustomUnits] = useState('days'); + const closeModal = useClosePlayerModal(); + + //Exposing methods to the parent + useImperativeHandle(ref, () => { + return { + getData: () => { + return { + reason: reasonRef.current?.value.trim(), + duration: currentDuration === 'custom' + ? `${customMultiplierRef.current?.value} ${customUnits}` + : currentDuration, + }; + }, + clearData: () => { + if (!reasonRef.current || !customMultiplierRef.current) return; + reasonRef.current.value = ''; + customMultiplierRef.current.value = ''; + setCurrentDuration('2 days'); + setCustomUnits('days'); + }, + focusReason: () => { + reasonRef.current?.focus(); + } + }; + }, [reasonRef, customMultiplierRef, currentDuration, customUnits]); + + const handleTemplateSelectChange = (value: string) => { + if (value === ADD_NEW_SELECT_OPTION) { + setLocation('/settings/ban-templates'); + onNavigateAway?.(); + } else { + if (!banTemplates) return; + const template = banTemplates.find(template => template.id === value); + if (!template) return; + + const processedDuration = banDurationToString(template.duration); + if (defaultDurations.includes(processedDuration)) { + setCurrentDuration(processedDuration); + } else if (typeof template.duration === 'object') { + setCurrentDuration('custom'); + customMultiplierRef.current!.value = template.duration.value.toString(); + setCustomUnits(template.duration.unit); + } + + reasonRef.current!.value = template.reason; + setTimeout(() => { + reasonRef.current!.focus(); + }, 50); + } + } + + //Ban templates render optimization + const processedTemplates = useMemo(() => { + if (!banTemplates) return; + return banTemplates.map((template, index) => { + const duration = banDurationToShortString(template.duration); + const reason = template.reason.length > reasonTruncateLength + ? template.reason.slice(0, reasonTruncateLength - 3) + '...' + : template.reason; + return ( + + {duration} {reason} + + ); + }); + }, [banTemplates]); + + // Simplifying the jsx below + let banTemplatesContentNode: React.ReactNode; + if (!Array.isArray(banTemplates)) { + banTemplatesContentNode = ( +
    + +
    + ); + } else { + if (!banTemplates.length) { + banTemplatesContentNode = ( +
    + You do not have any template configured.
    + { closeModal(); }} + > + Add Ban Template + + +
    + ); + } else { + banTemplatesContentNode = <> + {processedTemplates} + + Add Ban Template + + + ; + } + } + + return ( +
    +
    + +
    + + + + + + + {banTemplatesContentNode} + + +
    +
    +
    + +
    + +
    + + +
    +
    +
    +
    + ); +}); diff --git a/panel/src/components/BigRadioItem.tsx b/panel/src/components/BigRadioItem.tsx new file mode 100644 index 0000000..224a9c1 --- /dev/null +++ b/panel/src/components/BigRadioItem.tsx @@ -0,0 +1,31 @@ +import { useId } from "react"; +import { Label } from "./ui/label"; +import { RadioGroupItem } from "./ui/radio-group"; +import { cn } from "@/lib/utils"; +import { RadioGroupIndicator } from "@radix-ui/react-radio-group"; + +type BigRadioItemProps = { + value: string; + title: string; + desc: React.ReactNode; + groupValue: string | undefined; +} + +export default function BigRadioItem(props: BigRadioItemProps) { + const radioId = 'radio' + useId(); + return ( +
    + +
    + ) +} diff --git a/panel/src/components/BreakpointDebugger.tsx b/panel/src/components/BreakpointDebugger.tsx new file mode 100644 index 0000000..3eb3669 --- /dev/null +++ b/panel/src/components/BreakpointDebugger.tsx @@ -0,0 +1,39 @@ +import { cn } from "@/lib/utils"; +import { useEffect, useState } from "react"; + +export default function BreakpointDebugger() { + const [isOverflowing, setIsOverflowing] = useState(false); + + useEffect(() => { + const handleResize = () => { + setIsOverflowing(document.documentElement.scrollWidth > window.innerWidth); + }; + + handleResize(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + return
    +
    +
    + Overflowing! +
    +

    xs

    +

    sm

    +

    md

    +

    lg

    +

    xl

    +

    2xl

    +
    +
    ; +} diff --git a/panel/src/components/CardContentOverlay.tsx b/panel/src/components/CardContentOverlay.tsx new file mode 100644 index 0000000..af44ce4 --- /dev/null +++ b/panel/src/components/CardContentOverlay.tsx @@ -0,0 +1,35 @@ +import { Loader2Icon, OctagonXIcon } from "lucide-react"; + +type CardContentOverlayProps = { + loading?: boolean; + error?: React.ReactNode; + message?: React.ReactNode; +}; +export default function CardContentOverlay({ loading, error, message }: CardContentOverlayProps) { + let innerNode: React.ReactNode; + if (loading) { + innerNode = ( + + ) + } else if (error) { + innerNode = ( + <> + + {error} + + ) + } else if (message) { + innerNode = ( + + {message} + + ) + } else { + return null; + } + return ( +
    + {innerNode} +
    + ) +} diff --git a/panel/src/components/ConfirmDialog.tsx b/panel/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..548d164 --- /dev/null +++ b/panel/src/components/ConfirmDialog.tsx @@ -0,0 +1,87 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useCloseConfirmDialog, useConfirmDialogState } from "@/hooks/dialogs"; +import { cn } from "@/lib/utils"; +import { useRef } from "react"; +import { buttonVariants } from "./ui/button"; +import { useOnClickOutside } from "usehooks-ts"; + + +export default function ConfirmDialog() { + const modalRef = useRef(null); + const dialogState = useConfirmDialogState(); + const closeDialog = useCloseConfirmDialog(); + + const handleCancel = () => { + if (!dialogState.isOpen) return; + closeDialog(); + if (dialogState.onCancel) { + dialogState.onCancel(); + } + } + + const handleConfirm = () => { + if (!dialogState.isOpen) return; + closeDialog(); + dialogState.onConfirm(); + } + + const handleConfirmKeyDown = (e: React.KeyboardEvent) => { + e.preventDefault(); + if (e.key === 'Enter' || e.key === 'NumpadEnter') { + handleConfirm(); + } else if (e.key === 'Backspace' || e.key === 'Escape') { + handleCancel(); + } + } + + const handleOpenClose = (newOpenState: boolean) => { + if (!newOpenState) { + handleCancel(); + } + } + + useOnClickOutside(modalRef, handleCancel); + + return ( + + { + e.preventDefault(); + if (e.target && 'querySelector' in e.target && typeof e.target.querySelector === 'function') { + e.target.querySelector('[data-autofocus]')?.focus(); + } + }} + > + + {dialogState.title} + + {dialogState.message} + + + + + {dialogState.cancelLabel ?? 'Cancel'} + + + {dialogState.actionLabel ?? 'Continue'} + + + + + ); +} diff --git a/panel/src/components/DateTimeCorrected.tsx b/panel/src/components/DateTimeCorrected.tsx new file mode 100644 index 0000000..cbd08a2 --- /dev/null +++ b/panel/src/components/DateTimeCorrected.tsx @@ -0,0 +1,39 @@ +import { tsToLocaleDateString, tsToLocaleDateTimeString } from "@/lib/dateTime"; +import { txToast } from "./TxToaster"; + +const clockSkewTolerance = 5 * 60; //5 minutes + +type Props = { + tsFetch: number; + tsObject: number; + serverTime: number; + className?: string; + isDateOnly?: boolean; + dateStyle?: 'full' | 'long' | 'medium' | 'short'; + timeStyle?: 'full' | 'long' | 'medium' | 'short'; +}; + +export default function DateTimeCorrected({ tsFetch, tsObject, serverTime, className, isDateOnly, dateStyle, timeStyle }: Props) { + const serverClockDrift = serverTime - tsFetch; //be positive if server is ahead + const howFarInThePast = serverTime - tsObject; + const localTime = tsFetch - howFarInThePast; + + const clockDriftBtnHandler = () => { + txToast.warning(`This means that the server clock is ${Math.abs(serverClockDrift)} seconds ${serverClockDrift > 0 ? 'ahead' : 'behind'} your computer time. Make sure both your computer and the server have their clocks synchronized.`); + } + const displayTime = isDateOnly + ? tsToLocaleDateString(localTime, dateStyle ?? 'medium') + : tsToLocaleDateTimeString(localTime, dateStyle ?? 'medium', timeStyle ?? 'short') + return + {displayTime} + {Math.abs(serverClockDrift) > clockSkewTolerance && ( + + )} + +} diff --git a/panel/src/components/DebouncedResizeContainer.tsx b/panel/src/components/DebouncedResizeContainer.tsx new file mode 100644 index 0000000..feac493 --- /dev/null +++ b/panel/src/components/DebouncedResizeContainer.tsx @@ -0,0 +1,92 @@ +import { debounce } from 'throttle-debounce'; +import { useRef, useEffect, useCallback, memo } from 'react'; +import { Loader2Icon } from 'lucide-react'; + +type SizeType = { + width: number; + height: number; +}; + +type DebouncedResizeContainerProps = { + delay?: number; + onDebouncedResize: ({ width, height }: SizeType) => void; + children: React.ReactNode; +}; + +/** + * A container that will call onDebouncedResize with the width and height of the container after a resize event. + */ +function DebouncedResizeContainerInner({ + delay, + onDebouncedResize, + children, +}: DebouncedResizeContainerProps) { + const containerRef = useRef(null); + const loaderRef = useRef(null); + const childRef = useRef(null); + const lastMeasure = useRef({ width: 0, height: 0 }); + if (delay === undefined) delay = 250; + + const updateSizeState = () => { + if (!containerRef.current || !containerRef.current.parentNode) return; + const measures = { + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight + } + lastMeasure.current = measures; + onDebouncedResize(measures); + childRef.current!.style.visibility = 'visible'; + loaderRef.current!.style.visibility = 'hidden'; + } + + const debouncedResizer = useCallback( + debounce(delay, updateSizeState, { atBegin: false }), + [containerRef] + ); + + useEffect(() => { + if (!containerRef.current) return; + const resizeObserver = new ResizeObserver(() => { + const currHeight = containerRef.current!.clientHeight; + const currWidth = containerRef.current!.clientWidth; + if (currHeight === 0 || currWidth === 0) return; + if (lastMeasure.current.width === currWidth && lastMeasure.current.height === currHeight) return; + if (lastMeasure.current.width === 0 || lastMeasure.current.height === 0) { + updateSizeState(); + } else { + debouncedResizer(); + childRef.current!.style.visibility = 'hidden'; + loaderRef.current!.style.visibility = 'visible'; + } + }); + resizeObserver.observe(containerRef.current); + updateSizeState(); + return () => resizeObserver.disconnect(); + }, [containerRef]); + + return ( +
    +
    + +
    +
    + {children} +
    +
    + ); +} + +export default memo(DebouncedResizeContainerInner); diff --git a/panel/src/components/Divider.tsx b/panel/src/components/Divider.tsx new file mode 100644 index 0000000..7b9422d --- /dev/null +++ b/panel/src/components/Divider.tsx @@ -0,0 +1,15 @@ +type DividerProps = { + text?: string; +}; + +export default function Divider({ text }: DividerProps) { + return ( +
    +
    + {text &&
    + {text} +
    } + {text &&
    } +
    + ) +} diff --git a/panel/src/components/DynamicNewBadge.tsx b/panel/src/components/DynamicNewBadge.tsx new file mode 100644 index 0000000..2239120 --- /dev/null +++ b/panel/src/components/DynamicNewBadge.tsx @@ -0,0 +1,79 @@ +import { cn } from "@/lib/utils"; +import { cva, VariantProps } from "class-variance-authority"; +import React from "react"; + + +/** + * Variants + */ +const badgeVariants = cva( + 'rounded bg-accent text-accent-foreground font-semibold self-center', + { + variants: { + size: { + xs: 'px-1 ml-1 text-[0.5rem] tracking-[0.2em] leading-loose', + md: 'px-1 ml-1.5 text-2xs tracking-wider', + }, + }, + defaultVariants: { + size: 'md', + }, + } +); + + +/** + * A dynamic component that shows its children for the first X days. + * NOTE: always on for dev mode to make sure I doesn't forget to remove it. + */ +function DynamicNewItemInner({ featName, durationDays, children }: DynamicNewItemProps) { + const storageKeyName = `dynamicNewFeatTs-${featName}`; + const storedTs = parseInt(localStorage.getItem(storageKeyName) ?? ''); + if (isNaN(storedTs) || window.txConsts.showAdvanced) { + localStorage.setItem(storageKeyName, Date.now().toString()); + } else { + const badgeDuration = (durationDays ?? 3) * 24 * 60 * 60 * 1000; + + if (storedTs + badgeDuration < Date.now()) { + return null; + } + } + + return children; +} + +export const DynamicNewItem = React.memo(DynamicNewItemInner); + + +/** + * A dynamic badge that shows "NEW" for the first X days. + */ +function DynamicNewBadgeInner({ badgeText, featName, durationDays, className, size }: DynamicNewBadgeProps) { + return ( + + + {badgeText ?? 'NEW'} + + + ); +} + +export const DynamicNewBadge = React.memo(DynamicNewBadgeInner); + + +/** + * Types + */ +type DynamicNewItemProps = { + featName: string; + durationDays?: number; //3d default + children: React.ReactNode; +}; + +type DynamicNewBadgeProps = { + featName: string; + durationDays?: number; //3d default + badgeText?: string; + className?: string; + size?: VariantProps['size']; +}; diff --git a/panel/src/components/ErrorFallback.tsx b/panel/src/components/ErrorFallback.tsx new file mode 100644 index 0000000..2fc8b94 --- /dev/null +++ b/panel/src/components/ErrorFallback.tsx @@ -0,0 +1,102 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/ui/card" +import { FallbackProps } from "react-error-boundary"; +import { FiAlertOctagon } from "react-icons/fi"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +//Used for global errors +export function AppErrorFallback({ error }: FallbackProps) { + const refreshPage = () => { + window.location.reload(); + } + return ( +
    + Refresh} + /> +
    + ); +} + +//Used for page errors (inside the shell) +export function PageErrorFallback({ error, resetErrorBoundary }: FallbackProps) { + return ( +
    + Go Back} + /> +
    + ); +} + + +type GenericErrorBoundaryCardProps = { + title: string; + description: string; + error: Error; + resetButton: React.ReactNode; +} + +export function GenericErrorBoundaryCard(props: GenericErrorBoundaryCardProps) { + return ( + + +

    + + {props.title} +

    + {props.description} +
    + +

    + Page:  + + {window.location.pathname ?? 'unknown'} + {window.location.search ?? ''} + +

    +

    + Versions:  + + txAdmin v{window.txConsts.txaVersion} atop FXServer b{window.txConsts.fxsVersion} + +

    +

    + Message:  + {props.error.message ?? 'unknown'} +

    +

    Stack:

    +
    +                    {props.error.stack}
    +                
    +
    + + {props.resetButton} + + +
    + ); +} diff --git a/panel/src/components/GenericSpinner.tsx b/panel/src/components/GenericSpinner.tsx new file mode 100644 index 0000000..7513430 --- /dev/null +++ b/panel/src/components/GenericSpinner.tsx @@ -0,0 +1,10 @@ +import { Loader2Icon } from "lucide-react"; + +type GenericSpinnerProps = { + msg?: string; +} +export default function GenericSpinner({ msg }: GenericSpinnerProps) { + return
    + {msg} +
    ; +} diff --git a/panel/src/components/InlineCode.tsx b/panel/src/components/InlineCode.tsx new file mode 100644 index 0000000..985d53b --- /dev/null +++ b/panel/src/components/InlineCode.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/utils"; +import { HTMLAttributes } from "react"; + +type Props = HTMLAttributes & { + children: React.ReactNode; +}; + +export default function InlineCode({ children, className, ...props }: Props) { + return ( + + {children} + + ); +} diff --git a/panel/src/components/KickIcons.tsx b/panel/src/components/KickIcons.tsx new file mode 100644 index 0000000..782ae09 --- /dev/null +++ b/panel/src/components/KickIcons.tsx @@ -0,0 +1,11 @@ +export function KickAllIcon({ style }: { style?: React.CSSProperties }) { + return + + ; +} + +export function KickOneIcon({ style }: { style?: React.CSSProperties }) { + return + + ; +} diff --git a/panel/src/components/Logos.tsx b/panel/src/components/Logos.tsx new file mode 100644 index 0000000..d407f36 --- /dev/null +++ b/panel/src/components/Logos.tsx @@ -0,0 +1,69 @@ +import { useId } from "react"; + +type LogoProps = { + className?: string; + style?: React.CSSProperties; +}; + +//NOTE: used Black_Full from figma +export function LogoFullSolidThin({ style, className }: LogoProps) { + return + + + + + + ; +} + +//NOTE: used Green_FullBox_resize from figma +export function LogoFullSquareGreen({ style, className }: LogoProps) { + const maskId = useId(); + const bgId = useId(); + return + + + + + + + + + + + + + + ; +} + + +export function LogoSquareGreen({ style, className }: LogoProps) { + const maskId = useId(); + const bgId = useId(); + return + + + + + + + + + ; +} diff --git a/panel/src/components/MainPageLink.tsx b/panel/src/components/MainPageLink.tsx new file mode 100644 index 0000000..f5678eb --- /dev/null +++ b/panel/src/components/MainPageLink.tsx @@ -0,0 +1,114 @@ +import { useCloseAllSheets } from "@/hooks/sheets"; +import { pageErrorStatusAtom, useContentRefresh } from "@/hooks/pages"; +import { useAtomValue } from "jotai"; +import { forwardRef } from "react"; +import { Link, useRoute } from "wouter"; +import { Button, buttonVariants } from "./ui/button"; +import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + + +type MainPageLinkProps = { + isActive?: boolean; + href: string; + children: React.ReactNode; + className?: string; + disabled?: boolean; +}; + +function MainPageLinkInner( + props: MainPageLinkProps, + ref: React.ForwardedRef +) { + const isPageInError = useAtomValue(pageErrorStatusAtom); + const refreshContent = useContentRefresh(); + const closeAllSheets = useCloseAllSheets(); + const checkOnClick = (e: React.MouseEvent) => { + if (props.disabled) { + e.preventDefault(); + return; + } + closeAllSheets(); + if (props.isActive || isPageInError) { + console.log('Page is already active or in error state. Forcing error boundry + router re-render.'); + refreshContent(); + e.preventDefault(); + } + } + + return ( + + {props.children} + + ); +} + +const MainPageLink = forwardRef(MainPageLinkInner); +export default MainPageLink; + + +type MenuNavProps = { + href: string; + children: React.ReactNode; + className?: string; + disabled?: boolean; +}; + +export function MenuNavLink({ href, children, className, disabled }: MenuNavProps) { + const [isActive] = useRoute(href); + if (disabled) { + return ( + + + + + + You do not have permission
    + to access this page. +
    +
    + ) + } else { + return ( + + {children} + + ) + } +} + +type NavLinkProps = { + href: string; + children: React.ReactNode; + className?: string; +}; + +export function NavLink({ href, children, className }: NavLinkProps) { + const [isActive] = useRoute(href); + + return ( + + {children} + + ) +} diff --git a/panel/src/components/MarkdownProse.tsx b/panel/src/components/MarkdownProse.tsx new file mode 100644 index 0000000..2b45b52 --- /dev/null +++ b/panel/src/components/MarkdownProse.tsx @@ -0,0 +1,37 @@ +import { cn, stripIndent } from '@/lib/utils'; +import Markdown, { Components } from 'react-markdown'; +import InlineCode from './InlineCode'; +import TxAnchor from './TxAnchor'; + + +// NOTE: we might not even need this +// https://tailwindcss.com/docs/typography-plugin#advanced-topics +const customComponents: Components = { + // blockquote: ({ children }) =>
    {children}
    , + code: ({ children }) => {children}, + pre: ({ children }) =>
    {children}
    , + a: ({ children, href }) => {children}, +} + + +type MarkdownProseProps = { + md: string; + isSmall?: boolean; + isTitle?: boolean; + isToast?: boolean; +}; +export default function MarkdownProse({ md, isSmall, isTitle, isToast }: MarkdownProseProps) { + return ( + + {stripIndent(md.replace(/\n/g, ' \n'))} + + ); +} diff --git a/panel/src/components/ModalCentralMessage.tsx b/panel/src/components/ModalCentralMessage.tsx new file mode 100644 index 0000000..f15c159 --- /dev/null +++ b/panel/src/components/ModalCentralMessage.tsx @@ -0,0 +1,7 @@ +export default function ModalCentralMessage({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ) +} diff --git a/panel/src/components/MultiIdsList.tsx b/panel/src/components/MultiIdsList.tsx new file mode 100644 index 0000000..c991e63 --- /dev/null +++ b/panel/src/components/MultiIdsList.tsx @@ -0,0 +1,236 @@ +import { txToast } from "@/components/TxToaster"; +import { cn, copyToClipboard } from "@/lib/utils"; +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { CopyIcon, ListTodoIcon, Trash2Icon, XIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useOpenConfirmDialog, useOpenPromptDialog } from "@/hooks/dialogs"; + + +const InvisibleNewLine = () => {'\n'}; + +const placeholderIds = [ + 'fivem:xxxxxxx', + 'license:xxxxxxxxxxxxxx', + 'discord:xxxxxxxxxxxxxxxxxx', + 'etc...', +].join('\n'); +const placeholderHwids = [ + '2:xxxxxxxxxxxxxx...', + '4:xxxxxxxxxxxxxx...', + '5:xxxxxxxxxxxxxx...', + 'etc...', +].join('\n'); + +type ActionFeedback = { + msg: string; + success: boolean; +} + +type MultiIdsList = { + list: string[]; + highlighted?: string[]; + type: 'hwid' | 'id'; + src: 'player' | 'action'; + isHwids?: boolean; + onWipeIds?: () => void; +} + +export default function MultiIdsList({ list, highlighted, type, src, onWipeIds }: MultiIdsList) { + const [autoAnimateParentRef, enableAnimations] = useAutoAnimate(); + const divRef = useRef(null); + const msgRef = useRef(null); + const [compareMatches, setCompareMatches] = useState(null); + const [actionFeedback, setActionFeedback] = useState(false); + + const hasHighlighted = Array.isArray(highlighted) && highlighted.length; + const idsHighlighted = hasHighlighted + ? highlighted.sort((a, b) => a.localeCompare(b)) + : list.sort((a, b) => a.localeCompare(b)); + const idsMuted = hasHighlighted + ? list.filter((id) => !highlighted.includes(id)).sort((a, b) => a.localeCompare(b)) + : []; + + const hasIdsAvailable = idsHighlighted.length || idsMuted.length; + const openConfirmDialog = useOpenConfirmDialog(); + const openPromptDialog = useOpenPromptDialog(); + + const isHwids = type === 'hwid'; + const typeStr = isHwids ? 'HWID' : 'ID'; + const emptyMessage = `This ${src} has no ${typeStr}s.`; + const isInCompareMode = Array.isArray(compareMatches); + const isCompareIdMatch = (id: string) => isInCompareMode && compareMatches.includes(id); + + useEffect(() => { + if (actionFeedback) { + const timer = setTimeout(() => { + setActionFeedback(false); + }, 2750); + + return () => { + clearTimeout(timer); + }; + } + }, [actionFeedback]); + + const handleWipeIds = () => { + if (!onWipeIds) return; + const target = isHwids ? `${typeStr}s` : `${typeStr}s (except license)`; + openConfirmDialog({ + title: `Wipe ${src} ${typeStr}s`, + message:

    + Are you sure you want wipe all {target} of this {src}?
    + This action cannot be undone. +

    , + onConfirm: onWipeIds, + }); + }; + + const handleCompareIds = () => { + openPromptDialog({ + title: `Compare ${typeStr}s`, + message:

    + Paste in a list of {typeStr}s to compare with the current list.
    + Separate each {typeStr} with a new line or comma. +

    , + placeholder: isHwids ? placeholderHwids : placeholderIds, + submitLabel: 'Compare', + required: true, + isMultiline: true, + isWide: true, + onSubmit: (input) => { + console.log(input); + const cleanIds = input + .split(/[\n\s,;]+/) + .map((id) => id.trim()) + .filter((id) => id.length) + .filter((id) => id.length && list.includes(id)); + setCompareMatches(cleanIds); + } + }); + }; + + const handleCopyIds = () => { + if (!divRef.current) throw new Error(`divRef.current undefined`); + if (!hasIdsAvailable) return; + + //Just to guarantee the correct visual order + const strToCopy = [...idsHighlighted, ...idsMuted].join('\r\n'); + + //Copy the ids to the clipboard + copyToClipboard(strToCopy, divRef.current).then((res) => { + if (res !== false) { + setActionFeedback({ + msg: 'Copied!', + success: true, + }); + } else { + txToast.error('Failed to copy to clipboard :('); + } + }).catch((error) => { + txToast.error({ + title: 'Failed to copy to clipboard:', + msg: error.message, + }); + setActionFeedback({ + msg: 'Error :(', + success: false, + }); + }); + } + + return
    +
    +

    + {isHwids ? 'Hardware IDs' : 'Player Identifiers'} + {isInCompareMode && compareMatches.length ? ( + + ({compareMatches.length} matches found) + + ) : null} +

    +
    + {actionFeedback ? ( + + {actionFeedback.msg} + + ) : ( + <> + {onWipeIds && ( + + )} + + + + )} +
    +
    +
    +

    + {!hasIdsAvailable && {emptyMessage}} + {idsHighlighted.map((id) => ( + + {id} + + ))} + {idsMuted.map((id) => ( + + {id} + + ))} + +

    + {isInCompareMode && !compareMatches.length && ( + <> +
    + No matching {typeStr} found. +
    + + + + )} +
    +
    +} diff --git a/panel/src/components/PageCalloutRow.tsx b/panel/src/components/PageCalloutRow.tsx new file mode 100644 index 0000000..5bbb4aa --- /dev/null +++ b/panel/src/components/PageCalloutRow.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState } from 'react'; + +const easeOutQuart = (t: number) => 1 - (--t) * t * t * t; +const frameDuration = 1000 / 60; + +type CountUpAnimationProps = { + countTo: number; + duration?: number; +}; + +//Snippet from: https://jshakespeare.com/simple-count-up-number-animation-javascript-react/ +const CountUpAnimation = ({ countTo, duration = 1250 }: CountUpAnimationProps) => { + const [count, setCount] = useState(0); + + useEffect(() => { + let frame = 0; + const totalFrames = Math.round(duration / frameDuration); + const counter = setInterval(() => { + frame++; + const progress = easeOutQuart(frame / totalFrames); + setCount(countTo * progress); + + if (frame === totalFrames) { + clearInterval(counter); + } + }, frameDuration); + }, []); + + return Math.floor(count).toLocaleString("en-US"); +}; + +function NumberLoading() { + return ( +
    + ) +} + + +export type PageCalloutProps = { + label: string; + icon: React.ReactNode; + value: number | false; + prefix?: string; +} + +export type PageCalloutRowProps = { + callouts: PageCalloutProps[]; +}; +export default function PageCalloutRow({ callouts }: PageCalloutRowProps) { + if (callouts.length !== 4) return null; + + return ( +
    +
    +
    +

    {callouts[0].label}

    +
    {callouts[0].icon}
    +
    + {callouts[0].value === false ? ( + + ) : ( +
    + {callouts[0].prefix} +
    + )} +
    +
    +
    +

    {callouts[1].label}

    +
    {callouts[1].icon}
    +
    + {callouts[1].value === false ? ( + + ) : ( +
    + {callouts[1].prefix} +
    + )} +
    +
    +
    +

    {callouts[2].label}

    +
    {callouts[2].icon}
    +
    + {callouts[2].value === false ? ( + + ) : ( +
    + {callouts[2].prefix} +
    + )} +
    +
    +
    +

    {callouts[3].label}

    +
    {callouts[3].icon}
    +
    + {callouts[3].value === false ? ( + + ) : ( +
    + {callouts[3].prefix} +
    + )} +
    +
    + ) +} diff --git a/panel/src/components/PromptDialog.tsx b/panel/src/components/PromptDialog.tsx new file mode 100644 index 0000000..0dc3f1d --- /dev/null +++ b/panel/src/components/PromptDialog.tsx @@ -0,0 +1,119 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useClosePromptDialog, usePromptDialogState } from "@/hooks/dialogs"; +import { useRef } from "react"; +import { cn } from "@/lib/utils"; +import { AutosizeTextarea } from "./ui/autosize-textarea"; + + +export default function PromptDialog() { + const inputRef = useRef(null); + const dialogState = usePromptDialogState(); + const closeDialog = useClosePromptDialog(); + + const handleSubmit = () => { + if (!dialogState.isOpen) return; + closeDialog(); + const input = inputRef.current?.value ?? inputRef.current?.textArea.value ?? ''; + dialogState.onSubmit(input.trim()); + } + + const handleForm = (e: React.FormEvent) => { + e.preventDefault(); + handleSubmit(); + } + + const handleOpenClose = (newOpenState: boolean) => { + if (!dialogState.isOpen) return; + if (!newOpenState) { + closeDialog(); + if (dialogState.onCancel) { + dialogState.onCancel(); + } + } + } + + return ( + + +
    + + {dialogState.title} + + {dialogState.message} + + + + {dialogState.isMultiline ? ( + + ) : ( + + )} + +
    + {dialogState.suggestions && dialogState.suggestions.map((suggestion, index) => ( + + ))} +
    + {/* TODO: mock for kick as punishment - consider the alternative of making a "timeout" */} + {/*
    +
    + +
    + +

    + Show in the player history as a sanction. +

    +
    +
    +
    */} + +
    + +
    +
    + ); +} diff --git a/panel/src/components/SwitchText.tsx b/panel/src/components/SwitchText.tsx new file mode 100644 index 0000000..abbcaba --- /dev/null +++ b/panel/src/components/SwitchText.tsx @@ -0,0 +1,70 @@ +import { cn } from "@/lib/utils"; +import { Switch } from "./ui/switch"; +import { cva, VariantProps } from "class-variance-authority"; +import { forwardRef } from 'react'; + +const switchVariants = cva( + 'peer', + { + variants: { + variant: { + default: "", + checkedGreen: "data-[state=unchecked]:bg-input data-[state=checked]:bg-success", + checkedYellow: "data-[state=unchecked]:bg-input data-[state=checked]:bg-warning", + checkedRed: "data-[state=unchecked]:bg-input data-[state=checked]:bg-destructive", + uncheckedGreen: "data-[state=unchecked]:bg-success data-[state=checked]:bg-input", + uncheckedYellow: "data-[state=unchecked]:bg-warning data-[state=checked]:bg-input", + uncheckedRed: "data-[state=unchecked]:bg-destructive data-[state=checked]:bg-input", + redGreen: "data-[state=unchecked]:bg-destructive data-[state=checked]:bg-success", + greenRed: "data-[state=unchecked]:bg-success data-[state=checked]:bg-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + + +type SwitchTextProps = Omit[0], 'children'> & { + checkedLabel: string; + uncheckedLabel: string; + className?: string; + variant?: VariantProps['variant']; +}; + +const SwitchText = forwardRef< + React.ElementRef, + SwitchTextProps +>(({ + id, + checkedLabel, + uncheckedLabel, + variant, + className, + ...props +}, ref) => { + return ( +
    + +
    + {checkedLabel} +
    +
    + {uncheckedLabel} +
    +
    + ) +}); + +SwitchText.displayName = 'SwitchText'; + +export default SwitchText; diff --git a/panel/src/components/ThemeProvider.tsx b/panel/src/components/ThemeProvider.tsx new file mode 100644 index 0000000..e5e6045 --- /dev/null +++ b/panel/src/components/ThemeProvider.tsx @@ -0,0 +1,26 @@ +import { useTheme } from "@/hooks/theme"; +import { useEffect, useRef } from "react"; + +type ThemeProviderProps = { + children: React.ReactNode; +}; +export default function ThemeProvider({ children }: ThemeProviderProps) { + const { theme, setTheme } = useTheme(); + + //Listener for system theme change - only overwrites default themes (light/dark) + useEffect(() => { + const changeHandler = (e: MediaQueryListEvent) => { + const prefersDarkTheme = e.matches; + if (theme === 'dark' && !prefersDarkTheme) { + setTheme('light'); + } else if (theme === 'light' && prefersDarkTheme) { + setTheme('dark'); + } + } + const browserTheme = window.matchMedia("(prefers-color-scheme: dark)"); + browserTheme.addEventListener('change', changeHandler); + return () => { browserTheme.removeEventListener('change', changeHandler) } + }, [theme]); + + return <>{children}; +} diff --git a/panel/src/components/TimeInputDialog.tsx b/panel/src/components/TimeInputDialog.tsx new file mode 100644 index 0000000..9ff5ae7 --- /dev/null +++ b/panel/src/components/TimeInputDialog.tsx @@ -0,0 +1,106 @@ +import { useId, useMemo, useState } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Button } from "@/components/ui/button" +import { Label } from './ui/label' + + +type StyledHourOptionProps = { + value: string + label24h: string + label12h: string +} + +export function StyledHourOption({ value, label24h, label12h }: StyledHourOptionProps) { + return ( + +
    +
    {label24h}
    +
    {label12h}
    +
    +
    + ) +} + + +type TimeInputDialogProps = { + title: string; + isOpen: boolean; + onClose: () => void; + onSubmit: (time: string) => void; +} + +export function TimeInputDialog({ title, isOpen, onClose, onSubmit }: TimeInputDialogProps) { + const [hour, setHour] = useState('00'); + const [minute, setMinute] = useState('00'); + const hourSelectId = `timeinput-${useId()}`; + const minuteSelectId = `timeinput-${useId()}`; + + const handleSubmit = () => { + onSubmit(`${hour}:${minute}`); + setHour('00'); + setMinute('00'); + onClose() + } + + const hoursArray = useMemo(() => Array.from({ length: 24 }, (_, i) => { + const h = i.toString().padStart(2, '0') + const ampm = i < 12 ? 'AM' : 'PM' + const h12 = i % 12 || 12 + return { + value: h, + label24h: h, + label12h: `${h12} ${ampm}`, + } satisfies StyledHourOptionProps + }), []); + + return ( + + console.log('blur', Math.random())}> + + {title} + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + + +
    +
    + ) +} diff --git a/panel/src/components/TxAnchor.tsx b/panel/src/components/TxAnchor.tsx new file mode 100644 index 0000000..a5c6317 --- /dev/null +++ b/panel/src/components/TxAnchor.tsx @@ -0,0 +1,60 @@ +import { cn } from "@/lib/utils"; +import { openExternalLink } from '@/lib/navigation'; +import { ExternalLinkIcon } from "lucide-react"; +import { useLocation } from "wouter"; + + +//Guarantees the icon doesn't break to the next line alone +function InnerExternal({ text }: { text: string }) { + const words = text.split(/\s+/); + const lastWord = words.pop(); + const startOfText = words.length ? words.join(' ') + ' ' : null; + + return ( + <> + {startOfText} + + {lastWord} + + + + ); +} + +type TxAnchorType = React.AnchorHTMLAttributes & { + href: string; + className?: string; + rel?: string; +}; +export default function TxAnchor({ children, href, className, rel, ...rest }: TxAnchorType) { + const setLocation = useLocation()[1]; + const isExternal = href?.startsWith('http') || href?.startsWith('//'); + const onClick = (e: React.MouseEvent) => { + if (!href) return; + e.preventDefault(); + if (isExternal) { + openExternalLink(href); + } else { + setLocation(href ?? '/'); + } + } + return ( + + {isExternal && typeof children === 'string' + ? + : children + } + + ); +} diff --git a/panel/src/components/TxToaster.tsx b/panel/src/components/TxToaster.tsx new file mode 100644 index 0000000..07ec30b --- /dev/null +++ b/panel/src/components/TxToaster.tsx @@ -0,0 +1,191 @@ +import MarkdownProse from "@/components/MarkdownProse"; +import { cn } from "@/lib/utils"; +import { handleExternalLinkClick } from "@/lib/navigation"; +import { cva } from "class-variance-authority"; +import { AlertCircleIcon, AlertOctagonIcon, CheckCircleIcon, ChevronRightCircle, InfoIcon, Loader2Icon, XIcon } from "lucide-react"; +import toast, { Toast, Toaster } from "react-hot-toast"; +import { useEffect, useState } from "react"; +import { ApiToastResp } from "@shared/genericApiTypes"; + + +//MARK: Types +export const validToastTypes = ['default', 'loading', 'info', 'success', 'warning', 'error'] as const; +type TxToastType = typeof validToastTypes[number]; + +type TxToastData = string | { + title?: string; + md?: boolean + msg: string; +} + +type TxToastOptions = { + id?: string; + duration?: number; +} + + +//MARK: Components +const toastBarVariants = cva( + `max-w-xl w-full sm:w-auto sm:min-w-[28rem] relative overflow-hidden z-40 + p-3 pr-10 flex items-center justify-between space-x-4 + rounded-xl border shadow-lg transition-all pointer-events-none + text-black/75 dark:text-white/90`, + { + variants: { + type: { + default: "dark:border-primary/25 bg-white dark:bg-secondary dark:text-secondary-foreground", + loading: "dark:border-primary/25 bg-white dark:bg-secondary dark:text-secondary-foreground", + info: "border-info/70 bg-info-hint", + success: "border-success/70 bg-success-hint", + warning: "border-warning/70 bg-warning-hint", + error: "border-destructive/70 bg-destructive-hint", + }, + }, + defaultVariants: { + type: "default", + }, + } +); + +const toastIconMap = { + default: , + loading: , + info: , + success: , + warning: , + error: , +} as const; + +type CustomToastProps = { + t: Toast, + type: TxToastType, + data: TxToastData, +} + +export const CustomToast = ({ t, type, data }: CustomToastProps) => { + const [elapsedTime, setElapsedTime] = useState(0); + + useEffect(() => { + let timer: NodeJS.Timeout | null = null; + const cleanup = () => { timer && clearInterval(timer) }; + + if (type === "loading" && t.visible) { + timer = setInterval(() => { + setElapsedTime((prevElapsedTime) => prevElapsedTime + 1); + }, 1000); + } else if (timer) { + cleanup(); + } + + return cleanup; + }, [type, t.visible]); + + return ( +
    +
    + {type === "loading" && elapsedTime > 5 ? ( +
    + {elapsedTime}s +
    + ) : toastIconMap[type]} +
    +
    + {typeof data === "string" ? ( + {data} + ) : data.md ? ( + <> + {data.title ? : null} + + + ) : ( + <> + {data.title} + {data.msg} + + )} + {type === 'error' && ( + + For support, visit  + + discord.gg/txAdmin + . + + )} +
    + + +
    + ); +}; + + +//Element to be added to MainShell +export default function TxToaster() { + return +} + + +//MARK: Utilities +/** + * Returns a toast with the given type + */ +const callToast = (type: TxToastType, data: TxToastData, options: TxToastOptions = {}) => { + const msg = typeof data === 'string' ? data : data.msg; + const msgWords = msg.split(/\s+/).length; + let defaultDuration: number; + if (msgWords < 15) { + defaultDuration = 5_000; + } else if (msgWords < 25) { + defaultDuration = 7_500; + } else if (msgWords < 50) { + defaultDuration = 10_000; + } else { + defaultDuration = 15_000; + } + options.duration ??= type === 'loading' ? Infinity : defaultDuration; + return toast.custom((t: Toast) => { + return ; + }, options); +} + + +/** + * Calls a toast with the given type + */ +const genericToast = (data: ApiToastResp & { title?: string }, options?: TxToastOptions) => { + return callToast(data.type, data, options); +} + + +/** + * Global Toast Caller, as function or as object with specific types. + */ +export const txToast = Object.assign(genericToast, { + default: (data: TxToastData, options?: TxToastOptions) => callToast('default', data, options), + loading: (data: TxToastData, options?: TxToastOptions) => callToast('loading', data, options), + info: (data: TxToastData, options?: TxToastOptions) => callToast('info', data, options), + success: (data: TxToastData, options?: TxToastOptions) => callToast('success', data, options), + warning: (data: TxToastData, options?: TxToastOptions) => callToast('warning', data, options), + error: (data: TxToastData, options?: TxToastOptions) => callToast('error', data, options), + dismiss: toast.dismiss, + remove: toast.remove, +}); diff --git a/panel/src/components/dndSortable.tsx b/panel/src/components/dndSortable.tsx new file mode 100644 index 0000000..03cad16 --- /dev/null +++ b/panel/src/components/dndSortable.tsx @@ -0,0 +1,117 @@ +import { cn } from "@/lib/utils"; +import { GripVerticalIcon } from "lucide-react"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent +} from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy +} from '@dnd-kit/sortable'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { useAutoAnimate } from '@formkit/auto-animate/react'; + + +type DndSortableItemProps = { + id: string; + disabled?: boolean; + className?: string; + children: React.ReactNode; +} + +export function DndSortableItem({ id, disabled, className, children }: DndSortableItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id, disabled }); + + attributes.role = 'listitem'; //Override role due to having a drag handle + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform && { + ...transform, + scaleY: 1, //prevent default squishing behavior for multiline items + }), + transition, + }; + + return ( +
  • +
    + +
    + {children} +
  • + ) +} + + +type DndSortableGroupProps = { + onDragEnd: (event: DragEndEvent) => void; + children: React.ReactNode; + className?: string; + ids: string[]; +} + +export function DndSortableGroup({ onDragEnd, className, children, ids }: DndSortableGroupProps) { + const [autoAnimateParentRef, enableAnimations] = useAutoAnimate(); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = (event: DragEndEvent) => { + onDragEnd(event); + setTimeout(() => { + enableAnimations(true); + }, 1); + } + + return ( + enableAnimations(false)} + onDragCancel={() => enableAnimations(true)} + onDragEnd={handleDragEnd} + > + +
      + {children} +
    +
    +
    + ) +} diff --git a/panel/src/components/dropDownSelect.tsx b/panel/src/components/dropDownSelect.tsx new file mode 100644 index 0000000..d9938b7 --- /dev/null +++ b/panel/src/components/dropDownSelect.tsx @@ -0,0 +1,116 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropDownSelect = SelectPrimitive.Root + +const DropDownSelectValue = SelectPrimitive.Value + +const DropDownSelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children }, ref) => ( + + {children} + +)) +DropDownSelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const DropDownSelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +DropDownSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const DropDownSelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +DropDownSelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName + +const DropDownSelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +DropDownSelectContent.displayName = SelectPrimitive.Content.displayName + + +const DropDownSelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)) +DropDownSelectItem.displayName = SelectPrimitive.Item.displayName + +export { + DropDownSelect, + DropDownSelectValue, + DropDownSelectTrigger, + DropDownSelectItem, + DropDownSelectContent, + DropDownSelectScrollUpButton, + DropDownSelectScrollDownButton, +} diff --git a/panel/src/components/page-header.tsx b/panel/src/components/page-header.tsx new file mode 100644 index 0000000..6cf069d --- /dev/null +++ b/panel/src/components/page-header.tsx @@ -0,0 +1,153 @@ +import { CalendarIcon, ChevronRightIcon, SaveIcon, UserIcon } from "lucide-react"; +import { ConfigChangelogEntry } from "@shared/otherTypes"; +import { useMemo, useState } from "react"; +import { dateToLocaleDateString, dateToLocaleTimeString, isDateToday, tsToLocaleDateTimeString } from "@/lib/dateTime"; +import TxAnchor from "@/components/TxAnchor"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Link } from "wouter"; + + +//MARK: PageHeaderChangelog +type PageHeaderChangelogProps = { + changelogData?: ConfigChangelogEntry[]; +} +export function PageHeaderChangelog({ changelogData }: PageHeaderChangelogProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + const mostRecent = useMemo(() => { + if (!changelogData?.length) return null; + const last = changelogData[changelogData.length - 1]; + const lastDate = new Date(last.ts); + const timeStr = dateToLocaleTimeString(lastDate, '2-digit', '2-digit'); + const dateStr = dateToLocaleDateString(lastDate, 'long'); + const titleTimeIndicator = isDateToday(lastDate) ? timeStr : dateStr; + return { + author: last.author, + dateTime: titleTimeIndicator, + } + }, [changelogData]); + + const reversedChangelog = useMemo(() => { + if (!changelogData) return null; + return [...changelogData].reverse(); + }, [changelogData]); + + const handleOpenChangelog = () => { + setIsModalOpen(true); + } + + const placeholder = Array.isArray(changelogData) ? 'No changes yet' : 'loading...'; + + return (<> +
    + {reversedChangelog?.length ? ( +
    + View Changelog +
    + ) : null} +
    + Last Updated + : +
    +
    + {mostRecent?.dateTime ?? placeholder} +
    + {/*
    + {mostRecent?.author ?? placeholder} +
    */} +
    + + + + Recent Changes + +
    + {reversedChangelog?.map((entry, i) => )} +
    +
    +
    + ) +} + + +function ChangelogEntry({ entry }: { entry: ConfigChangelogEntry }) { + return ( +
    +
    +
    + + {entry.author} +
    +
    + {tsToLocaleDateTimeString(entry.ts, 'short', 'short')} +
    +
    +
    + {entry.keys.length ? entry.keys.map((cfg, index) => ( + +
    {cfg}
    + {index < entry.keys.length - 1 && ','} +
    + )) : ( +
    No changes
    + )} +
    +
    + ) +} + + +//MARK: PageHeaderLinks +type PageHeaderLinksProps = { + topLabel: string; + topLink: string; + bottomLabel: string; + bottomLink: string; +} +export function PageHeaderLinks(props: PageHeaderLinksProps) { + return ( +
    + {props.topLabel} + {props.bottomLabel} +
    + ) +} + + +//MARK: PageHeader +type PageHeaderProps = { + title: string; + icon: React.ReactNode; + parentName?: string; + parentLink?: string; + children?: React.ReactNode; +} +export function PageHeader(props: PageHeaderProps) { + const titleNodes = useMemo(() => { + if (props.parentName && props.parentLink) { + return (<> + {props.parentName} + +
  • {props.title}
  • + ) + } else { + return
  • {props.title}
  • ; + } + }, [props]); + return ( +
    +
    +
      + {props.icon} + {titleNodes} +
    + {props.children} +
    +
    + ) +} diff --git a/panel/src/components/serverIcon.tsx b/panel/src/components/serverIcon.tsx new file mode 100644 index 0000000..2a2510c --- /dev/null +++ b/panel/src/components/serverIcon.tsx @@ -0,0 +1,48 @@ +import { cn } from "@/lib/utils"; +import { AvatarFallback, AvatarImage, Avatar as ShadcnAvatar } from "./ui/avatar"; + + +type ServerIconProps = { + serverName?: string; + gameName?: string; + iconFilename?: string; + className?: string; + extraClasses?: string; +}; + +export function ServerIcon({ serverName, gameName, iconFilename, className, extraClasses }: ServerIconProps) { + let fallbackUrl: string; + if (gameName === 'fivem') { + fallbackUrl = '/img/fivem-server-icon.png'; + } else if (gameName === 'redm') { + fallbackUrl = '/img/redm-server-icon.png'; + } else { + fallbackUrl = '/img/unknown-server-icon.png'; + } + + let iconUrl = fallbackUrl; + if (iconFilename && /^icon-([a-f0-9]{16})\.png$/.test(iconFilename)) { + iconUrl = `/.runtime/${iconFilename}`; + } + + return ( + + + + + + + ); +} + +export function ServerGlowIcon(props: Omit) { + return ( +
    + + +
    + ); +} diff --git a/panel/src/components/ui/alert-dialog.tsx b/panel/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..70a72fb --- /dev/null +++ b/panel/src/components/ui/alert-dialog.tsx @@ -0,0 +1,143 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/panel/src/components/ui/alert.tsx b/panel/src/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/panel/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
    +)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/panel/src/components/ui/autosize-textarea.tsx b/panel/src/components/ui/autosize-textarea.tsx new file mode 100644 index 0000000..bd8d665 --- /dev/null +++ b/panel/src/components/ui/autosize-textarea.tsx @@ -0,0 +1,111 @@ +//NOTE: this is not part of the original shadcn/ui +// ref: https://shadcnui-expansions.typeart.cc/docs/autosize-textarea + +'use client'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { useImperativeHandle } from 'react'; + +interface UseAutosizeTextAreaProps { + textAreaRef: HTMLTextAreaElement | null; + minHeight?: number; + maxHeight?: number; + triggerAutoSize: string; +} + +export const useAutosizeTextArea = ({ + textAreaRef, + triggerAutoSize, + maxHeight = Number.MAX_SAFE_INTEGER, + minHeight = 0, +}: UseAutosizeTextAreaProps) => { + const [init, setInit] = React.useState(true); + React.useEffect(() => { + // We need to reset the height momentarily to get the correct scrollHeight for the textarea + const offsetBorder = 2; + if (textAreaRef) { + if (init) { + textAreaRef.style.minHeight = `${minHeight + offsetBorder}px`; + if (maxHeight > minHeight) { + textAreaRef.style.maxHeight = `${maxHeight}px`; + } + setInit(false); + } + textAreaRef.style.height = `${minHeight + offsetBorder}px`; + const scrollHeight = textAreaRef.scrollHeight; + // We then set the height directly, outside of the render loop + // Trying to set this with state or a ref will product an incorrect value. + if (scrollHeight > maxHeight) { + textAreaRef.style.height = `${maxHeight}px`; + } else { + textAreaRef.style.height = `${scrollHeight + offsetBorder}px`; + } + } + }, [textAreaRef, triggerAutoSize]); +}; + +export type AutosizeTextAreaRef = { + textArea: HTMLTextAreaElement; + maxHeight: number; + minHeight: number; +}; + +type AutosizeTextAreaProps = { + maxHeight?: number; + minHeight?: number; +} & React.TextareaHTMLAttributes; + +export const AutosizeTextarea = React.forwardRef( + ( + { + maxHeight = Number.MAX_SAFE_INTEGER, + minHeight = 52, + className, + onChange, + value, + ...props + }: AutosizeTextAreaProps, + ref: React.Ref, + ) => { + const textAreaRef = React.useRef(null); + const [triggerAutoSize, setTriggerAutoSize] = React.useState(''); + + useAutosizeTextArea({ + textAreaRef: textAreaRef.current, + triggerAutoSize: triggerAutoSize, + maxHeight, + minHeight, + }); + + useImperativeHandle(ref, () => ({ + textArea: textAreaRef.current as HTMLTextAreaElement, + maxHeight, + minHeight, + })); + + React.useEffect(() => { + setTriggerAutoSize(value as string); + }, [value, props?.defaultValue, props?.placeholder]); + + return ( + +
    + + +
    +
    + +
    + +
    + + +
    +
    + + +
    + +
    +
    + +
    + +
    +
    +
    + + diff --git a/scripts/lint-formatter.js b/scripts/lint-formatter.js new file mode 100644 index 0000000..42aa763 --- /dev/null +++ b/scripts/lint-formatter.js @@ -0,0 +1,28 @@ +module.exports = (results) => { + const byRuleId = results.reduce( + (map, current) => { + current.messages.forEach(({ ruleId, line, column }) => { + if (!map[ruleId]) { + map[ruleId] = []; + } + + const occurrence = `${current.filePath}:${line}:${column}`; + map[ruleId].push(occurrence); + }); + return map; + }, {}, + ); + + const ruleCounts = Object.entries(byRuleId) + .map((rule) => ({id: rule[0], count: rule[1].length})); + + ruleCounts.sort((a, b) => { + if (a.count > b.count) return -1; + if (a.count < b.count) return 1; + return 0; + }); + + return ruleCounts + .map((rule) => `${rule.count}\t${rule.id}`) + .join('\n'); +}; diff --git a/scripts/list-dependencies.js b/scripts/list-dependencies.js new file mode 100644 index 0000000..4c908ce --- /dev/null +++ b/scripts/list-dependencies.js @@ -0,0 +1,89 @@ +//NOTE: This script is not perfect, it was quickly made to help with the migration to the new package system. + +import chalk from 'chalk'; +import fs from 'node:fs'; +import path from 'node:path'; + +//Get list of all dependencies in package.json +const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8')); +const dependencies = new Set(Object.keys(packageJson.dependencies).concat(Object.keys(packageJson.devDependencies))); + +//Get target folder path +const targetPath = process.argv[2]; +if (typeof targetPath !== 'string' || !targetPath.length) { + console.log('Invalid target package name'); + process.exit(1); +} +console.clear(); +console.log('Scanning for dependencies in:', chalk.blue(targetPath)); + + +//NOTE: To generate this list, use `node -pe "require('repl')._builtinLibs"` in both node16 and 22, then merge them. +const builtInModules = [ + 'assert', 'assert/strict', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console', 'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'dns/promises', 'domain', 'events', 'fs', 'fs/promises', 'http', 'http2', 'https', 'inspector', 'inspector/promises', 'module', 'net', 'os', 'path', 'path/posix', 'path/win32', 'perf_hooks', 'process', 'punycode', 'querystring', 'readline', 'readline/promises', 'repl', 'stream', 'stream/consumers', 'stream/promises', 'stream/web', 'string_decoder', 'sys', 'test/reporters', 'timers', 'timers/promises', 'tls', 'trace_events', 'tty', 'url', 'util', 'util/types', 'v8', 'vm', 'wasi', 'worker_threads', 'zlib' +]; + + +//Process file and extract all dependencies +const allDependencies = new Set(); +const ignoredPrefixes = [ + 'node:', + './', + '../', + '@shared', + '@utils', + '@logic', + '@modules', + '@routes', + '@core', + '@locale/', + '@nui/', + '@shared/', +]; +const importRegex = /import\s+.+\s+from\s+['"](.*)['"]/gm; +const processFile = (filePath) => { + const fileContent = fs.readFileSync(filePath, 'utf8'); + const matches = [...fileContent.matchAll(importRegex)].map((match) => match[1]); + for (const importedModule of matches) { + if (ignoredPrefixes.some((prefix) => importedModule.startsWith(prefix))) continue; + if (!importedModule) { + console.log(chalk.red(`[ERROR] Invalid import in file: ${filePath}: ${importedModule}`)); + continue; + } + if (builtInModules.includes(importedModule)) { + console.log(chalk.red(`[ERROR] builtin module '${importedModule}' without 'node:' from: ${filePath}`)); + continue; + } + if (importedModule === '.') { + console.log(chalk.red(`[ERROR] Invalid import in file: ${filePath}: ${importedModule}`)); + continue; + } + if (!dependencies.has(importedModule)) { + console.log(chalk.yellow(`[WARN] imported module '${importedModule}' not found in package.json`)); + continue; + } + allDependencies.add(importedModule); + } +}; + + +//Recursively read all files in the targetPath and its subfolders +const validExtensions = ['.cjs', '.js', '.ts', '.jsx', '.tsx']; +const processFolder = (dirPath) => { + const files = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const file of files) { + const currFilePath = path.join(dirPath, file.name); + if (file.isDirectory()) { + processFolder(currFilePath); + } else if (!file.isFile()) { + console.log(chalk.red(`[ERROR] Invalid file: ${currFilePath}`)); + } else if (!validExtensions.includes(path.extname(currFilePath))) { + console.log(chalk.grey(`[INFO] Ignoring file: ${currFilePath}`)); + } else { + processFile(currFilePath); + } + } +}; +processFolder(targetPath); +console.log(allDependencies); diff --git a/scripts/list-licenses.js b/scripts/list-licenses.js new file mode 100644 index 0000000..ebee7a3 --- /dev/null +++ b/scripts/list-licenses.js @@ -0,0 +1,28 @@ +import fs from 'node:fs'; +import chalk from 'chalk'; + +const packageJson = JSON.parse(fs.readFileSync('./package-lock.json', 'utf8')); + +//Processing package-lock.json +const licenseUsage = new Map(); +let packagesWithLicense = Object.keys(packageJson.packages).length; +for (const [packageName, packageData] of Object.entries(packageJson.packages)) { + const licenseName = packageData.license; + if (!licenseName) { + packagesWithLicense--; + console.log(chalk.yellow('No license found for package: ') + packageName); + } + const currCount = licenseUsage.get(licenseName) ?? 0; + licenseUsage.set(licenseName, currCount + 1); +} + +//Printing results +console.group(chalk.green(`--- Detected Licenses (${packagesWithLicense}) ---`)); +const sortedLicenseUsage = [...licenseUsage.entries()] + .sort((a, b) => a[0] - b[0]); +for (const [license, count] of sortedLicenseUsage) { + console.log( + license ? `${license}: ` : chalk.red('unknown: '), + chalk.yellow(count) + ); +} diff --git a/scripts/locale-utils.js b/scripts/locale-utils.js new file mode 100644 index 0000000..03afd55 --- /dev/null +++ b/scripts/locale-utils.js @@ -0,0 +1,543 @@ +import chalk from 'chalk'; +import humanizeDuration from 'humanize-duration'; +import { defaults, defaultsDeep, xor, difference } from 'lodash-es'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Prepping +const defaultLang = JSON.parse(fs.readFileSync('./locale/en.json', 'utf8')); +const langFiles = fs.readdirSync('./locale/', { withFileTypes: true }) + .filter((dirent) => !dirent.isDirectory() && dirent.name.endsWith('.json') && dirent.name !== 'en.json') + .map((dirent) => dirent.name); +const loadedLocales = langFiles.map((fName) => { + const fPath = path.join('./locale/', fName); + let raw, data; + try { + raw = fs.readFileSync(fPath, 'utf8'); + data = JSON.parse(raw); + } catch (error) { + console.log(chalk.red(`Failed to load ${fName}:`)); + console.log(error.message); + process.exit(1); + } + return { + name: fName, + path: fPath, + raw, + data, + }; +}); + +// Clean en.json +// fs.writeFileSync('./locale/en.json', JSON.stringify(defaultLang, null, 4) + '\n'); +// console.log('clean en.json'); +// process.exit(); + +// const customLocale = 'E://FiveM//BUILDS//txData//locale.json'; +// loadedLocales.push({ +// name: 'custom', +// path: customLocale, +// data: JSON.parse(fs.readFileSync(customLocale, 'utf8')), +// }); + + +/** + * Adds missing tags to files based on en.json + */ +const rebaseCommand = () => { + console.log("Rebasing language files on 'en.json' for missing keys"); + for (const { name, path, data } of loadedLocales) { + const synced = defaultsDeep(data, defaultLang); + try { + // synced.ban_messages.reject_temporary = undefined; + // synced.ban_messages.reject_permanent = undefined; + // synced.nui_menu.player_modal.info.notes_placeholder = "Notes about this player..."; + // synced.nui_menu.player_modal.history.action_types = undefined; + } catch (error) { + console.log(name); + console.dir(error); + process.exit(); + } + + // synced.nui_menu = defaultLang.nui_menu; + const out = JSON.stringify(synced, null, 4) + '\n'; + fs.writeFileSync(path, out); + console.log(`Edited file: ${name}`); + } + console.log(`Process finished: ${langFiles.length} files.`); +}; + + +/** + * Creates a prompt for easily using some LLM to translate stuff + */ +const BACKTICKS = '```'; +const promptTemplateLines = [ + '# Task', + '{{task}}', + null, + '## English Source String:', + BACKTICKS, + '{{source}}', + BACKTICKS, + null, + '## Context', + '{{context}}', + null, + '## Example Result', + BACKTICKS, + '# English', + '{{finalEnglish}}', + '# Portuguese', + '{{finalPortuguese}}', + BACKTICKS, + null, + '## Prompt Response Format', + 'The return format for this prompt is a JSON file with the type:', + BACKTICKS + 'ts', + '{{responseType}}', + BACKTICKS, + null, + // 'Save the result to #file:{{targetFile}}', +]; +const getPromptMarkdown = (replacers) => { + return promptTemplateLines.map((line) => { + if (line === null) return ''; + for (let [placeholder, text] of Object.entries(replacers)) { + if (Array.isArray(text)) { + text = text.join('\n'); + } + line = line.replace(`{{${placeholder}}}`, text); + } + return line; + }).join('\n').trim(); +}; + +const taskTranslate = `You have the task of translating one string into all the languages in the #file:./locale-source.json. +- Your translated string must be capitalized and punctuated in the same way as the English source string, like the examples below: + - \`Hello, World!\` -> \`Bonjour, le monde!\`. + - \`hello, world\` -> \`bonjour, le monde\`. +- Your translated string should be as short/concise as the English source string. +- Instead of formal language, keep it as informal as the English source string. +- Make sure the verbs are in the correct tense and form. +- Pay attention to the context of the string, and make sure it makes sense in the target language. +- If the string contains placeholders (like \`%{reason}\`), make sure to keep them in the translation. +- Do not translate the \`wrapper\` field, only the \`translation\` field.`; +const taskTranslateResponse = `type PromptResponse = { + [langCode: string]: { + language: string; + translation: string; + }; +}`; + +const taskReview = `You have the task of reviewing how one string was translated into multiple languages. +Attached you will find the #file:./locale-translate-result.json, which contains all the translations to be reviewed. +- You must review each translation and provide feedback on the quality of the translation. +- Optionally, you can add a short comment on the review, to be reviewed by a reviewer. + - For example, if it is impossible to translate one word, you can add a comment explaining why. + - Do not add comments for every translation, only when there is something important to note. +- The feedback criteria should be based on if the translation: + - **Verbs are in the correct tense and form.** + - Is as formal or informal as the English source string. + - Is capitalized and punctuated in the same way as the English source string. + - Is as short/concise as the English source string. + - Keeps the same semantic value (makes sense) in the target language as the source string. + - Keeps the placeholders (like \`%{reason}\`) in the translation. + - Makes sense in the context provided. +- The review should be one of the following: + - \`below-average\`: The translation is really bad and needs to be redone. + - \`average\`: The translation is okay, does not require changes. + - \`above-average\`: The translation is really good, no changes needed.`; +const taskReviewResponse = `type PromptResponse = { + [langCode: string]: { + language: string; + translation: string; + review: 'below-average' | 'average' | 'above-average'; + comment?: string; + }; +}`; + +//FIXME: Edit these two below +const promptSourceString = 'the server needs to be restarted, please reconnect'; +const promptPortugueseString = 'o servidor precisa ser reiniciado, por favor conecte novamente'; +const promptContext = [ + 'This string is going to take place into the placeholder `%{reason}` in the `wrapper` field.', + 'This string is displayed to players on a game server as the reason why the player is being kicked out of the server.', +]; + +//If wrapper +const promptSourceWrapper = (lang) => lang.kick_messages.everyone; +const promptFinalEnglish = 'All players kicked: {{source}}'; +const promptFinalPortuguese = 'Todos os jogadores expulsos: {{portuguese}}'; + + +/* + Instructions: + - bun run scripts/locale-utils.js buildGptPrompts + - Prompt ./locale-translate.prompt.md + - Attach ./locale-source.json + - Save the result to ./locale-translate.result.json + - Prompt ./locale-review.prompt.md + - Attach ./locale-translate.result.json + - Save the result to ./locale-review.result.json + - bun run scripts/locale-utils.js applyGptResults + - Go through the review and change anything that needs to be changed + - bun run scripts/locale-utils.js check +*/ + +/** + * Creates a prompt for easily using some LLM to translate stuff + */ +const buildGptPrompts = () => { + console.log(`Making prompt files for ${loadedLocales.length} languages.`); + + //Make prompt files + const finalEnglish = promptFinalEnglish.replace('{{source}}', promptSourceString); + const finalPortuguese = promptFinalPortuguese.replace('{{portuguese}}', promptPortugueseString); + const replacers = { + source: promptSourceString, + context: promptContext, + finalEnglish, + finalPortuguese, + }; + const translatePromptMd = getPromptMarkdown({ + ...replacers, + task: taskTranslate, + responseType: taskTranslateResponse, + targetFile: './locale-translate.result.json', + }); + const reviewPromptMd = getPromptMarkdown({ + ...replacers, + task: taskReview, + responseType: taskReviewResponse, + targetFile: './locale-review.result.json', + }); + + //Saving prompts to the github folder + // const promptsDir = './.github/prompts'; + const promptsDir = './'; + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, 'locale-translate.prompt.md'), translatePromptMd); + fs.writeFileSync(path.join(promptsDir, 'locale-review.prompt.md'), reviewPromptMd); + + //Make JSON source file + const promptObj = {}; + for (const { name, path, data } of loadedLocales) { + const [langCode] = name.split('.', 1); + promptObj[langCode] = { + language: data.$meta.label, + wrapper: promptSourceWrapper(data), + translation: '', + }; + } + const out = JSON.stringify(promptObj, null, 2) + '\n'; + fs.writeFileSync('./locale-source.json', out); + fs.writeFileSync('./locale-translate.result.json', '{\n "error": "empty"\n}'); //empty file + fs.writeFileSync('./locale-review.result.json', '{\n "error": "empty"\n}'); //empty file + // try { fs.unlinkSync('./locale-translate.result.json'); } catch (error) { } + // try { fs.unlinkSync('./locale-review.result.json'); } catch (error) { } + + console.log('Prompt files created.'); +}; + + +/** + * Applies the results from the GPT to the locale files + */ +const applyGptResults = () => { + console.log(`Applying GPT results to ${loadedLocales.length} languages.`); + + // Load the results + const resultFile = fs.readFileSync('./locale-translate.result.json', 'utf8'); + const results = JSON.parse(resultFile); + + // Load evaluation results + const reviewsFile = fs.readFileSync('./locale-review.result.json', 'utf8'); + const reviews = JSON.parse(reviewsFile); + + // Print translations with color-coded review + console.log('\nTranslation Results:'); + console.log('===================\n'); + for (const [langCode, result] of Object.entries(reviews)) { + const { language, translation, review, comment } = result; + + // Choose color based on review + const colorMap = { + 'below-average': chalk.redBright, + 'average': chalk.yellowBright, + 'above-average': chalk.greenBright, + }; + const colorFn = colorMap[review] || chalk.inverse; + + console.group(chalk.inverse(`[${langCode}] ${language}:`)); + console.log(chalk.dim('Result:'), colorFn(translation)); + if (comment) { + console.log(chalk.dim('Comment:'), chalk.hex('#FF45FF')(comment)); + } + console.groupEnd(); + console.log(''); + } + + // Apply the results + for (const [langCode, { translation }] of Object.entries(results)) { + const locale = loadedLocales.find((l) => l.name.startsWith(`${langCode}.`)); + if (!locale) { + throw new Error(`Locale not found for ${langCode}`); + } + locale.data.restarter.server_unhealthy_kick_reason = translation; //FIXME: change target here + const out = JSON.stringify(locale.data, null, 4) + '\n'; + fs.writeFileSync(locale.path, out); + } + + console.log('Applied GPT results.'); +}; + + +/** + * Processes all locale files and "changes stuff" + * This is just a quick way to do some stuff without having to open all files + */ +const processStuff = () => { + // const joined = []; + // for (const { name, path, data } of loadedLocales) { + // joined.push({ + // file: name, + // language: data.$meta.label, + // instruction: data.nui_warning.instruction, + // }); + // } + // const out = JSON.stringify(joined, null, 4) + '\n'; + // fs.writeFileSync('./locale-joined.json', out); + // console.log(`Saved joined file`); + + for (const { name, path, data } of loadedLocales) { + // rename + // data.restarter.boot_timeout = data.restarter.start_timeout; + // remove + // data.restarter.start_timeout = undefined; + // edit + // data.restarter.crash_detected = 'xxx'; + + // Save file - FIXME: commented out just to make sure i don't fuck it up by accident + // const out = JSON.stringify(data, null, 4) + '\n'; + // fs.writeFileSync(path, out); + // console.log(`Edited file: ${name}`); + } +}; + + +/** + * Parses a locale file by "unfolding" it into an object of objects instead of strings + */ +function parseLocale(input, prefix = '') { + if (!input) return {}; + let result = {}; + + for (const [key, value] of Object.entries(input)) { + const newPrefix = prefix ? `${prefix}.` : ''; + if ( + typeof value === 'object' + && !Array.isArray(value) + && value !== null + ) { + const recParse = parseLocale(value, `${newPrefix}${key}`); + result = defaults(result, recParse); + } else { + if (typeof value === 'string') { + const specials = value.matchAll(/(%{\w+}|\|\|\|\|)/g); + result[`${newPrefix}${key}`] = { + value, + specials: [...specials].map((m) => m[0]), + }; + } else { + throw new Error(`Invalid value type '${typeof value}' for key '${newPrefix}${key}'`); + } + } + } + + return result; +} + + +/** + * Get a list of all mapped locales on shared/localeMap.ts + */ +const getMappedLocales = () => { + const mapFileData = fs.readFileSync('./shared/localeMap.ts', 'utf8'); + const importRegex = /import lang_(?[\w\-]+) from "@locale\/(\k)\.json";/gm; + const mappedImports = [...mapFileData.matchAll(importRegex)].map((m) => m.groups.fname); + + const mapRegex = /(?['"]?)(?[\w\-]+)(\k): lang_(\k),/gm; + const mappedLocales = [...mapFileData.matchAll(mapRegex)].map((m) => m.groups.fname); + + return { mappedImports, mappedLocales }; +}; + + +/** + * Checks all locale files for: + * - localeMap.ts: bad import or mapping + * - localeMap.ts: locale file not mapped + * - localeMap.ts: import or mapping not alphabetically sorted + * - invalid humanizer-duration language + * - missing/excess keys + * - mismatched specials (placeholders or smart time division) + * - empty strings + * - untrimmed strings + */ +const checkCommand = () => { + console.log("Checking validity of the locale files based on 'en.json'."); + const defaultLocaleParsed = parseLocale(defaultLang); + const defaultLocaleKeys = Object.keys(defaultLocaleParsed); + const humanizerLocales = humanizeDuration.getSupportedLanguages(); + let totalErrors = 0; + + // Checks for localeMap.ts + { + // Check if any locale on localeMap.ts is either not imported or mapped + const { mappedImports, mappedLocales } = getMappedLocales(); + const unmappedLocales = xor(mappedImports, mappedLocales); + for (const locale of unmappedLocales) { + totalErrors++; + console.log(chalk.yellow(`[${locale}] is not correctly mapped on localeMap.ts`)); + } + + // Check if any loaded locale is not on localeMap.ts + const loadedLocalesNames = loadedLocales.map((l) => l.name.replace('.json', '')); + const unmappedLoadedLocales = xor(loadedLocalesNames, mappedLocales); + for (const locale of unmappedLoadedLocales) { + if (locale === 'custom' || locale === 'en') continue; + totalErrors++; + console.log(chalk.yellow(`[${locale}] is not mapped on localeMap.ts or not on locales folder`)); + } + + // Check if both localeMap.ts imports and maps are alphabetically sorted + const sortedMappedImports = [...mappedImports].sort(); + if (JSON.stringify(sortedMappedImports) !== JSON.stringify(mappedImports)) { + totalErrors++; + console.log(chalk.yellow(`localeMap.ts imports are not alphabetically sorted`)); + } + const sortedMappedLocales = [...mappedLocales].sort(); + if (JSON.stringify(sortedMappedLocales) !== JSON.stringify(mappedLocales)) { + totalErrors++; + console.log(chalk.yellow(`localeMap.ts mappings are not alphabetically sorted`)); + } + } + + // For each locale + for (const { name, raw, data } of loadedLocales) { + try { + const parsedLocale = parseLocale(data); + const parsedLocaleKeys = Object.keys(parsedLocale); + const errorsFound = []; + + // Checking humanizer-duration key + if (!humanizerLocales.includes(data.$meta.humanizer_language)) { + errorsFound.push(['$meta.humanizer_language', 'language not supported']); + } + + // Checking keys + const diffKeys = xor(defaultLocaleKeys, parsedLocaleKeys); + for (const key of diffKeys) { + const errorType = (defaultLocaleKeys.includes(key)) ? 'missing' : 'excess'; + errorsFound.push([key, `${errorType} key`]); + } + + // Skip the rest of the checks if there are missing/excess keys + if (!diffKeys.length) { + // Checking specials (placeholders or smart time division) + for (const key of defaultLocaleKeys) { + const missing = difference(defaultLocaleParsed[key].specials, parsedLocale[key].specials); + if (missing.length) { + errorsFound.push([key, `must contain the placeholders ${missing.join(', ')}`]); + } + const excess = difference(parsedLocale[key].specials, defaultLocaleParsed[key].specials); + if (excess.length) { + errorsFound.push([key, `contain unknown placeholders: ${excess.join(', ')}`]); + } + } + + // Check for untrimmed strings + const keysWithUntrimmedStrings = parsedLocaleKeys.filter((k) => { + return parsedLocale[k].value !== parsedLocale[k].value.trim(); + }); + for (const key of keysWithUntrimmedStrings) { + errorsFound.push([key, `untrimmed string`]); + } + + // Checking empty strings + const keysWithEmptyStrings = parsedLocaleKeys.filter((k) => { + return parsedLocale[k].value === ''; + }); + for (const key of keysWithEmptyStrings) { + errorsFound.push([key, `empty string`]); + } + } + + // Check if raw file is formatted correctly + const rawLinesNormalized = raw.split(/\r?\n/ug).map((l) => l.replace(/\r?\n$/, '\n')); + const correctFormatting = JSON.stringify(data, null, 4) + '\n'; + const correctLines = correctFormatting.split(/\n/ug); + if (rawLinesNormalized.at(-1).length) { + errorsFound.push(['file', 'is not formatted correctly (must end with a newline)']); + } else if (rawLinesNormalized.length !== correctLines.length) { + errorsFound.push(['file', 'is not formatted correctly (line count)']); + } else { + for (let i = 0; i < rawLinesNormalized.length; i++) { + const rawIndentSize = rawLinesNormalized[i].search(/\S/); + const correctIndentSize = correctLines[i].search(/\S/); + if (rawIndentSize === -1 ^ correctIndentSize === -1) { + errorsFound.push([`line ${i + 1}`, 'empty line']); + break; + } + if (rawIndentSize !== correctIndentSize) { + errorsFound.push([`line ${i + 1}`, `has wrong indentation (expected ${correctIndentSize} spaces)`]); + break; + } + if (rawLinesNormalized[i].endsWith(' ')) { + errorsFound.push([`line ${i + 1}`, 'has trailing whitespace']); + break; + } + } + } + + // Print errors + totalErrors += errorsFound.length; + if (errorsFound.length) { + console.log(chalk.yellow(`[${name}] Errors found in ${data.$meta.label} locale:`)); + console.log(errorsFound.map(x => `- ${x[0]}: ${x[1]}`).join('\n')); + console.log(''); + } + } catch (error) { + totalErrors++; + console.log(chalk.yellow(`[${name}] ${error.message}`)); + } + } + + // Print result + if (totalErrors) { + console.log(chalk.red(`Errors found: ${totalErrors}`)); + process.exit(1); + } else { + console.log(chalk.green('No errors found!')); + } +}; + + +/** + * CLI entrypoint + */ +const command = process.argv[2]; +if (command === 'check') { + checkCommand(); +} else if (command === 'rebase') { + rebaseCommand(); +} else if (command === 'processStuff') { + processStuff(); +} else if (command === 'buildGptPrompts') { + buildGptPrompts(); +} else if (command === 'applyGptResults') { + applyGptResults(); +} else { + console.log("Usage: 'scripts/locale-utils.js '"); +} diff --git a/scripts/test_build.sh b/scripts/test_build.sh new file mode 100644 index 0000000..e86a63a --- /dev/null +++ b/scripts/test_build.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# exit when any command fails +set -e + +# testing target folder +if [ -z "$TXDEV_FXSERVER_PATH" ]; then + TARGET_PATH=$1 +else + TARGET_PATH=$TXDEV_FXSERVER_PATH +fi + +echo "Target path: ${TARGET_PATH}" + +if [ -z "$TARGET_PATH" ]; then + echo "Usage: $0 "; + echo "Example: $0 /e/FiveM/10309"; + exit 1; +fi + +if [ -d "$TARGET_PATH" ]; then + echo "Copying build files to ${TARGET_PATH}..." +else + echo "Error: ${TARGET_PATH} not found. Can not continue." + exit 1 +fi + +# copying and running target fxserver +rm -rf "${TARGET_PATH}/citizen/system_resources/monitor/*" +cp -r dist/* "${TARGET_PATH}/citizen/system_resources/monitor" +cd $TARGET_PATH +./FXServer.exe diff --git a/scripts/typecheck-formatter.js b/scripts/typecheck-formatter.js new file mode 100644 index 0000000..f80fc68 --- /dev/null +++ b/scripts/typecheck-formatter.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// npx tsc -p core/tsconfig.json --noEmit | ./scripts/typecheck-formatter.js + +import chalk from 'chalk'; + +const filterOut = [ + `Cannot find name 'globals'.`, + `Property 'body' does not exist on type 'Request'.`, + `'ctx.params' is of type 'unknown'.`, + `has not been built from source file`, +]; + +let rawInput = ''; +process.stdin.on('data', (data) => { + rawInput += data.toString('utf8'); +}); +process.stdin.on('end', () => { + processInput(rawInput.trim()); +}); + + +function processInput(rawInput) { + let errorCount = 0; + const allLines = rawInput.split('\n'); + const filtered = allLines + .filter(errorLine => errorLine.includes('error TS')) + .filter(errorLine => !filterOut.some(filter => errorLine.includes(filter))); + for (const errorLine of filtered) { + // console.log(errorLine); + const [file, tsError, desc] = errorLine.split(': ', 3); + + if (tsError) { + errorCount++; + console.log(chalk.yellow(file)); + console.log(`\t${chalk.red(desc)}`); + } else { + console.log(`\t${chalk.red(errorLine)}`); + } + } + console.log('==========================='); + console.log('errorCount', errorCount); +} diff --git a/shared/authApiTypes.ts b/shared/authApiTypes.ts new file mode 100644 index 0000000..3adb1d2 --- /dev/null +++ b/shared/authApiTypes.ts @@ -0,0 +1,70 @@ +import type { ApiAuthErrorResp } from "./genericApiTypes"; +import type { ApiVerifyPasswordReqSchema } from '@core/routes/authentication/verifyPassword'; +import type { ApiOauthCallbackReqSchema } from "@core/routes/authentication/providerCallback"; +import type { ApiAddMasterPinReqSchema } from "@core/routes/authentication/addMasterPin"; +import type { ApiAddMasterCallbackReqSchema } from "@core/routes/authentication/addMasterCallback"; +import type { ApiAddMasterSaveReqSchema } from "@core/routes/authentication/addMasterSave"; +import type { ApiChangePasswordReqSchema } from "@core/routes/authentication/changePassword"; +import type { ApiChangeIdentifiersReqSchema } from "@core/routes/authentication/changeIdentifiers"; + +export type ReactAuthDataType = { + name: string; + permissions: string[]; + isMaster: boolean; + isTempPassword: boolean; + profilePicture?: string; + csrfToken?: string; +} + +export type ApiSelfResp = ApiAuthErrorResp | ReactAuthDataType; + +export type ApiLogoutResp = { + logout: true; +}; + + +export type ApiVerifyPasswordReq = ApiVerifyPasswordReqSchema; +export type ApiVerifyPasswordResp = { + error: string; +} | ReactAuthDataType; + + +export type ApiOauthRedirectResp = { + authUrl: string; +} | { + error: string; +}; + + +export type ApiOauthCallbackReq = ApiOauthCallbackReqSchema; +export type ApiOauthCallbackErrorResp = { + errorCode: string; + errorContext?: { + [key: string]: string; + }; +} | { + errorTitle: string; + errorMessage: string; +}; +export type ApiOauthCallbackResp = ApiOauthCallbackErrorResp | ReactAuthDataType; + + +export type ApiAddMasterPinReq = ApiAddMasterPinReqSchema; +export type ApiAddMasterPinResp = ApiOauthRedirectResp; + +export type ApiAddMasterCallbackReq = ApiAddMasterCallbackReqSchema; +export type ApiAddMasterCallbackFivemData = { + fivemName: string; + fivemId: string; + profilePicture?: string; +} +export type ApiAddMasterCallbackResp = ApiOauthCallbackErrorResp | ApiAddMasterCallbackFivemData; + +export type ApiAddMasterSaveReq = ApiAddMasterSaveReqSchema; +export type ApiAddMasterSaveResp = { + error: string; +} | ReactAuthDataType; + + +export type ApiChangePasswordReq = ApiChangePasswordReqSchema; +export type ApiChangeIdentifiersReq = ApiChangeIdentifiersReqSchema; diff --git a/shared/cleanFullPath.ts b/shared/cleanFullPath.ts new file mode 100644 index 0000000..7594236 --- /dev/null +++ b/shared/cleanFullPath.ts @@ -0,0 +1,40 @@ +type CleanFullPathReturn = { path: string } | { error: string }; + +export default function cleanFullPath(input: string, isWindows): CleanFullPathReturn { + //Path must be a string + if (typeof input !== 'string') { + return { error: 'path must be a string' }; + } + + //Path must not be a windows extended path (namespace?) + if (input.startsWith('\\\\?\\')) { + return { error: 'unsupported windows path format' }; + } + + //Convert backslashes to slashes and remove duplicate slashes + const slashified = input.replaceAll(/\\/g, '/').replaceAll(/\/+/g, '/'); + + //Path must not be empty + if (slashified.length === 0) { + return { error: 'empty path' }; + } + + //Path must be absolute + if (isWindows && !/^[a-zA-Z]:\//.test(slashified)) { + return { error: 'windows paths must be absolute and start with a drive letter like `c:/`' }; + } else if (!isWindows && !/^\//.test(slashified)) { + return { error: 'linux paths must be absolute and start with a slash' }; + } + + //Path must be fully resolved + if (/\/[\s\.](\/|$)/.test(slashified)) { + return { error: 'path contains unresolved parts (eg. `/../`)' }; + } + + //Return without the trailing slash + if (slashified.endsWith('/')) { + return { path: slashified.slice(0, -1) }; + } else { + return { path: slashified }; + } +} diff --git a/shared/cleanPlayerName.ts b/shared/cleanPlayerName.ts new file mode 100644 index 0000000..a82fa80 --- /dev/null +++ b/shared/cleanPlayerName.ts @@ -0,0 +1,276 @@ +/* + ⚠ BEHOLD, THE RESULT OF TENS OF HOURS OF TESTING AND RESEARCH ⚠ + + Building this code was not easy, took me many days of testing and research to get it right. + Althought still not perfect, I believe this code is the best way to clean up player names. + In general: + - It removes spammy characters, like character repetitions and zalgo text + - Does that without breaking scripts like hebreu and thai (or just a tiny bit) + - It respects up to a single invisible character in the string, no matter which one + - It does not break emoji variants that end up with 0xFE0E/0xFE0F + - It does not break up any emoji, actually. THe only broken ones you will see are the ones that + are already broken (likely due to missing 0xFE0E/0xFE0F) + - It splits characters into valid Unicode CodePoints instead of UTF16 codepoints + - All names are trimmed to 36 Unicode characters AFTER ALL THE CLEANING, which is a very + generous limit and only affects about 0.1% of of all names (usually the spammy ones) + - If the name is empty after any cleaning step, it will return a hex representation of the + original name, prefixed with ∅, and that allows for it to be searched char-by-char + - Removes all prefix invisible characters, or Nonspacing_Mark (diacritics) at the beginning + - It should be impossible to have an invisible/empty name right now. + - The code is not particularly optimized for performance, but should still clean up 1000 names + in under a millisecond, which is more than enough for any use case. + + RESEARCH REFERENCES: + https://unicode.org/Public/15.1.0/ucd/emoji/emoji-variation-sequences.txt + https://unicode.org/Public/15.1.0/ucd/emoji/emoji-data.txt + https://unicode.org/Public/emoji/latest/emoji-test.txt + https://unicode.org/Public/emoji/latest/emoji-sequences.txt + https://v8.dev/features/regexp-v-flag + https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/unicodeSets + https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt +*/ + + +/** + * Set of invisible characters generated by https://github.com/hediet/vscode-unicode-data + * This also includes some characters that are removed prior to this step, namely: + * C0 controls, C1 controls, RTL marks, and RTL overrides + */ +const invisibleChars = new Set([ + 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x0020, 0x007F, 0x00A0, 0x00AD, 0x034F, 0x061C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180C, 0x180D, 0x180E, 0x1CBB, 0x1CBC, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200A, 0x200B, 0x200C, 0x200D, 0x200E, 0x200F, 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x202F, 0x205F, 0x2060, 0x2061, 0x2062, 0x2063, 0x2064, 0x2065, 0x2066, 0x2067, 0x2068, 0x2069, 0x206A, 0x206B, 0x206C, 0x206D, 0x206E, 0x206F, 0x2800, 0x3000, 0x3164, 0xFE00, 0xFE01, 0xFE02, 0xFE03, 0xFE04, 0xFE05, 0xFE06, 0xFE07, 0xFE08, 0xFE09, 0xFE0A, 0xFE0B, 0xFE0C, 0xFE0D, 0xFE0E, 0xFE0F, 0xFEFF, 0xFFA0, 0xFFF0, 0xFFF1, 0xFFF2, 0xFFF3, 0xFFF4, 0xFFF5, 0xFFF6, 0xFFF7, 0xFFF8, 0xFFFC, 0x133FC, 0x1D173, 0x1D174, 0x1D175, 0x1D176, 0x1D177, 0x1D178, 0x1D179, 0x1D17A, 0xE0000, 0xE0001, 0xE0002, 0xE0003, 0xE0004, 0xE0005, 0xE0006, 0xE0007, 0xE0008, 0xE0009, 0xE000A, 0xE000B, 0xE000C, 0xE000D, 0xE000E, 0xE000F, 0xE0010, 0xE0011, 0xE0012, 0xE0013, 0xE0014, 0xE0015, 0xE0016, 0xE0017, 0xE0018, 0xE0019, 0xE001A, 0xE001B, 0xE001C, 0xE001D, 0xE001E, 0xE001F, 0xE0020, 0xE0021, 0xE0022, 0xE0023, 0xE0024, 0xE0025, 0xE0026, 0xE0027, 0xE0028, 0xE0029, 0xE002A, 0xE002B, 0xE002C, 0xE002D, 0xE002E, 0xE002F, 0xE0030, 0xE0031, 0xE0032, 0xE0033, 0xE0034, 0xE0035, 0xE0036, 0xE0037, 0xE0038, 0xE0039, 0xE003A, 0xE003B, 0xE003C, 0xE003D, 0xE003E, 0xE003F, 0xE0040, 0xE0041, 0xE0042, 0xE0043, 0xE0044, 0xE0045, 0xE0046, 0xE0047, 0xE0048, 0xE0049, 0xE004A, 0xE004B, 0xE004C, 0xE004D, 0xE004E, 0xE004F, 0xE0050, 0xE0051, 0xE0052, 0xE0053, 0xE0054, 0xE0055, 0xE0056, 0xE0057, 0xE0058, 0xE0059, 0xE005A, 0xE005B, 0xE005C, 0xE005D, 0xE005E, 0xE005F, 0xE0060, 0xE0061, 0xE0062, 0xE0063, 0xE0064, 0xE0065, 0xE0066, 0xE0067, 0xE0068, 0xE0069, 0xE006A, 0xE006B, 0xE006C, 0xE006D, 0xE006E, 0xE006F, 0xE0070, 0xE0071, 0xE0072, 0xE0073, 0xE0074, 0xE0075, 0xE0076, 0xE0077, 0xE0078, 0xE0079, 0xE007A, 0xE007B, 0xE007C, 0xE007D, 0xE007E, 0xE007F, 0xE0100, 0xE0101, 0xE0102, 0xE0103, 0xE0104, 0xE0105, 0xE0106, 0xE0107, 0xE0108, 0xE0109, 0xE010A, 0xE010B, 0xE010C, 0xE010D, 0xE010E, 0xE010F, 0xE0110, 0xE0111, 0xE0112, 0xE0113, 0xE0114, 0xE0115, 0xE0116, 0xE0117, 0xE0118, 0xE0119, 0xE011A, 0xE011B, 0xE011C, 0xE011D, 0xE011E, 0xE011F, 0xE0120, 0xE0121, 0xE0122, 0xE0123, 0xE0124, 0xE0125, 0xE0126, 0xE0127, 0xE0128, 0xE0129, 0xE012A, 0xE012B, 0xE012C, 0xE012D, 0xE012E, 0xE012F, 0xE0130, 0xE0131, 0xE0132, 0xE0133, 0xE0134, 0xE0135, 0xE0136, 0xE0137, 0xE0138, 0xE0139, 0xE013A, 0xE013B, 0xE013C, 0xE013D, 0xE013E, 0xE013F, 0xE0140, 0xE0141, 0xE0142, 0xE0143, 0xE0144, 0xE0145, 0xE0146, 0xE0147, 0xE0148, 0xE0149, 0xE014A, 0xE014B, 0xE014C, 0xE014D, 0xE014E, 0xE014F, 0xE0150, 0xE0151, 0xE0152, 0xE0153, 0xE0154, 0xE0155, 0xE0156, 0xE0157, 0xE0158, 0xE0159, 0xE015A, 0xE015B, 0xE015C, 0xE015D, 0xE015E, 0xE015F, 0xE0160, 0xE0161, 0xE0162, 0xE0163, 0xE0164, 0xE0165, 0xE0166, 0xE0167, 0xE0168, 0xE0169, 0xE016A, 0xE016B, 0xE016C, 0xE016D, 0xE016E, 0xE016F, 0xE0170, 0xE0171, 0xE0172, 0xE0173, 0xE0174, 0xE0175, 0xE0176, 0xE0177, 0xE0178, 0xE0179, 0xE017A, 0xE017B, 0xE017C, 0xE017D, 0xE017E, 0xE017F, 0xE0180, 0xE0181, 0xE0182, 0xE0183, 0xE0184, 0xE0185, 0xE0186, 0xE0187, 0xE0188, 0xE0189, 0xE018A, 0xE018B, 0xE018C, 0xE018D, 0xE018E, 0xE018F, 0xE0190, 0xE0191, 0xE0192, 0xE0193, 0xE0194, 0xE0195, 0xE0196, 0xE0197, 0xE0198, 0xE0199, 0xE019A, 0xE019B, 0xE019C, 0xE019D, 0xE019E, 0xE019F, 0xE01A0, 0xE01A1, 0xE01A2, 0xE01A3, 0xE01A4, 0xE01A5, 0xE01A6, 0xE01A7, 0xE01A8, 0xE01A9, 0xE01AA, 0xE01AB, 0xE01AC, 0xE01AD, 0xE01AE, 0xE01AF, 0xE01B0, 0xE01B1, 0xE01B2, 0xE01B3, 0xE01B4, 0xE01B5, 0xE01B6, 0xE01B7, 0xE01B8, 0xE01B9, 0xE01BA, 0xE01BB, 0xE01BC, 0xE01BD, 0xE01BE, 0xE01BF, 0xE01C0, 0xE01C1, 0xE01C2, 0xE01C3, 0xE01C4, 0xE01C5, 0xE01C6, 0xE01C7, 0xE01C8, 0xE01C9, 0xE01CA, 0xE01CB, 0xE01CC, 0xE01CD, 0xE01CE, 0xE01CF, 0xE01D0, 0xE01D1, 0xE01D2, 0xE01D3, 0xE01D4, 0xE01D5, 0xE01D6, 0xE01D7, 0xE01D8, 0xE01D9, 0xE01DA, 0xE01DB, 0xE01DC, 0xE01DD, 0xE01DE, 0xE01DF, 0xE01E0, 0xE01E1, 0xE01E2, 0xE01E3, 0xE01E4, 0xE01E5, 0xE01E6, 0xE01E7, 0xE01E8, 0xE01E9, 0xE01EA, 0xE01EB, 0xE01EC, 0xE01ED, 0xE01EE, 0xE01EF, +]); + + +/** + * Converts a string to a hex array of its characters. + * Default limit of 35 characters to account for the ∅ + */ +const hexInvalidString = (str: string, limit = 35) => { + const hexArr = [...str].map(c => (c.codePointAt(0) ?? 0x00).toString(16)); + const outArr: string[] = []; + let charCountWithSpace = 0; + for (let i = 0; i < hexArr.length; i++) { + if (outArr.includes(hexArr[i])) continue; + if ((charCountWithSpace + hexArr[i].length) > limit) break + outArr.push(hexArr[i]); + charCountWithSpace += hexArr[i].length + 1; //account for space + } + return outArr; +} + + +/** + * Checks if a character is an emoji. + * TODO: this is not perfect, use @mathiasbynens/emoji-regex + * or await for NodeJS 20 and use the native regex /^\p{RGI_Emoji}$/v + * ref: https://v8.dev/features/regexp-v-flag +* NOTE: also remove the library unicode-emoji-json, being used by the discord bot + */ +const isEmoji = (char: string) => { + const codePoint = char.codePointAt(0)!; + return ( + (codePoint >= 0x1F300 && codePoint <= 0x1F5FF) || // Miscellaneous Symbols and Pictographs + (codePoint >= 0x1F600 && codePoint <= 0x1F64F) || // Emoticons + (codePoint >= 0x1F680 && codePoint <= 0x1F6FF) || // Transport and Map Symbols + (codePoint >= 0x1F700 && codePoint <= 0x1F77F) || // Alchemical Symbols + (codePoint >= 0x1F780 && codePoint <= 0x1F7FF) || // Geometric Shapes Extended + (codePoint >= 0x1F800 && codePoint <= 0x1F8FF) || // Supplemental Arrows-C + (codePoint >= 0x1F900 && codePoint <= 0x1F9FF) || // Supplemental Symbols and Pictographs + (codePoint >= 0x1FA00 && codePoint <= 0x1FA6F) || // Chess Symbols + (codePoint >= 0x1FA70 && codePoint <= 0x1FAFF) || // Symbols and Pictographs Extended-A + (codePoint >= 0x2600 && codePoint <= 0x26FF) || // Miscellaneous Symbols + (codePoint >= 0x2700 && codePoint <= 0x27BF) // Dingbats + ); +} + + +/** + * Objectives: + * - remove leading invisible characters + * - remove over 3 repeated chars + * - remove any repeated invisible chars + * - only allow a single trailing codepoint if it's 0xFE0E/0xFE0F and the previous char is an emoji + * NOTE: need to use [...str] to split the string into unicode codepoints, otherwise they become UTF8 codepoints + */ +const cleanTrimCodePoints = (str: string, lenLimit = 36) => { + let out = ''; + let lastChar = ''; + let lastCharCount = 0; + let pendingInvisibleChar = ''; + let totalCodePoints = 0; + const chars = [...str]; //do not use .split('') + for (let i = 0; i < chars.length; i++) { + //ensure size limit + //NOTE: only about 0.1% of names are longer than 36 chars after cleaning + if (totalCodePoints >= lenLimit) break; + + //remove leading invisible characters + const currChar = chars[i]; + const currCharCodePoint = currChar.codePointAt(0)!; + const isCharInvible = invisibleChars.has(currCharCodePoint); + if (!out.length && isCharInvible) continue; + + //remove repeated chars + const isCharRepeated = currChar === lastChar; + if (isCharRepeated && lastCharCount >= (isCharInvible ? 1 : 3)) { + continue; + } + + //deal with trailing invisible chars + let isCurrentPending = false; + if (isCharInvible) { + //if it's 0xFE0E/0xFE0F and the previous char is an emoji, don't hold it back + const isVariationSelector = (currCharCodePoint === 0xFE0E || currCharCodePoint === 0xFE0F) && isEmoji(lastChar); + if (!isVariationSelector) { + //keep only the first pending + if (!pendingInvisibleChar) { + pendingInvisibleChar = currChar; + isCurrentPending = true; + } + continue; + } + } + if (!isCurrentPending && pendingInvisibleChar) { + if (totalCodePoints >= lenLimit - 1) break; + out += pendingInvisibleChar; + totalCodePoints++; + pendingInvisibleChar = ''; + } + + //append char + lastChar = currChar; + lastCharCount = isCharRepeated ? lastCharCount + 1 : 1; + out += currChar; + totalCodePoints++; + } + + return out; +} + +// Types +type TransformStep = (input: string) => string; +type CleanPlayerNameResult = { + displayName: string; + displayNameEmpty: boolean; + pureName: string; + pureNameEmpty: boolean; +}; + +// Constants +const EMPTY_SET = String.fromCodePoint(0x2205, 0x200B); // ∅ + + +/** + * Cleans up a player name and returns one version to be displayed, and one pure version to be used for fuzzy matching. + * If the name does not contain any valid characters, it will return a searchable hex representation of the name. +*/ +export default (originalName: string): CleanPlayerNameResult => { + if (!originalName) { + return { + displayName: EMPTY_SET + 'EMPTY NAME', + displayNameEmpty: true, + pureName: 'emptyname', + pureNameEmpty: true, + }; + } + + //Order of operations: + // 1. operations that remove the match completely + // 2. operations that reduce the name + // 3. finalization + const displaySteps: TransformStep[] = [ + //should have been truncated by lua, but double checking due to the risk of DoS + (x) => x.substring(0, 128), + + // https://docs.fivem.net/docs/game-references/text-formatting/ + // NOTE: never seen these being used: nrt|EX_R\*|BLIP_\S+|ACCEPT|CANCEL|PAD_\S+|INPUT_\S+|INPUTGROUP_\S+ + (x) => x.replace(/~(HUD_\S+|HC_\S+|[a-z]|[a1]_\d+|bold|italic|ws|wanted_star|nrt|EX_R\*|BLIP_\S+|ACCEPT|CANCEL|PAD_\S+|INPUT_\S+|INPUTGROUP_\S+)~/ig, ''), + + // console & chat color codes + (x) => x.replace(/\^\d/ig, ''), + + // C0 controls + delete + C1 controls + // \u200E RTL mark + // \u2067 RTL override + (x) => x.replace(/[\p{Control}\u200E\u2067]/ug, ''), + + // \uA980-\uA9DF javanese (oversized) + // \u239B-\u23AD Miscellaneous Technical — Bracket pieces items (oversized) + // \u534D\u5350 swastika + // \u1000-\u109F Myanmar + // \u0B80-\u0BFF Tamil + // \uFDFD\u2E3B oversized characters + (x) => x.replace(/[\uA980-\uA9DF\u239B-\u23AD\u534D\u5350\u1000-\u109F\u0B80-\u0BFF\uFDFD\u2E3B]/ug, ''), + + // UTF-16 ranges + // U+12000 - U+123FF Cuneiform + // U+12400 - U+1247F Cuneiform Numbers and Punctuation + // U+12480 - U+1254F Early Dynastic Cuneiform + // U+1D000 - U+1D0FF Byzantine Musical Symbols + (x) => x.replace(/[\u{12000}-\u{123FF}\u{12400}-\u{1247F}\u{12480}-\u{1254F}\u{1D000}-\u{1D0FF}]/gu, ''), + + //2+ consecutive marks (zalgo text) + (x) => x.replace(/(^\p{Nonspacing_Mark}+)|(\p{Nonspacing_Mark}{3,})/ug, (match, leading, repeating) => { + if (leading) return ''; // Remove leading non-spacing marks + if (repeating) return repeating.substring(0, 2); // Truncate sequences of three or more non-spacing marks to two + }), + + // remove leading invisible characters + // remove over 3 repeated chars, or any repeated invisible chars + // only allow a single trailing codepoint if it's 0xFE0E/0xFE0F and the previous char is an emoji + // trimming to 36 chars + cleanTrimCodePoints, + ]; + + let prevDisplayName = originalName; + for (const step of displaySteps) { + const result = step(prevDisplayName); + if (!result.length) { + const prevHex = hexInvalidString(prevDisplayName); + return { + displayName: `${EMPTY_SET}${prevHex.join(' ').toUpperCase()}`, + displayNameEmpty: true, + pureName: prevHex.join(''), + pureNameEmpty: true, + }; + } else { + prevDisplayName = result; + } + } + const displayName = prevDisplayName; + + const pureSteps: TransformStep[] = [ + //convert characters to their canonical form for consistent comparison + (x) => x.normalize('NFKC'), + + //remove non-letter, non-number + (x) => x.replace(/[^\p{Letter}\p{Number}]/gu, ''), + + //lowercase + (x) => x.toLocaleLowerCase(), + ]; + + let prevPureName = prevDisplayName; + for (const step of pureSteps) { + const result = step(prevPureName); + if (!result.length) { + const prevHex = hexInvalidString(prevPureName); + return { + displayName, + displayNameEmpty: false, + pureName: prevHex.join(''), + pureNameEmpty: true, + }; + } else { + prevPureName = result; + } + } + const pureName = prevPureName; + + return { + displayName, + displayNameEmpty: false, + pureName, + pureNameEmpty: false, + }; +}; diff --git a/shared/consts.ts b/shared/consts.ts new file mode 100644 index 0000000..b09b015 --- /dev/null +++ b/shared/consts.ts @@ -0,0 +1,45 @@ +//All uppercase and [0,I,O] removed +const actionIdAlphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZ'; + +const regexDiscordSnowflake = /^\d{17,20}$/; + +export default { + //Identifier stuff + regexValidHwidToken: /^[0-9A-Fa-f]{1,2}:[0-9A-Fa-f]{64}$/, + validIdentifiers: { + // https://github.com/discordjs/discord.js/pull/9144 + // validated in txtracker dataset + discord: /^discord:\d{17,20}$/, + fivem: /^fivem:\d{1,8}$/, + license: /^license:[0-9A-Fa-f]{40}$/, + license2: /^license2:[0-9A-Fa-f]{40}$/, + live: /^live:\d{14,20}$/, + steam: /^steam:1100001[0-9A-Fa-f]{8}$/, + xbl: /^xbl:\d{14,20}$/, + }, + validIdentifierParts: { + discord: regexDiscordSnowflake, + fivem: /^\d{1,8}$/, + license: /^[0-9A-Fa-f]{40}$/, + license2: /^[0-9A-Fa-f]{40}$/, + live: /^\d{14,20}$/, + steam: /^1100001[0-9A-Fa-f]{8}$/, + xbl: /^\d{14,20}$/, + }, + + // Database stuff + adminPasswordMinLength: 6, + adminPasswordMaxLength: 128, + regexValidFivemUsername: /^\w[\w.-]{1,18}\w$/, //also cant have repeated non-alphanum chars + regexActionID: new RegExp(`^[${actionIdAlphabet}]{4}-[${actionIdAlphabet}]{4}$`), + regexWhitelistReqID: new RegExp(`R[${actionIdAlphabet}]{4}`), + + //Other stuff + regexDiscordSnowflake, + regexSvLicenseOld: /^\w{32}$/, + regexSvLicenseNew: /^cfxk_\w{1,60}_\w{1,20}$/, + regexValidIP: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + actionIdAlphabet, + nuiWebpipePath: 'https://monitor/WebPipe/', + regexCustomThemeName: /^[a-z0-9]+(-[a-z0-9]+)*$/ +} as const; diff --git a/shared/enums.ts b/shared/enums.ts new file mode 100644 index 0000000..b40ec8c --- /dev/null +++ b/shared/enums.ts @@ -0,0 +1,19 @@ +export enum TxConfigState { + Unkown = 'unknown', + Setup = 'setup', + Deployer = 'deployer', + Ready = 'ready', +} + +export enum FxMonitorHealth { + OFFLINE = 'OFFLINE', + ONLINE = 'ONLINE', + PARTIAL = 'PARTIAL', +} + +export enum DiscordBotStatus { + Disabled, + Starting, + Ready, + Error, +} diff --git a/shared/genericApiTypes.ts b/shared/genericApiTypes.ts new file mode 100644 index 0000000..0449520 --- /dev/null +++ b/shared/genericApiTypes.ts @@ -0,0 +1,19 @@ +export type ApiAuthErrorResp = { + logout: true; + reason: string; +} +export type GenericApiSuccessResp = { + success: true; +} +export type GenericApiErrorResp = { + error: string; +} +export type GenericApiResp = ApiAuthErrorResp | GenericApiSuccessResp | GenericApiErrorResp; +export type GenericApiOkResp = GenericApiSuccessResp | GenericApiErrorResp; + +export type ApiToastResp = { + type: 'default' | 'info' | 'success' | 'warning' | 'error', + title?: string, + msg: string, + md?: boolean, +} diff --git a/shared/historyApiTypes.ts b/shared/historyApiTypes.ts new file mode 100644 index 0000000..8dfcf91 --- /dev/null +++ b/shared/historyApiTypes.ts @@ -0,0 +1,61 @@ +import { DatabaseActionType } from "@modules/Database/databaseTypes"; +import { GenericApiErrorResp } from "./genericApiTypes"; + +export type HistoryStatsResp = { + totalWarns: number; + warnsLast7d: number; + totalBans: number; + bansLast7d: number; + groupedByAdmins: { + name: string; + actions: number; + }[]; +} | GenericApiErrorResp; + + +export type HistoryTableSearchType = { + value: string; + type: string; +} + +export type HistoryTableSortingType = { + key: 'timestamp'; + desc: boolean; +}; + +export type HistoryTableReqType = { + search: HistoryTableSearchType; + filterbyType: string | undefined; + filterbyAdmin: string | undefined; + sorting: HistoryTableSortingType; + //NOTE: the query needs to be offset.param inclusive, but offset.actionId exclusive + // therefore, optimistically always limit to x + 1 + offset?: { + param: number; + actionId: string; + } +}; + +export type HistoryTableActionType = { + id: string; + type: "ban" | "warn"; + playerName: string | false; + author: string; + reason: string; + timestamp: number; + isRevoked: boolean; + banExpiration?: 'expired' | 'active' | 'permanent'; + warnAcked?: boolean; +} + +export type HistoryTableSearchResp = { + history: HistoryTableActionType[]; + hasReachedEnd: boolean; +} | GenericApiErrorResp; + + +export type HistoryActionModalSuccess = { + serverTime: number; //required to calculate if bans have expired on frontend + action: DatabaseActionType; +} +export type HistoryActionModalResp = HistoryActionModalSuccess | GenericApiErrorResp; diff --git a/shared/localeMap.ts b/shared/localeMap.ts new file mode 100644 index 0000000..09fd4a6 --- /dev/null +++ b/shared/localeMap.ts @@ -0,0 +1,83 @@ +//NOTE: Don't modify the structure of this file without updating the locale:check script. + +//Statically requiring languages because of the builders +import lang_ar from "@locale/ar.json"; +import lang_bg from "@locale/bg.json"; +import lang_bs from "@locale/bs.json"; +import lang_cs from "@locale/cs.json"; +import lang_da from "@locale/da.json"; +import lang_de from "@locale/de.json"; +import lang_el from "@locale/el.json"; +import lang_en from "@locale/en.json"; +import lang_es from "@locale/es.json"; +import lang_et from "@locale/et.json"; +import lang_fa from "@locale/fa.json"; +import lang_fi from "@locale/fi.json"; +import lang_fr from "@locale/fr.json"; +import lang_hr from "@locale/hr.json"; +import lang_hu from "@locale/hu.json"; +import lang_id from "@locale/id.json"; +import lang_it from "@locale/it.json"; +import lang_ja from "@locale/ja.json"; +import lang_lt from "@locale/lt.json"; +import lang_lv from "@locale/lv.json"; +import lang_mn from "@locale/mn.json"; +import lang_ne from "@locale/ne.json"; +import lang_nl from "@locale/nl.json"; +import lang_no from "@locale/no.json"; +import lang_pl from "@locale/pl.json"; +import lang_pt from "@locale/pt.json"; +import lang_ro from "@locale/ro.json"; +import lang_ru from "@locale/ru.json"; +import lang_sl from "@locale/sl.json"; +import lang_sv from "@locale/sv.json"; +import lang_th from "@locale/th.json"; +import lang_tr from "@locale/tr.json"; +import lang_uk from "@locale/uk.json"; +import lang_vi from "@locale/vi.json"; +import lang_zh from "@locale/zh.json"; + +export type LocaleType = typeof lang_en; +export type LocaleMapType = { + [key: string]: LocaleType; +} + +const localeMap: LocaleMapType = { + ar: lang_ar, + bg: lang_bg, + bs: lang_bs, + cs: lang_cs, + da: lang_da, + de: lang_de, + el: lang_el, + en: lang_en, + es: lang_es, + et: lang_et, + fa: lang_fa, + fi: lang_fi, + fr: lang_fr, + hr: lang_hr, + hu: lang_hu, + id: lang_id, + it: lang_it, + ja: lang_ja, + lt: lang_lt, + lv: lang_lv, + mn: lang_mn, + ne: lang_ne, + nl: lang_nl, + no: lang_no, + pl: lang_pl, + pt: lang_pt, + ro: lang_ro, + ru: lang_ru, + sl: lang_sl, + sv: lang_sv, + th: lang_th, + tr: lang_tr, + uk: lang_uk, + vi: lang_vi, + zh: lang_zh, +}; + +export default localeMap; diff --git a/shared/otherTypes.ts b/shared/otherTypes.ts new file mode 100644 index 0000000..96ffcce --- /dev/null +++ b/shared/otherTypes.ts @@ -0,0 +1,71 @@ +import type { ReactAuthDataType } from "./authApiTypes"; + +//Config stuff +export type { TxConfigs, PartialTxConfigs } from "@core/modules/ConfigStore/schema"; +export type { ConfigChangelogEntry } from "@core/modules/ConfigStore/changelog"; +export type { GetConfigsResp } from "@core/routes/settings/getConfigs"; +export type { SaveConfigsReq, SaveConfigsResp } from "@core/routes/settings/saveConfigs"; +export type { BanTemplatesDataType, BanDurationType } from "@core/modules/ConfigStore/schema/banlist"; +export type { ResetServerDataPathResp } from "@core/routes/settings/resetServerDataPath"; +export type { GetBanTemplatesSuccessResp } from "@core/routes/banTemplates/getBanTemplates"; +export type { SaveBanTemplatesResp, SaveBanTemplatesReq } from "@core/routes/banTemplates/saveBanTemplates"; + +//Stats stuff +export type { SvRtLogFilteredType, SvRtPerfCountsThreadType } from "@core/modules/Metrics/svRuntime/perfSchemas"; +export type { SvRtPerfThreadNamesType } from "@core/modules/Metrics/svRuntime/config"; +export type { PerfChartApiResp, PerfChartApiSuccessResp } from "@core/routes/perfChart"; +export type { PlayerDropsApiResp, PlayerDropsApiSuccessResp, PlayerDropsDetailedWindow, PlayerDropsSummaryHour } from "@core/routes/playerDrops"; +export type { PDLChangeEventType } from '@core/modules/Metrics/playerDrop/playerDropSchemas'; + +//Other stuff +export type { ApiAddLegacyBanReqSchema, ApiRevokeActionReqSchema } from "@core/routes/history/actions"; + +export type UpdateDataType = { + version: string; + isImportant: boolean; +} | undefined; + +export type ThemeType = { + name: string; + isDark: boolean; + style: { [key: string]: string }; +}; + +export type AdsDataType = { + login: { img: string, url: string } | null; + main: { img: string, url: string } | null; +}; + +export type InjectedTxConsts = { + //Env + fxsVersion: string; + fxsOutdated: UpdateDataType, + txaVersion: string; + txaOutdated: UpdateDataType, + + serverTimezone: string; + isWindows: boolean; + isWebInterface: boolean; + showAdvanced: boolean; + hasMasterAccount: boolean; + defaultTheme: string; + customThemes: Omit[]; + adsData: AdsDataType; + providerLogo: string | undefined; + providerName: string | undefined; + hostConfigSource: string; + server: { + name: string; + game: string | undefined; + icon: string | undefined; + }; + + //Auth + preAuth: ReactAuthDataType | false; +} + + +//Maybe extract to some shared folder +export type PlayerIdsObjectType = { + [key: string]: string | null; +}; diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..d9b3671 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,16 @@ +{ + "name": "txadmin-shared", + "version": "1.0.0", + "description": "The shared package contains stuff used in more than one package, no build step required.", + "main": "index.js", + "scripts": { + "test": "echo \"No tests for this workspace. Skipping...\"", + "typecheck": "tsc -p tsconfig.json --noEmit", + "license:report": "npx license-report > ../.reports/license/shared.html" + }, + "keywords": [], + "author": "André Tabarra", + "license": "MIT", + "dependencies": {}, + "devDependencies": {} +} diff --git a/shared/playerApiTypes.ts b/shared/playerApiTypes.ts new file mode 100644 index 0000000..c38f8ab --- /dev/null +++ b/shared/playerApiTypes.ts @@ -0,0 +1,104 @@ +import { GenericApiErrorResp } from "./genericApiTypes"; +import { BanTemplatesDataType } from "./otherTypes"; + +//Already compliant with new db specs +export type PlayerHistoryItem = { + id: string; + type: "ban" | "warn"; + author: string; + reason: string; + ts: number; + exp?: number; + revokedBy?: string; + revokedAt?: number; +} + +export type PlayerModalPlayerData = { + //common + displayName: string; + pureName: string; + isRegistered: boolean; + isConnected: boolean; + ids: string[]; //can be empty + hwids: string[]; //can be empty + license: string | null; + actionHistory: PlayerHistoryItem[]; //can be empty + + //only if server player + netid?: number; + sessionTime?: number; //calcular baseado no tsConnected + + //only if registered + tsJoined?: number; + tsWhitelisted?: number; + playTime?: number; + notesLog?: string; + notes?: string; + oldIds?: string[]; //will also include the current ones + oldHwids?: string[]; //will also include the current ones + tsLastConnection?: number; //only show if offline +} + +export type PlayerModalSuccess = { + serverTime: number; //required to calculate if bans have expired on frontend + banTemplates: BanTemplatesDataType[]; //TODO: move this to websocket push + player: PlayerModalPlayerData; +} +export type PlayerModalResp = PlayerModalSuccess | GenericApiErrorResp; + + +/** + * Used in the players page + */ +export type PlayersStatsResp = { + total: number; + playedLast24h: number; + joinedLast24h: number; + joinedLast7d: number; +} | GenericApiErrorResp; + + +export type PlayersTableSearchType = { + value: string; + type: string; +} + +export type PlayersTableFiltersType = string[]; + +export type PlayersTableSortingType = { + key: 'playTime' | 'tsJoined' | 'tsLastConnection'; + desc: boolean; +}; + +export type PlayersTableReqType = { + search: PlayersTableSearchType; + filters: PlayersTableFiltersType; + sorting: PlayersTableSortingType; + //NOTE: the query needs to be offset.param inclusive, but ignore offset.license + // therefore, optimistically always limit to x + 1 + offset?: { + param: number; + license: string; + } +}; + +export type PlayersTablePlayerType = { + license: string; + displayName: string; + playTime: number; + tsJoined: number; + tsLastConnection: number; + notes?: string; + + isAdmin: boolean; + isOnline: boolean; + isWhitelisted: boolean; + // isBanned: boolean; + // warnCount: number; + // banCount: number; +} + +export type PlayersTableSearchResp = { + players: PlayersTablePlayerType[]; + hasReachedEnd: boolean; +} | GenericApiErrorResp; diff --git a/shared/socketioTypes.ts b/shared/socketioTypes.ts new file mode 100644 index 0000000..c007da5 --- /dev/null +++ b/shared/socketioTypes.ts @@ -0,0 +1,119 @@ +import { SvRtPerfThreadNamesType } from "@core/modules/Metrics/svRuntime/config"; +import { SvRtNodeMemoryType, SvRtPerfBoundariesType } from "@core/modules/Metrics/svRuntime/perfSchemas"; +import type { ReactAuthDataType } from "./authApiTypes"; +import type { UpdateDataType } from "./otherTypes"; +import { DiscordBotStatus, TxConfigState, type FxMonitorHealth } from "./enums"; + +/** + * Status channel + */ +export type GlobalStatusType = { + configState: TxConfigState; + discord: DiscordBotStatus; + runner: { + isIdle: boolean; + isChildAlive: boolean; + }; + server: { + name: string; + uptime: number; + health: FxMonitorHealth; + healthReason: string; + whitelist: 'disabled' | 'adminOnly' | 'approvedLicense' | 'discordMember' | 'discordRoles'; + }; + scheduler: { + nextRelativeMs: number; + nextSkip: boolean; + nextIsTemp: boolean; + } | { + nextRelativeMs: false; + nextSkip: false; + nextIsTemp: false; + }; +} + + +/** + * Status channel + */ +export type DashboardSvRuntimeDataType = { + fxsMemory?: number; + nodeMemory?: SvRtNodeMemoryType; + perfBoundaries?: SvRtPerfBoundariesType; + perfBucketCounts?: { + [key in SvRtPerfThreadNamesType]: number[]; + }; +} +export type DashboardPleyerDropDataType = { + summaryLast6h: [reasonCategory: string, count: number][]; +}; +export type DashboardDataEventType = { + svRuntime: DashboardSvRuntimeDataType; + playerDrop: DashboardPleyerDropDataType; + // joinLeaveTally30m: { + // joined: number; + // left: number; + // }; +} + + +/** + * Playerlist channel + * TODO: apply those types to the playerlistManager + */ +export type FullPlayerlistEventType = { + mutex: string | null, + type: 'fullPlayerlist', + playerlist: PlayerlistPlayerType[], +} + +export type PlayerlistPlayerType = { + netid: number, + displayName: string, + pureName: string, + ids: string[], + license: string | null, +} + +export type PlayerDroppedEventType = { + mutex: string, + type: 'playerDropped', + netid: number, + reasonCategory?: string, //missing in case of server shutdown +} + +export type PlayerJoiningEventType = { + mutex: string, + type: 'playerJoining', +} & PlayerlistPlayerType; + + +export type PlayerlistEventType = FullPlayerlistEventType | PlayerDroppedEventType | PlayerJoiningEventType; + + +/** + * Standalone events (no room) + */ +export type UpdateAvailableEventType = { + fxserver?: UpdateDataType; + txadmin?: UpdateDataType; +} + + +/** + * Listen Events Map + */ +export type ListenEventsMap = { + error: (reason?: string) => void; + logout: (reason?: string) => void; + refreshToUpdate: () => void; + txAdminShuttingDown: () => void; + status: (status: GlobalStatusType) => void; + playerlist: (playerlistData: PlayerlistEventType[]) => void; + updateAuthData: (authData: ReactAuthDataType) => void; + consoleData: (data: string) => void; + dashboard: (data: DashboardDataEventType) => void; + + //Standalone events + updateAvailable: (event: UpdateAvailableEventType) => void +}; diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..35f4611 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + /* Truncated compiler options to list only relevant options */ + "target": "es2021", + "declaration": true, + "declarationMap": true, + "composite": true, + + "moduleResolution": "bundler", + "resolveJsonModule": true, + "esModuleInterop": true, + "outDir": "../.tsc/shared", + + "rootDir": "../", + "baseUrl": "./", + "paths": { + "@core/*": ["../core/*"], + "@locale/*": ["../locale/*"], + }, + }, + "include": [ + "**/*", + "../locale/*.json" + ], + "references": [ + { "path": "../core" } + ] +} diff --git a/shared/txDevEnv.ts b/shared/txDevEnv.ts new file mode 100644 index 0000000..0bce5e7 --- /dev/null +++ b/shared/txDevEnv.ts @@ -0,0 +1,94 @@ +import type { DeepReadonly } from 'utility-types'; + +/** + * The type of the TXDEV_ env variables + */ +export type TxDevEnvType = { + //has default + //set in scripts/build/dev.ts + ENABLED: boolean, + + //required in core/webserver, core/getReactIndex.ts + //set in scripts/build/dev.ts + SRC_PATH?: string, + + //has default + //required in core/getReactIndex.ts, panel/vite.config.ts + VITE_URL: string, + + //must be in .env + //required in scripts/dev.ts and nui/vite.config.ts + FXSERVER_PATH?: string, + + //Can be used even without ENABLED + VERBOSE: boolean, //has default + CFXKEY?: string, + STEAMKEY?: string, + EXT_STATS_HOST?: string, + LAUNCH_ARGS?: string[], +}; +type EnvConfigsType = { + default?: T | T[], + parser?: (val: string) => T | T[] | undefined, +}; +type EnvConfigs = { + [K in keyof TxDevEnvType]: EnvConfigsType +}; + + +/** + * Configuration for the TXDEV_ env variables + */ +const envConfigs = { + SRC_PATH: {}, + FXSERVER_PATH: {}, + VITE_URL: { + default: 'http://localhost:40122', + }, + + ENABLED: { + default: false, + parser: (val) => Boolean(val) + }, + VERBOSE: { + default: false, + parser: (val) => Boolean(val) + }, + CFXKEY: {}, + STEAMKEY: {}, + EXT_STATS_HOST: {}, + LAUNCH_ARGS: { + parser: (val) => { + const filtered = val.split(/\s+/).filter(Boolean); + return filtered.length ? filtered : undefined; + } + }, +} satisfies EnvConfigs; + + +/** + * Parses the TXDEV_ env variables + */ +export const parseTxDevEnv = () => { + //@ts-ignore will be filled below + const txDevEnv: TxDevEnvType = {}; + for (const key of Object.keys(envConfigs)) { + const keyConfig = envConfigs[key as keyof TxDevEnvType]; + const value = process.env[`TXDEV_` + key]; + if (value === undefined) { + if ('default' in keyConfig) { + txDevEnv[key] = keyConfig.default; + } + } else { + if ('parser' in keyConfig) { + const parsed = keyConfig.parser(value); + if (parsed !== undefined) { + txDevEnv[key] = parsed; + } + } else { + txDevEnv[key] = value; + } + } + } + return txDevEnv; +} diff --git a/web/main/adminManager.ejs b/web/main/adminManager.ejs new file mode 100644 index 0000000..287aae0 --- /dev/null +++ b/web/main/adminManager.ejs @@ -0,0 +1,315 @@ +<%- await include('parts/header.ejs', locals) %> + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + +
    +
    + All Admins (<%= admins.length %>) + +
    +
    +
    + + + + + + + + + + + + <% for (const admin of admins) { %> + + + + + + + <% } %> + +
    UsernameAuthPermissionsActions
    <%= admin.name %> + + + Password Authentication + + + + + + Cfx.re Authentication + + + + + + Discord Authentication + + + + <%= admin.perms %> + <% if (admin.isSelf) { %> + + <% } else { %> + <% if (admin.disableEdit) { %> + + <% } else { %> + + <% } %> +   + <% if (admin.disableDelete) { %> + + <% } else { %> + + <% } %> + <% } %> +
    +
    +
    +
    + +
    +
    + + +<%- await include('parts/footer.ejs', locals) %> + + + + + + + + + + + diff --git a/web/main/advanced.ejs b/web/main/advanced.ejs new file mode 100644 index 0000000..f99e3b9 --- /dev/null +++ b/web/main/advanced.ejs @@ -0,0 +1,155 @@ +<%- await include('parts/header.ejs', locals) %> + +
    +
    + +
    +
    + + +
    + +
    +
    +
    Random buttons, knobs and data:
    +
    + + + With verbosity enabled, you will see more detailed information on the terminal.
    + Good to help getting information on errors.
    +
    + <% if (verbosityEnabled) { %> + + <% } else { %> + + <% } %> +
    + + + + This will execute the profiler in the Monitor for 5 seconds.
    + Required the Server to be started for showing the profiler URL.
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    What will happen when its pressed?!
    +
    +
    + + +
    + + Not A Meme + +
    + +<%- await include('parts/footer.ejs', locals) %> + + diff --git a/web/main/cfgEditor.ejs b/web/main/cfgEditor.ejs new file mode 100644 index 0000000..0314abe --- /dev/null +++ b/web/main/cfgEditor.ejs @@ -0,0 +1,131 @@ +<%- await include('parts/header.ejs', locals) %> + + + + + + + + +<% if (!isWebInterface) { %> + +<% } %> + + +
    +
    + +
    +
    +
    +
    + +
    +
    + + +<%- await include('parts/footer.ejs', locals) %> + + + + + + + + + + + + diff --git a/web/main/diagnostics.ejs b/web/main/diagnostics.ejs new file mode 100644 index 0000000..32cf597 --- /dev/null +++ b/web/main/diagnostics.ejs @@ -0,0 +1,314 @@ +<%- await include('parts/header.ejs', locals) %> + + +
    +
    + +
    +
    Environment:
    +
    + <% if (host.error) { %> + <%- host.error %> + <% } else { %> + Node: <%= host.static.nodeVersion %>
    + OS: <%= host.static.osDistro %>
    + Username: <%= host.static.username %>
    + + CPU Model: <%- host.static.cpu.manufacturer %> <%- host.static.cpu.brand %>
    + CPU Stats: + <%- host.static.cpu.physicalCores %>c/<%- host.static.cpu.cores %>t - + <%- host.static.cpu.speedMin %> GHz + <%- host.static.cpu.clockWarning %>
    + <% if (host.dynamic) { %> + CPU Usage: <%= host.dynamic.cpuUsage %>%
    + Memory: + <%= host.dynamic.memory.usage %>% + (<%= host.dynamic.memory.used.toFixed(2) %>/<%= host.dynamic.memory.total.toFixed(2) %>) + <% } else { %> + Dynamic usage data not available. + <% } %> + <% } %> +
    +
    + + +
    +
    txAdmin Runtime:
    +
    + Uptime: <%= txadmin.uptime %>
    + Versions: + v<%= txAdminVersion %> / + b<%= fxServerVersion %>
    + Database File Size: <%= txadmin.databaseFileSize %>
    + Env:
    + ├─ FXServer: <%= txadmin.txEnv.fxsPath %>
    + ├─ Profile: <%= txadmin.txEnv.profilePath %>
    + ├─ Defaults: <%= txadmin.txHostConfig.defaults.length > 0 ? txadmin.txHostConfig.defaults.join(', ') : '--' %>
    + ├─ Interface: <%= txadmin.txHostConfig.netInterface ?? '--' %>
    + └─ Provider: <%= txadmin.txHostConfig.providerName ?? '--' %>
    + Monitor:
    + ├─ HB Fails: + HTTP <%= txadmin.monitor.hbFails.http %> / + FD3 <%= txadmin.monitor.hbFails.fd3 %>
    + └─ Restarts: + BT <%= txadmin.monitor.restarts.bootTimeout %> / + CL <%= txadmin.monitor.restarts.close %> / + HB <%= txadmin.monitor.restarts.heartBeat %> / + HC <%= txadmin.monitor.restarts.healthCheck %> / + BO <%= txadmin.monitor.restarts.both %>
    + Performance Times:
    + ├─ BanCheck: <%= txadmin.performance.banCheck %>
    + ├─ WhitelistCheck: <%= txadmin.performance.whitelistCheck %>
    + ├─ PlayersTable: <%= txadmin.performance.playersTableSearch %>
    + ├─ HistoryTable: <%= txadmin.performance.historyTableSearch %>
    + ├─ DatabaseSave: <%= txadmin.performance.databaseSave %>
    + └─ PerfCollection: <%= txadmin.performance.perfCollection %>
    + Memory:
    + ├─ Heap: <%= txadmin.memoryUsage.heap_used %> / <%= txadmin.memoryUsage.heap_limit %> (<%= txadmin.memoryUsage.heap_pct %>%)
    + ├─ Physical: <%= txadmin.memoryUsage.physical %>
    + └─ Peak. Alloc.: <%= txadmin.memoryUsage.peak_malloced %>
    + Logger Status:
    + ├─ Storage Size: <%= txadmin.logger.storageSize %>
    + ├─ Admin: <%= txadmin.logger.statusAdmin %>
    + ├─ FXServer: <%= txadmin.logger.statusFXServer %>
    + └─ Server: <%= txadmin.logger.statusServer %>
    +
    +
    + + +
    + <%- message %> +
    + +
    + + + +
    + +
    +
    Diagnostics Report:
    +
    +
    +
    + To receive txAdmin Support, it is recommended that you send the diagnostics data directly to the Support Team. +
    +
    + +
    +
    +
    +
    + + +
    +
    FXServer /info.json:
    +
    + <% if (fxserver.versionMismatch) { %> + + <% } %> + <% if (fxserver.error !== false) { %> + <%- fxserver.error %> + <% } else { %> + Status: <%= fxserver.status %>
    + Version: <%= fxserver.version %>
    + Resources: <%= fxserver.resources %>
    + OneSync: <%= fxserver.onesync %>
    + Max Clients: <%= fxserver.maxClients %>
    + txAdmin Version: <%= fxserver.txAdminVersion %>
    + <% } %> +
    +
    + + +
    +
    Processes:
    +
    + <% if (!proccesses.length) { %> + Failed to retrieve processed data.
    + Check the terminal for more information (if verbosity is enabled) + <% } else { %> + <% for (const process of proccesses) { %> + Process: (<%= process.pid %>) <%= process.name %>
    + Parent: <%= process.ppid %>
    + Memory: <%= process.memory.toFixed(2) %>MB
    + CPU: <%= process.cpu.toFixed(2) %>%
    +
    + <% } %> + <% } %> +
    +
    + +
    + +
    + +<%- await include('parts/footer.ejs', locals) %> + + + + + + diff --git a/web/main/insights.ejs b/web/main/insights.ejs new file mode 100644 index 0000000..f4beabd --- /dev/null +++ b/web/main/insights.ejs @@ -0,0 +1,354 @@ +<%- await include('parts/header.ejs', locals) %> + + + + + + + + + +
    +
    + +
    +
    + + + +<%- await include('parts/footer.ejs', locals) %> + + diff --git a/web/main/masterActions.ejs b/web/main/masterActions.ejs new file mode 100644 index 0000000..40394a0 --- /dev/null +++ b/web/main/masterActions.ejs @@ -0,0 +1,358 @@ +<%- await include('parts/header.ejs', locals) %> + + +
    +
    + <% if (!isMasterAdmin) { %> + + <% } %> + <% if (!isWebInterface) { %> + + <% } %> +
    +
    + + +
    +
    + + +
    + + +
    + +
    + + +
    + +
    +
    + + +<%- await include('parts/footer.ejs', locals) %> + + + diff --git a/web/main/message.ejs b/web/main/message.ejs new file mode 100644 index 0000000..fa49db5 --- /dev/null +++ b/web/main/message.ejs @@ -0,0 +1,20 @@ +<%- await include('parts/header.ejs', locals) %> + +
    +

    Message

    +
    + +
    +
    + +
    +
    + + + + +<%- await include('parts/footer.ejs', locals) %> diff --git a/web/main/resources.ejs b/web/main/resources.ejs new file mode 100644 index 0000000..bb81af9 --- /dev/null +++ b/web/main/resources.ejs @@ -0,0 +1,436 @@ +<%- await include('parts/header.ejs', locals) %> + + + +
    +
    +
    +
    + +
    +
    +
    + + Default resources: + + +
    + +
    + + Only stopped resources: + + +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + <% for (const resGroup of resGroups) { %> +
    + + + + + + + + + <% for (const resource of resGroup.resources) { %> + + + + + <% } %> + +
    + <%= resGroup.subPath %> + + + +
    + <%= resource.name %> + <% if (resource.version !== '') { %> + <%= resource.version %> + <% } %> + <% if (resource.author !== '') { %> + by <%= resource.author %> + <% } %> + <% if (resource.description !== '') { %> +
    + <%= resource.description %> + <% } %> +
    + <% if (resource.status === 'started') { %> + > + Restart + + > + Stop + + <% } else { %> + > + Start + + <% } %> +
    +
    + <% } %> +
    +
    + + +<%- await include('parts/footer.ejs', locals) %> + + + + + diff --git a/web/main/serverLog.ejs b/web/main/serverLog.ejs new file mode 100644 index 0000000..d4adea6 --- /dev/null +++ b/web/main/serverLog.ejs @@ -0,0 +1,509 @@ +<%- await include('parts/header.ejs', locals) %> + + + + + +
    +
    +
    +
    +
    +

    Mode: LIVE

    +

    + From: --
    + To: --
    +

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

    Logger Filters

    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    
    +                
    + +
    +
    +
    +
    +
    + + +<%- await include('parts/footer.ejs', locals) %> + + + diff --git a/web/main/whitelist.ejs b/web/main/whitelist.ejs new file mode 100644 index 0000000..db2a999 --- /dev/null +++ b/web/main/whitelist.ejs @@ -0,0 +1,620 @@ +<%- await include('parts/header.ejs', locals) %> + + + +<% if (currentWhitelistMode !== 'approvedLicense') { %> +
    +
    + +
    +
    +<% } %> + +
    + +
    +
    +
    +
    + Whitelist Requests:
    + Players that tried to join the server but were not whitelisted. +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    +
    +
    Loading...
    +
    +
    +

    no players here yet

    +
    +
    + + + + + + + + + + + +
    IDNameDiscordJoin TimeActions
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + Approved Whitelists Pending Join:
    + Players that are already approved, but haven't joined the server yet. +
    +
    + +
    +
    +
    +
    +
    Loading...
    +
    +
    +

    no players here yet

    +
    +
    + + + + + + + + + + + +
    PlayerApproved ByApproval DateActions
    +
    +
    +
    +
    +
    + + + + + + + + + + +<%- await include('parts/footer.ejs', locals) %> + + + diff --git a/web/parts/adminModal.ejs b/web/parts/adminModal.ejs new file mode 100644 index 0000000..5c50f3a --- /dev/null +++ b/web/parts/adminModal.ejs @@ -0,0 +1,80 @@ + +
    + +
    +
    + > +
    +
    +
    + + +
    + +
    +
    + +
    + + The admin's https://forum.cfx.re/ username. This is required if you want to login using the Cfx.re button. + +
    +
    + + +
    + +
    +
    + +
    + + The admin's Discord User ID. Follow this guide if you don't know how to get the User ID. + +
    +
    + + +
    +
    +
    Permissions
    +
    +
    +
    + <% for (const [key, perm] of permsMenu.entries()) { %> +
    + > + +
    + <% } %> +
    +
    + <% for (const [key, perm] of permsGeneral.entries()) { %> +
    + > + +
    + <% } %> +
    +
    + + + diff --git a/web/parts/footer.ejs b/web/parts/footer.ejs new file mode 100644 index 0000000..35e8d96 --- /dev/null +++ b/web/parts/footer.ejs @@ -0,0 +1,19 @@ + +
    + +
    +
    + + + + + + + + + + + + diff --git a/web/parts/header.ejs b/web/parts/header.ejs new file mode 100644 index 0000000..df9407b --- /dev/null +++ b/web/parts/header.ejs @@ -0,0 +1,31 @@ + + + + + + + + + + + txAdmin + + + + + + + + + + + + + +
    + + +
    +
    +
    + diff --git a/web/public/css/codemirror.css b/web/public/css/codemirror.css new file mode 100644 index 0000000..dca02a8 --- /dev/null +++ b/web/public/css/codemirror.css @@ -0,0 +1,349 @@ +/* BASICS */ + +.CodeMirror { + /* Set height, width, borders, and global font properties here */ + font-family: monospace; + height: calc(100vh - 348px); + color: black; + direction: ltr; +} + +/* PADDING */ + +.CodeMirror-lines { + padding: 4px 0; /* Vertical padding around content */ +} +.CodeMirror pre.CodeMirror-line, +.CodeMirror pre.CodeMirror-line-like { + padding: 0 4px; /* Horizontal padding of content */ +} + +.CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { + background-color: white; /* The little square between H and V scrollbars */ +} + +/* GUTTER */ + +.CodeMirror-gutters { + border-right: 1px solid #ddd; + background-color: #f7f7f7; + white-space: nowrap; +} +.CodeMirror-linenumbers {} +.CodeMirror-linenumber { + padding: 0 3px 0 5px; + min-width: 20px; + text-align: right; + color: #999; + white-space: nowrap; +} + +.CodeMirror-guttermarker { color: black; } +.CodeMirror-guttermarker-subtle { color: #999; } + +/* CURSOR */ + +.CodeMirror-cursor { + border-left: 1px solid black; + border-right: none; + width: 0; +} +/* Shown when moving in bi-directional text */ +.CodeMirror div.CodeMirror-secondarycursor { + border-left: 1px solid silver; +} +.cm-fat-cursor .CodeMirror-cursor { + width: auto; + border: 0 !important; + background: #7e7; +} +.cm-fat-cursor div.CodeMirror-cursors { + z-index: 1; +} +.cm-fat-cursor-mark { + background-color: rgba(20, 255, 20, 0.5); + -webkit-animation: blink 1.06s steps(1) infinite; + -moz-animation: blink 1.06s steps(1) infinite; + animation: blink 1.06s steps(1) infinite; +} +.cm-animate-fat-cursor { + width: auto; + border: 0; + -webkit-animation: blink 1.06s steps(1) infinite; + -moz-animation: blink 1.06s steps(1) infinite; + animation: blink 1.06s steps(1) infinite; + background-color: #7e7; +} +@-moz-keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} +@-webkit-keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} +@keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} + +/* Can style cursor different in overwrite (non-insert) mode */ +.CodeMirror-overwrite .CodeMirror-cursor {} + +.cm-tab { display: inline-block; text-decoration: inherit; } + +.CodeMirror-rulers { + position: absolute; + left: 0; right: 0; top: -50px; bottom: 0; + overflow: hidden; +} +.CodeMirror-ruler { + border-left: 1px solid #ccc; + top: 0; bottom: 0; + position: absolute; +} + +/* DEFAULT THEME */ + +.cm-s-default .cm-header {color: blue;} +.cm-s-default .cm-quote {color: #090;} +.cm-negative {color: #d44;} +.cm-positive {color: #292;} +.cm-header, .cm-strong {font-weight: bold;} +.cm-em {font-style: italic;} +.cm-link {text-decoration: underline;} +.cm-strikethrough {text-decoration: line-through;} + +.cm-s-default .cm-keyword {color: #708;} +.cm-s-default .cm-atom {color: #219;} +.cm-s-default .cm-number {color: #164;} +.cm-s-default .cm-def {color: #00f;} +.cm-s-default .cm-variable, +.cm-s-default .cm-punctuation, +.cm-s-default .cm-property, +.cm-s-default .cm-operator {} +.cm-s-default .cm-variable-2 {color: #05a;} +.cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;} +.cm-s-default .cm-comment {color: #a50;} +.cm-s-default .cm-string {color: #a11;} +.cm-s-default .cm-string-2 {color: #f50;} +.cm-s-default .cm-meta {color: #555;} +.cm-s-default .cm-qualifier {color: #555;} +.cm-s-default .cm-builtin {color: #30a;} +.cm-s-default .cm-bracket {color: #997;} +.cm-s-default .cm-tag {color: #170;} +.cm-s-default .cm-attribute {color: #00c;} +.cm-s-default .cm-hr {color: #999;} +.cm-s-default .cm-link {color: #00c;} + +.cm-s-default .cm-error {color: #f00;} +.cm-invalidchar {color: #f00;} + +.CodeMirror-composing { border-bottom: 2px solid; } + +/* Default styles for common addons */ + +div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;} +div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;} +.CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); } +.CodeMirror-activeline-background {background: #e8f2ff;} + +/* STOP */ + +/* The rest of this file contains styles related to the mechanics of + the editor. You probably shouldn't touch them. */ + +.CodeMirror { + position: relative; + overflow: hidden; + background: white; +} + +.CodeMirror-scroll { + overflow: scroll !important; /* Things will break if this is overridden */ + /* 30px is the magic margin used to hide the element's real scrollbars */ + /* See overflow: hidden in .CodeMirror */ + margin-bottom: -30px; margin-right: -30px; + padding-bottom: 30px; + height: 100%; + outline: none; /* Prevent dragging from highlighting the element */ + position: relative; +} +.CodeMirror-sizer { + position: relative; + border-right: 30px solid transparent; +} + +/* The fake, visible scrollbars. Used to force redraw during scrolling + before actual scrolling happens, thus preventing shaking and + flickering artifacts. */ +.CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { + position: absolute; + z-index: 6; + display: none; +} +.CodeMirror-vscrollbar { + right: 0; top: 0; + overflow-x: hidden; + overflow-y: scroll; +} +.CodeMirror-hscrollbar { + bottom: 0; left: 0; + overflow-y: hidden; + overflow-x: scroll; +} +.CodeMirror-scrollbar-filler { + right: 0; bottom: 0; +} +.CodeMirror-gutter-filler { + left: 0; bottom: 0; +} + +.CodeMirror-gutters { + position: absolute; left: 0; top: 0; + min-height: 100%; + z-index: 3; +} +.CodeMirror-gutter { + white-space: normal; + height: 100%; + display: inline-block; + vertical-align: top; + margin-bottom: -30px; +} +.CodeMirror-gutter-wrapper { + position: absolute; + z-index: 4; + background: none !important; + border: none !important; +} +.CodeMirror-gutter-background { + position: absolute; + top: 0; bottom: 0; + z-index: 4; +} +.CodeMirror-gutter-elt { + position: absolute; + cursor: default; + z-index: 4; +} +.CodeMirror-gutter-wrapper ::selection { background-color: transparent } +.CodeMirror-gutter-wrapper ::-moz-selection { background-color: transparent } + +.CodeMirror-lines { + cursor: text; + min-height: 1px; /* prevents collapsing before first draw */ +} +.CodeMirror pre.CodeMirror-line, +.CodeMirror pre.CodeMirror-line-like { + /* Reset some styles that the rest of the page might have set */ + -moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0; + border-width: 0; + background: transparent; + font-family: inherit; + font-size: inherit; + margin: 0; + white-space: pre; + word-wrap: normal; + line-height: inherit; + color: inherit; + z-index: 2; + position: relative; + overflow: visible; + -webkit-tap-highlight-color: transparent; + -webkit-font-variant-ligatures: contextual; + font-variant-ligatures: contextual; +} +.CodeMirror-wrap pre.CodeMirror-line, +.CodeMirror-wrap pre.CodeMirror-line-like { + word-wrap: break-word; + white-space: pre-wrap; + word-break: normal; +} + +.CodeMirror-linebackground { + position: absolute; + left: 0; right: 0; top: 0; bottom: 0; + z-index: 0; +} + +.CodeMirror-linewidget { + position: relative; + z-index: 2; + padding: 0.1px; /* Force widget margins to stay inside of the container */ +} + +.CodeMirror-widget {} + +.CodeMirror-rtl pre { direction: rtl; } + +.CodeMirror-code { + outline: none; +} + +/* Force content-box sizing for the elements where we expect it */ +.CodeMirror-scroll, +.CodeMirror-sizer, +.CodeMirror-gutter, +.CodeMirror-gutters, +.CodeMirror-linenumber { + -moz-box-sizing: content-box; + box-sizing: content-box; +} + +.CodeMirror-measure { + position: absolute; + width: 100%; + height: 0; + overflow: hidden; + visibility: hidden; +} + +.CodeMirror-cursor { + position: absolute; + pointer-events: none; +} +.CodeMirror-measure pre { position: static; } + +div.CodeMirror-cursors { + visibility: hidden; + position: relative; + z-index: 3; +} +div.CodeMirror-dragcursors { + visibility: visible; +} + +.CodeMirror-focused div.CodeMirror-cursors { + visibility: visible; +} + +.CodeMirror-selected { background: #d9d9d9; } +.CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; } +.CodeMirror-crosshair { cursor: crosshair; } +.CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; } +.CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; } + +.cm-searching { + background-color: #ffa; + background-color: rgba(255, 255, 0, .4); +} + +/* Used to force a border model for a node */ +.cm-force-border { padding-right: .1px; } + +@media print { + /* Hide the cursor when printing */ + .CodeMirror div.CodeMirror-cursors { + visibility: hidden; + } +} + +/* See issue #2901 */ +.cm-tab-wrap-hack:after { content: ''; } + +/* Help users use markselection to safely style text background */ +span.CodeMirror-selectedtext { background: none; } diff --git a/web/public/css/codemirror_lucario.css b/web/public/css/codemirror_lucario.css new file mode 100644 index 0000000..3cd90e8 --- /dev/null +++ b/web/public/css/codemirror_lucario.css @@ -0,0 +1,37 @@ +/* + Name: lucario + Author: Raphael Amorim + + Original Lucario color scheme (https://github.com/raphamorim/lucario) +*/ + +.cm-s-lucario.CodeMirror, .cm-s-lucario .CodeMirror-gutters { + background-color: #2b3e50 !important; + color: #f8f8f2 !important; + border: none; +} +.cm-s-lucario .CodeMirror-gutters { color: #2b3e50; } +.cm-s-lucario .CodeMirror-cursor { border-left: solid thin #E6C845; } +.cm-s-lucario .CodeMirror-linenumber { color: #f8f8f2; } +.cm-s-lucario .CodeMirror-selected { background: #243443; } +.cm-s-lucario .CodeMirror-line::selection, .cm-s-lucario .CodeMirror-line > span::selection, .cm-s-lucario .CodeMirror-line > span > span::selection { background: #243443; } +.cm-s-lucario .CodeMirror-line::-moz-selection, .cm-s-lucario .CodeMirror-line > span::-moz-selection, .cm-s-lucario .CodeMirror-line > span > span::-moz-selection { background: #243443; } +.cm-s-lucario span.cm-comment { color: #8bc8fd; } +.cm-s-lucario span.cm-string, .cm-s-lucario span.cm-string-2 { color: #E6DB74; } +.cm-s-lucario span.cm-number { color: #ca94ff; } +.cm-s-lucario span.cm-variable { color: #f8f8f2; } +.cm-s-lucario span.cm-variable-2 { color: #fdfdc6; } +.cm-s-lucario span.cm-def { color: #72C05D; } +.cm-s-lucario span.cm-operator { color: #66D9EF; } +.cm-s-lucario span.cm-keyword { color: #ff6541; } +.cm-s-lucario span.cm-atom { color: #bd93f9; } +.cm-s-lucario span.cm-meta { color: #f8f8f2; } +.cm-s-lucario span.cm-tag { color: #ff6541; } +.cm-s-lucario span.cm-attribute { color: #66D9EF; } +.cm-s-lucario span.cm-qualifier { color: #72C05D; } +.cm-s-lucario span.cm-property { color: #f8f8f2; } +.cm-s-lucario span.cm-builtin { color: #72C05D; } +.cm-s-lucario span.cm-variable-3, .cm-s-lucario span.cm-type { color: #ffb86c; } + +.cm-s-lucario .CodeMirror-activeline-background { background: #243443; } +.cm-s-lucario .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/web/public/css/coreui.css b/web/public/css/coreui.css new file mode 100644 index 0000000..1a872ac --- /dev/null +++ b/web/public/css/coreui.css @@ -0,0 +1,16577 @@ +@charset "UTF-8"; +/*! + * CoreUI - HTML, CSS, and JavaScript UI Components Library + * @version v3.0.0 + * @link https://coreui.io/ + * Copyright (c) 2020 creativeLabs Łukasz Holeczek + * License MIT (https://coreui.io/license/) + */ +:root { + --primary: #321fdb; + --secondary: #ced2d8; + --success: #2eb85c; + --info: #39f; + --warning: #f9b115; + --danger: #e55353; + --light: #ebedef; + --dark: #636f83; + --breakpoint-xs: 0; + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 992px; + --breakpoint-xl: 1200px; + --font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-family: sans-serif; + line-height: 1.15; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: rgba(0, 0, 21, 0); +} + +article, aside, figcaption, figure, footer, header, hgroup, main, nav, section { + display: block; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + text-align: left; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; +} + +body,.c-app { + color: #3c4b64; + background-color: #ebedef; +} + +.c-app { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; + min-height: 100vh; +} + +[tabindex="-1"]:focus:not(:focus-visible) { + outline: 0 !important; +} + +hr { + box-sizing: content-box; + height: 0; + overflow: visible; + margin-top: 1rem; + margin-bottom: 1rem; + border: 0; + border-top: 1px solid rgba(0, 0, 21, 0.2); +} + +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: 0.5rem; +} + +p { + margin-top: 0; + margin-bottom: 1rem; +} + +abbr[title], +abbr[data-original-title] { + text-decoration: underline; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + cursor: help; + border-bottom: 0; + -webkit-text-decoration-skip-ink: none; + text-decoration-skip-ink: none; +} + +address { + font-style: normal; + line-height: inherit; +} + +address,ol, +ul, +dl { + margin-bottom: 1rem; +} + +ol, +ul, +dl { + margin-top: 0; +} + +ol ol, +ul ul, +ol ul, +ul ol { + margin-bottom: 0; +} + +dt { + font-weight: 700; +} + +dd { + margin-bottom: .5rem; +} + +html:not([dir="rtl"]) dd { + margin-left: 0; +} + +*[dir="rtl"] dd { + margin-right: 0; +} + +blockquote { + margin: 0 0 1rem; +} + +b, +strong { + font-weight: bolder; +} + +small { + font-size: 80%; +} + +sub, +sup { + position: relative; + font-size: 75%; + line-height: 0; + vertical-align: baseline; +} + +sub { + bottom: -.25em; +} + +sup { + top: -.5em; +} + +a { + text-decoration: none; + background-color: transparent; +} + +a,a:hover { + color: #321fdb; +} + +a:hover { + text-decoration: underline; +} + +a:not([href]),a:not([href]):hover { + color: inherit; + text-decoration: none; +} + +pre, +code, +kbd, +samp { + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 1em; +} + +pre { + margin-top: 0; + margin-bottom: 1rem; + overflow: auto; + display: block; + font-size: 87.5%; + color: #4f5d73; +} + +figure { + margin: 0 0 1rem; +} + +img { + border-style: none; +} + +img,svg { + vertical-align: middle; +} + +svg { + overflow: hidden; +} + +table { + border-collapse: collapse; +} + +caption { + padding-top: 0.75rem; + padding-bottom: 0.75rem; + color: #768192; + text-align: left; + caption-side: bottom; +} + +th { + text-align: inherit; +} + +label { + display: inline-block; + margin-bottom: 0.5rem; +} + +button { + border-radius: 0; +} + +button:focus { + outline: 1px dotted; + outline: 5px auto -webkit-focus-ring-color; +} + +input, +button, +select, +optgroup, +textarea { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +button, +input { + overflow: visible; +} + +button, +select { + text-transform: none; +} + +select { + word-wrap: normal; +} + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +button:not(:disabled), +[type="button"]:not(:disabled), +[type="reset"]:not(:disabled), +[type="submit"]:not(:disabled) { + cursor: pointer; +} + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + padding: 0; + border-style: none; +} + +input[type="radio"], +input[type="checkbox"] { + box-sizing: border-box; + padding: 0; +} + +input[type="date"], +input[type="time"], +input[type="datetime-local"], +input[type="month"] { + -webkit-appearance: listbox; +} + +textarea { + overflow: auto; + resize: vertical; +} + +fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} + +legend { + display: block; + width: 100%; + max-width: 100%; + padding: 0; + margin-bottom: .5rem; + font-size: 1.5rem; + line-height: inherit; + color: inherit; + white-space: normal; +} + +progress { + vertical-align: baseline; +} + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +[type="search"] { + outline-offset: -2px; + -webkit-appearance: none; +} + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-webkit-file-upload-button { + font: inherit; + -webkit-appearance: button; +} + +output { + display: inline-block; +} + +summary { + display: list-item; + cursor: pointer; +} + +template { + display: none; +} + +[hidden] { + display: none !important; +} + +.ps { + overflow: hidden !important; + -ms-touch-action: auto; + touch-action: auto; + -ms-overflow-style: none; + overflow-anchor: none; +} + +.ps__rail-x { + bottom: 0; + height: 15px; +} + +.ps__rail-x,.ps__rail-y { + position: absolute; + display: none; + opacity: 0; + transition: background-color .2s linear, opacity .2s linear; +} + +.ps__rail-y { + width: 15px; +} + +html:not([dir="rtl"]) .ps__rail-y { + right: 0; +} + +*[dir="rtl"] .ps__rail-y { + left: 0; +} + +.ps--active-x > .ps__rail-x, +.ps--active-y > .ps__rail-y { + display: block; + background-color: transparent; +} + +.ps:hover > .ps__rail-x, +.ps:hover > .ps__rail-y, +.ps--focus > .ps__rail-x, +.ps--focus > .ps__rail-y, +.ps--scrolling-x > .ps__rail-x, +.ps--scrolling-y > .ps__rail-y { + opacity: .6; +} + +.ps__rail-x:hover, +.ps__rail-y:hover, +.ps__rail-x:focus, +.ps__rail-y:focus { + background-color: #eee; + opacity: .9; +} + +/* + * Scrollbar thumb styles + */ +.ps__thumb-x { + bottom: 2px; + height: 6px; + transition: background-color .2s linear, height .2s ease-in-out; +} + +.ps__thumb-x,.ps__thumb-y { + position: absolute; + background-color: #aaa; + border-radius: 6px; +} + +.ps__thumb-y { + width: 6px; + transition: background-color .2s linear, width .2s ease-in-out; +} + +html:not([dir="rtl"]) .ps__thumb-y { + right: 2px; +} + +*[dir="rtl"] .ps__thumb-y { + left: 2px; +} + +.ps__rail-x:hover > .ps__thumb-x, +.ps__rail-x:focus > .ps__thumb-x { + height: 11px; + background-color: #999; +} + +.ps__rail-y:hover > .ps__thumb-y, +.ps__rail-y:focus > .ps__thumb-y { + width: 11px; + background-color: #999; +} + +@supports (-ms-overflow-style: none) { + .ps { + overflow: auto !important; + } +} + +@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) { + .ps { + overflow: auto !important; + } +} + +.alert { + position: relative; + padding: 0.75rem 1.25rem; + margin-bottom: 1rem; + border: 1px solid transparent; + border-radius: 0.25rem; +} + +.alert-heading { + color: inherit; +} + +.alert-link { + font-weight: 700; +} + +html:not([dir="rtl"]) .alert-dismissible { + padding-right: 3.8125rem; +} + +*[dir="rtl"] .alert-dismissible { + padding-left: 3.8125rem; +} + +.alert-dismissible .close { + position: absolute; + top: 0; + padding: 0.75rem 1.25rem; + color: inherit; +} + +html:not([dir="rtl"]) .alert-dismissible .close { + right: 0; +} + +*[dir="rtl"] .alert-dismissible .close { + left: 0; +} + +.alert-primary { + color: #1a107c; + background-color: #d6d2f8; + border-color: #c6c0f5; +} + +.alert-primary hr { + border-top-color: #b2aaf2; +} + +.alert-primary .alert-link { + color: #110a4f; +} + +.alert-secondary { + color: #6b6d7a; + background-color: #f5f6f7; + border-color: #f1f2f4; +} + +.alert-secondary hr { + border-top-color: #e3e5e9; +} + +.alert-secondary .alert-link { + color: #53555f; +} + +.alert-success { + color: #18603a; + background-color: #d5f1de; + border-color: #c4ebd1; +} + +.alert-success hr { + border-top-color: #b1e5c2; +} + +.alert-success .alert-link { + color: #0e3721; +} + +.alert-info { + color: #1b508f; + background-color: #d6ebff; + border-color: #c6e2ff; +} + +.alert-info hr { + border-top-color: #add5ff; +} + +.alert-info .alert-link { + color: #133864; +} + +.alert-warning { + color: #815c15; + background-color: #feefd0; + border-color: #fde9bd; +} + +.alert-warning hr { + border-top-color: #fce1a4; +} + +.alert-warning .alert-link { + color: #553d0e; +} + +.alert-danger { + color: #772b35; + background-color: #fadddd; + border-color: #f8cfcf; +} + +.alert-danger hr { + border-top-color: #f5b9b9; +} + +.alert-danger .alert-link { + color: #521d24; +} + +.alert-light { + color: #7a7b86; + background-color: #fbfbfc; + border-color: #f9fafb; +} + +.alert-light hr { + border-top-color: #eaedf1; +} + +.alert-light .alert-link { + color: #62626b; +} + +.alert-dark { + color: #333a4e; + background-color: #e0e2e6; + border-color: #d3d7dc; +} + +.alert-dark hr { + border-top-color: #c5cad1; +} + +.alert-dark .alert-link { + color: #1f232f; +} + +.c-avatar { + position: relative; + display: -ms-inline-flexbox; + display: inline-flex; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; + border-radius: 50em; + width: 36px; + height: 36px; + font-size: 14.4px; +} + +.c-avatar .c-avatar-status { + width: 10px; + height: 10px; +} + +.c-avatar-img { + width: 100%; + height: auto; + border-radius: 50em; +} + +.c-avatar-status { + position: absolute; + bottom: 0; + display: block; + border: 1px solid #fff; + border-radius: 50em; +} + +html:not([dir="rtl"]) .c-avatar-status { + right: 0; +} + +*[dir="rtl"] .c-avatar-status { + left: 0; +} + +.c-avatar-sm { + width: 24px; + height: 24px; + font-size: 9.6px; +} + +.c-avatar-sm .c-avatar-status { + width: 8px; + height: 8px; +} + +.c-avatar-lg { + width: 48px; + height: 48px; + font-size: 19.2px; +} + +.c-avatar-lg .c-avatar-status { + width: 12px; + height: 12px; +} + +.c-avatar-xl { + width: 64px; + height: 64px; + font-size: 25.6px; +} + +.c-avatar-xl .c-avatar-status { + width: 14px; + height: 14px; +} + +.c-avatars-stack { + display: -ms-flexbox; + display: flex; +} + +.c-avatars-stack .c-avatar { + margin-right: -18px; + transition: margin-right 0.25s; +} + +.c-avatars-stack .c-avatar:hover { + margin-right: 0; +} + +.c-avatars-stack .c-avatar-sm { + margin-right: -12px; +} + +.c-avatars-stack .c-avatar-lg { + margin-right: -24px; +} + +.c-avatars-stack .c-avatar-xl { + margin-right: -32px; +} + +.c-avatar-rounded { + border-radius: 0.25rem; +} + +.c-avatar-square { + border-radius: 0; +} + +.badge { + display: inline-block; + padding: 0.25em 0.4em; + font-size: 75%; + font-weight: 700; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 0.25rem; + 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; +} + +@media (prefers-reduced-motion: reduce) { + .badge { + transition: none; + } +} + +a.badge:hover, a.badge:focus { + text-decoration: none; +} + +.badge:empty { + display: none; +} + +.btn .badge { + position: relative; + top: -1px; +} + +.badge-pill { + padding-right: 0.6em; + padding-left: 0.6em; + border-radius: 10rem; +} + +.badge-primary { + color: #fff; + background-color: #321fdb; +} + +a.badge-primary:hover, a.badge-primary:focus { + color: #fff; + background-color: #2819ae; +} + +a.badge-primary:focus, a.badge-primary.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.5); +} + +.badge-secondary { + color: #4f5d73; + background-color: #ced2d8; +} + +a.badge-secondary:hover, a.badge-secondary:focus { + color: #4f5d73; + background-color: #b2b8c1; +} + +a.badge-secondary:focus, a.badge-secondary.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(206, 210, 216, 0.5); +} + +.badge-success { + color: #fff; + background-color: #2eb85c; +} + +a.badge-success:hover, a.badge-success:focus { + color: #fff; + background-color: #248f48; +} + +a.badge-success:focus, a.badge-success.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(46, 184, 92, 0.5); +} + +.badge-info { + color: #fff; + background-color: #39f; +} + +a.badge-info:hover, a.badge-info:focus { + color: #fff; + background-color: #0080ff; +} + +a.badge-info:focus, a.badge-info.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(51, 153, 255, 0.5); +} + +.badge-warning { + color: #4f5d73; + background-color: #f9b115; +} + +a.badge-warning:hover, a.badge-warning:focus { + color: #4f5d73; + background-color: #d69405; +} + +a.badge-warning:focus, a.badge-warning.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(249, 177, 21, 0.5); +} + +.badge-danger { + color: #fff; + background-color: #e55353; +} + +a.badge-danger:hover, a.badge-danger:focus { + color: #fff; + background-color: #de2727; +} + +a.badge-danger:focus, a.badge-danger.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(229, 83, 83, 0.5); +} + +.badge-light { + color: #4f5d73; + background-color: #ebedef; +} + +a.badge-light:hover, a.badge-light:focus { + color: #4f5d73; + background-color: #cfd4d8; +} + +a.badge-light:focus, a.badge-light.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(235, 237, 239, 0.5); +} + +.badge-dark { + color: #fff; + background-color: #636f83; +} + +a.badge-dark:hover, a.badge-dark:focus { + color: #fff; + background-color: #4d5666; +} + +a.badge-dark:focus, a.badge-dark.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(99, 111, 131, 0.5); +} + +html:not([dir="rtl"]) .breadcrumb-menu { + margin-left: auto; + margin-right: auto; +} + +.breadcrumb-menu::before { + display: none; +} + +.breadcrumb-menu .btn-group,.breadcrumb-menu .btn { + vertical-align: top; +} + +.breadcrumb-menu .btn { + padding: 0 0.75rem; + color: #768192; + border: 0; +} + +.breadcrumb-menu .btn:hover, .breadcrumb-menu .btn.active,.breadcrumb-menu .show .btn { + color: #4f5d73; + background: transparent; +} + +.breadcrumb-menu .dropdown-menu { + min-width: 180px; + line-height: 1.5; +} + +.breadcrumb { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + list-style: none; + border-radius: 0; + border-bottom: 1px solid; + background-color: transparent; + border-color: #d8dbe0; +} + +html:not([dir="rtl"]) .breadcrumb-item + .breadcrumb-item { + padding-left: 0.5rem; +} + +*[dir="rtl"] .breadcrumb-item + .breadcrumb-item { + padding-right: 0.5rem; +} + +.breadcrumb-item + .breadcrumb-item::before { + display: inline-block; + color: #8a93a2; + content: "/"; +} + +html:not([dir="rtl"]) .breadcrumb-item + .breadcrumb-item::before { + padding-right: 0.5rem; +} + +*[dir="rtl"] .breadcrumb-item + .breadcrumb-item::before { + padding-left: 0.5rem; +} + +.breadcrumb-item + .breadcrumb-item:hover::before { + text-decoration: underline; + text-decoration: none; +} + +.breadcrumb-item.active { + color: #8a93a2; +} + +.btn-group, +.btn-group-vertical { + position: relative; + display: -ms-inline-flexbox; + display: inline-flex; + vertical-align: middle; +} + +.btn-group > .btn, +.btn-group-vertical > .btn { + position: relative; + -ms-flex: 1 1 auto; + flex: 1 1 auto; +} + +.btn-group > .btn:hover, +.btn-group-vertical > .btn:hover,.btn-group > .btn:focus, .btn-group > .btn:active, .btn-group > .btn.active, +.btn-group-vertical > .btn:focus, +.btn-group-vertical > .btn:active, +.btn-group-vertical > .btn.active { + z-index: 1; +} + +.btn-toolbar { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.btn-toolbar .input-group { + width: auto; +} + +html:not([dir="rtl"]) .btn-group > .btn:not(:first-child), html:not([dir="rtl"]) +.btn-group > .btn-group:not(:first-child) { + margin-left: -1px; +} + +*[dir="rtl"] .btn-group > .btn:not(:first-child), *[dir="rtl"] +.btn-group > .btn-group:not(:first-child) { + margin-right: -1px; +} + +html:not([dir="rtl"]) .btn-group > .btn:not(:last-child):not(.dropdown-toggle), +html:not([dir="rtl"]) .btn-group > .btn-group:not(:last-child) > .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +html:not([dir="rtl"]) .btn-group > .btn:not(:first-child), +html:not([dir="rtl"]) .btn-group > .btn-group:not(:first-child) > .btn,*[dir="rtl"] .btn-group > .btn:not(:last-child):not(.dropdown-toggle), +*[dir="rtl"] .btn-group > .btn-group:not(:last-child) > .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +*[dir="rtl"] .btn-group > .btn:not(:first-child), +*[dir="rtl"] .btn-group > .btn-group:not(:first-child) > .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.dropdown-toggle-split { + padding-right: 0.5625rem; + padding-left: 0.5625rem; +} + +html:not([dir="rtl"]) .dropdown-toggle-split::after, html:not([dir="rtl"]) +.dropup .dropdown-toggle-split::after, html:not([dir="rtl"]) +.dropright .dropdown-toggle-split::after { + margin-left: 0; +} + +*[dir="rtl"] .dropdown-toggle-split::after, *[dir="rtl"] +.dropup .dropdown-toggle-split::after, *[dir="rtl"] +.dropright .dropdown-toggle-split::after,html:not([dir="rtl"]) .dropleft .dropdown-toggle-split::before { + margin-right: 0; +} + +*[dir="rtl"] .dropleft .dropdown-toggle-split::before { + margin-left: 0; +} + +.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split { + padding-right: 0.375rem; + padding-left: 0.375rem; +} + +.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split { + padding-right: 0.75rem; + padding-left: 0.75rem; +} + +.btn-group-vertical { + -ms-flex-direction: column; + flex-direction: column; + -ms-flex-align: start; + align-items: flex-start; + -ms-flex-pack: center; + justify-content: center; +} + +.btn-group-vertical > .btn, +.btn-group-vertical > .btn-group { + width: 100%; +} + +.btn-group-vertical > .btn:not(:first-child), +.btn-group-vertical > .btn-group:not(:first-child) { + margin-top: -1px; +} + +.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), +.btn-group-vertical > .btn-group:not(:last-child) > .btn { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.btn-group-vertical > .btn:not(:first-child), +.btn-group-vertical > .btn-group:not(:first-child) > .btn { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.btn-group-toggle > .btn, +.btn-group-toggle > .btn-group > .btn { + margin-bottom: 0; +} + +.btn-group-toggle > .btn input[type="radio"], +.btn-group-toggle > .btn input[type="checkbox"], +.btn-group-toggle > .btn-group > .btn input[type="radio"], +.btn-group-toggle > .btn-group > .btn input[type="checkbox"] { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} + +.btn { + display: inline-block; + font-weight: 400; + color: #4f5d73; + text-align: center; + vertical-align: middle; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-color: transparent; + border: 1px solid transparent; + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + line-height: 1.5; + border-radius: 0.25rem; + 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; +} + +.btn i, +.btn .c-icon { + width: 0.875rem; + height: 0.875rem; + margin: 0.21875rem 0; + height: 0.875rem; + margin: 0.21875rem 0; +} + +@media (prefers-reduced-motion: reduce) { + .btn { + transition: none; + } +} + +.btn:hover { + color: #4f5d73; + text-decoration: none; +} + +.btn:focus, .btn.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.25); +} + +.btn.disabled, .btn:disabled { + opacity: 0.65; +} + +a.btn.disabled, +fieldset:disabled a.btn { + pointer-events: none; +} + +.btn-primary { + color: #fff; + background-color: #321fdb; + border-color: #321fdb; +} + +.btn-primary:hover,.btn-primary:focus, .btn-primary.focus { + color: #fff; + background-color: #2a1ab9; + border-color: #2819ae; +} + +.btn-primary:focus, .btn-primary.focus { + box-shadow: 0 0 0 0.2rem rgba(81, 65, 224, 0.5); +} + +.btn-primary.disabled, .btn-primary:disabled { + color: #fff; + background-color: #321fdb; + border-color: #321fdb; +} + +.btn-primary:not(:disabled):not(.disabled):active, .btn-primary:not(:disabled):not(.disabled).active, +.show > .btn-primary.dropdown-toggle { + color: #fff; + background-color: #2819ae; + border-color: #2517a3; +} + +.btn-primary:not(:disabled):not(.disabled):active:focus, .btn-primary:not(:disabled):not(.disabled).active:focus, +.show > .btn-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(81, 65, 224, 0.5); +} + +.btn-secondary { + color: #4f5d73; + background-color: #ced2d8; + border-color: #ced2d8; +} + +.btn-secondary:hover,.btn-secondary:focus, .btn-secondary.focus { + color: #4f5d73; + background-color: #b9bec7; + border-color: #b2b8c1; +} + +.btn-secondary:focus, .btn-secondary.focus { + box-shadow: 0 0 0 0.2rem rgba(187, 192, 201, 0.5); +} + +.btn-secondary.disabled, .btn-secondary:disabled { + color: #4f5d73; + background-color: #ced2d8; + border-color: #ced2d8; +} + +.btn-secondary:not(:disabled):not(.disabled):active, .btn-secondary:not(:disabled):not(.disabled).active, +.show > .btn-secondary.dropdown-toggle { + color: #4f5d73; + background-color: #b2b8c1; + border-color: #abb1bc; +} + +.btn-secondary:not(:disabled):not(.disabled):active:focus, .btn-secondary:not(:disabled):not(.disabled).active:focus, +.show > .btn-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(187, 192, 201, 0.5); +} + +.btn-success { + color: #fff; + background-color: #2eb85c; + border-color: #2eb85c; +} + +.btn-success:hover,.btn-success:focus, .btn-success.focus { + color: #fff; + background-color: #26994d; + border-color: #248f48; +} + +.btn-success:focus, .btn-success.focus { + box-shadow: 0 0 0 0.2rem rgba(77, 195, 116, 0.5); +} + +.btn-success.disabled, .btn-success:disabled { + color: #fff; + background-color: #2eb85c; + border-color: #2eb85c; +} + +.btn-success:not(:disabled):not(.disabled):active, .btn-success:not(:disabled):not(.disabled).active, +.show > .btn-success.dropdown-toggle { + color: #fff; + background-color: #248f48; + border-color: #218543; +} + +.btn-success:not(:disabled):not(.disabled):active:focus, .btn-success:not(:disabled):not(.disabled).active:focus, +.show > .btn-success.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(77, 195, 116, 0.5); +} + +.btn-info { + color: #fff; + background-color: #39f; + border-color: #39f; +} + +.btn-info:hover,.btn-info:focus, .btn-info.focus { + color: #fff; + background-color: #0d86ff; + border-color: #0080ff; +} + +.btn-info:focus, .btn-info.focus { + box-shadow: 0 0 0 0.2rem rgba(82, 168, 255, 0.5); +} + +.btn-info.disabled, .btn-info:disabled { + color: #fff; + background-color: #39f; + border-color: #39f; +} + +.btn-info:not(:disabled):not(.disabled):active, .btn-info:not(:disabled):not(.disabled).active, +.show > .btn-info.dropdown-toggle { + color: #fff; + background-color: #0080ff; + border-color: #0079f2; +} + +.btn-info:not(:disabled):not(.disabled):active:focus, .btn-info:not(:disabled):not(.disabled).active:focus, +.show > .btn-info.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(82, 168, 255, 0.5); +} + +.btn-warning { + color: #4f5d73; + background-color: #f9b115; + border-color: #f9b115; +} + +.btn-warning:hover,.btn-warning:focus, .btn-warning.focus { + color: #4f5d73; + background-color: #e29c06; + border-color: #d69405; +} + +.btn-warning:focus, .btn-warning.focus { + box-shadow: 0 0 0 0.2rem rgba(224, 164, 35, 0.5); +} + +.btn-warning.disabled, .btn-warning:disabled { + color: #4f5d73; + background-color: #f9b115; + border-color: #f9b115; +} + +.btn-warning:not(:disabled):not(.disabled):active, .btn-warning:not(:disabled):not(.disabled).active, +.show > .btn-warning.dropdown-toggle { + color: #4f5d73; + background-color: #d69405; + border-color: #c98b05; +} + +.btn-warning:not(:disabled):not(.disabled):active:focus, .btn-warning:not(:disabled):not(.disabled).active:focus, +.show > .btn-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(224, 164, 35, 0.5); +} + +.btn-danger { + color: #fff; + background-color: #e55353; + border-color: #e55353; +} + +.btn-danger:hover,.btn-danger:focus, .btn-danger.focus { + color: #fff; + background-color: #e03232; + border-color: #de2727; +} + +.btn-danger:focus, .btn-danger.focus { + box-shadow: 0 0 0 0.2rem rgba(233, 109, 109, 0.5); +} + +.btn-danger.disabled, .btn-danger:disabled { + color: #fff; + background-color: #e55353; + border-color: #e55353; +} + +.btn-danger:not(:disabled):not(.disabled):active, .btn-danger:not(:disabled):not(.disabled).active, +.show > .btn-danger.dropdown-toggle { + color: #fff; + background-color: #de2727; + border-color: #d82121; +} + +.btn-danger:not(:disabled):not(.disabled):active:focus, .btn-danger:not(:disabled):not(.disabled).active:focus, +.show > .btn-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(233, 109, 109, 0.5); +} + +.btn-light { + color: #4f5d73; + background-color: #ebedef; + border-color: #ebedef; +} + +.btn-light:hover,.btn-light:focus, .btn-light.focus { + color: #4f5d73; + background-color: #d6dade; + border-color: #cfd4d8; +} + +.btn-light:focus, .btn-light.focus { + box-shadow: 0 0 0 0.2rem rgba(212, 215, 220, 0.5); +} + +.btn-light.disabled, .btn-light:disabled { + color: #4f5d73; + background-color: #ebedef; + border-color: #ebedef; +} + +.btn-light:not(:disabled):not(.disabled):active, .btn-light:not(:disabled):not(.disabled).active, +.show > .btn-light.dropdown-toggle { + color: #4f5d73; + background-color: #cfd4d8; + border-color: #c8cdd3; +} + +.btn-light:not(:disabled):not(.disabled):active:focus, .btn-light:not(:disabled):not(.disabled).active:focus, +.show > .btn-light.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(212, 215, 220, 0.5); +} + +.btn-dark { + color: #fff; + background-color: #636f83; + border-color: #636f83; +} + +.btn-dark:hover,.btn-dark:focus, .btn-dark.focus { + color: #fff; + background-color: #535d6d; + border-color: #4d5666; +} + +.btn-dark:focus, .btn-dark.focus { + box-shadow: 0 0 0 0.2rem rgba(122, 133, 150, 0.5); +} + +.btn-dark.disabled, .btn-dark:disabled { + color: #fff; + background-color: #636f83; + border-color: #636f83; +} + +.btn-dark:not(:disabled):not(.disabled):active, .btn-dark:not(:disabled):not(.disabled).active, +.show > .btn-dark.dropdown-toggle { + color: #fff; + background-color: #4d5666; + border-color: #48505f; +} + +.btn-dark:not(:disabled):not(.disabled):active:focus, .btn-dark:not(:disabled):not(.disabled).active:focus, +.show > .btn-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(122, 133, 150, 0.5); +} + +.btn-transparent { + color: rgba(255, 255, 255, 0.8); +} + +.btn-transparent:hover { + color: white; +} + +.btn-outline-primary { + color: #321fdb; + border-color: #321fdb; +} + +.btn-outline-primary:hover { + color: #fff; + background-color: #321fdb; + border-color: #321fdb; +} + +.btn-outline-primary:focus, .btn-outline-primary.focus { + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.5); +} + +.btn-outline-primary.disabled, .btn-outline-primary:disabled { + color: #321fdb; + background-color: transparent; +} + +.btn-outline-primary:not(:disabled):not(.disabled):active, .btn-outline-primary:not(:disabled):not(.disabled).active, +.show > .btn-outline-primary.dropdown-toggle { + color: #fff; + background-color: #321fdb; + border-color: #321fdb; +} + +.btn-outline-primary:not(:disabled):not(.disabled):active:focus, .btn-outline-primary:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.5); +} + +.btn-outline-secondary { + color: #ced2d8; + border-color: #ced2d8; +} + +.btn-outline-secondary:hover { + color: #4f5d73; + background-color: #ced2d8; + border-color: #ced2d8; +} + +.btn-outline-secondary:focus, .btn-outline-secondary.focus { + box-shadow: 0 0 0 0.2rem rgba(206, 210, 216, 0.5); +} + +.btn-outline-secondary.disabled, .btn-outline-secondary:disabled { + color: #ced2d8; + background-color: transparent; +} + +.btn-outline-secondary:not(:disabled):not(.disabled):active, .btn-outline-secondary:not(:disabled):not(.disabled).active, +.show > .btn-outline-secondary.dropdown-toggle { + color: #4f5d73; + background-color: #ced2d8; + border-color: #ced2d8; +} + +.btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .btn-outline-secondary:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(206, 210, 216, 0.5); +} + +.btn-outline-success { + color: #2eb85c; + border-color: #2eb85c; +} + +.btn-outline-success:hover { + color: #fff; + background-color: #2eb85c; + border-color: #2eb85c; +} + +.btn-outline-success:focus, .btn-outline-success.focus { + box-shadow: 0 0 0 0.2rem rgba(46, 184, 92, 0.5); +} + +.btn-outline-success.disabled, .btn-outline-success:disabled { + color: #2eb85c; + background-color: transparent; +} + +.btn-outline-success:not(:disabled):not(.disabled):active, .btn-outline-success:not(:disabled):not(.disabled).active, +.show > .btn-outline-success.dropdown-toggle { + color: #fff; + background-color: #2eb85c; + border-color: #2eb85c; +} + +.btn-outline-success:not(:disabled):not(.disabled):active:focus, .btn-outline-success:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-success.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(46, 184, 92, 0.5); +} + +.btn-outline-info { + color: #39f; + border-color: #39f; +} + +.btn-outline-info:hover { + color: #fff; + background-color: #39f; + border-color: #39f; +} + +.btn-outline-info:focus, .btn-outline-info.focus { + box-shadow: 0 0 0 0.2rem rgba(51, 153, 255, 0.5); +} + +.btn-outline-info.disabled, .btn-outline-info:disabled { + color: #39f; + background-color: transparent; +} + +.btn-outline-info:not(:disabled):not(.disabled):active, .btn-outline-info:not(:disabled):not(.disabled).active, +.show > .btn-outline-info.dropdown-toggle { + color: #fff; + background-color: #39f; + border-color: #39f; +} + +.btn-outline-info:not(:disabled):not(.disabled):active:focus, .btn-outline-info:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-info.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(51, 153, 255, 0.5); +} + +.btn-outline-warning { + color: #f9b115; + border-color: #f9b115; +} + +.btn-outline-warning:hover { + color: #4f5d73; + background-color: #f9b115; + border-color: #f9b115; +} + +.btn-outline-warning:focus, .btn-outline-warning.focus { + box-shadow: 0 0 0 0.2rem rgba(249, 177, 21, 0.5); +} + +.btn-outline-warning.disabled, .btn-outline-warning:disabled { + color: #f9b115; + background-color: transparent; +} + +.btn-outline-warning:not(:disabled):not(.disabled):active, .btn-outline-warning:not(:disabled):not(.disabled).active, +.show > .btn-outline-warning.dropdown-toggle { + color: #4f5d73; + background-color: #f9b115; + border-color: #f9b115; +} + +.btn-outline-warning:not(:disabled):not(.disabled):active:focus, .btn-outline-warning:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(249, 177, 21, 0.5); +} + +.btn-outline-danger { + color: #e55353; + border-color: #e55353; +} + +.btn-outline-danger:hover { + color: #fff; + background-color: #e55353; + border-color: #e55353; +} + +.btn-outline-danger:focus, .btn-outline-danger.focus { + box-shadow: 0 0 0 0.2rem rgba(229, 83, 83, 0.5); +} + +.btn-outline-danger.disabled, .btn-outline-danger:disabled { + color: #e55353; + background-color: transparent; +} + +.btn-outline-danger:not(:disabled):not(.disabled):active, .btn-outline-danger:not(:disabled):not(.disabled).active, +.show > .btn-outline-danger.dropdown-toggle { + color: #fff; + background-color: #e55353; + border-color: #e55353; +} + +.btn-outline-danger:not(:disabled):not(.disabled):active:focus, .btn-outline-danger:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(229, 83, 83, 0.5); +} + +.btn-outline-light { + color: #ebedef; + border-color: #ebedef; +} + +.btn-outline-light:hover { + color: #4f5d73; + background-color: #ebedef; + border-color: #ebedef; +} + +.btn-outline-light:focus, .btn-outline-light.focus { + box-shadow: 0 0 0 0.2rem rgba(235, 237, 239, 0.5); +} + +.btn-outline-light.disabled, .btn-outline-light:disabled { + color: #ebedef; + background-color: transparent; +} + +.btn-outline-light:not(:disabled):not(.disabled):active, .btn-outline-light:not(:disabled):not(.disabled).active, +.show > .btn-outline-light.dropdown-toggle { + color: #4f5d73; + background-color: #ebedef; + border-color: #ebedef; +} + +.btn-outline-light:not(:disabled):not(.disabled):active:focus, .btn-outline-light:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-light.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(235, 237, 239, 0.5); +} + +.btn-outline-dark { + color: #636f83; + border-color: #636f83; +} + +.btn-outline-dark:hover { + color: #fff; + background-color: #636f83; + border-color: #636f83; +} + +.btn-outline-dark:focus, .btn-outline-dark.focus { + box-shadow: 0 0 0 0.2rem rgba(99, 111, 131, 0.5); +} + +.btn-outline-dark.disabled, .btn-outline-dark:disabled { + color: #636f83; + background-color: transparent; +} + +.btn-outline-dark:not(:disabled):not(.disabled):active, .btn-outline-dark:not(:disabled):not(.disabled).active, +.show > .btn-outline-dark.dropdown-toggle { + color: #fff; + background-color: #636f83; + border-color: #636f83; +} + +.btn-outline-dark:not(:disabled):not(.disabled):active:focus, .btn-outline-dark:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(99, 111, 131, 0.5); +} + +.btn-link { + font-weight: 400; + color: #321fdb; + text-decoration: none; +} + +.btn-link:hover { + color: #231698; + text-decoration: underline; +} + +.btn-link:focus, .btn-link.focus { + text-decoration: underline; + box-shadow: none; +} + +.btn-link:disabled, .btn-link.disabled { + color: #8a93a2; + pointer-events: none; +} + +.btn-lg, .btn-group-lg > .btn { + padding: 0.5rem 1rem; + font-size: 1.09375rem; + line-height: 1.5; + border-radius: 0.3rem; +} + +.btn-lg i, .btn-group-lg > .btn i, +.btn-lg .c-icon, +.btn-group-lg > .btn .c-icon { + width: 1.09375rem; + height: 1.09375rem; + margin: 0.2734375rem 0; +} + +.btn-sm, .btn-group-sm > .btn { + padding: 0.25rem 0.5rem; + font-size: 0.765625rem; + line-height: 1.5; + border-radius: 0.2rem; +} + +.btn-sm i, .btn-group-sm > .btn i, +.btn-sm .c-icon, +.btn-group-sm > .btn .c-icon { + width: 0.765625rem; + height: 0.765625rem; + margin: 0.19140625rem 0; +} + +.btn-block { + display: block; + width: 100%; +} + +.btn-block + .btn-block { + margin-top: 0.5rem; +} + +input[type="submit"].btn-block, +input[type="reset"].btn-block, +input[type="button"].btn-block { + width: 100%; +} + +.btn-pill { + border-radius: 50em; +} + +.btn-square { + border-radius: 0; +} + +.btn-ghost-primary { + color: #321fdb; + background-color: transparent; + background-image: none; + border-color: transparent; +} + +.btn-ghost-primary:hover { + color: #fff; + background-color: #321fdb; + border-color: #321fdb; +} + +.btn-ghost-primary:focus, .btn-ghost-primary.focus { + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.5); +} + +.btn-ghost-primary.disabled, .btn-ghost-primary:disabled { + color: #321fdb; + background-color: transparent; + border-color: transparent; +} + +.btn-ghost-primary:not(:disabled):not(.disabled):active, .btn-ghost-primary:not(:disabled):not(.disabled).active, +.show > .btn-ghost-primary.dropdown-toggle { + color: #fff; + background-color: #321fdb; + border-color: #321fdb; +} + +.btn-ghost-primary:not(:disabled):not(.disabled):active:focus, .btn-ghost-primary:not(:disabled):not(.disabled).active:focus, +.show > .btn-ghost-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.5); +} + +.btn-ghost-secondary { + color: #ced2d8; + background-color: transparent; + background-image: none; + border-color: transparent; +} + +.btn-ghost-secondary:hover { + color: #4f5d73; + background-color: #ced2d8; + border-color: #ced2d8; +} + +.btn-ghost-secondary:focus, .btn-ghost-secondary.focus { + box-shadow: 0 0 0 0.2rem rgba(206, 210, 216, 0.5); +} + +.btn-ghost-secondary.disabled, .btn-ghost-secondary:disabled { + color: #ced2d8; + background-color: transparent; + border-color: transparent; +} + +.btn-ghost-secondary:not(:disabled):not(.disabled):active, .btn-ghost-secondary:not(:disabled):not(.disabled).active, +.show > .btn-ghost-secondary.dropdown-toggle { + color: #4f5d73; + background-color: #ced2d8; + border-color: #ced2d8; +} + +.btn-ghost-secondary:not(:disabled):not(.disabled):active:focus, .btn-ghost-secondary:not(:disabled):not(.disabled).active:focus, +.show > .btn-ghost-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(206, 210, 216, 0.5); +} + +.btn-ghost-success { + color: #2eb85c; + background-color: transparent; + background-image: none; + border-color: transparent; +} + +.btn-ghost-success:hover { + color: #fff; + background-color: #2eb85c; + border-color: #2eb85c; +} + +.btn-ghost-success:focus, .btn-ghost-success.focus { + box-shadow: 0 0 0 0.2rem rgba(46, 184, 92, 0.5); +} + +.btn-ghost-success.disabled, .btn-ghost-success:disabled { + color: #2eb85c; + background-color: transparent; + border-color: transparent; +} + +.btn-ghost-success:not(:disabled):not(.disabled):active, .btn-ghost-success:not(:disabled):not(.disabled).active, +.show > .btn-ghost-success.dropdown-toggle { + color: #fff; + background-color: #2eb85c; + border-color: #2eb85c; +} + +.btn-ghost-success:not(:disabled):not(.disabled):active:focus, .btn-ghost-success:not(:disabled):not(.disabled).active:focus, +.show > .btn-ghost-success.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(46, 184, 92, 0.5); +} + +.btn-ghost-info { + color: #39f; + background-color: transparent; + background-image: none; + border-color: transparent; +} + +.btn-ghost-info:hover { + color: #fff; + background-color: #39f; + border-color: #39f; +} + +.btn-ghost-info:focus, .btn-ghost-info.focus { + box-shadow: 0 0 0 0.2rem rgba(51, 153, 255, 0.5); +} + +.btn-ghost-info.disabled, .btn-ghost-info:disabled { + color: #39f; + background-color: transparent; + border-color: transparent; +} + +.btn-ghost-info:not(:disabled):not(.disabled):active, .btn-ghost-info:not(:disabled):not(.disabled).active, +.show > .btn-ghost-info.dropdown-toggle { + color: #fff; + background-color: #39f; + border-color: #39f; +} + +.btn-ghost-info:not(:disabled):not(.disabled):active:focus, .btn-ghost-info:not(:disabled):not(.disabled).active:focus, +.show > .btn-ghost-info.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(51, 153, 255, 0.5); +} + +.btn-ghost-warning { + color: #f9b115; + background-color: transparent; + background-image: none; + border-color: transparent; +} + +.btn-ghost-warning:hover { + color: #4f5d73; + background-color: #f9b115; + border-color: #f9b115; +} + +.btn-ghost-warning:focus, .btn-ghost-warning.focus { + box-shadow: 0 0 0 0.2rem rgba(249, 177, 21, 0.5); +} + +.btn-ghost-warning.disabled, .btn-ghost-warning:disabled { + color: #f9b115; + background-color: transparent; + border-color: transparent; +} + +.btn-ghost-warning:not(:disabled):not(.disabled):active, .btn-ghost-warning:not(:disabled):not(.disabled).active, +.show > .btn-ghost-warning.dropdown-toggle { + color: #4f5d73; + background-color: #f9b115; + border-color: #f9b115; +} + +.btn-ghost-warning:not(:disabled):not(.disabled):active:focus, .btn-ghost-warning:not(:disabled):not(.disabled).active:focus, +.show > .btn-ghost-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(249, 177, 21, 0.5); +} + +.btn-ghost-danger { + color: #e55353; + background-color: transparent; + background-image: none; + border-color: transparent; +} + +.btn-ghost-danger:hover { + color: #fff; + background-color: #e55353; + border-color: #e55353; +} + +.btn-ghost-danger:focus, .btn-ghost-danger.focus { + box-shadow: 0 0 0 0.2rem rgba(229, 83, 83, 0.5); +} + +.btn-ghost-danger.disabled, .btn-ghost-danger:disabled { + color: #e55353; + background-color: transparent; + border-color: transparent; +} + +.btn-ghost-danger:not(:disabled):not(.disabled):active, .btn-ghost-danger:not(:disabled):not(.disabled).active, +.show > .btn-ghost-danger.dropdown-toggle { + color: #fff; + background-color: #e55353; + border-color: #e55353; +} + +.btn-ghost-danger:not(:disabled):not(.disabled):active:focus, .btn-ghost-danger:not(:disabled):not(.disabled).active:focus, +.show > .btn-ghost-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(229, 83, 83, 0.5); +} + +.btn-ghost-light { + color: #ebedef; + background-color: transparent; + background-image: none; + border-color: transparent; +} + +.btn-ghost-light:hover { + color: #4f5d73; + background-color: #ebedef; + border-color: #ebedef; +} + +.btn-ghost-light:focus, .btn-ghost-light.focus { + box-shadow: 0 0 0 0.2rem rgba(235, 237, 239, 0.5); +} + +.btn-ghost-light.disabled, .btn-ghost-light:disabled { + color: #ebedef; + background-color: transparent; + border-color: transparent; +} + +.btn-ghost-light:not(:disabled):not(.disabled):active, .btn-ghost-light:not(:disabled):not(.disabled).active, +.show > .btn-ghost-light.dropdown-toggle { + color: #4f5d73; + background-color: #ebedef; + border-color: #ebedef; +} + +.btn-ghost-light:not(:disabled):not(.disabled):active:focus, .btn-ghost-light:not(:disabled):not(.disabled).active:focus, +.show > .btn-ghost-light.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(235, 237, 239, 0.5); +} + +.btn-ghost-dark { + color: #636f83; + background-color: transparent; + background-image: none; + border-color: transparent; +} + +.btn-ghost-dark:hover { + color: #fff; + background-color: #636f83; + border-color: #636f83; +} + +.btn-ghost-dark:focus, .btn-ghost-dark.focus { + box-shadow: 0 0 0 0.2rem rgba(99, 111, 131, 0.5); +} + +.btn-ghost-dark.disabled, .btn-ghost-dark:disabled { + color: #636f83; + background-color: transparent; + border-color: transparent; +} + +.btn-ghost-dark:not(:disabled):not(.disabled):active, .btn-ghost-dark:not(:disabled):not(.disabled).active, +.show > .btn-ghost-dark.dropdown-toggle { + color: #fff; + background-color: #636f83; + border-color: #636f83; +} + +.btn-ghost-dark:not(:disabled):not(.disabled):active:focus, .btn-ghost-dark:not(:disabled):not(.disabled).active:focus, +.show > .btn-ghost-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(99, 111, 131, 0.5); +} + +.btn-facebook { + color: #fff; + background-color: #3b5998; + border-color: #3b5998; +} + +.btn-facebook:hover,.btn-facebook:focus, .btn-facebook.focus { + color: #fff; + background-color: #30497c; + border-color: #2d4373; +} + +.btn-facebook:focus, .btn-facebook.focus { + box-shadow: 0 0 0 0.2rem rgba(88, 114, 167, 0.5); +} + +.btn-facebook.disabled, .btn-facebook:disabled { + color: #fff; + background-color: #3b5998; + border-color: #3b5998; +} + +.btn-facebook:not(:disabled):not(.disabled):active, .btn-facebook:not(:disabled):not(.disabled).active, +.show > .btn-facebook.dropdown-toggle { + color: #fff; + background-color: #2d4373; + border-color: #293e6a; +} + +.btn-facebook:not(:disabled):not(.disabled):active:focus, .btn-facebook:not(:disabled):not(.disabled).active:focus, +.show > .btn-facebook.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(88, 114, 167, 0.5); +} + +.btn-twitter { + color: #fff; + background-color: #00aced; + border-color: #00aced; +} + +.btn-twitter:hover,.btn-twitter:focus, .btn-twitter.focus { + color: #fff; + background-color: #0090c7; + border-color: #0087ba; +} + +.btn-twitter:focus, .btn-twitter.focus { + box-shadow: 0 0 0 0.2rem rgba(38, 184, 240, 0.5); +} + +.btn-twitter.disabled, .btn-twitter:disabled { + color: #fff; + background-color: #00aced; + border-color: #00aced; +} + +.btn-twitter:not(:disabled):not(.disabled):active, .btn-twitter:not(:disabled):not(.disabled).active, +.show > .btn-twitter.dropdown-toggle { + color: #fff; + background-color: #0087ba; + border-color: #007ead; +} + +.btn-twitter:not(:disabled):not(.disabled):active:focus, .btn-twitter:not(:disabled):not(.disabled).active:focus, +.show > .btn-twitter.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(38, 184, 240, 0.5); +} + +.btn-linkedin { + color: #fff; + background-color: #4875b4; + border-color: #4875b4; +} + +.btn-linkedin:hover,.btn-linkedin:focus, .btn-linkedin.focus { + color: #fff; + background-color: #3d6399; + border-color: #395d90; +} + +.btn-linkedin:focus, .btn-linkedin.focus { + box-shadow: 0 0 0 0.2rem rgba(99, 138, 191, 0.5); +} + +.btn-linkedin.disabled, .btn-linkedin:disabled { + color: #fff; + background-color: #4875b4; + border-color: #4875b4; +} + +.btn-linkedin:not(:disabled):not(.disabled):active, .btn-linkedin:not(:disabled):not(.disabled).active, +.show > .btn-linkedin.dropdown-toggle { + color: #fff; + background-color: #395d90; + border-color: #365786; +} + +.btn-linkedin:not(:disabled):not(.disabled):active:focus, .btn-linkedin:not(:disabled):not(.disabled).active:focus, +.show > .btn-linkedin.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(99, 138, 191, 0.5); +} + +.btn-flickr { + color: #fff; + background-color: #ff0084; + border-color: #ff0084; +} + +.btn-flickr:hover,.btn-flickr:focus, .btn-flickr.focus { + color: #fff; + background-color: #d90070; + border-color: #cc006a; +} + +.btn-flickr:focus, .btn-flickr.focus { + box-shadow: 0 0 0 0.2rem rgba(255, 38, 150, 0.5); +} + +.btn-flickr.disabled, .btn-flickr:disabled { + color: #fff; + background-color: #ff0084; + border-color: #ff0084; +} + +.btn-flickr:not(:disabled):not(.disabled):active, .btn-flickr:not(:disabled):not(.disabled).active, +.show > .btn-flickr.dropdown-toggle { + color: #fff; + background-color: #cc006a; + border-color: #bf0063; +} + +.btn-flickr:not(:disabled):not(.disabled):active:focus, .btn-flickr:not(:disabled):not(.disabled).active:focus, +.show > .btn-flickr.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(255, 38, 150, 0.5); +} + +.btn-tumblr { + color: #fff; + background-color: #32506d; + border-color: #32506d; +} + +.btn-tumblr:hover,.btn-tumblr:focus, .btn-tumblr.focus { + color: #fff; + background-color: #263d53; + border-color: #22364a; +} + +.btn-tumblr:focus, .btn-tumblr.focus { + box-shadow: 0 0 0 0.2rem rgba(81, 106, 131, 0.5); +} + +.btn-tumblr.disabled, .btn-tumblr:disabled { + color: #fff; + background-color: #32506d; + border-color: #32506d; +} + +.btn-tumblr:not(:disabled):not(.disabled):active, .btn-tumblr:not(:disabled):not(.disabled).active, +.show > .btn-tumblr.dropdown-toggle { + color: #fff; + background-color: #22364a; + border-color: #1e3041; +} + +.btn-tumblr:not(:disabled):not(.disabled):active:focus, .btn-tumblr:not(:disabled):not(.disabled).active:focus, +.show > .btn-tumblr.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(81, 106, 131, 0.5); +} + +.btn-xing { + color: #fff; + background-color: #026466; + border-color: #026466; +} + +.btn-xing:hover,.btn-xing:focus, .btn-xing.focus { + color: #fff; + background-color: #013f40; + border-color: #013334; +} + +.btn-xing:focus, .btn-xing.focus { + box-shadow: 0 0 0 0.2rem rgba(40, 123, 125, 0.5); +} + +.btn-xing.disabled, .btn-xing:disabled { + color: #fff; + background-color: #026466; + border-color: #026466; +} + +.btn-xing:not(:disabled):not(.disabled):active, .btn-xing:not(:disabled):not(.disabled).active, +.show > .btn-xing.dropdown-toggle { + color: #fff; + background-color: #013334; + border-color: #012727; +} + +.btn-xing:not(:disabled):not(.disabled):active:focus, .btn-xing:not(:disabled):not(.disabled).active:focus, +.show > .btn-xing.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(40, 123, 125, 0.5); +} + +.btn-github { + color: #fff; + background-color: #4183c4; + border-color: #4183c4; +} + +.btn-github:hover,.btn-github:focus, .btn-github.focus { + color: #fff; + background-color: #3570aa; + border-color: #3269a0; +} + +.btn-github:focus, .btn-github.focus { + box-shadow: 0 0 0 0.2rem rgba(94, 150, 205, 0.5); +} + +.btn-github.disabled, .btn-github:disabled { + color: #fff; + background-color: #4183c4; + border-color: #4183c4; +} + +.btn-github:not(:disabled):not(.disabled):active, .btn-github:not(:disabled):not(.disabled).active, +.show > .btn-github.dropdown-toggle { + color: #fff; + background-color: #3269a0; + border-color: #2f6397; +} + +.btn-github:not(:disabled):not(.disabled):active:focus, .btn-github:not(:disabled):not(.disabled).active:focus, +.show > .btn-github.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(94, 150, 205, 0.5); +} + +.btn-stack-overflow { + color: #fff; + background-color: #fe7a15; + border-color: #fe7a15; +} + +.btn-stack-overflow:hover,.btn-stack-overflow:focus, .btn-stack-overflow.focus { + color: #fff; + background-color: #ec6701; + border-color: #df6101; +} + +.btn-stack-overflow:focus, .btn-stack-overflow.focus { + box-shadow: 0 0 0 0.2rem rgba(254, 142, 56, 0.5); +} + +.btn-stack-overflow.disabled, .btn-stack-overflow:disabled { + color: #fff; + background-color: #fe7a15; + border-color: #fe7a15; +} + +.btn-stack-overflow:not(:disabled):not(.disabled):active, .btn-stack-overflow:not(:disabled):not(.disabled).active, +.show > .btn-stack-overflow.dropdown-toggle { + color: #fff; + background-color: #df6101; + border-color: #d25c01; +} + +.btn-stack-overflow:not(:disabled):not(.disabled):active:focus, .btn-stack-overflow:not(:disabled):not(.disabled).active:focus, +.show > .btn-stack-overflow.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(254, 142, 56, 0.5); +} + +.btn-youtube { + color: #fff; + background-color: #b00; + border-color: #b00; +} + +.btn-youtube:hover,.btn-youtube:focus, .btn-youtube.focus { + color: #fff; + background-color: #950000; + border-color: #880000; +} + +.btn-youtube:focus, .btn-youtube.focus { + box-shadow: 0 0 0 0.2rem rgba(197, 38, 38, 0.5); +} + +.btn-youtube.disabled, .btn-youtube:disabled { + color: #fff; + background-color: #b00; + border-color: #b00; +} + +.btn-youtube:not(:disabled):not(.disabled):active, .btn-youtube:not(:disabled):not(.disabled).active, +.show > .btn-youtube.dropdown-toggle { + color: #fff; + background-color: #880000; + border-color: #7b0000; +} + +.btn-youtube:not(:disabled):not(.disabled):active:focus, .btn-youtube:not(:disabled):not(.disabled).active:focus, +.show > .btn-youtube.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(197, 38, 38, 0.5); +} + +.btn-dribbble { + color: #fff; + background-color: #ea4c89; + border-color: #ea4c89; +} + +.btn-dribbble:hover,.btn-dribbble:focus, .btn-dribbble.focus { + color: #fff; + background-color: #e62a72; + border-color: #e51e6b; +} + +.btn-dribbble:focus, .btn-dribbble.focus { + box-shadow: 0 0 0 0.2rem rgba(237, 103, 155, 0.5); +} + +.btn-dribbble.disabled, .btn-dribbble:disabled { + color: #fff; + background-color: #ea4c89; + border-color: #ea4c89; +} + +.btn-dribbble:not(:disabled):not(.disabled):active, .btn-dribbble:not(:disabled):not(.disabled).active, +.show > .btn-dribbble.dropdown-toggle { + color: #fff; + background-color: #e51e6b; + border-color: #dc1a65; +} + +.btn-dribbble:not(:disabled):not(.disabled):active:focus, .btn-dribbble:not(:disabled):not(.disabled).active:focus, +.show > .btn-dribbble.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(237, 103, 155, 0.5); +} + +.btn-instagram { + color: #fff; + background-color: #517fa4; + border-color: #517fa4; +} + +.btn-instagram:hover,.btn-instagram:focus, .btn-instagram.focus { + color: #fff; + background-color: #446b8a; + border-color: #406582; +} + +.btn-instagram:focus, .btn-instagram.focus { + box-shadow: 0 0 0 0.2rem rgba(107, 146, 178, 0.5); +} + +.btn-instagram.disabled, .btn-instagram:disabled { + color: #fff; + background-color: #517fa4; + border-color: #517fa4; +} + +.btn-instagram:not(:disabled):not(.disabled):active, .btn-instagram:not(:disabled):not(.disabled).active, +.show > .btn-instagram.dropdown-toggle { + color: #fff; + background-color: #406582; + border-color: #3c5e79; +} + +.btn-instagram:not(:disabled):not(.disabled):active:focus, .btn-instagram:not(:disabled):not(.disabled).active:focus, +.show > .btn-instagram.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(107, 146, 178, 0.5); +} + +.btn-pinterest { + color: #fff; + background-color: #cb2027; + border-color: #cb2027; +} + +.btn-pinterest:hover,.btn-pinterest:focus, .btn-pinterest.focus { + color: #fff; + background-color: #aa1b21; + border-color: #9f191f; +} + +.btn-pinterest:focus, .btn-pinterest.focus { + box-shadow: 0 0 0 0.2rem rgba(211, 65, 71, 0.5); +} + +.btn-pinterest.disabled, .btn-pinterest:disabled { + color: #fff; + background-color: #cb2027; + border-color: #cb2027; +} + +.btn-pinterest:not(:disabled):not(.disabled):active, .btn-pinterest:not(:disabled):not(.disabled).active, +.show > .btn-pinterest.dropdown-toggle { + color: #fff; + background-color: #9f191f; + border-color: #94171c; +} + +.btn-pinterest:not(:disabled):not(.disabled):active:focus, .btn-pinterest:not(:disabled):not(.disabled).active:focus, +.show > .btn-pinterest.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(211, 65, 71, 0.5); +} + +.btn-vk { + color: #fff; + background-color: #45668e; + border-color: #45668e; +} + +.btn-vk:hover,.btn-vk:focus, .btn-vk.focus { + color: #fff; + background-color: #385474; + border-color: #344d6c; +} + +.btn-vk:focus, .btn-vk.focus { + box-shadow: 0 0 0 0.2rem rgba(97, 125, 159, 0.5); +} + +.btn-vk.disabled, .btn-vk:disabled { + color: #fff; + background-color: #45668e; + border-color: #45668e; +} + +.btn-vk:not(:disabled):not(.disabled):active, .btn-vk:not(:disabled):not(.disabled).active, +.show > .btn-vk.dropdown-toggle { + color: #fff; + background-color: #344d6c; + border-color: #304763; +} + +.btn-vk:not(:disabled):not(.disabled):active:focus, .btn-vk:not(:disabled):not(.disabled).active:focus, +.show > .btn-vk.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(97, 125, 159, 0.5); +} + +.btn-yahoo { + color: #fff; + background-color: #400191; + border-color: #400191; +} + +.btn-yahoo:hover,.btn-yahoo:focus, .btn-yahoo.focus { + color: #fff; + background-color: #2f016b; + border-color: #2a015e; +} + +.btn-yahoo:focus, .btn-yahoo.focus { + box-shadow: 0 0 0 0.2rem rgba(93, 39, 162, 0.5); +} + +.btn-yahoo.disabled, .btn-yahoo:disabled { + color: #fff; + background-color: #400191; + border-color: #400191; +} + +.btn-yahoo:not(:disabled):not(.disabled):active, .btn-yahoo:not(:disabled):not(.disabled).active, +.show > .btn-yahoo.dropdown-toggle { + color: #fff; + background-color: #2a015e; + border-color: #240152; +} + +.btn-yahoo:not(:disabled):not(.disabled):active:focus, .btn-yahoo:not(:disabled):not(.disabled).active:focus, +.show > .btn-yahoo.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(93, 39, 162, 0.5); +} + +.btn-behance { + color: #fff; + background-color: #1769ff; + border-color: #1769ff; +} + +.btn-behance:hover,.btn-behance:focus, .btn-behance.focus { + color: #fff; + background-color: #0055f0; + border-color: #0050e3; +} + +.btn-behance:focus, .btn-behance.focus { + box-shadow: 0 0 0 0.2rem rgba(58, 128, 255, 0.5); +} + +.btn-behance.disabled, .btn-behance:disabled { + color: #fff; + background-color: #1769ff; + border-color: #1769ff; +} + +.btn-behance:not(:disabled):not(.disabled):active, .btn-behance:not(:disabled):not(.disabled).active, +.show > .btn-behance.dropdown-toggle { + color: #fff; + background-color: #0050e3; + border-color: #004cd6; +} + +.btn-behance:not(:disabled):not(.disabled):active:focus, .btn-behance:not(:disabled):not(.disabled).active:focus, +.show > .btn-behance.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(58, 128, 255, 0.5); +} + +.btn-reddit { + color: #fff; + background-color: #ff4500; + border-color: #ff4500; +} + +.btn-reddit:hover,.btn-reddit:focus, .btn-reddit.focus { + color: #fff; + background-color: #d93b00; + border-color: #cc3700; +} + +.btn-reddit:focus, .btn-reddit.focus { + box-shadow: 0 0 0 0.2rem rgba(255, 97, 38, 0.5); +} + +.btn-reddit.disabled, .btn-reddit:disabled { + color: #fff; + background-color: #ff4500; + border-color: #ff4500; +} + +.btn-reddit:not(:disabled):not(.disabled):active, .btn-reddit:not(:disabled):not(.disabled).active, +.show > .btn-reddit.dropdown-toggle { + color: #fff; + background-color: #cc3700; + border-color: #bf3400; +} + +.btn-reddit:not(:disabled):not(.disabled):active:focus, .btn-reddit:not(:disabled):not(.disabled).active:focus, +.show > .btn-reddit.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(255, 97, 38, 0.5); +} + +.btn-vimeo { + color: #4f5d73; + background-color: #aad450; + border-color: #aad450; +} + +.btn-vimeo:hover,.btn-vimeo:focus, .btn-vimeo.focus { + color: #4f5d73; + background-color: #9bcc32; + border-color: #93c130; +} + +.btn-vimeo:focus, .btn-vimeo.focus { + box-shadow: 0 0 0 0.2rem rgba(156, 194, 85, 0.5); +} + +.btn-vimeo.disabled, .btn-vimeo:disabled { + color: #4f5d73; + background-color: #aad450; + border-color: #aad450; +} + +.btn-vimeo:not(:disabled):not(.disabled):active, .btn-vimeo:not(:disabled):not(.disabled).active, +.show > .btn-vimeo.dropdown-toggle { + color: #4f5d73; + background-color: #93c130; + border-color: #8bb72d; +} + +.btn-vimeo:not(:disabled):not(.disabled):active:focus, .btn-vimeo:not(:disabled):not(.disabled).active:focus, +.show > .btn-vimeo.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(156, 194, 85, 0.5); +} + +.c-callout { + position: relative; + padding: 0 1rem; + margin: 1rem 0; + border-radius: 0.25rem; +} + +html:not([dir="rtl"]) .c-callout { + border-left: 4px solid #d8dbe0; +} + +*[dir="rtl"] .c-callout { + border-right: 4px solid #d8dbe0; +} + +.c-callout-bordered { + border: 1px solid #d8dbe0; + border-left-width: 4px; +} + +.c-callout code { + border-radius: 0.25rem; +} + +.c-callout h4 { + margin-top: 0; + margin-bottom: .25rem; +} + +.c-callout p:last-child { + margin-bottom: 0; +} + +.c-callout + .c-callout { + margin-top: -0.25rem; +} + +html:not([dir="rtl"]) .c-callout-primary { + border-left-color: #321fdb; +} + +*[dir="rtl"] .c-callout-primary { + border-right-color: #321fdb; +} + +.c-callout-primary h4 { + color: #321fdb; +} + +html:not([dir="rtl"]) .c-callout-secondary { + border-left-color: #ced2d8; +} + +*[dir="rtl"] .c-callout-secondary { + border-right-color: #ced2d8; +} + +.c-callout-secondary h4 { + color: #ced2d8; +} + +html:not([dir="rtl"]) .c-callout-success { + border-left-color: #2eb85c; +} + +*[dir="rtl"] .c-callout-success { + border-right-color: #2eb85c; +} + +.c-callout-success h4 { + color: #2eb85c; +} + +html:not([dir="rtl"]) .c-callout-info { + border-left-color: #39f; +} + +*[dir="rtl"] .c-callout-info { + border-right-color: #39f; +} + +.c-callout-info h4 { + color: #39f; +} + +html:not([dir="rtl"]) .c-callout-warning { + border-left-color: #f9b115; +} + +*[dir="rtl"] .c-callout-warning { + border-right-color: #f9b115; +} + +.c-callout-warning h4 { + color: #f9b115; +} + +html:not([dir="rtl"]) .c-callout-danger { + border-left-color: #e55353; +} + +*[dir="rtl"] .c-callout-danger { + border-right-color: #e55353; +} + +.c-callout-danger h4 { + color: #e55353; +} + +html:not([dir="rtl"]) .c-callout-light { + border-left-color: #ebedef; +} + +*[dir="rtl"] .c-callout-light { + border-right-color: #ebedef; +} + +.c-callout-light h4 { + color: #ebedef; +} + +html:not([dir="rtl"]) .c-callout-dark { + border-left-color: #636f83; +} + +*[dir="rtl"] .c-callout-dark { + border-right-color: #636f83; +} + +.c-callout-dark h4 { + color: #636f83; +} + +.card { + position: relative; + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; + min-width: 0; + margin-bottom: 1.5rem; + word-wrap: break-word; + background-clip: border-box; + border: 1px solid; + border-radius: 0.25rem; + background-color: #fff; + border-color: #d8dbe0; +} + +.card > hr { + margin-right: 0; + margin-left: 0; +} + +.card > .list-group:first-child .list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +.card > .list-group:last-child .list-group-item:last-child { + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.card.drag, +.card .drag { + cursor: move; +} + +.card.bg-primary { + border-color: #2517a3; +} + +.card.bg-primary .card-header { + background-color: #2f1dce; + border-color: #2517a3; +} + +.card.bg-secondary { + border-color: #abb1bc; +} + +.card.bg-secondary .card-header { + background-color: #c5cad1; + border-color: #abb1bc; +} + +.card.bg-success { + border-color: #218543; +} + +.card.bg-success .card-header { + background-color: #2bac56; + border-color: #218543; +} + +.card.bg-info { + border-color: #0079f2; +} + +.card.bg-info .card-header { + background-color: #2491ff; + border-color: #0079f2; +} + +.card.bg-warning { + border-color: #c98b05; +} + +.card.bg-warning .card-header { + background-color: #f8ac06; + border-color: #c98b05; +} + +.card.bg-danger { + border-color: #d82121; +} + +.card.bg-danger .card-header { + background-color: #e34646; + border-color: #d82121; +} + +.card.bg-light { + border-color: #c8cdd3; +} + +.card.bg-light .card-header { + background-color: #e3e5e8; + border-color: #c8cdd3; +} + +.card.bg-dark { + border-color: #48505f; +} + +.card.bg-dark .card-header { + background-color: #5c687a; + border-color: #48505f; +} + +.card-body { + -ms-flex: 1 1 auto; + flex: 1 1 auto; + min-height: 1px; + padding: 1.25rem; +} + +.card-title { + margin-bottom: 0.75rem; +} + +.card-subtitle { + margin-top: -0.375rem; +} + +.card-subtitle,.card-text:last-child { + margin-bottom: 0; +} + +.card-link:hover { + text-decoration: none; +} + +html:not([dir="rtl"]) .card-link + .card-link { + margin-left: 1.25rem; +} + +*[dir="rtl"] .card-link + .card-link { + margin-right: 1.25rem; +} + +.card-header { + padding: 0.75rem 1.25rem; + margin-bottom: 0; + border-bottom: 1px solid; + background-color: #fff; + border-color: #d8dbe0; +} + +.card-header:first-child { + border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0; +} + +.card-header + .list-group .list-group-item:first-child { + border-top: 0; +} + +.card-header .c-chart-wrapper { + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; +} + +.card-footer { + padding: 0.75rem 1.25rem; + border-top: 1px solid; + background-color: #fff; + border-color: #d8dbe0; +} + +.card-footer:last-child { + border-radius: 0 0 calc(0.25rem - 1px) calc(0.25rem - 1px); +} + +.card-header-tabs { + margin-bottom: -0.75rem; + border-bottom: 0; +} + +.card-header-tabs,.card-header-pills { + margin-right: -0.625rem; + margin-left: -0.625rem; +} + +.card-img-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + padding: 1.25rem; +} + +.card-img, +.card-img-top, +.card-img-bottom { + -ms-flex-negative: 0; + flex-shrink: 0; + width: 100%; +} + +.card-img, +.card-img-top { + border-top-left-radius: calc(0.25rem - 1px); + border-top-right-radius: calc(0.25rem - 1px); +} + +.card-img, +.card-img-bottom { + border-bottom-right-radius: calc(0.25rem - 1px); + border-bottom-left-radius: calc(0.25rem - 1px); +} + +.card-deck .card { + margin-bottom: 15px; +} + +@media (min-width: 576px) { + .card-deck { + display: -ms-flexbox; + display: flex; + -ms-flex-flow: row wrap; + flex-flow: row wrap; + margin-right: -15px; + margin-left: -15px; + } + .card-deck .card { + -ms-flex: 1 0 0%; + flex: 1 0 0%; + margin-right: 15px; + margin-bottom: 0; + margin-left: 15px; + } +} + +.card-group > .card { + margin-bottom: 15px; +} + +@media (min-width: 576px) { + .card-group { + display: -ms-flexbox; + display: flex; + -ms-flex-flow: row wrap; + flex-flow: row wrap; + } + .card-group > .card { + -ms-flex: 1 0 0%; + flex: 1 0 0%; + margin-bottom: 0; + } + html:not([dir="rtl"]) .card-group > .card + .card { + margin-left: 0; + border-left: 0; + } + *[dir="rtl"] .card-group > .card + .card { + margin-right: 0; + border-right: 0; + } + .card-group > .card:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + .card-group > .card:not(:last-child) .card-img-top, + .card-group > .card:not(:last-child) .card-header { + border-top-right-radius: 0; + } + .card-group > .card:not(:last-child) .card-img-bottom, + .card-group > .card:not(:last-child) .card-footer { + 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-img-top, + .card-group > .card:not(:first-child) .card-header { + border-top-left-radius: 0; + } + .card-group > .card:not(:first-child) .card-img-bottom, + .card-group > .card:not(:first-child) .card-footer { + border-bottom-left-radius: 0; + } +} + +.card-columns .card { + margin-bottom: 0.75rem; +} + +@media (min-width: 576px) { + .card-columns { + -webkit-column-count: 3; + -moz-column-count: 3; + column-count: 3; + -webkit-column-gap: 1.25rem; + -moz-column-gap: 1.25rem; + column-gap: 1.25rem; + orphans: 1; + widows: 1; + } + .card-columns .card { + display: inline-block; + width: 100%; + } +} + +.accordion > .card { + overflow: hidden; +} + +.accordion > .card:not(:last-of-type) { + border-bottom: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.accordion > .card:not(:first-of-type) { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.accordion > .card > .card-header { + border-radius: 0; + margin-bottom: -1px; +} + +.card-placeholder { + background: rgba(0, 0, 21, 0.025); + border: 1px dashed #c4c9d0; +} + +.card-header-icon-bg { + width: 2.8125rem; + padding: 0.75rem 0; + margin: -0.75rem 1.25rem -0.75rem -1.25rem; + line-height: inherit; + color: #4f5d73; + text-align: center; + background: transparent; + border-right: 1px solid; + border-right: #d8dbe0; +} + +.card-header-icon-bg,.card-header-actions { + display: inline-block; +} + +html:not([dir="rtl"]) .card-header-actions { + float: right; + margin-right: -0.25rem; +} + +*[dir="rtl"] .card-header-actions { + float: left; + margin-left: -0.25rem; +} + +.card-header-action { + padding: 0 0.25rem; + color: #8a93a2; +} + +.card-header-action:hover { + color: #4f5d73; + text-decoration: none; +} + +.card-accent-primary { + border-top: 2px solid #321fdb !important; +} + +.card-accent-secondary { + border-top: 2px solid #ced2d8 !important; +} + +.card-accent-success { + border-top: 2px solid #2eb85c !important; +} + +.card-accent-info { + border-top: 2px solid #39f !important; +} + +.card-accent-warning { + border-top: 2px solid #f9b115 !important; +} + +.card-accent-danger { + border-top: 2px solid #e55353 !important; +} + +.card-accent-light { + border-top: 2px solid #ebedef !important; +} + +.card-accent-dark { + border-top: 2px solid #636f83 !important; +} + +.card-full { + margin-top: -1rem; + margin-right: -15px; + margin-left: -15px; + border: 0; + border-bottom: 1px solid #d8dbe0; +} + +@media (min-width: 576px) { + .card-columns.cols-2 { + -webkit-column-count: 2; + -moz-column-count: 2; + column-count: 2; + } +} + +.carousel { + position: relative; +} + +.carousel.pointer-event { + -ms-touch-action: pan-y; + 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%; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + transition: -webkit-transform 0.6s ease-in-out; + transition: transform 0.6s ease-in-out; + transition: transform 0.6s ease-in-out, -webkit-transform 0.6s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-item { + transition: none; + } +} + +.carousel-item.active, +.carousel-item-next, +.carousel-item-prev { + display: block; +} + +.carousel-item-next:not(.carousel-item-left), +.active.carousel-item-right { + -webkit-transform: translateX(100%); + transform: translateX(100%); +} + +.carousel-item-prev:not(.carousel-item-right), +.active.carousel-item-left { + -webkit-transform: translateX(-100%); + transform: translateX(-100%); +} + +.carousel-fade .carousel-item { + opacity: 0; + transition-property: opacity; + -webkit-transform: none; + transform: none; +} + +.carousel-fade .carousel-item.active, +.carousel-fade .carousel-item-next.carousel-item-left, +.carousel-fade .carousel-item-prev.carousel-item-right { + z-index: 1; + opacity: 1; +} + +.carousel-fade .active.carousel-item-left, +.carousel-fade .active.carousel-item-right { + z-index: 0; + opacity: 0; + transition: opacity 0s 0.6s; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-fade .active.carousel-item-left, + .carousel-fade .active.carousel-item-right { + transition: none; + } +} + +.carousel-control-prev, +.carousel-control-next { + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; + width: 15%; + color: #fff; + text-align: center; + opacity: 0.5; + transition: opacity 0.15s ease; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-control-prev, + .carousel-control-next { + transition: none; + } +} + +.carousel-control-prev:hover, .carousel-control-prev:focus, +.carousel-control-next:hover, +.carousel-control-next:focus { + color: #fff; + text-decoration: none; + outline: 0; + opacity: 0.9; +} + +.carousel-control-prev { + left: 0; +} + +.carousel-control-next { + right: 0; +} + +.carousel-control-prev-icon, +.carousel-control-next-icon { + display: inline-block; + width: 20px; + height: 20px; + background: no-repeat 50% / 100% 100%; +} + +.carousel-control-prev-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e"); +} + +.carousel-control-next-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e"); +} + +.carousel-indicators { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 15; + display: -ms-flexbox; + display: flex; + -ms-flex-pack: center; + justify-content: center; + margin-right: 15%; + margin-left: 15%; + list-style: none; +} + +html:not([dir="rtl"]) .carousel-indicators { + padding-left: 0; +} + +*[dir="rtl"] .carousel-indicators { + padding-right: 0; +} + +.carousel-indicators li { + box-sizing: content-box; + -ms-flex: 0 1 auto; + flex: 0 1 auto; + width: 30px; + height: 3px; + margin-right: 3px; + margin-left: 3px; + text-indent: -999px; + cursor: pointer; + background-color: #fff; + background-clip: padding-box; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + opacity: .5; + transition: opacity 0.6s ease; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-indicators li { + transition: none; + } +} + +.carousel-indicators .active { + opacity: 1; +} + +.carousel-caption { + position: absolute; + right: 15%; + bottom: 20px; + left: 15%; + z-index: 10; + padding-top: 20px; + padding-bottom: 20px; + color: #fff; + text-align: center; +} + +.c-chart-wrapper canvas { + width: 100%; +} + +base-chart.chart { + display: block; +} + +canvas { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.close { + float: right; + font-size: 1.3125rem; + font-weight: 700; + line-height: 1; + opacity: .5; + color: #000015; + text-shadow: 0 1px 0 #fff; +} + +.close:hover { + text-decoration: none; + color: #000015; +} + +.close:not(:disabled):not(.disabled):hover, .close:not(:disabled):not(.disabled):focus { + opacity: .75; +} + +button.close { + padding: 0; + background-color: transparent; + border: 0; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +a.close.disabled { + pointer-events: none; +} + +code { + font-size: 87.5%; + color: #e83e8c; + word-wrap: break-word; +} + +a > code { + color: inherit; +} + +kbd { + padding: 0.2rem 0.4rem; + font-size: 87.5%; + color: #fff; + background-color: #4f5d73; + border-radius: 0.2rem; +} + +kbd kbd { + padding: 0; + font-size: 100%; + font-weight: 700; +} + +pre code { + font-size: inherit; + color: inherit; + word-break: normal; +} + +.pre-scrollable { + max-height: 340px; + overflow-y: scroll; +} + +.custom-control { + position: relative; + display: block; + min-height: 1.3125rem; +} + +html:not([dir="rtl"]) .custom-control { + padding-left: 1.5rem; +} + +*[dir="rtl"] .custom-control { + padding-right: 1.5rem; +} + +.custom-control-inline { + display: -ms-inline-flexbox; + display: inline-flex; + margin-right: 1rem; +} + +.custom-control-input { + position: absolute; + z-index: -1; + width: 1rem; + height: 1.15625rem; + opacity: 0; +} + +html:not([dir="rtl"]) .custom-control-input { + left: 0; +} + +*[dir="rtl"] .custom-control-input { + right: 0; +} + +.custom-control-input:checked ~ .custom-control-label::before { + color: #fff; + border-color: #321fdb; + background-color: #321fdb; +} + +.custom-control-input:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.25); +} + +.custom-control-input:focus:not(:checked) ~ .custom-control-label::before { + border-color: #958bef; +} + +.custom-control-input:not(:disabled):active ~ .custom-control-label::before { + color: #fff; + background-color: #beb8f5; + border-color: #beb8f5; +} + +.custom-control-input[disabled] ~ .custom-control-label, .custom-control-input:disabled ~ .custom-control-label { + color: #8a93a2; +} + +.custom-control-input[disabled] ~ .custom-control-label::before, .custom-control-input:disabled ~ .custom-control-label::before { + background-color: #d8dbe0; +} + +.custom-control-label { + position: relative; + margin-bottom: 0; + vertical-align: top; +} + +.custom-control-label::before { + position: absolute; + top: 0.15625rem; + display: block; + width: 1rem; + height: 1rem; + pointer-events: none; + content: ""; + border: solid 1px; + background-color: #fff; + border-color: #9da5b1; +} + +html:not([dir="rtl"]) .custom-control-label::before { + left: -1.5rem; +} + +*[dir="rtl"] .custom-control-label::before { + right: -1.5rem; +} + +.custom-control-label::after { + position: absolute; + top: 0.15625rem; + display: block; + width: 1rem; + height: 1rem; + content: ""; + background: no-repeat 50% / 50% 50%; +} + +html:not([dir="rtl"]) .custom-control-label::after { + left: -1.5rem; +} + +*[dir="rtl"] .custom-control-label::after { + right: -1.5rem; +} + +.custom-checkbox .custom-control-label::before { + border-radius: 0.25rem; +} + +.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e"); +} + +.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before { + border-color: #321fdb; + background-color: #321fdb; +} + +.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e"); +} + +.custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before { + background-color: rgba(50, 31, 219, 0.5); +} + +.custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before { + background-color: rgba(50, 31, 219, 0.5); +} + +.custom-radio .custom-control-label::before { + border-radius: 50%; +} + +.custom-radio .custom-control-input:checked ~ .custom-control-label::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); +} + +.custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before { + background-color: rgba(50, 31, 219, 0.5); +} + +html:not([dir="rtl"]) .custom-switch { + padding-left: 2.25rem; +} + +*[dir="rtl"] .custom-switch { + padding-right: 2.25rem; +} + +.custom-switch .custom-control-label::before { + width: 1.75rem; + pointer-events: all; + border-radius: 0.5rem; +} + +html:not([dir="rtl"]) .custom-switch .custom-control-label::before { + left: -2.25rem; +} + +*[dir="rtl"] .custom-switch .custom-control-label::before { + right: -2.25rem; +} + +.custom-switch .custom-control-label::after { + top: calc(0.15625rem + 2px); + width: calc(1rem - 4px); + height: calc(1rem - 4px); + background-color: #9da5b1; + border-radius: 0.5rem; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-transform 0.15s ease-in-out; + transition: transform 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; + transition: transform 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, -webkit-transform 0.15s ease-in-out; + background-color: #9da5b1; +} + +html:not([dir="rtl"]) .custom-switch .custom-control-label::after { + left: calc(-2.25rem + 2px); +} + +*[dir="rtl"] .custom-switch .custom-control-label::after { + right: calc(-2.25rem + 2px); +} + +@media (prefers-reduced-motion: reduce) { + .custom-switch .custom-control-label::after { + transition: none; + } +} + +.custom-switch .custom-control-input:checked ~ .custom-control-label::after { + background-color: #fff; + -webkit-transform: translateX(0.75rem); + transform: translateX(0.75rem); +} + +.custom-switch .custom-control-input:disabled:checked ~ .custom-control-label::before { + background-color: rgba(50, 31, 219, 0.5); +} + +.custom-select { + display: inline-block; + width: 100%; + height: calc(1.5em + 0.75rem + 2px); + padding: 0.375rem 1.75rem 0.375rem 0.75rem; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + vertical-align: middle; + border: 1px solid; + border-radius: 0.25rem; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + color: #768192; + background: #fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23636f83' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px; + border-color: #d8dbe0; +} + +.custom-select:focus { + border-color: #958bef; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.25); +} + +.custom-select:focus::-ms-value { + color: #768192; + background-color: #fff; +} + +.custom-select[multiple], .custom-select[size]:not([size="1"]) { + height: auto; + background-image: none; +} + +html:not([dir="rtl"]) .custom-select[multiple], html:not([dir="rtl"]) .custom-select[size]:not([size="1"]) { + padding-right: 0.75rem; +} + +*[dir="rtl"] .custom-select[multiple], *[dir="rtl"] .custom-select[size]:not([size="1"]) { + padding-left: 0.75rem; +} + +.custom-select:disabled { + color: #8a93a2; + background-color: #d8dbe0; +} + +.custom-select::-ms-expand { + display: none; +} + +.custom-select:-moz-focusring { + color: transparent; + text-shadow: 0 0 0 #768192; +} + +.custom-select-sm { + height: calc(1.5em + 0.5rem + 2px); + padding-top: 0.25rem; + padding-bottom: 0.25rem; + font-size: 0.765625rem; +} + +html:not([dir="rtl"]) .custom-select-sm { + padding-left: 0.5rem; +} + +*[dir="rtl"] .custom-select-sm { + padding-right: 0.5rem; +} + +.custom-select-lg { + height: calc(1.5em + 1rem + 2px); + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 1.09375rem; +} + +html:not([dir="rtl"]) .custom-select-lg { + padding-left: 1rem; +} + +*[dir="rtl"] .custom-select-lg { + padding-right: 1rem; +} + +.custom-file { + display: inline-block; + margin-bottom: 0; +} + +.custom-file,.custom-file-input { + position: relative; + width: 100%; + height: calc(1.5em + 0.75rem + 2px); +} + +.custom-file-input { + z-index: 2; + margin: 0; + opacity: 0; +} + +.custom-file-input:focus ~ .custom-file-label { + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.25); + border-color: #958bef; +} + +.custom-file-input[disabled] ~ .custom-file-label, +.custom-file-input:disabled ~ .custom-file-label { + background-color: #d8dbe0; +} + +.custom-file-input:lang(en) ~ .custom-file-label::after { + content: "Browse"; +} + +.custom-file-input ~ .custom-file-label[data-browse]::after { + content: attr(data-browse); +} + +.custom-file-label { + right: 0; + left: 0; + z-index: 1; + height: calc(1.5em + 0.75rem + 2px); + font-weight: 400; + border: 1px solid; + border-radius: 0.25rem; + background-color: #fff; + border-color: #d8dbe0; +} + +.custom-file-label,.custom-file-label::after { + position: absolute; + top: 0; + padding: 0.375rem 0.75rem; + line-height: 1.5; + color: #768192; +} + +.custom-file-label::after { + bottom: 0; + z-index: 3; + display: block; + height: calc(1.5em + 0.75rem); + content: "Browse"; + border-left: inherit; + border-radius: 0 0.25rem 0.25rem 0; + background-color: #ebedef; +} + +html:not([dir="rtl"]) .custom-file-label::after { + right: 0; +} + +*[dir="rtl"] .custom-file-label::after { + left: 0; +} + +.custom-range { + width: 100%; + height: 1.4rem; + padding: 0; + background-color: transparent; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.custom-range:focus { + outline: none; +} + +.custom-range:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 1px #ebedef, 0 0 0 0.2rem rgba(50, 31, 219, 0.25); +} + +.custom-range:focus::-moz-range-thumb { + box-shadow: 0 0 0 1px #ebedef, 0 0 0 0.2rem rgba(50, 31, 219, 0.25); +} + +.custom-range:focus::-ms-thumb { + box-shadow: 0 0 0 1px #ebedef, 0 0 0 0.2rem rgba(50, 31, 219, 0.25); +} + +.custom-range::-moz-focus-outer { + border: 0; +} + +.custom-range::-webkit-slider-thumb { + width: 1rem; + height: 1rem; + margin-top: -0.25rem; + background-color: #321fdb; + border: 0; + border-radius: 1rem; + -webkit-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + -webkit-appearance: none; + appearance: none; +} + +@media (prefers-reduced-motion: reduce) { + .custom-range::-webkit-slider-thumb { + -webkit-transition: none; + transition: none; + } +} + +.custom-range::-webkit-slider-thumb:active { + background-color: ghten(#321fdb, 35%); +} + +.custom-range::-webkit-slider-runnable-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + border-color: transparent; + border-radius: 1rem; + background-color: #c4c9d0; +} + +.custom-range::-moz-range-thumb { + width: 1rem; + height: 1rem; + background-color: #321fdb; + border: 0; + border-radius: 1rem; + -moz-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + -moz-appearance: none; + appearance: none; +} + +@media (prefers-reduced-motion: reduce) { + .custom-range::-moz-range-thumb { + -moz-transition: none; + transition: none; + } +} + +.custom-range::-moz-range-thumb:active { + background-color: ghten(#321fdb, 35%); +} + +.custom-range::-moz-range-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: #c4c9d0; + border-color: transparent; + border-radius: 1rem; +} + +.custom-range::-ms-thumb { + width: 1rem; + height: 1rem; + margin-top: 0; + margin-right: 0.2rem; + margin-left: 0.2rem; + background-color: #321fdb; + border: 0; + border-radius: 1rem; + -ms-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + appearance: none; +} + +@media (prefers-reduced-motion: reduce) { + .custom-range::-ms-thumb { + -ms-transition: none; + transition: none; + } +} + +.custom-range::-ms-thumb:active { + background-color: ghten(#321fdb, 35%); +} + +.custom-range::-ms-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: transparent; + border-color: transparent; + border-width: 0.5rem; +} + +.custom-range::-ms-fill-lower,.custom-range::-ms-fill-upper { + background-color: #c4c9d0; + border-radius: 1rem; +} + +.custom-range::-ms-fill-upper { + margin-right: 15px; +} + +.custom-range:disabled::-webkit-slider-thumb { + background-color: #9da5b1; +} + +.custom-range:disabled::-webkit-slider-runnable-track { + cursor: default; +} + +.custom-range:disabled::-moz-range-thumb { + background-color: #9da5b1; +} + +.custom-range:disabled::-moz-range-track { + cursor: default; +} + +.custom-range:disabled::-ms-thumb { + background-color: #9da5b1; +} + +.custom-control-label::before, +.custom-file-label, +.custom-select { + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .custom-control-label::before, + .custom-file-label, + .custom-select { + transition: none; + } +} + +.dropup, +.dropright, +.dropdown, +.dropleft { + position: relative; +} + +.dropdown-toggle { + white-space: nowrap; +} + +.dropdown-toggle::after { + display: inline-block; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid; + border-right: 0.3em solid transparent; + border-bottom: 0; + border-left: 0.3em solid transparent; +} + +html:not([dir="rtl"]) .dropdown-toggle::after { + margin-left: 0.255em; +} + +*[dir="rtl"] .dropdown-toggle::after { + margin-right: 0.255em; +} + +html:not([dir="rtl"]) .dropdown-toggle:empty::after { + margin-left: 0; +} + +*[dir="rtl"] .dropdown-toggle:empty::after { + margin-right: 0; +} + +.dropdown-menu { + position: absolute; + top: 100%; + z-index: 1000; + display: none; + float: left; + min-width: 10rem; + padding: 0.5rem 0; + margin: 0.125rem 0 0; + font-size: 0.875rem; + text-align: left; + list-style: none; + background-clip: padding-box; + border: 1px solid; + border-radius: 0.25rem; + color: #4f5d73; + background-color: #fff; + border-color: #d8dbe0; +} + +html:not([dir="rtl"]) .dropdown-menu { + left: 0; +} + +*[dir="rtl"] .dropdown-menu { + right: 0; +} + +html:not([dir="rtl"]) .dropdown-menu-left { + right: auto; + left: 0; +} + +*[dir="rtl"] .dropdown-menu-left,html:not([dir="rtl"]) .dropdown-menu-right { + right: 0; + left: auto; +} + +*[dir="rtl"] .dropdown-menu-right { + right: auto; + left: 0; +} + +@media (min-width: 576px) { + html:not([dir="rtl"]) .dropdown-menu-sm-left { + right: auto; + left: 0; + } + *[dir="rtl"] .dropdown-menu-sm-left,html:not([dir="rtl"]) .dropdown-menu-sm-right { + right: 0; + left: auto; + } + *[dir="rtl"] .dropdown-menu-sm-right { + right: auto; + left: 0; + } +} + +@media (min-width: 768px) { + html:not([dir="rtl"]) .dropdown-menu-md-left { + right: auto; + left: 0; + } + *[dir="rtl"] .dropdown-menu-md-left,html:not([dir="rtl"]) .dropdown-menu-md-right { + right: 0; + left: auto; + } + *[dir="rtl"] .dropdown-menu-md-right { + right: auto; + left: 0; + } +} + +@media (min-width: 992px) { + html:not([dir="rtl"]) .dropdown-menu-lg-left { + right: auto; + left: 0; + } + *[dir="rtl"] .dropdown-menu-lg-left,html:not([dir="rtl"]) .dropdown-menu-lg-right { + right: 0; + left: auto; + } + *[dir="rtl"] .dropdown-menu-lg-right { + right: auto; + left: 0; + } +} + +@media (min-width: 1200px) { + html:not([dir="rtl"]) .dropdown-menu-xl-left { + right: auto; + left: 0; + } + *[dir="rtl"] .dropdown-menu-xl-left,html:not([dir="rtl"]) .dropdown-menu-xl-right { + right: 0; + left: auto; + } + *[dir="rtl"] .dropdown-menu-xl-right { + right: auto; + left: 0; + } +} + +.dropup .dropdown-menu { + top: auto; + bottom: 100%; + margin-top: 0; + margin-bottom: 0.125rem; +} + +.dropup .dropdown-toggle::after { + display: inline-block; + vertical-align: 0.255em; + content: ""; + border-top: 0; + border-right: 0.3em solid transparent; + border-bottom: 0.3em solid; + border-left: 0.3em solid transparent; +} + +html:not([dir="rtl"]) .dropup .dropdown-toggle::after { + margin-left: 0.255em; +} + +*[dir="rtl"] .dropup .dropdown-toggle::after { + margin-right: 0.255em; +} + +html:not([dir="rtl"]) .dropup .dropdown-toggle:empty::after { + margin-left: 0; +} + +*[dir="rtl"] .dropup .dropdown-toggle:empty::after { + margin-right: 0; +} + +.dropright .dropdown-menu { + top: 0; + margin-top: 0; +} + +html:not([dir="rtl"]) .dropright .dropdown-menu { + right: auto; + left: 100%; + margin-left: 0.125rem; +} + +*[dir="rtl"] .dropright .dropdown-menu { + right: 100%; + left: auto; + margin-right: 0.125rem; +} + +.dropright .dropdown-toggle::after { + display: inline-block; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid transparent; + border-right: 0; + border-bottom: 0.3em solid transparent; + border-left: 0.3em solid; + vertical-align: 0; +} + +html:not([dir="rtl"]) .dropright .dropdown-toggle::after { + margin-left: 0.255em; +} + +*[dir="rtl"] .dropright .dropdown-toggle::after { + margin-right: 0.255em; +} + +html:not([dir="rtl"]) .dropright .dropdown-toggle:empty::after { + margin-left: 0; +} + +*[dir="rtl"] .dropright .dropdown-toggle:empty::after { + margin-right: 0; +} + +.dropleft .dropdown-menu { + top: 0; + margin-top: 0; +} + +html:not([dir="rtl"]) .dropleft .dropdown-menu { + right: 100%; + left: auto; + margin-right: 0.125rem; +} + +*[dir="rtl"] .dropleft .dropdown-menu { + right: auto; + left: 100%; + margin-left: 0.125rem; +} + +.dropleft .dropdown-toggle::after { + display: inline-block; + vertical-align: 0.255em; + content: ""; + display: none; +} + +html:not([dir="rtl"]) .dropleft .dropdown-toggle::after { + margin-left: 0.255em; +} + +*[dir="rtl"] .dropleft .dropdown-toggle::after { + margin-right: 0.255em; +} + +.dropleft .dropdown-toggle::before { + display: inline-block; + margin-right: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid transparent; + border-right: 0.3em solid; + border-bottom: 0.3em solid transparent; + vertical-align: 0; +} + +html:not([dir="rtl"]) .dropleft .dropdown-toggle:empty::after { + margin-left: 0; +} + +*[dir="rtl"] .dropleft .dropdown-toggle:empty::after { + margin-right: 0; +} + +.dropdown-menu[x-placement^="top"], .dropdown-menu[x-placement^="right"], .dropdown-menu[x-placement^="bottom"], .dropdown-menu[x-placement^="left"] { + right: auto; + bottom: auto; +} + +.dropdown-divider { + height: 0; + margin: 0.5rem 0; + overflow: hidden; + border-top: 1px solid #d8dbe0; +} + +.dropdown-item { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + width: 100%; + padding: 0.5rem 1.25rem; + clear: both; + font-weight: 400; + text-align: inherit; + white-space: nowrap; + background-color: transparent; + border: 0; + color: #4f5d73; +} + +.dropdown-item:hover, .dropdown-item:focus { + text-decoration: none; + color: #455164; + background-color: #ebedef; +} + +.dropdown-item.active, .dropdown-item:active { + text-decoration: none; + color: #fff; + background-color: #321fdb; +} + +.dropdown-item.disabled, .dropdown-item:disabled { + pointer-events: none; + background-color: transparent; + color: #8a93a2; +} + +.dropdown-menu.show { + display: block; +} + +.dropdown-header { + margin-bottom: 0; + font-size: 0.765625rem; + white-space: nowrap; + color: #8a93a2; +} + +.dropdown-header,.dropdown-item-text { + display: block; + padding: 0.5rem 1.25rem; +} + +.dropdown-item-text,.c-footer { + color: #4f5d73; +} + +.c-footer { + display: -ms-flexbox; + display: flex; + -ms-flex: 0 0 50px; + flex: 0 0 50px; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-align: center; + align-items: center; + height: 50px; + padding: 0 1rem; + background: #ebedef; + border-top: 1px solid #d8dbe0; +} + +.c-footer[class*="bg-"] { + border-color: rgba(0, 0, 21, 0.1); +} + +.c-footer.c-footer-fixed { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 1030; +} + +.c-footer.c-footer-dark { + color: #fff; + background: #636f83; +} + +.form-control { + display: block; + width: 100%; + height: calc(1.5em + 0.75rem + 2px); + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + background-clip: padding-box; + border: 1px solid; + color: #768192; + background-color: #fff; + border-color: #d8dbe0; + border-radius: 0.25rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .form-control { + transition: none; + } +} + +.form-control::-ms-expand { + background-color: transparent; + border: 0; +} + +.form-control:-moz-focusring { + color: transparent; + text-shadow: 0 0 0 #768192; +} + +.form-control:focus { + color: #768192; + background-color: #fff; + border-color: #958bef; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.25); +} + +.form-control::-webkit-input-placeholder { + color: #8a93a2; + opacity: 1; +} + +.form-control::-moz-placeholder { + color: #8a93a2; + opacity: 1; +} + +.form-control:-ms-input-placeholder { + color: #8a93a2; + opacity: 1; +} + +.form-control::-ms-input-placeholder { + color: #8a93a2; + opacity: 1; +} + +.form-control::placeholder { + color: #8a93a2; + opacity: 1; +} + +.form-control:disabled, .form-control[readonly] { + background-color: #d8dbe0; + opacity: 1; +} + +select.form-control:focus::-ms-value { + color: #768192; + background-color: #fff; +} + +.form-control-file, +.form-control-range { + display: block; + width: 100%; +} + +.col-form-label { + padding-top: calc(0.375rem + 1px); + padding-bottom: calc(0.375rem + 1px); + margin-bottom: 0; + font-size: inherit; + line-height: 1.5; +} + +.col-form-label-lg { + padding-top: calc(0.5rem + 1px); + padding-bottom: calc(0.5rem + 1px); + font-size: 1.09375rem; + line-height: 1.5; +} + +.col-form-label-sm { + padding-top: calc(0.25rem + 1px); + padding-bottom: calc(0.25rem + 1px); + font-size: 0.765625rem; + line-height: 1.5; +} + +.form-control-plaintext { + display: block; + width: 100%; + padding: 0.375rem 0; + margin-bottom: 0; + font-size: 0.875rem; + line-height: 1.5; + background-color: transparent; + border: solid transparent; + border-width: 1px 0; + color: #4f5d73; +} + +.form-control-plaintext.form-control-sm, .form-control-plaintext.form-control-lg { + padding-right: 0; + padding-left: 0; +} + +.form-control-sm { + height: calc(1.5em + 0.5rem + 2px); + padding: 0.25rem 0.5rem; + font-size: 0.765625rem; + line-height: 1.5; + border-radius: 0.2rem; +} + +.form-control-lg { + height: calc(1.5em + 1rem + 2px); + padding: 0.5rem 1rem; + font-size: 1.09375rem; + line-height: 1.5; + border-radius: 0.3rem; +} + +select.form-control[size], select.form-control[multiple],textarea.form-control { + height: auto; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-text { + display: block; + margin-top: 0.25rem; +} + +.form-row { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + margin-right: -5px; + margin-left: -5px; +} + +.form-row > .col, +.form-row > [class*="col-"] { + padding-right: 5px; + padding-left: 5px; +} + +.form-check { + position: relative; + display: block; +} + +html:not([dir="rtl"]) .form-check { + padding-left: 1.25rem; +} + +*[dir="rtl"] .form-check { + padding-right: 1.25rem; +} + +.form-check-input { + position: absolute; + margin-top: 0.3rem; +} + +html:not([dir="rtl"]) .form-check-input { + margin-left: -1.25rem; +} + +*[dir="rtl"] .form-check-input { + margin-right: -1.25rem; +} + +.form-check-input[disabled] ~ .form-check-label, +.form-check-input:disabled ~ .form-check-label { + color: #768192; +} + +.form-check-label { + margin-bottom: 0; +} + +.form-check-inline { + display: -ms-inline-flexbox; + display: inline-flex; + -ms-flex-align: center; + align-items: center; +} + +html:not([dir="rtl"]) .form-check-inline { + padding-left: 0; + margin-right: 0.75rem; +} + +*[dir="rtl"] .form-check-inline { + padding-right: 0; + margin-left: 0.75rem; +} + +.form-check-inline .form-check-input { + position: static; + margin-top: 0; +} + +html:not([dir="rtl"]) .form-check-inline .form-check-input { + margin-right: 0.3125rem; + margin-left: 0; +} + +*[dir="rtl"] .form-check-inline .form-check-input { + margin-right: 0; + margin-left: 0.3125rem; +} + +.valid-feedback { + display: none; + width: 100%; + margin-top: 0.25rem; + font-size: 80%; + color: #2eb85c; +} + +.valid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: 0.25rem 0.5rem; + margin-top: .1rem; + font-size: 0.765625rem; + line-height: 1.5; + color: #fff; + background-color: rgba(46, 184, 92, 0.9); + border-radius: 0.25rem; +} + +.was-validated :valid ~ .valid-feedback, +.was-validated :valid ~ .valid-tooltip, +.is-valid ~ .valid-feedback, +.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .form-control:valid, .form-control.is-valid { + border-color: #2eb85c; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%232eb85c' d='M2.3 6.73L.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.1z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} + +html:not([dir="rtl"]) .was-validated .form-control:valid, html:not([dir="rtl"]) .form-control.is-valid { + padding-right: calc(1.5em + 0.75rem); + background-position: right calc(0.375em + 0.1875rem) center; +} + +*[dir="rtl"] .was-validated .form-control:valid, *[dir="rtl"] .form-control.is-valid { + padding-left: calc(1.5em + 0.75rem); + background-position: left calc(0.375em + 0.1875rem) center; +} + +.was-validated .form-control:valid:focus, .form-control.is-valid:focus { + border-color: #2eb85c; + box-shadow: 0 0 0 0.2rem rgba(46, 184, 92, 0.25); +} + +html:not([dir="rtl"]) .was-validated textarea.form-control:valid, html:not([dir="rtl"]) textarea.form-control.is-valid { + padding-right: calc(1.5em + 0.75rem); + background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); +} + +*[dir="rtl"] .was-validated textarea.form-control:valid, *[dir="rtl"] textarea.form-control.is-valid { + padding-left: calc(1.5em + 0.75rem); + background-position: top calc(0.375em + 0.1875rem) left calc(0.375em + 0.1875rem); +} + +.was-validated .custom-select:valid, .custom-select.is-valid { + border-color: #2eb85c; + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23636f83' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px, url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%232eb85c' d='M2.3 6.73L.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.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} + +html:not([dir="rtl"]) .was-validated .custom-select:valid, html:not([dir="rtl"]) .custom-select.is-valid { + padding-right: calc(0.75em + 2.3125rem); +} + +*[dir="rtl"] .was-validated .custom-select:valid, *[dir="rtl"] .custom-select.is-valid { + padding-left: calc(0.75em + 2.3125rem); +} + +.was-validated .custom-select:valid:focus, .custom-select.is-valid:focus { + border-color: #2eb85c; + box-shadow: 0 0 0 0.2rem rgba(46, 184, 92, 0.25); +} + +.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label { + color: #2eb85c; +} + +.was-validated .form-check-input:valid ~ .valid-feedback, +.was-validated .form-check-input:valid ~ .valid-tooltip, .form-check-input.is-valid ~ .valid-feedback, +.form-check-input.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .custom-control-input:valid ~ .custom-control-label, .custom-control-input.is-valid ~ .custom-control-label { + color: #2eb85c; +} + +.was-validated .custom-control-input:valid ~ .custom-control-label::before, .custom-control-input.is-valid ~ .custom-control-label::before { + border-color: #2eb85c; +} + +.was-validated .custom-control-input:valid:checked ~ .custom-control-label::before, .custom-control-input.is-valid:checked ~ .custom-control-label::before { + border-color: #48d176; + background-color: #48d176; +} + +.was-validated .custom-control-input:valid:focus ~ .custom-control-label::before, .custom-control-input.is-valid:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 0.2rem rgba(46, 184, 92, 0.25); +} + +.was-validated .custom-control-input:valid:focus:not(:checked) ~ .custom-control-label::before, .custom-control-input.is-valid:focus:not(:checked) ~ .custom-control-label::before,.was-validated .custom-file-input:valid ~ .custom-file-label, .custom-file-input.is-valid ~ .custom-file-label { + border-color: #2eb85c; +} + +.was-validated .custom-file-input:valid:focus ~ .custom-file-label, .custom-file-input.is-valid:focus ~ .custom-file-label { + border-color: #2eb85c; + box-shadow: 0 0 0 0.2rem rgba(46, 184, 92, 0.25); +} + +.invalid-feedback { + display: none; + width: 100%; + margin-top: 0.25rem; + font-size: 80%; + color: #e55353; +} + +.invalid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: 0.25rem 0.5rem; + margin-top: .1rem; + font-size: 0.765625rem; + line-height: 1.5; + color: #fff; + background-color: rgba(229, 83, 83, 0.9); + border-radius: 0.25rem; +} + +.was-validated :invalid ~ .invalid-feedback, +.was-validated :invalid ~ .invalid-tooltip, +.is-invalid ~ .invalid-feedback, +.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .form-control:invalid, .form-control.is-invalid { + border-color: #e55353; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23e55353' viewBox='0 0 12 12'%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='%23e55353' stroke='none'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} + +html:not([dir="rtl"]) .was-validated .form-control:invalid, html:not([dir="rtl"]) .form-control.is-invalid { + padding-right: calc(1.5em + 0.75rem); + background-position: right calc(0.375em + 0.1875rem) center; +} + +*[dir="rtl"] .was-validated .form-control:invalid, *[dir="rtl"] .form-control.is-invalid { + padding-left: calc(1.5em + 0.75rem); + background-position: left calc(0.375em + 0.1875rem) center; +} + +.was-validated .form-control:invalid:focus, .form-control.is-invalid:focus { + border-color: #e55353; + box-shadow: 0 0 0 0.2rem rgba(229, 83, 83, 0.25); +} + +html:not([dir="rtl"]) .was-validated textarea.form-control:invalid, html:not([dir="rtl"]) textarea.form-control.is-invalid { + padding-right: calc(1.5em + 0.75rem); + background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); +} + +*[dir="rtl"] .was-validated textarea.form-control:invalid, *[dir="rtl"] textarea.form-control.is-invalid { + padding-left: calc(1.5em + 0.75rem); + background-position: top calc(0.375em + 0.1875rem) left calc(0.375em + 0.1875rem); +} + +.was-validated .custom-select:invalid, .custom-select.is-invalid { + border-color: #e55353; + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23636f83' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px, url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23e55353' viewBox='0 0 12 12'%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='%23e55353' stroke='none'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} + +html:not([dir="rtl"]) .was-validated .custom-select:invalid, html:not([dir="rtl"]) .custom-select.is-invalid { + padding-right: calc(0.75em + 2.3125rem); +} + +*[dir="rtl"] .was-validated .custom-select:invalid, *[dir="rtl"] .custom-select.is-invalid { + padding-left: calc(0.75em + 2.3125rem); +} + +.was-validated .custom-select:invalid:focus, .custom-select.is-invalid:focus { + border-color: #e55353; + box-shadow: 0 0 0 0.2rem rgba(229, 83, 83, 0.25); +} + +.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label { + color: #e55353; +} + +.was-validated .form-check-input:invalid ~ .invalid-feedback, +.was-validated .form-check-input:invalid ~ .invalid-tooltip, .form-check-input.is-invalid ~ .invalid-feedback, +.form-check-input.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .custom-control-input:invalid ~ .custom-control-label, .custom-control-input.is-invalid ~ .custom-control-label { + color: #e55353; +} + +.was-validated .custom-control-input:invalid ~ .custom-control-label::before, .custom-control-input.is-invalid ~ .custom-control-label::before { + border-color: #e55353; +} + +.was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before, .custom-control-input.is-invalid:checked ~ .custom-control-label::before { + border-color: #ec7f7f; + background-color: #ec7f7f; +} + +.was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before, .custom-control-input.is-invalid:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 0.2rem rgba(229, 83, 83, 0.25); +} + +.was-validated .custom-control-input:invalid:focus:not(:checked) ~ .custom-control-label::before, .custom-control-input.is-invalid:focus:not(:checked) ~ .custom-control-label::before,.was-validated .custom-file-input:invalid ~ .custom-file-label, .custom-file-input.is-invalid ~ .custom-file-label { + border-color: #e55353; +} + +.was-validated .custom-file-input:invalid:focus ~ .custom-file-label, .custom-file-input.is-invalid:focus ~ .custom-file-label { + border-color: #e55353; + box-shadow: 0 0 0 0.2rem rgba(229, 83, 83, 0.25); +} + +.form-inline { + display: -ms-flexbox; + display: flex; + -ms-flex-flow: row wrap; + flex-flow: row wrap; + -ms-flex-align: center; + align-items: center; +} + +.form-inline .form-check { + width: 100%; +} + +@media (min-width: 576px) { + .form-inline label { + -ms-flex-align: center; + -ms-flex-pack: center; + justify-content: center; + } + .form-inline label,.form-inline .form-group { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + margin-bottom: 0; + } + .form-inline .form-group { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + -ms-flex-flow: row wrap; + flex-flow: row wrap; + -ms-flex-align: center; + } + .form-inline .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + .form-inline .form-control-plaintext { + display: inline-block; + } + .form-inline .input-group, + .form-inline .custom-select { + width: auto; + } + .form-inline .form-check { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; + width: auto; + } + html:not([dir="rtl"]) .form-inline .form-check { + padding-left: 0; + } + *[dir="rtl"] .form-inline .form-check { + padding-right: 0; + } + .form-inline .form-check-input { + position: relative; + -ms-flex-negative: 0; + flex-shrink: 0; + margin-top: 0; + } + html:not([dir="rtl"]) .form-inline .form-check-input { + margin-right: 0.25rem; + margin-left: 0; + } + *[dir="rtl"] .form-inline .form-check-input { + margin-right: 0; + margin-left: 0.25rem; + } + .form-inline .custom-control { + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; + } + .form-inline .custom-control-label { + margin-bottom: 0; + } +} + +.container { + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} + +@media (min-width: 576px) { + .container { + max-width: 540px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 720px; + } +} + +@media (min-width: 992px) { + .container { + max-width: 960px; + } +} + +@media (min-width: 1200px) { + .container { + max-width: 1140px; + } +} + +.container-fluid, .container-sm, .container-md, .container-lg, .container-xl { + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} + +@media (min-width: 576px) { + .container, .container-sm { + max-width: 540px; + } +} + +@media (min-width: 768px) { + .container, .container-sm, .container-md { + max-width: 720px; + } +} + +@media (min-width: 992px) { + .container, .container-sm, .container-md, .container-lg { + max-width: 960px; + } +} + +@media (min-width: 1200px) { + .container, .container-sm, .container-md, .container-lg, .container-xl { + max-width: 1140px; + } +} + +.row { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + margin-right: -15px; + margin-left: -15px; +} + +.no-gutters { + margin-right: 0; + margin-left: 0; +} + +.no-gutters > .col, +.no-gutters > [class*="col-"] { + padding-right: 0; + padding-left: 0; +} + +.col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12, .col, +.col-auto, .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm, +.col-sm-auto, .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12, .col-md, +.col-md-auto, .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg, +.col-lg-auto, .col-xl-1, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl, +.col-xl-auto { + position: relative; + width: 100%; + padding-right: 15px; + padding-left: 15px; +} + +.col { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; +} + +.row-cols-1 > * { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; +} + +.row-cols-2 > * { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; +} + +.row-cols-3 > * { + -ms-flex: 0 0 33.33333333%; + flex: 0 0 33.33333333%; + max-width: 33.33333333%; +} + +.row-cols-4 > * { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; +} + +.row-cols-5 > * { + -ms-flex: 0 0 20%; + flex: 0 0 20%; + max-width: 20%; +} + +.row-cols-6 > * { + -ms-flex: 0 0 16.66666667%; + flex: 0 0 16.66666667%; + max-width: 16.66666667%; +} + +.col-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: 100%; +} + +.col-1 { + -ms-flex: 0 0 8.33333333%; + flex: 0 0 8.33333333%; + max-width: 8.33333333%; +} + +.col-2 { + -ms-flex: 0 0 16.66666667%; + flex: 0 0 16.66666667%; + max-width: 16.66666667%; +} + +.col-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; +} + +.col-4 { + -ms-flex: 0 0 33.33333333%; + flex: 0 0 33.33333333%; + max-width: 33.33333333%; +} + +.col-5 { + -ms-flex: 0 0 41.66666667%; + flex: 0 0 41.66666667%; + max-width: 41.66666667%; +} + +.col-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; +} + +.col-7 { + -ms-flex: 0 0 58.33333333%; + flex: 0 0 58.33333333%; + max-width: 58.33333333%; +} + +.col-8 { + -ms-flex: 0 0 66.66666667%; + flex: 0 0 66.66666667%; + max-width: 66.66666667%; +} + +.col-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; +} + +.col-10 { + -ms-flex: 0 0 83.33333333%; + flex: 0 0 83.33333333%; + max-width: 83.33333333%; +} + +.col-11 { + -ms-flex: 0 0 91.66666667%; + flex: 0 0 91.66666667%; + max-width: 91.66666667%; +} + +.col-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; +} + +.order-first { + -ms-flex-order: -1; + order: -1; +} + +.order-last { + -ms-flex-order: 13; + order: 13; +} + +.order-0 { + -ms-flex-order: 0; + order: 0; +} + +.order-1 { + -ms-flex-order: 1; + order: 1; +} + +.order-2 { + -ms-flex-order: 2; + order: 2; +} + +.order-3 { + -ms-flex-order: 3; + order: 3; +} + +.order-4 { + -ms-flex-order: 4; + order: 4; +} + +.order-5 { + -ms-flex-order: 5; + order: 5; +} + +.order-6 { + -ms-flex-order: 6; + order: 6; +} + +.order-7 { + -ms-flex-order: 7; + order: 7; +} + +.order-8 { + -ms-flex-order: 8; + order: 8; +} + +.order-9 { + -ms-flex-order: 9; + order: 9; +} + +.order-10 { + -ms-flex-order: 10; + order: 10; +} + +.order-11 { + -ms-flex-order: 11; + order: 11; +} + +.order-12 { + -ms-flex-order: 12; + order: 12; +} + +.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%; +} + +@media (min-width: 576px) { + .col-sm { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .row-cols-sm-1 > * { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .row-cols-sm-2 > * { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .row-cols-sm-3 > * { + -ms-flex: 0 0 33.33333333%; + flex: 0 0 33.33333333%; + max-width: 33.33333333%; + } + .row-cols-sm-4 > * { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .row-cols-sm-5 > * { + -ms-flex: 0 0 20%; + flex: 0 0 20%; + max-width: 20%; + } + .row-cols-sm-6 > * { + -ms-flex: 0 0 16.66666667%; + flex: 0 0 16.66666667%; + max-width: 16.66666667%; + } + .col-sm-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: 100%; + } + .col-sm-1 { + -ms-flex: 0 0 8.33333333%; + flex: 0 0 8.33333333%; + max-width: 8.33333333%; + } + .col-sm-2 { + -ms-flex: 0 0 16.66666667%; + flex: 0 0 16.66666667%; + max-width: 16.66666667%; + } + .col-sm-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-sm-4 { + -ms-flex: 0 0 33.33333333%; + flex: 0 0 33.33333333%; + max-width: 33.33333333%; + } + .col-sm-5 { + -ms-flex: 0 0 41.66666667%; + flex: 0 0 41.66666667%; + max-width: 41.66666667%; + } + .col-sm-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-sm-7 { + -ms-flex: 0 0 58.33333333%; + flex: 0 0 58.33333333%; + max-width: 58.33333333%; + } + .col-sm-8 { + -ms-flex: 0 0 66.66666667%; + flex: 0 0 66.66666667%; + max-width: 66.66666667%; + } + .col-sm-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-sm-10 { + -ms-flex: 0 0 83.33333333%; + flex: 0 0 83.33333333%; + max-width: 83.33333333%; + } + .col-sm-11 { + -ms-flex: 0 0 91.66666667%; + flex: 0 0 91.66666667%; + max-width: 91.66666667%; + } + .col-sm-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-sm-first { + -ms-flex-order: -1; + order: -1; + } + .order-sm-last { + -ms-flex-order: 13; + order: 13; + } + .order-sm-0 { + -ms-flex-order: 0; + order: 0; + } + .order-sm-1 { + -ms-flex-order: 1; + order: 1; + } + .order-sm-2 { + -ms-flex-order: 2; + order: 2; + } + .order-sm-3 { + -ms-flex-order: 3; + order: 3; + } + .order-sm-4 { + -ms-flex-order: 4; + order: 4; + } + .order-sm-5 { + -ms-flex-order: 5; + order: 5; + } + .order-sm-6 { + -ms-flex-order: 6; + order: 6; + } + .order-sm-7 { + -ms-flex-order: 7; + order: 7; + } + .order-sm-8 { + -ms-flex-order: 8; + order: 8; + } + .order-sm-9 { + -ms-flex-order: 9; + order: 9; + } + .order-sm-10 { + -ms-flex-order: 10; + order: 10; + } + .order-sm-11 { + -ms-flex-order: 11; + order: 11; + } + .order-sm-12 { + -ms-flex-order: 12; + order: 12; + } + .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%; + } +} + +@media (min-width: 768px) { + .col-md { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .row-cols-md-1 > * { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .row-cols-md-2 > * { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .row-cols-md-3 > * { + -ms-flex: 0 0 33.33333333%; + flex: 0 0 33.33333333%; + max-width: 33.33333333%; + } + .row-cols-md-4 > * { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .row-cols-md-5 > * { + -ms-flex: 0 0 20%; + flex: 0 0 20%; + max-width: 20%; + } + .row-cols-md-6 > * { + -ms-flex: 0 0 16.66666667%; + flex: 0 0 16.66666667%; + max-width: 16.66666667%; + } + .col-md-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: 100%; + } + .col-md-1 { + -ms-flex: 0 0 8.33333333%; + flex: 0 0 8.33333333%; + max-width: 8.33333333%; + } + .col-md-2 { + -ms-flex: 0 0 16.66666667%; + flex: 0 0 16.66666667%; + max-width: 16.66666667%; + } + .col-md-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-md-4 { + -ms-flex: 0 0 33.33333333%; + flex: 0 0 33.33333333%; + max-width: 33.33333333%; + } + .col-md-5 { + -ms-flex: 0 0 41.66666667%; + flex: 0 0 41.66666667%; + max-width: 41.66666667%; + } + .col-md-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-md-7 { + -ms-flex: 0 0 58.33333333%; + flex: 0 0 58.33333333%; + max-width: 58.33333333%; + } + .col-md-8 { + -ms-flex: 0 0 66.66666667%; + flex: 0 0 66.66666667%; + max-width: 66.66666667%; + } + .col-md-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-md-10 { + -ms-flex: 0 0 83.33333333%; + flex: 0 0 83.33333333%; + max-width: 83.33333333%; + } + .col-md-11 { + -ms-flex: 0 0 91.66666667%; + flex: 0 0 91.66666667%; + max-width: 91.66666667%; + } + .col-md-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-md-first { + -ms-flex-order: -1; + order: -1; + } + .order-md-last { + -ms-flex-order: 13; + order: 13; + } + .order-md-0 { + -ms-flex-order: 0; + order: 0; + } + .order-md-1 { + -ms-flex-order: 1; + order: 1; + } + .order-md-2 { + -ms-flex-order: 2; + order: 2; + } + .order-md-3 { + -ms-flex-order: 3; + order: 3; + } + .order-md-4 { + -ms-flex-order: 4; + order: 4; + } + .order-md-5 { + -ms-flex-order: 5; + order: 5; + } + .order-md-6 { + -ms-flex-order: 6; + order: 6; + } + .order-md-7 { + -ms-flex-order: 7; + order: 7; + } + .order-md-8 { + -ms-flex-order: 8; + order: 8; + } + .order-md-9 { + -ms-flex-order: 9; + order: 9; + } + .order-md-10 { + -ms-flex-order: 10; + order: 10; + } + .order-md-11 { + -ms-flex-order: 11; + order: 11; + } + .order-md-12 { + -ms-flex-order: 12; + order: 12; + } + .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%; + } +} + +@media (min-width: 992px) { + .col-lg { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .row-cols-lg-1 > * { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .row-cols-lg-2 > * { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .row-cols-lg-3 > * { + -ms-flex: 0 0 33.33333333%; + flex: 0 0 33.33333333%; + max-width: 33.33333333%; + } + .row-cols-lg-4 > * { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .row-cols-lg-5 > * { + -ms-flex: 0 0 20%; + flex: 0 0 20%; + max-width: 20%; + } + .row-cols-lg-6 > * { + -ms-flex: 0 0 16.66666667%; + flex: 0 0 16.66666667%; + max-width: 16.66666667%; + } + .col-lg-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: 100%; + } + .col-lg-1 { + -ms-flex: 0 0 8.33333333%; + flex: 0 0 8.33333333%; + max-width: 8.33333333%; + } + .col-lg-2 { + -ms-flex: 0 0 16.66666667%; + flex: 0 0 16.66666667%; + max-width: 16.66666667%; + } + .col-lg-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-lg-4 { + -ms-flex: 0 0 33.33333333%; + flex: 0 0 33.33333333%; + max-width: 33.33333333%; + } + .col-lg-5 { + -ms-flex: 0 0 41.66666667%; + flex: 0 0 41.66666667%; + max-width: 41.66666667%; + } + .col-lg-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-lg-7 { + -ms-flex: 0 0 58.33333333%; + flex: 0 0 58.33333333%; + max-width: 58.33333333%; + } + .col-lg-8 { + -ms-flex: 0 0 66.66666667%; + flex: 0 0 66.66666667%; + max-width: 66.66666667%; + } + .col-lg-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-lg-10 { + -ms-flex: 0 0 83.33333333%; + flex: 0 0 83.33333333%; + max-width: 83.33333333%; + } + .col-lg-11 { + -ms-flex: 0 0 91.66666667%; + flex: 0 0 91.66666667%; + max-width: 91.66666667%; + } + .col-lg-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-lg-first { + -ms-flex-order: -1; + order: -1; + } + .order-lg-last { + -ms-flex-order: 13; + order: 13; + } + .order-lg-0 { + -ms-flex-order: 0; + order: 0; + } + .order-lg-1 { + -ms-flex-order: 1; + order: 1; + } + .order-lg-2 { + -ms-flex-order: 2; + order: 2; + } + .order-lg-3 { + -ms-flex-order: 3; + order: 3; + } + .order-lg-4 { + -ms-flex-order: 4; + order: 4; + } + .order-lg-5 { + -ms-flex-order: 5; + order: 5; + } + .order-lg-6 { + -ms-flex-order: 6; + order: 6; + } + .order-lg-7 { + -ms-flex-order: 7; + order: 7; + } + .order-lg-8 { + -ms-flex-order: 8; + order: 8; + } + .order-lg-9 { + -ms-flex-order: 9; + order: 9; + } + .order-lg-10 { + -ms-flex-order: 10; + order: 10; + } + .order-lg-11 { + -ms-flex-order: 11; + order: 11; + } + .order-lg-12 { + -ms-flex-order: 12; + order: 12; + } + .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%; + } +} + +@media (min-width: 1200px) { + .col-xl { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .row-cols-xl-1 > * { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .row-cols-xl-2 > * { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .row-cols-xl-3 > * { + -ms-flex: 0 0 33.33333333%; + flex: 0 0 33.33333333%; + max-width: 33.33333333%; + } + .row-cols-xl-4 > * { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .row-cols-xl-5 > * { + -ms-flex: 0 0 20%; + flex: 0 0 20%; + max-width: 20%; + } + .row-cols-xl-6 > * { + -ms-flex: 0 0 16.66666667%; + flex: 0 0 16.66666667%; + max-width: 16.66666667%; + } + .col-xl-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: 100%; + } + .col-xl-1 { + -ms-flex: 0 0 8.33333333%; + flex: 0 0 8.33333333%; + max-width: 8.33333333%; + } + .col-xl-2 { + -ms-flex: 0 0 16.66666667%; + flex: 0 0 16.66666667%; + max-width: 16.66666667%; + } + .col-xl-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-xl-4 { + -ms-flex: 0 0 33.33333333%; + flex: 0 0 33.33333333%; + max-width: 33.33333333%; + } + .col-xl-5 { + -ms-flex: 0 0 41.66666667%; + flex: 0 0 41.66666667%; + max-width: 41.66666667%; + } + .col-xl-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-xl-7 { + -ms-flex: 0 0 58.33333333%; + flex: 0 0 58.33333333%; + max-width: 58.33333333%; + } + .col-xl-8 { + -ms-flex: 0 0 66.66666667%; + flex: 0 0 66.66666667%; + max-width: 66.66666667%; + } + .col-xl-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-xl-10 { + -ms-flex: 0 0 83.33333333%; + flex: 0 0 83.33333333%; + max-width: 83.33333333%; + } + .col-xl-11 { + -ms-flex: 0 0 91.66666667%; + flex: 0 0 91.66666667%; + max-width: 91.66666667%; + } + .col-xl-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-xl-first { + -ms-flex-order: -1; + order: -1; + } + .order-xl-last { + -ms-flex-order: 13; + order: 13; + } + .order-xl-0 { + -ms-flex-order: 0; + order: 0; + } + .order-xl-1 { + -ms-flex-order: 1; + order: 1; + } + .order-xl-2 { + -ms-flex-order: 2; + order: 2; + } + .order-xl-3 { + -ms-flex-order: 3; + order: 3; + } + .order-xl-4 { + -ms-flex-order: 4; + order: 4; + } + .order-xl-5 { + -ms-flex-order: 5; + order: 5; + } + .order-xl-6 { + -ms-flex-order: 6; + order: 6; + } + .order-xl-7 { + -ms-flex-order: 7; + order: 7; + } + .order-xl-8 { + -ms-flex-order: 8; + order: 8; + } + .order-xl-9 { + -ms-flex-order: 9; + order: 9; + } + .order-xl-10 { + -ms-flex-order: 10; + order: 10; + } + .order-xl-11 { + -ms-flex-order: 11; + order: 11; + } + .order-xl-12 { + -ms-flex-order: 12; + order: 12; + } + .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%; + } +} + +.row.row-equal { + margin-right: -15px; + margin-left: -15px; +} + +.row.row-equal,.row.row-equal [class*="col-"] { + padding-right: 7.5px; + padding-left: 7.5px; +} + +.main .container-fluid, .main .container-sm, .main .container-md, .main .container-lg, .main .container-xl { + padding: 0 30px; +} + +.c-header { + position: relative; + display: -ms-flexbox; + display: flex; + -ms-flex-direction: row; + flex-direction: row; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-negative: 0; + flex-shrink: 0; + min-height: 56px; + background: #fff; + border-bottom: 1px solid #d8dbe0; +} + +.c-header[class*="bg-"] { + border-color: rgba(0, 0, 21, 0.1); +} + +.c-header.c-header-fixed { + position: fixed; + right: 0; + left: 0; + z-index: 1030; +} + +.c-header .c-subheader { + border-bottom: 0; + margin-top: -1px; + border-top: 1px solid #d8dbe0; +} + +.c-header-brand { + display: -ms-inline-flexbox; + display: inline-flex; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; + width: auto; + min-height: 56px; + transition: width 0.25s; +} + +.c-header-brand.c-header-brand-center { + position: absolute; + top: 56px; + -webkit-transform: translate(-50%, -100%); + transform: translate(-50%, -100%); +} + +html:not([dir="rtl"]) .c-header-brand.c-header-brand-center { + left: 50%; +} + +*[dir="rtl"] .c-header-brand.c-header-brand-center { + right: 50%; +} + +@media (max-width: 575.98px) { + .c-header-brand.c-header-brand-xs-down-center { + position: absolute; + top: 56px; + -webkit-transform: translate(-50%, -100%); + transform: translate(-50%, -100%); + } + html:not([dir="rtl"]) .c-header-brand.c-header-brand-xs-down-center { + left: 50%; + } + *[dir="rtl"] .c-header-brand.c-header-brand-xs-down-center { + right: 50%; + } +} + +.c-header-brand.c-header-brand-xs-up-center { + position: absolute; + top: 56px; + -webkit-transform: translate(-50%, -100%); + transform: translate(-50%, -100%); +} + +html:not([dir="rtl"]) .c-header-brand.c-header-brand-xs-up-center { + left: 50%; +} + +*[dir="rtl"] .c-header-brand.c-header-brand-xs-up-center { + right: 50%; +} + +@media (max-width: 767.98px) { + .c-header-brand.c-header-brand-sm-down-center { + position: absolute; + top: 56px; + -webkit-transform: translate(-50%, -100%); + transform: translate(-50%, -100%); + } + html:not([dir="rtl"]) .c-header-brand.c-header-brand-sm-down-center { + left: 50%; + } + *[dir="rtl"] .c-header-brand.c-header-brand-sm-down-center { + right: 50%; + } +} + +@media (min-width: 576px) { + .c-header-brand.c-header-brand-sm-up-center { + position: absolute; + top: 56px; + -webkit-transform: translate(-50%, -100%); + transform: translate(-50%, -100%); + } + html:not([dir="rtl"]) .c-header-brand.c-header-brand-sm-up-center { + left: 50%; + } + *[dir="rtl"] .c-header-brand.c-header-brand-sm-up-center { + right: 50%; + } +} + +@media (max-width: 991.98px) { + .c-header-brand.c-header-brand-md-down-center { + position: absolute; + top: 56px; + -webkit-transform: translate(-50%, -100%); + transform: translate(-50%, -100%); + } + html:not([dir="rtl"]) .c-header-brand.c-header-brand-md-down-center { + left: 50%; + } + *[dir="rtl"] .c-header-brand.c-header-brand-md-down-center { + right: 50%; + } +} + +@media (min-width: 768px) { + .c-header-brand.c-header-brand-md-up-center { + position: absolute; + top: 56px; + -webkit-transform: translate(-50%, -100%); + transform: translate(-50%, -100%); + } + html:not([dir="rtl"]) .c-header-brand.c-header-brand-md-up-center { + left: 50%; + } + *[dir="rtl"] .c-header-brand.c-header-brand-md-up-center { + right: 50%; + } +} + +@media (max-width: 1199.98px) { + .c-header-brand.c-header-brand-lg-down-center { + position: absolute; + top: 56px; + -webkit-transform: translate(-50%, -100%); + transform: translate(-50%, -100%); + } + html:not([dir="rtl"]) .c-header-brand.c-header-brand-lg-down-center { + left: 50%; + } + *[dir="rtl"] .c-header-brand.c-header-brand-lg-down-center { + right: 50%; + } +} + +@media (min-width: 992px) { + .c-header-brand.c-header-brand-lg-up-center { + position: absolute; + top: 56px; + -webkit-transform: translate(-50%, -100%); + transform: translate(-50%, -100%); + } + html:not([dir="rtl"]) .c-header-brand.c-header-brand-lg-up-center { + left: 50%; + } + *[dir="rtl"] .c-header-brand.c-header-brand-lg-up-center { + right: 50%; + } +} + +.c-header-brand.c-header-brand-xl-down-center { + position: absolute; + top: 56px; + -webkit-transform: translate(-50%, -100%); + transform: translate(-50%, -100%); +} + +html:not([dir="rtl"]) .c-header-brand.c-header-brand-xl-down-center { + left: 50%; +} + +*[dir="rtl"] .c-header-brand.c-header-brand-xl-down-center { + right: 50%; +} + +@media (min-width: 1200px) { + .c-header-brand.c-header-brand-xl-up-center { + position: absolute; + top: 56px; + -webkit-transform: translate(-50%, -100%); + transform: translate(-50%, -100%); + } + html:not([dir="rtl"]) .c-header-brand.c-header-brand-xl-up-center { + left: 50%; + } + *[dir="rtl"] .c-header-brand.c-header-brand-xl-up-center { + right: 50%; + } +} + +.c-header-toggler { + min-width: 50px; + font-size: 1.09375rem; + background-color: transparent; + border: 0; + border-radius: 0.25rem; +} + +.c-header-toggler:hover, .c-header-toggler:focus { + text-decoration: none; +} + +.c-header-toggler:not(:disabled):not(.c-disabled) { + cursor: pointer; +} + +.c-header-toggler-icon { + display: block; + height: 1.3671875rem; + background-repeat: no-repeat; + background-position: center center; + background-size: 100% 100%; +} + +.c-header-nav { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: row; + flex-direction: row; + -ms-flex-align: center; + align-items: center; + min-height: 56px; + padding: 0; + margin-bottom: 0; + list-style: none; +} + +.c-header-nav .c-header-nav-item { + position: relative; +} + +.c-header-nav .c-header-nav-btn { + background-color: transparent; + border: 1px solid transparent; +} + +.c-header-nav .c-header-nav-link, +.c-header-nav .c-header-nav-btn { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + padding-right: 0.5rem; + padding-left: 0.5rem; +} + +.c-header-nav .c-header-nav-link .badge, +.c-header-nav .c-header-nav-btn .badge { + position: absolute; + top: 50%; + margin-top: -16px; +} + +html:not([dir="rtl"]) .c-header-nav .c-header-nav-link .badge, html:not([dir="rtl"]) +.c-header-nav .c-header-nav-btn .badge { + left: 50%; + margin-left: 0; +} + +*[dir="rtl"] .c-header-nav .c-header-nav-link .badge, *[dir="rtl"] +.c-header-nav .c-header-nav-btn .badge { + right: 50%; + margin-right: 0; +} + +.c-header-nav .c-header-nav-link:hover, +.c-header-nav .c-header-nav-btn:hover { + text-decoration: none; +} + +.c-header-nav .dropdown-item { + min-width: 180px; +} + +.c-header.c-header-dark { + background: #3c4b64; + border-bottom: 1px solid #636f83; +} + +.c-header.c-header-dark .c-subheader { + margin-top: -1px; + border-top: 1px solid #636f83; +} + +.c-header.c-header-dark .c-header-brand { + color: #fff; + background-color: transparent; +} + +.c-header.c-header-dark .c-header-brand:hover, .c-header.c-header-dark .c-header-brand:focus { + color: #fff; +} + +.c-header.c-header-dark .c-header-nav .c-header-nav-link, +.c-header.c-header-dark .c-header-nav .c-header-nav-btn { + color: rgba(255, 255, 255, 0.75); +} + +.c-header.c-header-dark .c-header-nav .c-header-nav-link:hover, .c-header.c-header-dark .c-header-nav .c-header-nav-link:focus, +.c-header.c-header-dark .c-header-nav .c-header-nav-btn:hover, +.c-header.c-header-dark .c-header-nav .c-header-nav-btn:focus { + color: rgba(255, 255, 255, 0.9); +} + +.c-header.c-header-dark .c-header-nav .c-header-nav-link.c-disabled, +.c-header.c-header-dark .c-header-nav .c-header-nav-btn.c-disabled { + color: rgba(255, 255, 255, 0.25); +} + +.c-header.c-header-dark .c-header-nav .c-show > .c-header-nav-link, +.c-header.c-header-dark .c-header-nav .c-active > .c-header-nav-link, +.c-header.c-header-dark .c-header-nav .c-header-nav-link.c-show, +.c-header.c-header-dark .c-header-nav .c-header-nav-link.c-active { + color: #fff; +} + +.c-header.c-header-dark .c-header-toggler { + color: rgba(255, 255, 255, 0.75); + border-color: rgba(255, 255, 255, 0.1); +} + +.c-header.c-header-dark .c-header-toggler-icon { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.75)' stroke-width='2.25' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); +} + +.c-header.c-header-dark .c-header-toggler-icon:hover { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.9)' stroke-width='2.25' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); +} + +.c-header.c-header-dark .c-header-text { + color: rgba(255, 255, 255, 0.75); +} + +.c-header.c-header-dark .c-header-text a,.c-header.c-header-dark .c-header-text a:hover, .c-header.c-header-dark .c-header-text a:focus { + color: #fff; +} + +.c-header .c-header-brand { + color: #4f5d73; + background-color: transparent; +} + +.c-header .c-header-brand:hover, .c-header .c-header-brand:focus { + color: #3a4555; +} + +.c-header .c-header-nav .c-header-nav-link, +.c-header .c-header-nav .c-header-nav-btn { + color: rgba(0, 0, 21, 0.5); +} + +.c-header .c-header-nav .c-header-nav-link:hover, .c-header .c-header-nav .c-header-nav-link:focus, +.c-header .c-header-nav .c-header-nav-btn:hover, +.c-header .c-header-nav .c-header-nav-btn:focus { + color: rgba(0, 0, 21, 0.7); +} + +.c-header .c-header-nav .c-header-nav-link.c-disabled, +.c-header .c-header-nav .c-header-nav-btn.c-disabled { + color: rgba(0, 0, 21, 0.3); +} + +.c-header .c-header-nav .c-show > .c-header-nav-link, +.c-header .c-header-nav .c-active > .c-header-nav-link, +.c-header .c-header-nav .c-header-nav-link.c-show, +.c-header .c-header-nav .c-header-nav-link.c-active { + color: rgba(0, 0, 21, 0.9); +} + +.c-header .c-header-toggler { + color: rgba(0, 0, 21, 0.5); + border-color: rgba(0, 0, 21, 0.1); +} + +.c-header .c-header-toggler-icon { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(0, 0, 21, 0.5)' stroke-width='2.25' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); +} + +.c-header .c-header-toggler-icon:hover { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(0, 0, 21, 0.7)' stroke-width='2.25' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); +} + +.c-header .c-header-text { + color: rgba(0, 0, 21, 0.5); +} + +.c-header .c-header-text a,.c-header .c-header-text a:hover, .c-header .c-header-text a:focus { + color: rgba(0, 0, 21, 0.9); +} + +.c-icon { + display: inline-block; + color: inherit; + text-align: center; + fill: currentColor; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size) { + width: 1rem; + height: 1rem; + font-size: 1rem; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-2xl { + width: 2rem; + height: 2rem; + font-size: 2rem; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-3xl { + width: 3rem; + height: 3rem; + font-size: 3rem; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-4xl { + width: 4rem; + height: 4rem; + font-size: 4rem; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-5xl { + width: 5rem; + height: 5rem; + font-size: 5rem; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-6xl { + width: 6rem; + height: 6rem; + font-size: 6rem; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-7xl { + width: 7rem; + height: 7rem; + font-size: 7rem; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-8xl { + width: 8rem; + height: 8rem; + font-size: 8rem; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-9xl { + width: 9rem; + height: 9rem; + font-size: 9rem; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-xl { + width: 1.5rem; + height: 1.5rem; + font-size: 1.5rem; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-lg { + width: 1.25rem; + height: 1.25rem; + font-size: 1.25rem; +} + +.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-sm { + width: 0.875rem; + height: 0.875rem; + font-size: 0.875rem; +} + +.input-group { + position: relative; + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-align: stretch; + align-items: stretch; + width: 100%; +} + +.input-group > .form-control, +.input-group > .form-control-plaintext, +.input-group > .custom-select, +.input-group > .custom-file { + position: relative; + -ms-flex: 1 1 0%; + flex: 1 1 0%; + min-width: 0; + margin-bottom: 0; +} + +html:not([dir="rtl"]) .input-group > .form-control + .form-control, html:not([dir="rtl"]) +.input-group > .form-control + .custom-select, html:not([dir="rtl"]) +.input-group > .form-control + .custom-file, html:not([dir="rtl"]) +.input-group > .form-control-plaintext + .form-control, html:not([dir="rtl"]) +.input-group > .form-control-plaintext + .custom-select, html:not([dir="rtl"]) +.input-group > .form-control-plaintext + .custom-file, html:not([dir="rtl"]) +.input-group > .custom-select + .form-control, html:not([dir="rtl"]) +.input-group > .custom-select + .custom-select, html:not([dir="rtl"]) +.input-group > .custom-select + .custom-file, html:not([dir="rtl"]) +.input-group > .custom-file + .form-control, html:not([dir="rtl"]) +.input-group > .custom-file + .custom-select, html:not([dir="rtl"]) +.input-group > .custom-file + .custom-file { + margin-left: -1px; +} + +*[dir="rtl"] .input-group > .form-control + .form-control, *[dir="rtl"] +.input-group > .form-control + .custom-select, *[dir="rtl"] +.input-group > .form-control + .custom-file, *[dir="rtl"] +.input-group > .form-control-plaintext + .form-control, *[dir="rtl"] +.input-group > .form-control-plaintext + .custom-select, *[dir="rtl"] +.input-group > .form-control-plaintext + .custom-file, *[dir="rtl"] +.input-group > .custom-select + .form-control, *[dir="rtl"] +.input-group > .custom-select + .custom-select, *[dir="rtl"] +.input-group > .custom-select + .custom-file, *[dir="rtl"] +.input-group > .custom-file + .form-control, *[dir="rtl"] +.input-group > .custom-file + .custom-select, *[dir="rtl"] +.input-group > .custom-file + .custom-file { + margin-right: -1px; +} + +.input-group > .form-control:focus, +.input-group > .custom-select:focus, +.input-group > .custom-file .custom-file-input:focus ~ .custom-file-label { + z-index: 3; +} + +.input-group > .custom-file .custom-file-input:focus { + z-index: 4; +} + +html:not([dir="rtl"]) .input-group > .form-control:not(:last-child), html:not([dir="rtl"]) +.input-group > .custom-select:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +*[dir="rtl"] .input-group > .form-control:not(:last-child), *[dir="rtl"] +.input-group > .custom-select:not(:last-child),html:not([dir="rtl"]) .input-group > .form-control:not(:first-child), html:not([dir="rtl"]) +.input-group > .custom-select:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +*[dir="rtl"] .input-group > .form-control:not(:first-child), *[dir="rtl"] +.input-group > .custom-select:not(:first-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group > .custom-file { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; +} + +html:not([dir="rtl"]) .input-group > .custom-file:not(:last-child) .custom-file-label, html:not([dir="rtl"]) +.input-group > .custom-file:not(:last-child) .custom-file-label::after { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +*[dir="rtl"] .input-group > .custom-file:not(:last-child) .custom-file-label, *[dir="rtl"] +.input-group > .custom-file:not(:last-child) .custom-file-label::after,html:not([dir="rtl"]) .input-group > .custom-file:not(:first-child) .custom-file-label { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +*[dir="rtl"] .input-group > .custom-file:not(:first-child) .custom-file-label { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group-prepend, +.input-group-append { + display: -ms-flexbox; + display: flex; +} + +.input-group-prepend .btn, +.input-group-append .btn { + position: relative; + z-index: 2; +} + +.input-group-prepend .btn:focus, +.input-group-append .btn:focus { + z-index: 3; +} + +html:not([dir="rtl"]) .input-group-prepend .btn + .btn, html:not([dir="rtl"]) +.input-group-prepend .btn + .input-group-text, html:not([dir="rtl"]) +.input-group-prepend .input-group-text + .input-group-text, html:not([dir="rtl"]) +.input-group-prepend .input-group-text + .btn, html:not([dir="rtl"]) +.input-group-append .btn + .btn, html:not([dir="rtl"]) +.input-group-append .btn + .input-group-text, html:not([dir="rtl"]) +.input-group-append .input-group-text + .input-group-text, html:not([dir="rtl"]) +.input-group-append .input-group-text + .btn { + margin-left: -1px; +} + +*[dir="rtl"] .input-group-prepend .btn + .btn, *[dir="rtl"] +.input-group-prepend .btn + .input-group-text, *[dir="rtl"] +.input-group-prepend .input-group-text + .input-group-text, *[dir="rtl"] +.input-group-prepend .input-group-text + .btn, *[dir="rtl"] +.input-group-append .btn + .btn, *[dir="rtl"] +.input-group-append .btn + .input-group-text, *[dir="rtl"] +.input-group-append .input-group-text + .input-group-text, *[dir="rtl"] +.input-group-append .input-group-text + .btn { + margin-right: -1px; +} + +.input-group-prepend { + white-space: nowrap; + vertical-align: middle; +} + +html:not([dir="rtl"]) .input-group-prepend { + margin-right: -1px; +} + +*[dir="rtl"] .input-group-prepend { + margin-left: -1px; +} + +.input-group-append { + white-space: nowrap; + vertical-align: middle; +} + +html:not([dir="rtl"]) .input-group-append { + margin-left: -1px; +} + +*[dir="rtl"] .input-group-append { + margin-right: -1px; +} + +.input-group-text { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + padding: 0.375rem 0.75rem; + margin-bottom: 0; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + text-align: center; + white-space: nowrap; + border: 1px solid; + border-radius: 0.25rem; + color: #768192; + background-color: #ebedef; + border-color: #d8dbe0; +} + +.input-group-text input[type="radio"], +.input-group-text input[type="checkbox"] { + margin-top: 0; +} + +.input-group-lg > .form-control:not(textarea), +.input-group-lg > .custom-select { + height: calc(1.5em + 1rem + 2px); +} + +.input-group-lg > .form-control, +.input-group-lg > .custom-select, +.input-group-lg > .input-group-prepend > .input-group-text, +.input-group-lg > .input-group-append > .input-group-text, +.input-group-lg > .input-group-prepend > .btn, +.input-group-lg > .input-group-append > .btn { + padding: 0.5rem 1rem; + font-size: 1.09375rem; + line-height: 1.5; + border-radius: 0.3rem; +} + +.input-group-sm > .form-control:not(textarea), +.input-group-sm > .custom-select { + height: calc(1.5em + 0.5rem + 2px); +} + +.input-group-sm > .form-control, +.input-group-sm > .custom-select, +.input-group-sm > .input-group-prepend > .input-group-text, +.input-group-sm > .input-group-append > .input-group-text, +.input-group-sm > .input-group-prepend > .btn, +.input-group-sm > .input-group-append > .btn { + padding: 0.25rem 0.5rem; + font-size: 0.765625rem; + line-height: 1.5; + border-radius: 0.2rem; +} + +html:not([dir="rtl"]) .input-group-lg > .custom-select, html:not([dir="rtl"]) +.input-group-sm > .custom-select { + padding-right: 1.75rem; +} + +*[dir="rtl"] .input-group-lg > .custom-select, *[dir="rtl"] +.input-group-sm > .custom-select { + padding-left: 1.75rem; +} + +html:not([dir="rtl"]) .input-group > .input-group-prepend > .btn, html:not([dir="rtl"]) +.input-group > .input-group-prepend > .input-group-text, html:not([dir="rtl"]) +.input-group > .input-group-append:not(:last-child) > .btn, html:not([dir="rtl"]) +.input-group > .input-group-append:not(:last-child) > .input-group-text, html:not([dir="rtl"]) +.input-group > .input-group-append:last-child > .btn:not(:last-child):not(.dropdown-toggle), html:not([dir="rtl"]) +.input-group > .input-group-append:last-child > .input-group-text:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +*[dir="rtl"] .input-group > .input-group-prepend > .btn, *[dir="rtl"] +.input-group > .input-group-prepend > .input-group-text, *[dir="rtl"] +.input-group > .input-group-append:not(:last-child) > .btn, *[dir="rtl"] +.input-group > .input-group-append:not(:last-child) > .input-group-text, *[dir="rtl"] +.input-group > .input-group-append:last-child > .btn:not(:last-child):not(.dropdown-toggle), *[dir="rtl"] +.input-group > .input-group-append:last-child > .input-group-text:not(:last-child),html:not([dir="rtl"]) .input-group > .input-group-append > .btn, html:not([dir="rtl"]) +.input-group > .input-group-append > .input-group-text, html:not([dir="rtl"]) +.input-group > .input-group-prepend:not(:first-child) > .btn, html:not([dir="rtl"]) +.input-group > .input-group-prepend:not(:first-child) > .input-group-text, html:not([dir="rtl"]) +.input-group > .input-group-prepend:first-child > .btn:not(:first-child), html:not([dir="rtl"]) +.input-group > .input-group-prepend:first-child > .input-group-text:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +*[dir="rtl"] .input-group > .input-group-append > .btn, *[dir="rtl"] +.input-group > .input-group-append > .input-group-text, *[dir="rtl"] +.input-group > .input-group-prepend:not(:first-child) > .btn, *[dir="rtl"] +.input-group > .input-group-prepend:not(:first-child) > .input-group-text, *[dir="rtl"] +.input-group > .input-group-prepend:first-child > .btn:not(:first-child), *[dir="rtl"] +.input-group > .input-group-prepend:first-child > .input-group-text:not(:first-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.img-fluid,.img-thumbnail { + max-width: 100%; + height: auto; +} + +.img-thumbnail { + padding: 0.25rem; + background-color: #ebedef; + border: 1px solid #c4c9d0; + border-radius: 0.25rem; +} + +.figure { + display: inline-block; +} + +.figure-img { + margin-bottom: 0.5rem; + line-height: 1; +} + +.figure-caption { + font-size: 90%; + color: #8a93a2; +} + +.jumbotron { + padding: 2rem 1rem; + margin-bottom: 2rem; + border-radius: 0.3rem; + background-color: #d8dbe0; +} + +@media (min-width: 576px) { + .jumbotron { + padding: 4rem 2rem; + } +} + +.jumbotron-fluid { + padding-right: 0; + padding-left: 0; + border-radius: 0; +} + +.list-group { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; + margin-bottom: 0; +} + +html:not([dir="rtl"]) .list-group { + padding-left: 0; +} + +*[dir="rtl"] .list-group { + padding-right: 0; +} + +.list-group-item-action { + width: 100%; + text-align: inherit; + color: #768192; +} + +.list-group-item-action:hover, .list-group-item-action:focus { + z-index: 1; + text-decoration: none; + color: #768192; + background-color: #ebedef; +} + +.list-group-item-action:active { + color: #4f5d73; + background-color: #321fdb; +} + +.list-group-item { + position: relative; + display: block; + padding: 0.75rem 1.25rem; + border: 1px solid; + background-color: inherit; + border-color: rgba(0, 0, 21, 0.125); +} + +.list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +.list-group-item:last-child { + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.list-group-item.disabled, .list-group-item:disabled { + pointer-events: none; + color: #8a93a2; + background-color: inherit; +} + +.list-group-item.active { + z-index: 2; + color: #fff; + background-color: #321fdb; + border-color: #321fdb; +} + +.list-group-item + .list-group-item { + border-top-width: 0; +} + +.list-group-item + .list-group-item.active { + margin-top: -1px; + border-top-width: 1px; +} + +.list-group-horizontal { + -ms-flex-direction: row; + flex-direction: row; +} + +.list-group-horizontal .list-group-item:first-child { + border-bottom-left-radius: 0.25rem; + border-top-right-radius: 0; +} + +.list-group-horizontal .list-group-item:last-child { + border-top-right-radius: 0.25rem; + 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: 1px; + border-left-width: 0; +} + +.list-group-horizontal .list-group-item + .list-group-item.active { + margin-left: -1px; + border-left-width: 1px; +} + +@media (min-width: 576px) { + .list-group-horizontal-sm { + -ms-flex-direction: row; + flex-direction: row; + } + .list-group-horizontal-sm .list-group-item:first-child { + border-bottom-left-radius: 0.25rem; + border-top-right-radius: 0; + } + .list-group-horizontal-sm .list-group-item:last-child { + border-top-right-radius: 0.25rem; + 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: 1px; + border-left-width: 0; + } + .list-group-horizontal-sm .list-group-item + .list-group-item.active { + margin-left: -1px; + border-left-width: 1px; + } +} + +@media (min-width: 768px) { + .list-group-horizontal-md { + -ms-flex-direction: row; + flex-direction: row; + } + .list-group-horizontal-md .list-group-item:first-child { + border-bottom-left-radius: 0.25rem; + border-top-right-radius: 0; + } + .list-group-horizontal-md .list-group-item:last-child { + border-top-right-radius: 0.25rem; + 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: 1px; + border-left-width: 0; + } + .list-group-horizontal-md .list-group-item + .list-group-item.active { + margin-left: -1px; + border-left-width: 1px; + } +} + +@media (min-width: 992px) { + .list-group-horizontal-lg { + -ms-flex-direction: row; + flex-direction: row; + } + .list-group-horizontal-lg .list-group-item:first-child { + border-bottom-left-radius: 0.25rem; + border-top-right-radius: 0; + } + .list-group-horizontal-lg .list-group-item:last-child { + border-top-right-radius: 0.25rem; + 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: 1px; + border-left-width: 0; + } + .list-group-horizontal-lg .list-group-item + .list-group-item.active { + margin-left: -1px; + border-left-width: 1px; + } +} + +@media (min-width: 1200px) { + .list-group-horizontal-xl { + -ms-flex-direction: row; + flex-direction: row; + } + .list-group-horizontal-xl .list-group-item:first-child { + border-bottom-left-radius: 0.25rem; + border-top-right-radius: 0; + } + .list-group-horizontal-xl .list-group-item:last-child { + border-top-right-radius: 0.25rem; + 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: 1px; + border-left-width: 0; + } + .list-group-horizontal-xl .list-group-item + .list-group-item.active { + margin-left: -1px; + border-left-width: 1px; + } +} + +.list-group-flush .list-group-item { + border-right-width: 0; + border-left-width: 0; + border-radius: 0; +} + +.list-group-flush .list-group-item:first-child { + border-top-width: 0; +} + +.list-group-flush:last-child .list-group-item:last-child { + border-bottom-width: 0; +} + +.list-group-item-primary { + color: #1a107c; + background-color: #c6c0f5; +} + +.list-group-item-primary.list-group-item-action:hover, .list-group-item-primary.list-group-item-action:focus { + color: #1a107c; + background-color: #b2aaf2; +} + +.list-group-item-primary.list-group-item-action.active { + color: #fff; + background-color: #1a107c; + border-color: #1a107c; +} + +.list-group-item-secondary { + color: #6b6d7a; + background-color: #f1f2f4; +} + +.list-group-item-secondary.list-group-item-action:hover, .list-group-item-secondary.list-group-item-action:focus { + color: #6b6d7a; + background-color: #e3e5e9; +} + +.list-group-item-secondary.list-group-item-action.active { + color: #fff; + background-color: #6b6d7a; + border-color: #6b6d7a; +} + +.list-group-item-success { + color: #18603a; + background-color: #c4ebd1; +} + +.list-group-item-success.list-group-item-action:hover, .list-group-item-success.list-group-item-action:focus { + color: #18603a; + background-color: #b1e5c2; +} + +.list-group-item-success.list-group-item-action.active { + color: #fff; + background-color: #18603a; + border-color: #18603a; +} + +.list-group-item-info { + color: #1b508f; + background-color: #c6e2ff; +} + +.list-group-item-info.list-group-item-action:hover, .list-group-item-info.list-group-item-action:focus { + color: #1b508f; + background-color: #add5ff; +} + +.list-group-item-info.list-group-item-action.active { + color: #fff; + background-color: #1b508f; + border-color: #1b508f; +} + +.list-group-item-warning { + color: #815c15; + background-color: #fde9bd; +} + +.list-group-item-warning.list-group-item-action:hover, .list-group-item-warning.list-group-item-action:focus { + color: #815c15; + background-color: #fce1a4; +} + +.list-group-item-warning.list-group-item-action.active { + color: #fff; + background-color: #815c15; + border-color: #815c15; +} + +.list-group-item-danger { + color: #772b35; + background-color: #f8cfcf; +} + +.list-group-item-danger.list-group-item-action:hover, .list-group-item-danger.list-group-item-action:focus { + color: #772b35; + background-color: #f5b9b9; +} + +.list-group-item-danger.list-group-item-action.active { + color: #fff; + background-color: #772b35; + border-color: #772b35; +} + +.list-group-item-light { + color: #7a7b86; + background-color: #f9fafb; +} + +.list-group-item-light.list-group-item-action:hover, .list-group-item-light.list-group-item-action:focus { + color: #7a7b86; + background-color: #eaedf1; +} + +.list-group-item-light.list-group-item-action.active { + color: #fff; + background-color: #7a7b86; + border-color: #7a7b86; +} + +.list-group-item-dark { + color: #333a4e; + background-color: #d3d7dc; +} + +.list-group-item-dark.list-group-item-action:hover, .list-group-item-dark.list-group-item-action:focus { + color: #333a4e; + background-color: #c5cad1; +} + +.list-group-item-dark.list-group-item-action.active { + color: #fff; + background-color: #333a4e; + border-color: #333a4e; +} + +.list-group-accent .list-group-item { + margin-bottom: 1px; + border-top: 0; + border-right: 0; + border-bottom: 0; + border-radius: 0; +} + +.list-group-accent .list-group-item.list-group-item-divider { + position: relative; +} + +.list-group-accent .list-group-item.list-group-item-divider::before { + position: absolute; + bottom: -1px; + width: 90%; + height: 1px; + content: ""; + background-color: rgba(0, 0, 21, 0.125); +} + +html:not([dir="rtl"]) .list-group-accent .list-group-item.list-group-item-divider::before { + left: 5%; +} + +*[dir="rtl"] .list-group-accent .list-group-item.list-group-item-divider::before { + right: 5%; +} + +.list-group-accent .list-group-item-accent-primary { + border-left: 4px solid #321fdb; +} + +.list-group-accent .list-group-item-accent-secondary { + border-left: 4px solid #ced2d8; +} + +.list-group-accent .list-group-item-accent-success { + border-left: 4px solid #2eb85c; +} + +.list-group-accent .list-group-item-accent-info { + border-left: 4px solid #39f; +} + +.list-group-accent .list-group-item-accent-warning { + border-left: 4px solid #f9b115; +} + +.list-group-accent .list-group-item-accent-danger { + border-left: 4px solid #e55353; +} + +.list-group-accent .list-group-item-accent-light { + border-left: 4px solid #ebedef; +} + +.list-group-accent .list-group-item-accent-dark { + border-left: 4px solid #636f83; +} + +.media { + display: -ms-flexbox; + display: flex; + -ms-flex-align: start; + align-items: flex-start; +} + +.media-body { + -ms-flex: 1; + flex: 1; +} + +.modal-open { + overflow: hidden; +} + +.modal-open .modal { + overflow-x: hidden; + overflow-y: auto; +} + +.modal { + position: fixed; + top: 0; + left: 0; + z-index: 1050; + display: none; + width: 100%; + height: 100%; + overflow: hidden; + outline: 0; +} + +.modal-dialog { + position: relative; + width: auto; + margin: 0.5rem; + pointer-events: none; +} + +.modal.fade .modal-dialog { + transition: -webkit-transform 0.3s ease-out; + transition: transform 0.3s ease-out; + transition: transform 0.3s ease-out, -webkit-transform 0.3s ease-out; + -webkit-transform: translate(0, -50px); + transform: translate(0, -50px); +} + +@media (prefers-reduced-motion: reduce) { + .modal.fade .modal-dialog { + transition: none; + } +} + +.modal.show .modal-dialog { + -webkit-transform: none; + transform: none; +} + +.modal.modal-static .modal-dialog { + -webkit-transform: scale(1.02); + transform: scale(1.02); +} + +.modal-dialog-scrollable { + display: -ms-flexbox; + display: flex; + max-height: calc(100% - 1rem); +} + +.modal-dialog-scrollable .modal-content { + max-height: calc(100vh - 1rem); + overflow: hidden; +} + +.modal-dialog-scrollable .modal-header, +.modal-dialog-scrollable .modal-footer { + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.modal-dialog-scrollable .modal-body { + overflow-y: auto; +} + +.modal-dialog-centered { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + min-height: calc(100% - 1rem); +} + +.modal-dialog-centered::before { + display: block; + height: calc(100vh - 1rem); + content: ""; +} + +.modal-dialog-centered.modal-dialog-scrollable { + -ms-flex-direction: column; + flex-direction: column; + -ms-flex-pack: center; + justify-content: center; + height: 100%; +} + +.modal-dialog-centered.modal-dialog-scrollable .modal-content { + max-height: none; +} + +.modal-dialog-centered.modal-dialog-scrollable::before { + content: none; +} + +.modal-content { + position: relative; + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; + width: 100%; + pointer-events: auto; + background-clip: padding-box; + border: 1px solid; + border-radius: 0.3rem; + outline: 0; + background-color: #fff; + border-color: rgba(0, 0, 21, 0.2); +} + +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + z-index: 1040; + width: 100vw; + height: 100vh; + background-color: #000015; +} + +.modal-backdrop.fade { + opacity: 0; +} + +.modal-backdrop.show { + opacity: 0.5; +} + +.modal-header { + display: -ms-flexbox; + display: flex; + -ms-flex-align: start; + align-items: flex-start; + -ms-flex-pack: justify; + justify-content: space-between; + border-bottom: 1px solid; + border-top-left-radius: calc(0.3rem - 1px); + border-top-right-radius: calc(0.3rem - 1px); + border-color: #d8dbe0; +} + +.modal-header,.modal-header .close { + padding: 1rem 1rem; +} + +html:not([dir="rtl"]) .modal-header .close { + margin: -1rem -1rem -1rem auto; +} + +*[dir="rtl"] .modal-header .close { + margin: -1rem auto -1rem -1rem; +} + +.modal-title { + margin-bottom: 0; + line-height: 1.5; +} + +.modal-body { + position: relative; + -ms-flex: 1 1 auto; + flex: 1 1 auto; + padding: 1rem; +} + +.modal-footer { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: end; + justify-content: flex-end; + padding: 0.75rem; + border-top: 1px solid; + border-bottom-right-radius: calc(0.3rem - 1px); + border-bottom-left-radius: calc(0.3rem - 1px); + border-color: #d8dbe0; +} + +.modal-footer > * { + margin: 0.25rem; +} + +.modal-scrollbar-measure { + position: absolute; + top: -9999px; + width: 50px; + height: 50px; + overflow: scroll; +} + +@media (min-width: 576px) { + .modal-dialog { + max-width: 500px; + margin: 1.75rem auto; + } + .modal-dialog-scrollable { + max-height: calc(100% - 3.5rem); + } + .modal-dialog-scrollable .modal-content { + max-height: calc(100vh - 3.5rem); + } + .modal-dialog-centered { + min-height: calc(100% - 3.5rem); + } + .modal-dialog-centered::before { + height: calc(100vh - 3.5rem); + } + .modal-sm { + max-width: 300px; + } +} + +@media (min-width: 992px) { + .modal-lg, + .modal-xl { + max-width: 800px; + } +} + +@media (min-width: 1200px) { + .modal-xl { + max-width: 1140px; + } +} + +.modal-primary .modal-content { + border-color: #321fdb; +} + +.modal-primary .modal-header { + color: #fff; + background-color: #321fdb; +} + +.modal-secondary .modal-content { + border-color: #ced2d8; +} + +.modal-secondary .modal-header { + color: #fff; + background-color: #ced2d8; +} + +.modal-success .modal-content { + border-color: #2eb85c; +} + +.modal-success .modal-header { + color: #fff; + background-color: #2eb85c; +} + +.modal-info .modal-content { + border-color: #39f; +} + +.modal-info .modal-header { + color: #fff; + background-color: #39f; +} + +.modal-warning .modal-content { + border-color: #f9b115; +} + +.modal-warning .modal-header { + color: #fff; + background-color: #f9b115; +} + +.modal-danger .modal-content { + border-color: #e55353; +} + +.modal-danger .modal-header { + color: #fff; + background-color: #e55353; +} + +.modal-light .modal-content { + border-color: #ebedef; +} + +.modal-light .modal-header { + color: #fff; + background-color: #ebedef; +} + +.modal-dark .modal-content { + border-color: #636f83; +} + +.modal-dark .modal-header { + color: #fff; + background-color: #636f83; +} + +.nav { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + margin-bottom: 0; + list-style: none; +} + +html:not([dir="rtl"]) .nav { + padding-left: 0; +} + +*[dir="rtl"] .nav { + padding-right: 0; +} + +.nav-link { + display: block; + padding: 0.5rem 1rem; +} + +.nav-link:hover, .nav-link:focus { + text-decoration: none; +} + +.nav-link.disabled { + color: #8a93a2; + pointer-events: none; + cursor: default; + color: #8a93a2; +} + +.nav-tabs { + border-bottom: 1px solid; + border-color: #c4c9d0; +} + +.nav-tabs .nav-item { + margin-bottom: -1px; +} + +.nav-tabs .nav-link { + border: 1px solid transparent; + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus { + border-color: #d8dbe0 #d8dbe0 #c4c9d0; +} + +.nav-tabs .nav-link.disabled { + background-color: transparent; + border-color: transparent; + color: #8a93a2; +} + +.nav-tabs .nav-link.active, +.nav-tabs .nav-item.show .nav-link { + color: #768192; + background-color: #ebedef; + border-color: #c4c9d0 #c4c9d0 #ebedef; +} + +.nav-tabs .dropdown-menu { + margin-top: -1px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.nav-tabs-boxed .nav-tabs { + border: 0; +} + +.nav-tabs-boxed .nav-tabs .nav-link.active { + background-color: #fff; + border-bottom-color: #fff; +} + +.nav-tabs-boxed .tab-content { + padding: 0.75rem 1.25rem; + border: 1px solid; + border-radius: 0 0.25rem 0.25rem 0.25rem; + color: #768192; + background-color: #fff; + border-color: #d8dbe0; +} + +.nav-tabs-boxed.nav-tabs-boxed-top-right .nav-tabs { + -ms-flex-pack: end; + justify-content: flex-end; +} + +.nav-tabs-boxed.nav-tabs-boxed-top-right .tab-content { + border-radius: 0.25rem 0 0.25rem 0.25rem; +} + +.nav-tabs-boxed.nav-tabs-boxed-left, .nav-tabs-boxed.nav-tabs-boxed-right { + display: -ms-flexbox; + display: flex; +} + +.nav-tabs-boxed.nav-tabs-boxed-left .nav-item, .nav-tabs-boxed.nav-tabs-boxed-right .nav-item { + z-index: 1; + -ms-flex-positive: 1; + flex-grow: 1; + margin-bottom: 0; +} + +*[dir="rtl"] .nav-tabs-boxed.nav-tabs-boxed-left { + -ms-flex-direction: row-reverse; + flex-direction: row-reverse; +} + +.nav-tabs-boxed.nav-tabs-boxed-left .nav-item { + margin-right: -1px; +} + +.nav-tabs-boxed.nav-tabs-boxed-left .nav-link { + border-radius: 0.25rem 0 0 0.25rem; +} + +.nav-tabs-boxed.nav-tabs-boxed-left .nav-link.active { + border-color: #d8dbe0 #fff #d8dbe0 #d8dbe0; +} + +html:not([dir="rtl"]) .nav-tabs-boxed.nav-tabs-boxed-right { + -ms-flex-direction: row-reverse; + flex-direction: row-reverse; +} + +*[dir="rtl"] .nav-tabs-boxed.nav-tabs-boxed-right { + -ms-flex-direction: row; + flex-direction: row; +} + +html:not([dir="rtl"]) .nav-tabs-boxed.nav-tabs-boxed-right .nav-item { + margin-left: -1px; +} + +*[dir="rtl"] .nav-tabs-boxed.nav-tabs-boxed-right .nav-item { + margin-right: -1px; +} + +.nav-tabs-boxed.nav-tabs-boxed-right .nav-link { + border-radius: 0 0.25rem 0.25rem 0; +} + +.nav-tabs-boxed.nav-tabs-boxed-right .nav-link.active { + border-color: #d8dbe0 #d8dbe0 #d8dbe0 #fff; +} + +.nav-tabs-boxed.nav-tabs-boxed-right .tab-content { + border-radius: 0.25rem 0 0.25rem 0.25rem; +} + +.nav-pills .nav-link { + border-radius: 0.25rem; +} + +.nav-pills .nav-link.active, +.nav-pills .show > .nav-link { + color: #fff; + background-color: #321fdb; +} + +.nav-underline { + border-bottom: 2px solid; + border-color: #c4c9d0; +} + +.nav-underline .nav-item { + margin-bottom: -2px; +} + +.nav-underline .nav-link { + border: 0; + border-bottom: 2px solid transparent; +} + +.nav-underline .nav-link.active, +.nav-underline .show > .nav-link { + background: transparent; +} + +.nav-underline.nav-underline-primary .nav-link.active, +.nav-underline.nav-underline-primary .show > .nav-link { + color: #321fdb; + border-color: #321fdb; +} + +.nav-underline.nav-underline-secondary .nav-link.active, +.nav-underline.nav-underline-secondary .show > .nav-link { + color: #ced2d8; + border-color: #ced2d8; +} + +.nav-underline.nav-underline-success .nav-link.active, +.nav-underline.nav-underline-success .show > .nav-link { + color: #2eb85c; + border-color: #2eb85c; +} + +.nav-underline.nav-underline-info .nav-link.active, +.nav-underline.nav-underline-info .show > .nav-link { + color: #39f; + border-color: #39f; +} + +.nav-underline.nav-underline-warning .nav-link.active, +.nav-underline.nav-underline-warning .show > .nav-link { + color: #f9b115; + border-color: #f9b115; +} + +.nav-underline.nav-underline-danger .nav-link.active, +.nav-underline.nav-underline-danger .show > .nav-link { + color: #e55353; + border-color: #e55353; +} + +.nav-underline.nav-underline-light .nav-link.active, +.nav-underline.nav-underline-light .show > .nav-link { + color: #ebedef; + border-color: #ebedef; +} + +.nav-underline.nav-underline-dark .nav-link.active, +.nav-underline.nav-underline-dark .show > .nav-link { + color: #636f83; + border-color: #636f83; +} + +.nav-fill .nav-item { + -ms-flex: 1 1 auto; + flex: 1 1 auto; + text-align: center; +} + +.nav-justified .nav-item { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + text-align: center; +} + +.tab-content > .tab-pane { + display: none; +} + +.tab-content > .active { + display: block; +} + +.c-sidebar .nav-tabs:first-child .nav-link, +.c-sidebar .c-sidebar-close + .nav-tabs .nav-link { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + height: 56px; + padding-top: 0; + padding-bottom: 0; +} + +.navbar { + position: relative; + padding: 0.5rem 1rem; +} + +.navbar,.navbar .container, +.navbar .container-fluid, .navbar .container-sm, .navbar .container-md, .navbar .container-lg, .navbar .container-xl { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.navbar-brand { + display: inline-block; + padding-top: 0.3359375rem; + padding-bottom: 0.3359375rem; + margin-right: 1rem; + font-size: 1.09375rem; + line-height: inherit; + white-space: nowrap; +} + +.navbar-brand:hover, .navbar-brand:focus { + text-decoration: none; +} + +.navbar-nav { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; + margin-bottom: 0; + list-style: none; +} + +html:not([dir="rtl"]) .navbar-nav { + padding-left: 0; +} + +*[dir="rtl"] .navbar-nav { + padding-right: 0; +} + +.navbar-nav .nav-link { + padding-right: 0; + padding-left: 0; +} + +.navbar-nav .dropdown-menu { + position: static; + float: none; +} + +.navbar-text { + display: inline-block; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.navbar-collapse { + -ms-flex-preferred-size: 100%; + flex-basis: 100%; + -ms-flex-positive: 1; + flex-grow: 1; + -ms-flex-align: center; + align-items: center; +} + +.navbar-toggler { + padding: 0.25rem 0.75rem; + font-size: 1.09375rem; + line-height: 1; + background-color: transparent; + border: 1px solid transparent; + border-radius: 0.25rem; +} + +.navbar-toggler:hover, .navbar-toggler:focus { + text-decoration: none; +} + +.navbar-toggler-icon { + display: inline-block; + width: 1.5em; + height: 1.5em; + vertical-align: middle; + content: ""; + background: no-repeat center center; + background-size: 100% 100%; +} + +@media (max-width: 575.98px) { + .navbar-expand-sm > .container, + .navbar-expand-sm > .container-fluid, .navbar-expand-sm > .container-sm, .navbar-expand-sm > .container-md, .navbar-expand-sm > .container-lg, .navbar-expand-sm > .container-xl { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 576px) { + .navbar-expand-sm { + -ms-flex-flow: row nowrap; + flex-flow: row nowrap; + -ms-flex-pack: start; + justify-content: flex-start; + } + .navbar-expand-sm .navbar-nav { + -ms-flex-direction: row; + flex-direction: row; + } + .navbar-expand-sm .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-sm .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-sm > .container, + .navbar-expand-sm > .container-fluid, .navbar-expand-sm > .container-sm, .navbar-expand-sm > .container-md, .navbar-expand-sm > .container-lg, .navbar-expand-sm > .container-xl { + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + } + .navbar-expand-sm .navbar-collapse { + display: -ms-flexbox !important; + display: flex !important; + -ms-flex-preferred-size: auto; + flex-basis: auto; + } + .navbar-expand-sm .navbar-toggler { + display: none; + } +} + +@media (max-width: 767.98px) { + .navbar-expand-md > .container, + .navbar-expand-md > .container-fluid, .navbar-expand-md > .container-sm, .navbar-expand-md > .container-md, .navbar-expand-md > .container-lg, .navbar-expand-md > .container-xl { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 768px) { + .navbar-expand-md { + -ms-flex-flow: row nowrap; + flex-flow: row nowrap; + -ms-flex-pack: start; + justify-content: flex-start; + } + .navbar-expand-md .navbar-nav { + -ms-flex-direction: row; + flex-direction: row; + } + .navbar-expand-md .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-md .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-md > .container, + .navbar-expand-md > .container-fluid, .navbar-expand-md > .container-sm, .navbar-expand-md > .container-md, .navbar-expand-md > .container-lg, .navbar-expand-md > .container-xl { + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + } + .navbar-expand-md .navbar-collapse { + display: -ms-flexbox !important; + display: flex !important; + -ms-flex-preferred-size: auto; + flex-basis: auto; + } + .navbar-expand-md .navbar-toggler { + display: none; + } +} + +@media (max-width: 991.98px) { + .navbar-expand-lg > .container, + .navbar-expand-lg > .container-fluid, .navbar-expand-lg > .container-sm, .navbar-expand-lg > .container-md, .navbar-expand-lg > .container-lg, .navbar-expand-lg > .container-xl { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 992px) { + .navbar-expand-lg { + -ms-flex-flow: row nowrap; + flex-flow: row nowrap; + -ms-flex-pack: start; + justify-content: flex-start; + } + .navbar-expand-lg .navbar-nav { + -ms-flex-direction: row; + flex-direction: row; + } + .navbar-expand-lg .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-lg .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-lg > .container, + .navbar-expand-lg > .container-fluid, .navbar-expand-lg > .container-sm, .navbar-expand-lg > .container-md, .navbar-expand-lg > .container-lg, .navbar-expand-lg > .container-xl { + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + } + .navbar-expand-lg .navbar-collapse { + display: -ms-flexbox !important; + display: flex !important; + -ms-flex-preferred-size: auto; + flex-basis: auto; + } + .navbar-expand-lg .navbar-toggler { + display: none; + } +} + +@media (max-width: 1199.98px) { + .navbar-expand-xl > .container, + .navbar-expand-xl > .container-fluid, .navbar-expand-xl > .container-sm, .navbar-expand-xl > .container-md, .navbar-expand-xl > .container-lg, .navbar-expand-xl > .container-xl { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 1200px) { + .navbar-expand-xl { + -ms-flex-flow: row nowrap; + flex-flow: row nowrap; + -ms-flex-pack: start; + justify-content: flex-start; + } + .navbar-expand-xl .navbar-nav { + -ms-flex-direction: row; + flex-direction: row; + } + .navbar-expand-xl .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-xl .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-xl > .container, + .navbar-expand-xl > .container-fluid, .navbar-expand-xl > .container-sm, .navbar-expand-xl > .container-md, .navbar-expand-xl > .container-lg, .navbar-expand-xl > .container-xl { + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + } + .navbar-expand-xl .navbar-collapse { + display: -ms-flexbox !important; + display: flex !important; + -ms-flex-preferred-size: auto; + flex-basis: auto; + } + .navbar-expand-xl .navbar-toggler { + display: none; + } +} + +.navbar-expand { + -ms-flex-flow: row nowrap; + flex-flow: row nowrap; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.navbar-expand > .container, +.navbar-expand > .container-fluid, .navbar-expand > .container-sm, .navbar-expand > .container-md, .navbar-expand > .container-lg, .navbar-expand > .container-xl { + padding-right: 0; + padding-left: 0; + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; +} + +.navbar-expand .navbar-nav { + -ms-flex-direction: row; + flex-direction: row; +} + +.navbar-expand .navbar-nav .dropdown-menu { + position: absolute; +} + +.navbar-expand .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; +} + +.navbar-expand .navbar-collapse { + display: -ms-flexbox !important; + display: flex !important; + -ms-flex-preferred-size: auto; + flex-basis: auto; +} + +.navbar-expand .navbar-toggler { + display: none; +} + +.navbar.navbar-dark .navbar-brand,.navbar.navbar-dark .navbar-brand:hover, .navbar.navbar-dark .navbar-brand:focus { + color: #fff; +} + +.navbar.navbar-dark .navbar-nav .nav-link { + color: rgba(255, 255, 255, 0.5); +} + +.navbar.navbar-dark .navbar-nav .nav-link:hover, .navbar.navbar-dark .navbar-nav .nav-link:focus { + color: rgba(255, 255, 255, 0.75); +} + +.navbar.navbar-dark .navbar-nav .nav-link.disabled { + color: rgba(255, 255, 255, 0.25); +} + +.navbar.navbar-dark .navbar-nav .show > .nav-link, +.navbar.navbar-dark .navbar-nav .active > .nav-link, +.navbar.navbar-dark .navbar-nav .nav-link.show, +.navbar.navbar-dark .navbar-nav .nav-link.active { + color: #fff; +} + +.navbar.navbar-dark .navbar-toggler { + color: rgba(255, 255, 255, 0.5); + border-color: rgba(255, 255, 255, 0.1); +} + +.navbar.navbar-dark .navbar-toggler-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba(255, 255, 255, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); +} + +.navbar.navbar-dark .navbar-text { + color: rgba(255, 255, 255, 0.5); +} + +.navbar.navbar-dark .navbar-text a,.navbar.navbar-dark .navbar-text a:hover, .navbar.navbar-dark .navbar-text a:focus { + color: #fff; +} + +.navbar.navbar-light .navbar-brand,.navbar.navbar-light .navbar-brand:hover, .navbar.navbar-light .navbar-brand:focus { + color: rgba(0, 0, 21, 0.9); +} + +.navbar.navbar-light .navbar-nav .nav-link { + color: rgba(0, 0, 21, 0.5); +} + +.navbar.navbar-light .navbar-nav .nav-link:hover, .navbar.navbar-light .navbar-nav .nav-link:focus { + color: rgba(0, 0, 21, 0.7); +} + +.navbar.navbar-light .navbar-nav .nav-link.disabled { + color: rgba(0, 0, 21, 0.3); +} + +.navbar.navbar-light .navbar-nav .show > .nav-link, +.navbar.navbar-light .navbar-nav .active > .nav-link, +.navbar.navbar-light .navbar-nav .nav-link.show, +.navbar.navbar-light .navbar-nav .nav-link.active { + color: rgba(0, 0, 21, 0.9); +} + +.navbar.navbar-light .navbar-toggler { + color: rgba(0, 0, 21, 0.5); + border-color: rgba(0, 0, 21, 0.1); +} + +.navbar.navbar-light .navbar-toggler-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba(0, 0, 21, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); +} + +.navbar.navbar-light .navbar-text { + color: rgba(0, 0, 21, 0.5); +} + +.navbar.navbar-light .navbar-text a,.navbar.navbar-light .navbar-text a:hover, .navbar.navbar-light .navbar-text a:focus { + color: rgba(0, 0, 21, 0.9); +} + +.pagination { + display: -ms-flexbox; + display: flex; + list-style: none; + border-radius: 0.25rem; +} + +html:not([dir="rtl"]) .pagination { + padding-left: 0; +} + +*[dir="rtl"] .pagination { + padding-right: 0; +} + +.page-link { + position: relative; + display: block; + padding: 0.5rem 0.75rem; + line-height: 1.25; + border: 1px solid; + color: #321fdb; + background-color: #fff; + border-color: #d8dbe0; +} + +html:not([dir="rtl"]) .page-link { + margin-left: -1px; +} + +*[dir="rtl"] .page-link { + margin-right: -1px; +} + +.page-link:hover { + z-index: 2; + text-decoration: none; + color: #231698; + background-color: #d8dbe0; + border-color: #c4c9d0; +} + +.page-link:focus { + z-index: 3; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.25); +} + +html:not([dir="rtl"]) .page-item:first-child .page-link { + margin-left: 0; + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +*[dir="rtl"] .page-item:first-child .page-link { + margin-right: 0; +} + +*[dir="rtl"] .page-item:first-child .page-link,html:not([dir="rtl"]) .page-item:last-child .page-link { + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +*[dir="rtl"] .page-item:last-child .page-link { + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.page-item.active .page-link { + z-index: 3; + color: #fff; + background-color: #321fdb; + border-color: #321fdb; +} + +.page-item.disabled .page-link { + pointer-events: none; + cursor: auto; + color: #8a93a2; + background-color: #fff; + border-color: #c4c9d0; +} + +.pagination-lg .page-link { + padding: 0.75rem 1.5rem; + font-size: 1.09375rem; + line-height: 1.5; +} + +html:not([dir="rtl"]) .pagination-lg .page-item:first-child .page-link { + border-top-left-radius: 0.3rem; + border-bottom-left-radius: 0.3rem; +} + +*[dir="rtl"] .pagination-lg .page-item:first-child .page-link,html:not([dir="rtl"]) .pagination-lg .page-item:last-child .page-link { + border-top-right-radius: 0.3rem; + border-bottom-right-radius: 0.3rem; +} + +*[dir="rtl"] .pagination-lg .page-item:last-child .page-link { + border-top-left-radius: 0.3rem; + border-bottom-left-radius: 0.3rem; +} + +.pagination-sm .page-link { + padding: 0.25rem 0.5rem; + font-size: 0.765625rem; + line-height: 1.5; +} + +html:not([dir="rtl"]) .pagination-sm .page-item:first-child .page-link { + border-top-left-radius: 0.2rem; + border-bottom-left-radius: 0.2rem; +} + +*[dir="rtl"] .pagination-sm .page-item:first-child .page-link,html:not([dir="rtl"]) .pagination-sm .page-item:last-child .page-link { + border-top-right-radius: 0.2rem; + border-bottom-right-radius: 0.2rem; +} + +*[dir="rtl"] .pagination-sm .page-item:last-child .page-link { + border-top-left-radius: 0.2rem; + border-bottom-left-radius: 0.2rem; +} + +.popover { + top: 0; + left: 0; + z-index: 1060; + max-width: 276px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + 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; + word-spacing: normal; + white-space: normal; + line-break: auto; + font-size: 0.765625rem; + word-wrap: break-word; + background-clip: padding-box; + border: 1px solid; + border-radius: 0.3rem; + background-color: #fff; + border-color: rgba(0, 0, 21, 0.2); +} + +.popover,.popover .arrow { + position: absolute; + display: block; +} + +.popover .arrow { + width: 1rem; + height: 0.5rem; + margin: 0 0.3rem; +} + +.popover .arrow::before, .popover .arrow::after { + position: absolute; + display: block; + content: ""; + border-color: transparent; + border-style: solid; +} + +.bs-popover-top, .bs-popover-auto[x-placement^="top"] { + margin-bottom: 0.5rem; +} + +.bs-popover-top > .arrow, .bs-popover-auto[x-placement^="top"] > .arrow { + bottom: calc(-0.5rem - 1px); +} + +.bs-popover-top > .arrow::before, .bs-popover-auto[x-placement^="top"] > .arrow::before { + bottom: 0; + border-width: 0.5rem 0.5rem 0; + border-top-color: rgba(0, 0, 21, 0.25); +} + +.bs-popover-top > .arrow::after, .bs-popover-auto[x-placement^="top"] > .arrow::after { + bottom: 1px; + border-width: 0.5rem 0.5rem 0; + border-top-color: #fff; +} + +.bs-popover-right, .bs-popover-auto[x-placement^="right"] { + margin-left: 0.5rem; +} + +.bs-popover-right > .arrow, .bs-popover-auto[x-placement^="right"] > .arrow { + left: calc(-0.5rem - 1px); + width: 0.5rem; + height: 1rem; + margin: 0.3rem 0; +} + +.bs-popover-right > .arrow::before, .bs-popover-auto[x-placement^="right"] > .arrow::before { + left: 0; + border-width: 0.5rem 0.5rem 0.5rem 0; + border-right-color: rgba(0, 0, 21, 0.25); +} + +.bs-popover-right > .arrow::after, .bs-popover-auto[x-placement^="right"] > .arrow::after { + left: 1px; + border-width: 0.5rem 0.5rem 0.5rem 0; + border-right-color: #fff; +} + +.bs-popover-bottom, .bs-popover-auto[x-placement^="bottom"] { + margin-top: 0.5rem; +} + +.bs-popover-bottom > .arrow, .bs-popover-auto[x-placement^="bottom"] > .arrow { + top: calc(-0.5rem - 1px); +} + +.bs-popover-bottom > .arrow::before, .bs-popover-auto[x-placement^="bottom"] > .arrow::before { + top: 0; + border-width: 0 0.5rem 0.5rem 0.5rem; + border-bottom-color: rgba(0, 0, 21, 0.25); +} + +.bs-popover-bottom > .arrow::after, .bs-popover-auto[x-placement^="bottom"] > .arrow::after { + top: 1px; + border-width: 0 0.5rem 0.5rem 0.5rem; + border-bottom-color: #fff; +} + +.bs-popover-bottom .popover-header::before, .bs-popover-auto[x-placement^="bottom"] .popover-header::before { + position: absolute; + top: 0; + left: 50%; + display: block; + width: 1rem; + margin-left: -0.5rem; + content: ""; + border-bottom: 1px solid; + border-bottom-color: #f7f7f7; +} + +.bs-popover-left, .bs-popover-auto[x-placement^="left"] { + margin-right: 0.5rem; +} + +.bs-popover-left > .arrow, .bs-popover-auto[x-placement^="left"] > .arrow { + right: calc(-0.5rem - 1px); + width: 0.5rem; + height: 1rem; + margin: 0.3rem 0; +} + +.bs-popover-left > .arrow::before, .bs-popover-auto[x-placement^="left"] > .arrow::before { + right: 0; + border-width: 0.5rem 0 0.5rem 0.5rem; + border-left-color: rgba(0, 0, 21, 0.25); +} + +.bs-popover-left > .arrow::after, .bs-popover-auto[x-placement^="left"] > .arrow::after { + right: 1px; + border-width: 0.5rem 0 0.5rem 0.5rem; + border-left-color: #fff; +} + +.popover-header { + padding: 0.5rem 0.75rem; + margin-bottom: 0; + font-size: 0.875rem; + border-bottom: 1px solid; + border-top-left-radius: calc(0.3rem - 1px); + border-top-right-radius: calc(0.3rem - 1px); + background-color: #f7f7f7; + border-bottom-color: #ebebeb; +} + +.popover-header:empty { + display: none; +} + +.popover-body { + padding: 0.5rem 0.75rem; + color: #4f5d73; +} + +@-webkit-keyframes progress-bar-stripes { + from { + background-position: 1rem 0; + } + to { + background-position: 0 0; + } +} + +@keyframes progress-bar-stripes { + from { + background-position: 1rem 0; + } + to { + background-position: 0 0; + } +} + +.progress { + height: 1rem; + font-size: 0.65625rem; + border-radius: 0.25rem; + background-color: #ebedef; +} + +.progress,.progress-bar { + display: -ms-flexbox; + display: flex; + overflow: hidden; +} + +.progress-bar { + -ms-flex-direction: column; + flex-direction: column; + -ms-flex-pack: center; + justify-content: center; + text-align: center; + white-space: nowrap; + transition: width 0.6s ease; + color: #fff; + background-color: #321fdb; +} + +@media (prefers-reduced-motion: reduce) { + .progress-bar { + transition: none; + } +} + +.progress-bar-striped { + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-size: 1rem 1rem; +} + +.progress-bar-animated { + -webkit-animation: progress-bar-stripes 1s linear infinite; + animation: progress-bar-stripes 1s linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .progress-bar-animated { + -webkit-animation: none; + animation: none; + } +} + +.progress-xs { + height: 4px; +} + +.progress-sm { + height: 8px; +} + +.progress.progress-white { + background-color: rgba(255, 255, 255, 0.2); +} + +.progress.progress-white .progress-bar { + background-color: #fff; +} + +.progress-group { + display: -ms-flexbox; + display: flex; + -ms-flex-flow: row wrap; + flex-flow: row wrap; + margin-bottom: 1rem; +} + +.progress-group-prepend { + -ms-flex: 0 0 100px; + flex: 0 0 100px; + -ms-flex-item-align: center; + align-self: center; +} + +.progress-group-icon { + font-size: 1.09375rem; +} + +html:not([dir="rtl"]) .progress-group-icon { + margin: 0 1rem 0 0.25rem; +} + +*[dir="rtl"] .progress-group-icon { + margin: 0 0.25rem 0 1rem; +} + +.progress-group-text { + font-size: 0.765625rem; + color: #768192; +} + +.progress-group-header { + display: -ms-flexbox; + display: flex; + -ms-flex-preferred-size: 100%; + flex-basis: 100%; + -ms-flex-align: end; + align-items: flex-end; + margin-bottom: 0.25rem; +} + +.progress-group-bars { + -ms-flex-positive: 1; + flex-grow: 1; + -ms-flex-item-align: center; + align-self: center; +} + +.progress-group-bars .progress:not(:last-child) { + margin-bottom: 2px; +} + +.progress-group-header + .progress-group-bars { + -ms-flex-preferred-size: 100%; + flex-basis: 100%; +} + +.c-sidebar { + display: -ms-flexbox; + display: flex; + -ms-flex: 0 0 256px; + flex: 0 0 256px; + -ms-flex-direction: column; + flex-direction: column; + -ms-flex-order: -1; + order: -1; + width: 256px; + padding: 0; + box-shadow: none; + color: #fff; + background: #3c4b64; + will-change: auto; + transition: box-shadow 0.25s, margin-left 0.25s, margin-right 0.25s, width 0.25s, z-index 0s ease 0.25s, -webkit-transform 0.25s; + transition: box-shadow 0.25s, transform 0.25s, margin-left 0.25s, margin-right 0.25s, width 0.25s, z-index 0s ease 0.25s; + transition: box-shadow 0.25s, transform 0.25s, margin-left 0.25s, margin-right 0.25s, width 0.25s, z-index 0s ease 0.25s, -webkit-transform 0.25s; +} + +@media (max-width: 991.98px) { + .c-sidebar { + --is-mobile: true; + position: fixed; + top: 0; + bottom: 0; + z-index: 1031; + } +} + +html:not([dir="rtl"]) .c-sidebar:not(.c-sidebar-right) { + margin-left: -256px; +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-right { + -ms-flex-order: 99; + order: 99; + margin-right: -256px; +} + +*[dir="rtl"] .c-sidebar:not(.c-sidebar-right) { + margin-right: -256px; +} + +*[dir="rtl"] .c-sidebar.c-sidebar-right { + margin-left: -256px; + border: 0; +} + +.c-sidebar[class*="bg-"] { + border-color: rgba(0, 0, 21, 0.1); +} + +.c-sidebar.c-sidebar-sm { + width: 192px; +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm:not(.c-sidebar-right) { + margin-left: -192px; +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm.c-sidebar-right,*[dir="rtl"] .c-sidebar.c-sidebar-sm:not(.c-sidebar-right) { + margin-right: -192px; +} + +*[dir="rtl"] .c-sidebar.c-sidebar-sm.c-sidebar-right { + margin-left: -192px; +} + +.c-sidebar.c-sidebar-lg { + width: 320px; +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg:not(.c-sidebar-right) { + margin-left: -320px; +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg.c-sidebar-right,*[dir="rtl"] .c-sidebar.c-sidebar-lg:not(.c-sidebar-right) { + margin-right: -320px; +} + +*[dir="rtl"] .c-sidebar.c-sidebar-lg.c-sidebar-right { + margin-left: -320px; +} + +.c-sidebar.c-sidebar-xl { + width: 384px; +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl:not(.c-sidebar-right) { + margin-left: -384px; +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl.c-sidebar-right,*[dir="rtl"] .c-sidebar.c-sidebar-xl:not(.c-sidebar-right) { + margin-right: -384px; +} + +*[dir="rtl"] .c-sidebar.c-sidebar-xl.c-sidebar-right { + margin-left: -384px; +} + +@media (min-width: 992px) { + .c-sidebar.c-sidebar-fixed { + position: fixed; + top: 0; + bottom: 0; + z-index: 1030; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-fixed:not(.c-sidebar-right) { + left: 0; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-fixed.c-sidebar-right,*[dir="rtl"] .c-sidebar.c-sidebar-fixed:not(.c-sidebar-right) { + right: 0; + } + *[dir="rtl"] .c-sidebar.c-sidebar-fixed.c-sidebar-right { + left: 0; + } +} + +.c-sidebar.c-sidebar-overlaid { + position: fixed; + top: 0; + bottom: 0; + z-index: 1032; +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-overlaid:not(.c-sidebar-right) { + left: 0; +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-overlaid.c-sidebar-right,*[dir="rtl"] .c-sidebar.c-sidebar-overlaid:not(.c-sidebar-right) { + right: 0; +} + +*[dir="rtl"] .c-sidebar.c-sidebar-overlaid.c-sidebar-right { + left: 0; +} + +.c-sidebar-close { + position: absolute; + width: 56px; + height: 56px; + background: transparent; + border: 0; +} + +html:not([dir="rtl"]) .c-sidebar-close { + right: 0; +} + +*[dir="rtl"] .c-sidebar-close { + left: 0; +} + +.c-sidebar-brand { + display: -ms-flexbox; + display: flex; + -ms-flex: 0 0 56px; + flex: 0 0 56px; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; +} + +.c-sidebar-brand .c-sidebar-brand-minimized { + display: none; +} + +.c-sidebar-header { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + padding: 0.75rem 1rem; + text-align: center; + transition: 0.25s; +} + +.c-sidebar-nav { + position: relative; + display: -ms-flexbox; + display: flex; + -ms-flex: 1; + flex: 1; + -ms-flex-direction: column; + flex-direction: column; + padding: 0; + margin-bottom: 0; + overflow-x: hidden; + overflow-y: auto; + list-style: none; +} + +.c-sidebar-nav-title { + padding: 0.75rem 1rem; + margin-top: 1rem; + font-size: 80%; + font-weight: 700; + text-transform: uppercase; + transition: 0.25s; +} + +.c-sidebar-nav-divider { + height: 10px; + transition: height 0.25s; +} + +.c-sidebar-nav-item { + width: inherit; +} + +.c-sidebar-nav-link, .c-sidebar-nav-dropdown-toggle { + display: -ms-flexbox; + display: flex; + -ms-flex: 1; + flex: 1; + -ms-flex-align: center; + align-items: center; + padding: 0.8445rem 1rem; + text-decoration: none; + white-space: nowrap; + transition: background 0.25s, color 0.25s; +} + +html:not([dir="rtl"]) .c-sidebar-nav-link .badge, html:not([dir="rtl"]) .c-sidebar-nav-dropdown-toggle .badge { + margin-left: auto; +} + +*[dir="rtl"] .c-sidebar-nav-link .badge, *[dir="rtl"] .c-sidebar-nav-dropdown-toggle .badge { + margin-right: auto; +} + +.c-sidebar-nav-link.c-disabled, .c-disabled.c-sidebar-nav-dropdown-toggle { + cursor: default; +} + +.c-sidebar-nav-link:hover, .c-sidebar-nav-dropdown-toggle:hover { + text-decoration: none; +} + +.c-sidebar-nav-icon { + -ms-flex: 0 0 56px; + flex: 0 0 56px; + height: 1.09375rem; + font-size: 1.09375rem; + text-align: center; + transition: 0.25s; + fill: currentColor; +} + +html:not([dir="rtl"]) .c-sidebar-nav-icon:first-child { + margin-left: -1rem; +} + +*[dir="rtl"] .c-sidebar-nav-icon:first-child { + margin-right: -1rem; +} + +.c-sidebar-nav-dropdown { + position: relative; + transition: background 0.25s ease-in-out; +} + +.c-sidebar-nav-dropdown.c-show > .c-sidebar-nav-dropdown-items { + max-height: 1500px; +} + +html:not([dir="rtl"]) .c-sidebar-nav-dropdown.c-show > .c-sidebar-nav-dropdown-toggle::after { + -webkit-transform: rotate(-90deg); + transform: rotate(-90deg); +} + +*[dir="rtl"] .c-sidebar-nav-dropdown.c-show > .c-sidebar-nav-dropdown-toggle::after { + -webkit-transform: rotate(270deg); + transform: rotate(270deg); +} + +.c-sidebar-nav-dropdown.c-show + .c-sidebar-nav-dropdown.c-show { + margin-top: 1px; +} + +.c-sidebar-nav-dropdown-toggle { + cursor: pointer; +} + +.c-sidebar-nav-dropdown-toggle::after { + display: block; + -ms-flex: 0 8px; + flex: 0 8px; + height: 8px; + content: ""; + background-repeat: no-repeat; + background-position: center; + transition: -webkit-transform 0.25s; + transition: transform 0.25s; + transition: transform 0.25s, -webkit-transform 0.25s; +} + +html:not([dir="rtl"]) .c-sidebar-nav-dropdown-toggle::after { + margin-left: auto; +} + +*[dir="rtl"] .c-sidebar-nav-dropdown-toggle::after { + margin-right: auto; + -webkit-transform: rotate(180deg); + transform: rotate(180deg); +} + +html:not([dir="rtl"]) .c-sidebar-nav-dropdown-toggle .badge { + margin-right: 1rem; +} + +*[dir="rtl"] .c-sidebar-nav-dropdown-toggle .badge { + margin-left: 1rem; +} + +.c-sidebar-nav-dropdown-items { + max-height: 0; + padding: 0; + overflow-y: hidden; + transition: max-height 0.25s ease-in-out; +} + +html:not([dir="rtl"]) .c-sidebar-nav-dropdown-items .c-sidebar-nav-link, html:not([dir="rtl"]) .c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown-toggle { + padding-left: 56px; +} + +*[dir="rtl"] .c-sidebar-nav-dropdown-items .c-sidebar-nav-link, *[dir="rtl"] .c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown-toggle { + padding-right: 56px; +} + +html:not([dir="rtl"]) .c-sidebar-nav-dropdown-items .c-sidebar-nav-link .c-sidebar-nav-icon, html:not([dir="rtl"]) .c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + margin-left: -56px; +} + +*[dir="rtl"] .c-sidebar-nav-dropdown-items .c-sidebar-nav-link .c-sidebar-nav-icon, *[dir="rtl"] .c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + margin-right: -56px; +} + +.c-sidebar-nav-label { + display: -ms-flexbox; + display: flex; + padding: 0.211125rem 1rem; + transition: 0.25s; +} + +.c-sidebar-nav-label:hover { + text-decoration: none; +} + +.c-sidebar-nav-label .c-sidebar-nav-icon { + margin-top: 1px; +} + +.c-sidebar-footer { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + padding: 0.75rem 1rem; + transition: 0.25s; +} + +.c-sidebar-minimizer { + display: -ms-flexbox; + display: flex; + -ms-flex: 0 0 50px; + flex: 0 0 50px; + -ms-flex-pack: end; + justify-content: flex-end; + width: inherit; + padding: 0; + cursor: pointer; + border: 0; +} + +@media (max-width: 991.98px) { + .c-sidebar-minimizer { + display: none; + } +} + +.c-sidebar-minimizer::before { + display: block; + width: 50px; + height: 50px; + content: ""; + background-repeat: no-repeat; + background-position: center; + background-size: 12.5px; + transition: 0.25s; +} + +*[dir="rtl"] .c-sidebar-minimizer::before { + -webkit-transform: rotate(180deg); + transform: rotate(180deg); +} + +.c-sidebar-minimizer:focus, .c-sidebar-minimizer.c-focus { + outline: 0; +} + +.c-sidebar-right .c-sidebar-minimizer { + -ms-flex-pack: start; + justify-content: flex-start; +} + +html:not([dir="rtl"]) .c-sidebar-right .c-sidebar-minimizer::before { + -webkit-transform: rotate(-180deg); + transform: rotate(-180deg); +} + +*[dir="rtl"] .c-sidebar-right .c-sidebar-minimizer::before { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); +} + +@media (max-width: 991.98px) { + .c-sidebar-backdrop { + position: fixed; + top: 0; + left: 0; + z-index: 1030; + width: 100vw; + height: 100vh; + background-color: #000015; + transition: 0.25s; + } + .c-sidebar-backdrop.c-fade { + opacity: 0; + } + .c-sidebar-backdrop.c-show { + opacity: 0.5; + } +} + +@media (min-width: 992px) { + .c-sidebar-minimized { + z-index: 1031; + -ms-flex: 0 0 56px; + flex: 0 0 56px; + } + .c-sidebar-minimized.c-sidebar-fixed { + z-index: 1031; + width: 56px; + } + html:not([dir="rtl"]) .c-sidebar-minimized:not(.c-sidebar-right) { + margin-left: -56px; + } + *[dir="rtl"] .c-sidebar-minimized:not(.c-sidebar-right) { + margin-right: -56px; + } + html:not([dir="rtl"]) .c-sidebar-minimized.c-sidebar-right { + margin-right: -56px; + margin-left: -56px; + } + .c-sidebar-minimized .c-sidebar-brand-full { + display: none; + } + .c-sidebar-minimized .c-sidebar-brand-minimized { + display: block; + } + .c-sidebar-minimized .c-sidebar-nav { + padding-bottom: 50px; + overflow: visible; + } + .c-sidebar-minimized .c-d-minimized-none, + .c-sidebar-minimized .c-sidebar-nav-divider, + .c-sidebar-minimized .c-sidebar-nav-label, + .c-sidebar-minimized .c-sidebar-nav-title, + .c-sidebar-minimized .c-sidebar-footer, + .c-sidebar-minimized .c-sidebar-form, + .c-sidebar-minimized .c-sidebar-header { + height: 0; + padding: 0; + margin: 0; + visibility: hidden; + opacity: 0; + } + .c-sidebar-minimized .c-sidebar-minimizer { + position: fixed; + bottom: 0; + width: inherit; + } + html:not([dir="rtl"]) .c-sidebar-minimized .c-sidebar-minimizer::before { + -webkit-transform: rotate(-180deg); + transform: rotate(-180deg); + } + *[dir="rtl"] .c-sidebar-minimized .c-sidebar-minimizer::before,html:not([dir="rtl"]) .c-sidebar-minimized.c-sidebar-right .c-sidebar-minimizer::before { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + *[dir="rtl"] .c-sidebar-minimized.c-sidebar-right .c-sidebar-minimizer::before { + -webkit-transform: rotate(180deg); + transform: rotate(180deg); + } + html:not([dir="rtl"]) .c-sidebar-minimized.c-sidebar-right .c-sidebar-nav > .c-sidebar-nav-item:hover, html:not([dir="rtl"]) + .c-sidebar-minimized.c-sidebar-right .c-sidebar-nav > .c-sidebar-nav-dropdown:hover { + margin-left: -256px; + } + *[dir="rtl"] .c-sidebar-minimized.c-sidebar-right .c-sidebar-nav > .c-sidebar-nav-item:hover, *[dir="rtl"] + .c-sidebar-minimized.c-sidebar-right .c-sidebar-nav > .c-sidebar-nav-dropdown:hover { + margin-right: -256px; + } + .c-sidebar-minimized .c-sidebar-nav-link, .c-sidebar-minimized .c-sidebar-nav-dropdown-toggle, + .c-sidebar-minimized .c-sidebar-nav-dropdown-toggle { + overflow: hidden; + white-space: nowrap; + border-left: 0; + } + .c-sidebar-minimized .c-sidebar-nav-link:hover, .c-sidebar-minimized .c-sidebar-nav-dropdown-toggle:hover, + .c-sidebar-minimized .c-sidebar-nav-dropdown-toggle:hover { + width: 312px; + } + .c-sidebar-minimized .c-sidebar-nav-dropdown-toggle::after { + display: none; + } + .c-sidebar-minimized .c-sidebar-nav-dropdown-items .c-sidebar-nav-link, .c-sidebar-minimized .c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown-toggle { + width: 256px; + } + .c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown { + position: relative; + } + .c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown > .c-sidebar-nav-dropdown-items,.c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown > .c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown:not(.c-show) > .c-sidebar-nav-dropdown-items { + display: none; + } + .c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown .c-sidebar-nav-dropdown-items { + max-height: 1500px; + } + .c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown:hover { + width: 312px; + overflow: visible; + } + .c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown:hover > .c-sidebar-nav-dropdown-items { + position: absolute; + display: inline; + } + html:not([dir="rtl"]) .c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown:hover > .c-sidebar-nav-dropdown-items { + left: 56px; + } + *[dir="rtl"] .c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown:hover > .c-sidebar-nav-dropdown-items { + right: 56px; + } + html:not([dir="rtl"]) .c-sidebar-minimized.c-sidebar-right > .c-sidebar-nav-dropdown:hover > .c-sidebar-nav-dropdown-items { + left: 0; + } + *[dir="rtl"] .c-sidebar-minimized.c-sidebar-right > .c-sidebar-nav-dropdown:hover > .c-sidebar-nav-dropdown-items { + right: 0; + } +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right), html:not([dir="rtl"]) +.c-sidebar.c-sidebar-show:not(.c-sidebar-right) { + margin-left: 0; +} + +*[dir="rtl"] .c-sidebar.c-sidebar-show:not(.c-sidebar-right), *[dir="rtl"] +.c-sidebar.c-sidebar-show:not(.c-sidebar-right) { + margin-right: 0; +} + +@media (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper { + margin-left: 256px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper { + margin-right: 256px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-left: 192px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-right: 192px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-left: 320px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-right: 320px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-left: 384px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-right: 384px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-left: 56px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-right: 56px; + } +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-show.c-sidebar-right, html:not([dir="rtl"]) +.c-sidebar.c-sidebar-show.c-sidebar-right { + margin-right: 0; +} + +*[dir="rtl"] .c-sidebar.c-sidebar-show.c-sidebar-right, *[dir="rtl"] +.c-sidebar.c-sidebar-show.c-sidebar-right { + margin-left: 0; +} + +@media (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper { + margin-right: 256px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper { + margin-left: 256px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-right: 192px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-left: 192px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-right: 320px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-left: 320px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-right: 384px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-left: 384px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-right: 56px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-left: 56px; + } +} + +@media (min-width: 576px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right), html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right) { + margin-left: 0; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right), *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right) { + margin-right: 0; + } +} + +@media (min-width: 576px) and (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper { + margin-left: 256px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper { + margin-right: 256px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-left: 192px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-right: 192px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-left: 320px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-right: 320px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-left: 384px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-right: 384px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-left: 56px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-right: 56px; + } +} + +@media (min-width: 576px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right { + margin-right: 0; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show.c-sidebar-right, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right { + margin-left: 0; + } +} + +@media (min-width: 576px) and (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper { + margin-right: 256px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper { + margin-left: 256px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-right: 192px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-left: 192px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-right: 320px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-left: 320px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-right: 384px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-left: 384px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-right: 56px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-left: 56px; + } +} + +@media (min-width: 768px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right), html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right) { + margin-left: 0; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right), *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right) { + margin-right: 0; + } +} + +@media (min-width: 768px) and (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper { + margin-left: 256px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper { + margin-right: 256px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-left: 192px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-right: 192px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-left: 320px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-right: 320px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-left: 384px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-right: 384px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-left: 56px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-right: 56px; + } +} + +@media (min-width: 768px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show.c-sidebar-right, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right { + margin-right: 0; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show.c-sidebar-right, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right { + margin-left: 0; + } +} + +@media (min-width: 768px) and (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper { + margin-right: 256px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper { + margin-left: 256px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-right: 192px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-left: 192px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-right: 320px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-left: 320px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-right: 384px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-left: 384px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-right: 56px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-left: 56px; + } +} + +@media (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right), html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right) { + margin-left: 0; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right), *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right) { + margin-right: 0; + } +} + +@media (min-width: 992px) and (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper { + margin-left: 256px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper { + margin-right: 256px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-left: 192px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-right: 192px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-left: 320px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-right: 320px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-left: 384px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-right: 384px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-left: 56px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-right: 56px; + } +} + +@media (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right { + margin-right: 0; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show.c-sidebar-right, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right { + margin-left: 0; + } +} + +@media (min-width: 992px) and (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper { + margin-right: 256px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper { + margin-left: 256px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-right: 192px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-left: 192px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-right: 320px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-left: 320px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-right: 384px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-left: 384px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-right: 56px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-left: 56px; + } +} + +@media (min-width: 1200px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right), html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right) { + margin-left: 0; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right), *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right) { + margin-right: 0; + } +} + +@media (min-width: 1200px) and (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper { + margin-left: 256px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed ~ .c-wrapper { + margin-right: 256px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-left: 192px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-right: 192px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-left: 320px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-right: 320px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-left: 384px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-right: 384px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-left: 56px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-right: 56px; + } +} + +@media (min-width: 1200px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right { + margin-right: 0; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show.c-sidebar-right, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right { + margin-left: 0; + } +} + +@media (min-width: 1200px) and (min-width: 992px) { + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper { + margin-right: 256px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed ~ .c-wrapper { + margin-left: 256px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-right: 192px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm ~ .c-wrapper { + margin-left: 192px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-right: 320px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg ~ .c-wrapper { + margin-left: 320px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-right: 384px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl ~ .c-wrapper { + margin-left: 384px; + } + html:not([dir="rtl"]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, html:not([dir="rtl"]) + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-right: 56px; + } + *[dir="rtl"] .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper, *[dir="rtl"] + .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized ~ .c-wrapper { + margin-left: 56px; + } +} + +.c-sidebar .c-sidebar-close,.c-sidebar .c-sidebar-brand { + color: #fff; +} + +.c-sidebar .c-sidebar-brand,.c-sidebar .c-sidebar-header { + background: rgba(0, 0, 21, 0.2); +} + +.c-sidebar .c-sidebar-form .c-form-control { + color: #fff; + background: rgba(0, 0, 21, 0.1); + border: 0; +} + +.c-sidebar .c-sidebar-form .c-form-control::-webkit-input-placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-form .c-form-control::-moz-placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-form .c-form-control:-ms-input-placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-form .c-form-control::-ms-input-placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-form .c-form-control::placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-nav-title { + color: rgba(255, 255, 255, 0.6); +} + +.c-sidebar .c-sidebar-nav-link, .c-sidebar .c-sidebar-nav-dropdown-toggle { + color: rgba(255, 255, 255, 0.8); + background: transparent; +} + +.c-sidebar .c-sidebar-nav-link .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.5); +} + +.c-sidebar .c-sidebar-nav-link.c-active, .c-sidebar .c-active.c-sidebar-nav-dropdown-toggle { + color: #fff; + background: rgba(255, 255, 255, 0.05); +} + +.c-sidebar .c-sidebar-nav-link.c-active .c-sidebar-nav-icon, .c-sidebar .c-active.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar .c-sidebar-nav-link:hover, .c-sidebar .c-sidebar-nav-dropdown-toggle:hover { + color: #fff; + background: #321fdb; +} + +.c-sidebar .c-sidebar-nav-link:hover .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar .c-sidebar-nav-link:hover.c-sidebar-nav-dropdown-toggle::after, .c-sidebar :hover.c-sidebar-nav-dropdown-toggle::after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23fff' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E"); +} + +.c-sidebar .c-sidebar-nav-link.c-disabled, .c-sidebar .c-disabled.c-sidebar-nav-dropdown-toggle { + color: #b3b2b2; + background: transparent; +} + +.c-sidebar .c-sidebar-nav-link.c-disabled .c-sidebar-nav-icon, .c-sidebar .c-disabled.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.5); +} + +.c-sidebar .c-sidebar-nav-link.c-disabled:hover, .c-sidebar .c-disabled.c-sidebar-nav-dropdown-toggle:hover { + color: #b3b2b2; +} + +.c-sidebar .c-sidebar-nav-link.c-disabled:hover .c-sidebar-nav-icon, .c-sidebar .c-disabled.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.5); +} + +.c-sidebar .c-sidebar-nav-link.c-disabled:hover.c-sidebar-nav-dropdown-toggle::after, .c-sidebar .c-disabled:hover.c-sidebar-nav-dropdown-toggle::after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23fff' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E"); +} + +.c-sidebar .c-sidebar-nav-dropdown-toggle { + position: relative; +} + +.c-sidebar .c-sidebar-nav-dropdown-toggle::after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='rgba(255, 255, 255, 0.5)' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E"); +} + +.c-sidebar .c-sidebar-nav-dropdown.c-show { + background: rgba(0, 0, 0, 0.2); +} + +.c-sidebar .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link, .c-sidebar .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-dropdown-toggle { + color: #fff; +} + +.c-sidebar .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled, .c-sidebar .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle { + color: #b3b2b2; + background: transparent; +} + +.c-sidebar .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled:hover, .c-sidebar .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle:hover { + color: #b3b2b2; +} + +.c-sidebar .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled:hover .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.5); +} + +.c-sidebar .c-sidebar-nav-label { + color: rgba(255, 255, 255, 0.6); +} + +.c-sidebar .c-sidebar-nav-label:hover { + color: #fff; +} + +.c-sidebar .c-sidebar-nav-label .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.5); +} + +.c-sidebar .c-progress { + background-color: #596f94 !important; +} + +.c-sidebar .c-sidebar-footer { + background: rgba(0, 0, 21, 0.2); +} + +.c-sidebar .c-sidebar-minimizer { + background-color: rgba(0, 0, 21, 0.2); +} + +.c-sidebar .c-sidebar-minimizer::before { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%238a93a2' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E"); +} + +.c-sidebar .c-sidebar-minimizer:focus, .c-sidebar .c-sidebar-minimizer.c-focus { + outline: 0; +} + +.c-sidebar .c-sidebar-minimizer:hover { + background-color: rgba(0, 0, 0, 0.3); +} + +.c-sidebar .c-sidebar-minimizer:hover::before { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23fff' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E"); +} + +.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-link, .c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-dropdown-toggle { + background: #321fdb; +} + +.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-link .c-sidebar-nav-icon, .c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-link.c-disabled, .c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-disabled.c-sidebar-nav-dropdown-toggle { + background: #3c4b64; +} + +.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-link.c-disabled .c-sidebar-nav-icon, .c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-disabled.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.5); +} + +.c-sidebar.c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown > .c-sidebar-nav-dropdown-items { + background: #3c4b64; +} + +.c-sidebar.c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown:hover { + background: #321fdb; +} + +.c-sidebar.c-sidebar-light { + color: #4f5d73; + background: #fff; + border-right: 1px solid rgba(159, 167, 179, 0.5); +} + +html:not([dir="rtl"]) .c-sidebar.c-sidebar-light.c-sidebar-right,*[dir="rtl"] .c-sidebar.c-sidebar-light { + border-right: 0; + border-left: 1px solid rgba(159, 167, 179, 0.5); +} + +*[dir="rtl"] .c-sidebar.c-sidebar-light.c-sidebar-right { + border: 0; + border-right: 1px solid rgba(159, 167, 179, 0.5); +} + +.c-sidebar.c-sidebar-light .c-sidebar-close { + color: #4f5d73; +} + +.c-sidebar.c-sidebar-light .c-sidebar-brand { + color: #fff; + background: #321fdb; +} + +.c-sidebar.c-sidebar-light .c-sidebar-header { + background: rgba(0, 0, 21, 0.2); +} + +.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control { + color: #fff; + background: rgba(0, 0, 21, 0.1); + border: 0; +} + +.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control::-webkit-input-placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control::-moz-placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control:-ms-input-placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control::-ms-input-placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control::placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-title { + color: rgba(0, 0, 21, 0.4); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link, .c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle { + color: rgba(0, 0, 21, 0.8); + background: transparent; +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link .c-sidebar-nav-icon, .c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(0, 0, 21, 0.5); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-active, .c-sidebar.c-sidebar-light .c-active.c-sidebar-nav-dropdown-toggle { + color: rgba(0, 0, 21, 0.8); + background: rgba(0, 0, 21, 0.05); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-active .c-sidebar-nav-icon, .c-sidebar.c-sidebar-light .c-active.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: #321fdb; +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link:hover, .c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle:hover { + color: #fff; + background: #321fdb; +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link:hover .c-sidebar-nav-icon, .c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link:hover.c-sidebar-nav-dropdown-toggle::after, .c-sidebar.c-sidebar-light :hover.c-sidebar-nav-dropdown-toggle::after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23fff' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E"); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-disabled, .c-sidebar.c-sidebar-light .c-disabled.c-sidebar-nav-dropdown-toggle { + color: #b3b2b2; + background: transparent; +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-disabled .c-sidebar-nav-icon, .c-sidebar.c-sidebar-light .c-disabled.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(0, 0, 21, 0.5); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-disabled:hover, .c-sidebar.c-sidebar-light .c-disabled.c-sidebar-nav-dropdown-toggle:hover { + color: #b3b2b2; +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-disabled:hover .c-sidebar-nav-icon, .c-sidebar.c-sidebar-light .c-disabled.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: rgba(0, 0, 21, 0.5); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-disabled:hover.c-sidebar-nav-dropdown-toggle::after, .c-sidebar.c-sidebar-light .c-disabled:hover.c-sidebar-nav-dropdown-toggle::after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23fff' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E"); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle { + position: relative; +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle::after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='rgba(0, 0, 21, 0.5)' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E"); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show { + background: rgba(0, 0, 0, 0.05); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link, .c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-dropdown-toggle { + color: rgba(0, 0, 21, 0.8); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled, .c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle { + color: #b3b2b2; + background: transparent; +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled:hover, .c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle:hover { + color: #b3b2b2; +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled:hover .c-sidebar-nav-icon, .c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: rgba(0, 0, 21, 0.5); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-label { + color: rgba(0, 0, 21, 0.4); +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-label:hover { + color: #4f5d73; +} + +.c-sidebar.c-sidebar-light .c-sidebar-nav-label .c-sidebar-nav-icon { + color: rgba(0, 0, 21, 0.5); +} + +.c-sidebar.c-sidebar-light .c-sidebar-footer { + background: rgba(0, 0, 21, 0.2); +} + +.c-sidebar.c-sidebar-light .c-sidebar-minimizer { + background-color: rgba(0, 0, 0, 0.05); +} + +.c-sidebar.c-sidebar-light .c-sidebar-minimizer::before { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%238a93a2' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E"); +} + +.c-sidebar.c-sidebar-light .c-sidebar-minimizer:focus, .c-sidebar.c-sidebar-light .c-sidebar-minimizer.c-focus { + outline: 0; +} + +.c-sidebar.c-sidebar-light .c-sidebar-minimizer:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +.c-sidebar.c-sidebar-light .c-sidebar-minimizer:hover::before { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23768192' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E"); +} + +.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-link, .c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-dropdown-toggle { + background: #321fdb; +} + +.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-link .c-sidebar-nav-icon, .c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-link.c-disabled, .c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-disabled.c-sidebar-nav-dropdown-toggle { + background: #fff; +} + +.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-sidebar-nav-link.c-disabled .c-sidebar-nav-icon, .c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover > .c-disabled.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(0, 0, 21, 0.5); +} + +.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown > .c-sidebar-nav-dropdown-items { + background: #fff; +} + +.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav > .c-sidebar-nav-dropdown:hover,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-primary, .c-sidebar .c-sidebar-nav-link-primary.c-sidebar-nav-dropdown-toggle { + background: #321fdb; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-primary .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-primary.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-primary:hover, .c-sidebar .c-sidebar-nav-link-primary.c-sidebar-nav-dropdown-toggle:hover { + background: #2d1cc5; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-primary:hover .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-primary.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-secondary, .c-sidebar .c-sidebar-nav-link-secondary.c-sidebar-nav-dropdown-toggle { + background: #ced2d8; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-secondary .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-secondary.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-secondary:hover, .c-sidebar .c-sidebar-nav-link-secondary.c-sidebar-nav-dropdown-toggle:hover { + background: #c0c5cd; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-secondary:hover .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-secondary.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-success, .c-sidebar .c-sidebar-nav-link-success.c-sidebar-nav-dropdown-toggle { + background: #2eb85c; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-success .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-success.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-success:hover, .c-sidebar .c-sidebar-nav-link-success.c-sidebar-nav-dropdown-toggle:hover { + background: #29a452; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-success:hover .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-success.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-info, .c-sidebar .c-sidebar-nav-link-info.c-sidebar-nav-dropdown-toggle { + background: #39f; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-info .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-info.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-info:hover, .c-sidebar .c-sidebar-nav-link-info.c-sidebar-nav-dropdown-toggle:hover { + background: #1a8cff; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-info:hover .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-info.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-warning, .c-sidebar .c-sidebar-nav-link-warning.c-sidebar-nav-dropdown-toggle { + background: #f9b115; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-warning .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-warning.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-warning:hover, .c-sidebar .c-sidebar-nav-link-warning.c-sidebar-nav-dropdown-toggle:hover { + background: #eea506; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-warning:hover .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-warning.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-danger, .c-sidebar .c-sidebar-nav-link-danger.c-sidebar-nav-dropdown-toggle { + background: #e55353; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-danger .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-danger.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-danger:hover, .c-sidebar .c-sidebar-nav-link-danger.c-sidebar-nav-dropdown-toggle:hover { + background: #e23d3d; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-danger:hover .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-danger.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-light, .c-sidebar .c-sidebar-nav-link-light.c-sidebar-nav-dropdown-toggle { + background: #ebedef; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-light .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-light.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-light:hover, .c-sidebar .c-sidebar-nav-link-light.c-sidebar-nav-dropdown-toggle:hover { + background: #dde0e4; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-light:hover .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-light.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: #fff; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-dark, .c-sidebar .c-sidebar-nav-link-dark.c-sidebar-nav-dropdown-toggle { + background: #636f83; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-dark .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-dark.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon { + color: rgba(255, 255, 255, 0.7); +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-dark:hover, .c-sidebar .c-sidebar-nav-link-dark.c-sidebar-nav-dropdown-toggle:hover { + background: #586374; +} + +.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-dark:hover .c-sidebar-nav-icon, .c-sidebar .c-sidebar-nav-link-dark.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon { + color: #fff; +} + +@-webkit-keyframes spinner-border { + to { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@keyframes spinner-border { + to { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +.spinner-border { + display: inline-block; + width: 2rem; + height: 2rem; + vertical-align: text-bottom; + border: 0.25em solid currentColor; + border-right-color: transparent; + border-radius: 50%; + -webkit-animation: spinner-border .75s linear infinite; + animation: spinner-border .75s linear infinite; +} + +.spinner-border-sm { + width: 1rem; + height: 1rem; + border-width: 0.2em; +} + +@-webkit-keyframes spinner-grow { + 0% { + -webkit-transform: scale(0); + transform: scale(0); + } + 50% { + opacity: 1; + } +} + +@keyframes spinner-grow { + 0% { + -webkit-transform: scale(0); + transform: scale(0); + } + 50% { + opacity: 1; + } +} + +.spinner-grow { + display: inline-block; + width: 2rem; + height: 2rem; + vertical-align: text-bottom; + background-color: currentColor; + border-radius: 50%; + opacity: 0; + -webkit-animation: spinner-grow .75s linear infinite; + animation: spinner-grow .75s linear infinite; +} + +.spinner-grow-sm { + width: 1rem; + height: 1rem; +} + +.c-subheader { + position: relative; + display: -ms-flexbox; + display: flex; + -ms-flex-direction: row; + flex-direction: row; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + width: 100%; + min-height: 48px; + background: #fff; + border-bottom: 1px solid #d8dbe0; +} + +.c-subheader[class*="bg-"] { + border-color: rgba(0, 0, 21, 0.1); +} + +.c-subheader.c-subheader-fixed { + position: fixed; + right: 0; + left: 0; + z-index: 1030; +} + +.c-subheader-nav { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: row; + flex-direction: row; + -ms-flex-align: center; + align-items: center; + min-height: 48px; + padding: 0; + margin-bottom: 0; + list-style: none; +} + +.c-subheader-nav .c-subheader-nav-item { + position: relative; +} + +.c-subheader-nav .c-subheader-nav-btn { + background-color: transparent; + border: 1px solid transparent; +} + +.c-subheader-nav .c-subheader-nav-link, +.c-subheader-nav .c-subheader-nav-btn { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + padding-right: 0.5rem; + padding-left: 0.5rem; +} + +.c-subheader-nav .c-subheader-nav-link .badge, +.c-subheader-nav .c-subheader-nav-btn .badge { + position: absolute; + top: 50%; + margin-top: -16px; +} + +html:not([dir="rtl"]) .c-subheader-nav .c-subheader-nav-link .badge, html:not([dir="rtl"]) +.c-subheader-nav .c-subheader-nav-btn .badge { + left: 50%; + margin-left: 0; +} + +*[dir="rtl"] .c-subheader-nav .c-subheader-nav-link .badge, *[dir="rtl"] +.c-subheader-nav .c-subheader-nav-btn .badge { + right: 50%; + margin-right: 0; +} + +.c-subheader-nav .c-subheader-nav-link:hover, +.c-subheader-nav .c-subheader-nav-btn:hover { + text-decoration: none; +} + +.c-subheader.c-subheader-dark { + background: #3c4b64; + border-bottom: 1px solid #636f83; +} + +.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link, +.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-btn { + color: rgba(255, 255, 255, 0.75); +} + +.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link:hover, .c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link:focus, +.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-btn:hover, +.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-btn:focus { + color: rgba(255, 255, 255, 0.9); +} + +.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link.c-disabled, +.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-btn.c-disabled { + color: rgba(255, 255, 255, 0.25); +} + +.c-subheader.c-subheader-dark .c-subheader-nav .c-show > .c-subheader-nav-link, +.c-subheader.c-subheader-dark .c-subheader-nav .c-active > .c-subheader-nav-link, +.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link.c-show, +.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link.c-active { + color: #fff; +} + +.c-subheader.c-subheader-dark .c-subheader-text { + color: rgba(255, 255, 255, 0.75); +} + +.c-subheader.c-subheader-dark .c-subheader-text a,.c-subheader.c-subheader-dark .c-subheader-text a:hover, .c-subheader.c-subheader-dark .c-subheader-text a:focus { + color: #fff; +} + +.c-subheader .c-subheader-nav .c-subheader-nav-link, +.c-subheader .c-subheader-nav .c-subheader-nav-btn { + color: rgba(0, 0, 21, 0.5); +} + +.c-subheader .c-subheader-nav .c-subheader-nav-link:hover, .c-subheader .c-subheader-nav .c-subheader-nav-link:focus, +.c-subheader .c-subheader-nav .c-subheader-nav-btn:hover, +.c-subheader .c-subheader-nav .c-subheader-nav-btn:focus { + color: rgba(0, 0, 21, 0.7); +} + +.c-subheader .c-subheader-nav .c-subheader-nav-link.c-disabled, +.c-subheader .c-subheader-nav .c-subheader-nav-btn.c-disabled { + color: rgba(0, 0, 21, 0.3); +} + +.c-subheader .c-subheader-nav .c-show > .c-subheader-nav-link, +.c-subheader .c-subheader-nav .c-active > .c-subheader-nav-link, +.c-subheader .c-subheader-nav .c-subheader-nav-link.c-show, +.c-subheader .c-subheader-nav .c-subheader-nav-link.c-active { + color: rgba(0, 0, 21, 0.9); +} + +.c-subheader .c-subheader-text { + color: rgba(0, 0, 21, 0.5); +} + +.c-subheader .c-subheader-text a,.c-subheader .c-subheader-text a:hover, .c-subheader .c-subheader-text a:focus { + color: rgba(0, 0, 21, 0.9); +} + +.c-switch { + display: inline-block; + width: 40px; + height: 26px; +} + +.c-switch-input { + position: absolute; + z-index: -1; + opacity: 0; +} + +.c-switch-slider { + position: relative; + display: block; + height: inherit; + cursor: pointer; + border: 1px solid #d8dbe0; + border-radius: 0.25rem; +} + +.c-switch-slider,.c-switch-slider::before { + background-color: #fff; + transition: .15s ease-out; +} + +.c-switch-slider::before { + position: absolute; + top: 2px; + left: 2px; + box-sizing: border-box; + width: 20px; + height: 20px; + content: ""; + border: 1px solid #d8dbe0; + border-radius: 0.125rem; +} + +.c-switch-input:checked ~ .c-switch-slider::before { + -webkit-transform: translateX(14px); + transform: translateX(14px); +} + +.c-switch-input:focus ~ .c-switch-slider { + color: #768192; + background-color: #fff; + border-color: #958bef; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(50, 31, 219, 0.25); +} + +.c-switch-input:disabled ~ .c-switch-slider { + cursor: not-allowed; + opacity: .5; +} + +.c-switch-lg { + width: 48px; + height: 30px; +} + +.c-switch-lg .c-switch-slider { + font-size: 12px; +} + +.c-switch-lg .c-switch-slider::before { + width: 24px; + height: 24px; +} + +.c-switch-lg .c-switch-slider::after { + font-size: 12px; +} + +.c-switch-lg .c-switch-input:checked ~ .c-switch-slider::before { + -webkit-transform: translateX(18px); + transform: translateX(18px); +} + +.c-switch-sm { + width: 32px; + height: 22px; +} + +.c-switch-sm .c-switch-slider { + font-size: 8px; +} + +.c-switch-sm .c-switch-slider::before { + width: 16px; + height: 16px; +} + +.c-switch-sm .c-switch-slider::after { + font-size: 8px; +} + +.c-switch-sm .c-switch-input:checked ~ .c-switch-slider::before { + -webkit-transform: translateX(10px); + transform: translateX(10px); +} + +.c-switch-label { + width: 48px; +} + +.c-switch-label .c-switch-slider::before { + z-index: 2; +} + +.c-switch-label .c-switch-slider::after { + position: absolute; + top: 50%; + z-index: 1; + width: 50%; + margin-top: -.5em; + font-size: 10px; + font-weight: 600; + line-height: 1; + color: #c4c9d0; + text-align: center; + text-transform: uppercase; + content: attr(data-unchecked); + transition: inherit; +} + +html:not([dir="rtl"]) .c-switch-label .c-switch-slider::after { + right: 1px; +} + +.c-switch-label .c-switch-input:checked ~ .c-switch-slider::before { + -webkit-transform: translateX(22px); + transform: translateX(22px); +} + +.c-switch-label .c-switch-input:checked ~ .c-switch-slider::after { + left: 1px; + color: #fff; + content: attr(data-checked); +} + +.c-switch-label.c-switch-lg { + width: 56px; + height: 30px; +} + +.c-switch-label.c-switch-lg .c-switch-slider { + font-size: 12px; +} + +.c-switch-label.c-switch-lg .c-switch-slider::before { + width: 24px; + height: 24px; +} + +.c-switch-label.c-switch-lg .c-switch-slider::after { + font-size: 12px; +} + +.c-switch-label.c-switch-lg .c-switch-input:checked ~ .c-switch-slider::before { + -webkit-transform: translateX(26px); + transform: translateX(26px); +} + +.c-switch-label.c-switch-sm { + width: 40px; + height: 22px; +} + +.c-switch-label.c-switch-sm .c-switch-slider { + font-size: 8px; +} + +.c-switch-label.c-switch-sm .c-switch-slider::before { + width: 16px; + height: 16px; +} + +.c-switch-label.c-switch-sm .c-switch-slider::after { + font-size: 8px; +} + +.c-switch-label.c-switch-sm .c-switch-input:checked ~ .c-switch-slider::before { + -webkit-transform: translateX(18px); + transform: translateX(18px); +} + +.c-switch[class*="-3d"] .c-switch-slider { + background-color: #ebedef; + border-radius: 50em; +} + +.c-switch[class*="-3d"] .c-switch-slider::before { + top: -1px; + left: -1px; + width: 26px; + height: 26px; + border: 0; + border-radius: 50em; + box-shadow: 0 2px 5px rgba(0, 0, 21, 0.3); +} + +.c-switch[class*="-3d"].c-switch-lg { + width: 48px; + height: 30px; +} + +.c-switch[class*="-3d"].c-switch-lg .c-switch-slider::before { + width: 30px; + height: 30px; +} + +.c-switch[class*="-3d"].c-switch-lg .c-switch-input:checked ~ .c-switch-slider::before { + -webkit-transform: translateX(18px); + transform: translateX(18px); +} + +.c-switch[class*="-3d"].c-switch-sm { + width: 32px; + height: 22px; +} + +.c-switch[class*="-3d"].c-switch-sm .c-switch-slider::before { + width: 22px; + height: 22px; +} + +.c-switch[class*="-3d"].c-switch-sm .c-switch-input:checked ~ .c-switch-slider::before { + -webkit-transform: translateX(10px); + transform: translateX(10px); +} + +.c-switch-primary .c-switch-input:checked + .c-switch-slider { + background-color: #321fdb; + border-color: #2819ae; +} + +.c-switch-primary .c-switch-input:checked + .c-switch-slider::before { + border-color: #2819ae; +} + +.c-switch-3d-primary .c-switch-input:checked + .c-switch-slider { + background-color: #321fdb; +} + +.c-switch-outline-primary .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #321fdb; +} + +.c-switch-outline-primary .c-switch-input:checked + .c-switch-slider::before { + border-color: #321fdb; +} + +.c-switch-outline-primary .c-switch-input:checked + .c-switch-slider::after { + color: #321fdb; +} + +.c-switch-opposite-primary .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #321fdb; +} + +.c-switch-opposite-primary .c-switch-input:checked + .c-switch-slider::before { + background-color: #321fdb; + border-color: #321fdb; +} + +.c-switch-opposite-primary .c-switch-input:checked + .c-switch-slider::after { + color: #321fdb; +} + +.c-switch-secondary .c-switch-input:checked + .c-switch-slider { + background-color: #ced2d8; + border-color: #b2b8c1; +} + +.c-switch-secondary .c-switch-input:checked + .c-switch-slider::before { + border-color: #b2b8c1; +} + +.c-switch-3d-secondary .c-switch-input:checked + .c-switch-slider { + background-color: #ced2d8; +} + +.c-switch-outline-secondary .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #ced2d8; +} + +.c-switch-outline-secondary .c-switch-input:checked + .c-switch-slider::before { + border-color: #ced2d8; +} + +.c-switch-outline-secondary .c-switch-input:checked + .c-switch-slider::after { + color: #ced2d8; +} + +.c-switch-opposite-secondary .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #ced2d8; +} + +.c-switch-opposite-secondary .c-switch-input:checked + .c-switch-slider::before { + background-color: #ced2d8; + border-color: #ced2d8; +} + +.c-switch-opposite-secondary .c-switch-input:checked + .c-switch-slider::after { + color: #ced2d8; +} + +.c-switch-success .c-switch-input:checked + .c-switch-slider { + background-color: #2eb85c; + border-color: #248f48; +} + +.c-switch-success .c-switch-input:checked + .c-switch-slider::before { + border-color: #248f48; +} + +.c-switch-3d-success .c-switch-input:checked + .c-switch-slider { + background-color: #2eb85c; +} + +.c-switch-outline-success .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #2eb85c; +} + +.c-switch-outline-success .c-switch-input:checked + .c-switch-slider::before { + border-color: #2eb85c; +} + +.c-switch-outline-success .c-switch-input:checked + .c-switch-slider::after { + color: #2eb85c; +} + +.c-switch-opposite-success .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #2eb85c; +} + +.c-switch-opposite-success .c-switch-input:checked + .c-switch-slider::before { + background-color: #2eb85c; + border-color: #2eb85c; +} + +.c-switch-opposite-success .c-switch-input:checked + .c-switch-slider::after { + color: #2eb85c; +} + +.c-switch-info .c-switch-input:checked + .c-switch-slider { + background-color: #39f; + border-color: #0080ff; +} + +.c-switch-info .c-switch-input:checked + .c-switch-slider::before { + border-color: #0080ff; +} + +.c-switch-3d-info .c-switch-input:checked + .c-switch-slider { + background-color: #39f; +} + +.c-switch-outline-info .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #39f; +} + +.c-switch-outline-info .c-switch-input:checked + .c-switch-slider::before { + border-color: #39f; +} + +.c-switch-outline-info .c-switch-input:checked + .c-switch-slider::after { + color: #39f; +} + +.c-switch-opposite-info .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #39f; +} + +.c-switch-opposite-info .c-switch-input:checked + .c-switch-slider::before { + background-color: #39f; + border-color: #39f; +} + +.c-switch-opposite-info .c-switch-input:checked + .c-switch-slider::after { + color: #39f; +} + +.c-switch-warning .c-switch-input:checked + .c-switch-slider { + background-color: #f9b115; + border-color: #d69405; +} + +.c-switch-warning .c-switch-input:checked + .c-switch-slider::before { + border-color: #d69405; +} + +.c-switch-3d-warning .c-switch-input:checked + .c-switch-slider { + background-color: #f9b115; +} + +.c-switch-outline-warning .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #f9b115; +} + +.c-switch-outline-warning .c-switch-input:checked + .c-switch-slider::before { + border-color: #f9b115; +} + +.c-switch-outline-warning .c-switch-input:checked + .c-switch-slider::after { + color: #f9b115; +} + +.c-switch-opposite-warning .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #f9b115; +} + +.c-switch-opposite-warning .c-switch-input:checked + .c-switch-slider::before { + background-color: #f9b115; + border-color: #f9b115; +} + +.c-switch-opposite-warning .c-switch-input:checked + .c-switch-slider::after { + color: #f9b115; +} + +.c-switch-danger .c-switch-input:checked + .c-switch-slider { + background-color: #e55353; + border-color: #de2727; +} + +.c-switch-danger .c-switch-input:checked + .c-switch-slider::before { + border-color: #de2727; +} + +.c-switch-3d-danger .c-switch-input:checked + .c-switch-slider { + background-color: #e55353; +} + +.c-switch-outline-danger .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #e55353; +} + +.c-switch-outline-danger .c-switch-input:checked + .c-switch-slider::before { + border-color: #e55353; +} + +.c-switch-outline-danger .c-switch-input:checked + .c-switch-slider::after { + color: #e55353; +} + +.c-switch-opposite-danger .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #e55353; +} + +.c-switch-opposite-danger .c-switch-input:checked + .c-switch-slider::before { + background-color: #e55353; + border-color: #e55353; +} + +.c-switch-opposite-danger .c-switch-input:checked + .c-switch-slider::after { + color: #e55353; +} + +.c-switch-light .c-switch-input:checked + .c-switch-slider { + background-color: #ebedef; + border-color: #cfd4d8; +} + +.c-switch-light .c-switch-input:checked + .c-switch-slider::before { + border-color: #cfd4d8; +} + +.c-switch-3d-light .c-switch-input:checked + .c-switch-slider { + background-color: #ebedef; +} + +.c-switch-outline-light .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #ebedef; +} + +.c-switch-outline-light .c-switch-input:checked + .c-switch-slider::before { + border-color: #ebedef; +} + +.c-switch-outline-light .c-switch-input:checked + .c-switch-slider::after { + color: #ebedef; +} + +.c-switch-opposite-light .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #ebedef; +} + +.c-switch-opposite-light .c-switch-input:checked + .c-switch-slider::before { + background-color: #ebedef; + border-color: #ebedef; +} + +.c-switch-opposite-light .c-switch-input:checked + .c-switch-slider::after { + color: #ebedef; +} + +.c-switch-dark .c-switch-input:checked + .c-switch-slider { + background-color: #636f83; + border-color: #4d5666; +} + +.c-switch-dark .c-switch-input:checked + .c-switch-slider::before { + border-color: #4d5666; +} + +.c-switch-3d-dark .c-switch-input:checked + .c-switch-slider { + background-color: #636f83; +} + +.c-switch-outline-dark .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #636f83; +} + +.c-switch-outline-dark .c-switch-input:checked + .c-switch-slider::before { + border-color: #636f83; +} + +.c-switch-outline-dark .c-switch-input:checked + .c-switch-slider::after { + color: #636f83; +} + +.c-switch-opposite-dark .c-switch-input:checked + .c-switch-slider { + background-color: #fff; + border-color: #636f83; +} + +.c-switch-opposite-dark .c-switch-input:checked + .c-switch-slider::before { + background-color: #636f83; + border-color: #636f83; +} + +.c-switch-opposite-dark .c-switch-input:checked + .c-switch-slider::after { + color: #636f83; +} + +.c-switch-pill .c-switch-slider,.c-switch-pill .c-switch-slider::before { + border-radius: 50em; +} + +.c-switch-square .c-switch-slider,.c-switch-square .c-switch-slider::before { + border-radius: 0; +} + +.table { + width: 100%; + margin-bottom: 1rem; + color: #4f5d73; +} + +.table th, +.table td { + padding: 0.75rem; + vertical-align: top; + border-top: 1px solid; + border-top-color: #d8dbe0; +} + +.table thead th { + vertical-align: bottom; + border-bottom: 2px solid; + border-bottom-color: #d8dbe0; +} + +.table tbody + tbody { + border-top: 2px solid; + border-top-color: #d8dbe0; +} + +.table-sm th, +.table-sm td { + padding: 0.3rem; +} + +.table-bordered,.table-bordered th, +.table-bordered td { + border: 1px solid; + border-color: #d8dbe0; +} + +.table-bordered thead th, +.table-bordered thead td { + border-bottom-width: 2px; +} + +.table-borderless th, +.table-borderless td, +.table-borderless thead th, +.table-borderless tbody + tbody { + border: 0; +} + +.table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(0, 0, 21, 0.05); +} + +.table-hover tbody tr:hover { + color: #4f5d73; + background-color: rgba(0, 0, 21, 0.075); +} + +.table-primary, +.table-primary > th, +.table-primary > td { + color: #4f5d73; + background-color: #c6c0f5; +} + +.table-primary th, +.table-primary td, +.table-primary thead th, +.table-primary tbody + tbody { + border-color: #948bec; +} + +.table-hover .table-primary:hover,.table-hover .table-primary:hover > td, +.table-hover .table-primary:hover > th { + background-color: #b2aaf2; +} + +.table-secondary, +.table-secondary > th, +.table-secondary > td { + color: #4f5d73; + background-color: #f1f2f4; +} + +.table-secondary th, +.table-secondary td, +.table-secondary thead th, +.table-secondary tbody + tbody { + border-color: #e6e8eb; +} + +.table-hover .table-secondary:hover,.table-hover .table-secondary:hover > td, +.table-hover .table-secondary:hover > th { + background-color: #e3e5e9; +} + +.table-success, +.table-success > th, +.table-success > td { + color: #4f5d73; + background-color: #c4ebd1; +} + +.table-success th, +.table-success td, +.table-success thead th, +.table-success tbody + tbody { + border-color: #92daaa; +} + +.table-hover .table-success:hover,.table-hover .table-success:hover > td, +.table-hover .table-success:hover > th { + background-color: #b1e5c2; +} + +.table-info, +.table-info > th, +.table-info > td { + color: #4f5d73; + background-color: #c6e2ff; +} + +.table-info th, +.table-info td, +.table-info thead th, +.table-info tbody + tbody { + border-color: #95caff; +} + +.table-hover .table-info:hover,.table-hover .table-info:hover > td, +.table-hover .table-info:hover > th { + background-color: #add5ff; +} + +.table-warning, +.table-warning > th, +.table-warning > td { + color: #4f5d73; + background-color: #fde9bd; +} + +.table-warning th, +.table-warning td, +.table-warning thead th, +.table-warning tbody + tbody { + border-color: #fcd685; +} + +.table-hover .table-warning:hover,.table-hover .table-warning:hover > td, +.table-hover .table-warning:hover > th { + background-color: #fce1a4; +} + +.table-danger, +.table-danger > th, +.table-danger > td { + color: #4f5d73; + background-color: #f8cfcf; +} + +.table-danger th, +.table-danger td, +.table-danger thead th, +.table-danger tbody + tbody { + border-color: #f1a6a6; +} + +.table-hover .table-danger:hover,.table-hover .table-danger:hover > td, +.table-hover .table-danger:hover > th { + background-color: #f5b9b9; +} + +.table-light, +.table-light > th, +.table-light > td { + color: #4f5d73; + background-color: #f9fafb; +} + +.table-light th, +.table-light td, +.table-light thead th, +.table-light tbody + tbody { + border-color: #f5f6f7; +} + +.table-hover .table-light:hover,.table-hover .table-light:hover > td, +.table-hover .table-light:hover > th { + background-color: #eaedf1; +} + +.table-dark, +.table-dark > th, +.table-dark > td { + color: #4f5d73; + background-color: #d3d7dc; +} + +.table-dark th, +.table-dark td, +.table-dark thead th, +.table-dark tbody + tbody { + border-color: #aeb4bf; +} + +.table-hover .table-dark:hover,.table-hover .table-dark:hover > td, +.table-hover .table-dark:hover > th { + background-color: #c5cad1; +} + +.table-active, +.table-active > th, +.table-active > td { + color: #fff; + background-color: rgba(0, 0, 21, 0.075); +} + +.table-hover .table-active:hover,.table-hover .table-active:hover > td, +.table-hover .table-active:hover > th { + background-color: rgba(0, 0, 0, 0.075); +} + +.table .thead-dark th { + color: #fff; + background-color: #636f83; + border-color: #758297; +} + +.table .thead-light th { + color: #768192; + background-color: #d8dbe0; + border-color: #d8dbe0; +} + +.table-dark { + color: #fff; + background-color: #636f83; +} + +.table-dark th, +.table-dark td, +.table-dark thead th { + border-color: #758297; +} + +.table-dark.table-bordered { + border: 0; +} + +.table-dark.table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(255, 255, 255, 0.05); +} + +.table-dark.table-hover tbody tr:hover { + color: #fff; + background-color: rgba(255, 255, 255, 0.075); +} + +@media (max-width: 575.98px) { + .table-responsive-sm { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .table-responsive-sm > .table-bordered { + border: 0; + } +} + +@media (max-width: 767.98px) { + .table-responsive-md { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .table-responsive-md > .table-bordered { + border: 0; + } +} + +@media (max-width: 991.98px) { + .table-responsive-lg { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .table-responsive-lg > .table-bordered { + border: 0; + } +} + +@media (max-width: 1199.98px) { + .table-responsive-xl { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .table-responsive-xl > .table-bordered { + border: 0; + } +} + +.table-responsive { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.table-responsive > .table-bordered { + border: 0; +} + +.table-outline { + border: 1px solid; + border-color: #d8dbe0; +} + +.table-outline td,.table-align-middle td { + vertical-align: middle; +} + +.table-clear td { + border: 0; +} + +.toast { + width: 350px; + max-width: 350px; + overflow: hidden; + font-size: 0.875rem; + background-clip: padding-box; + border: 1px solid; + box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 21, 0.1); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + opacity: 0; + border-radius: 0.25rem; + background-color: rgba(255, 255, 255, 0.85); + border-color: rgba(0, 0, 21, 0.1); +} + +.toast:not(:last-child) { + margin-bottom: 0.75rem; +} + +.toast.showing,.toast.show { + opacity: 1; +} + +.toast.show { + display: block; +} + +.toast.hide { + display: none; +} + +.toast-full { + width: 100%; + max-width: 100%; +} + +.toast-header { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + padding: 0.25rem 0.75rem; + background-clip: padding-box; + border-bottom: 1px solid; + color: #8a93a2; + background-color: rgba(255, 255, 255, 0.85); + border-color: rgba(0, 0, 21, 0.05); +} + +.toast-body { + padding: 0.75rem; +} + +.toaster { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column-reverse; + flex-direction: column-reverse; + width: 100%; + padding: 0.25rem 0.5rem; +} + +.toaster-top-full, .toaster-top-center, .toaster-top-right, .toaster-top-left, .toaster-bottom-full, .toaster-bottom-center, .toaster-bottom-right, .toaster-bottom-left { + position: fixed; + z-index: 1080; + width: 350px; +} + +.toaster-top-full, .toaster-top-center, .toaster-top-right, .toaster-top-left { + top: 0; +} + +.toaster-bottom-full, .toaster-bottom-center, .toaster-bottom-right, .toaster-bottom-left { + bottom: 0; + -ms-flex-direction: column; + flex-direction: column; +} + +.toaster-top-full, .toaster-bottom-full { + width: auto; +} + +.toaster-top-center, .toaster-bottom-center { + left: 50%; + -webkit-transform: translateX(-50%); + transform: translateX(-50%); +} + +.toaster-top-full, .toaster-bottom-full, .toaster-top-right, .toaster-bottom-right { + right: 0; +} + +.toaster-top-full, .toaster-bottom-full, .toaster-top-left, .toaster-bottom-left { + left: 0; +} + +.toaster .toast { + width: 100%; + max-width: 100%; + margin-top: 0.125rem; + margin-bottom: 0.125rem; +} + +.tooltip { + position: absolute; + z-index: 1070; + display: block; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + 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; + word-spacing: normal; + white-space: normal; + line-break: auto; + font-size: 0.765625rem; + word-wrap: break-word; + opacity: 0; +} + +.tooltip.show { + opacity: 0.9; +} + +.tooltip .arrow { + position: absolute; + display: block; + width: 0.8rem; + height: 0.4rem; +} + +.tooltip .arrow::before { + position: absolute; + content: ""; + border-color: transparent; + border-style: solid; +} + +.bs-tooltip-top, .bs-tooltip-auto[x-placement^="top"] { + padding: 0.4rem 0; +} + +.bs-tooltip-top .arrow, .bs-tooltip-auto[x-placement^="top"] .arrow { + bottom: 0; +} + +.bs-tooltip-top .arrow::before, .bs-tooltip-auto[x-placement^="top"] .arrow::before { + top: 0; + border-width: 0.4rem 0.4rem 0; + border-top-color: #000015; +} + +.bs-tooltip-right, .bs-tooltip-auto[x-placement^="right"] { + padding: 0 0.4rem; +} + +.bs-tooltip-right .arrow, .bs-tooltip-auto[x-placement^="right"] .arrow { + left: 0; + width: 0.4rem; + height: 0.8rem; +} + +.bs-tooltip-right .arrow::before, .bs-tooltip-auto[x-placement^="right"] .arrow::before { + right: 0; + border-width: 0.4rem 0.4rem 0.4rem 0; + border-right-color: #000015; +} + +.bs-tooltip-bottom, .bs-tooltip-auto[x-placement^="bottom"] { + padding: 0.4rem 0; +} + +.bs-tooltip-bottom .arrow, .bs-tooltip-auto[x-placement^="bottom"] .arrow { + top: 0; +} + +.bs-tooltip-bottom .arrow::before, .bs-tooltip-auto[x-placement^="bottom"] .arrow::before { + bottom: 0; + border-width: 0 0.4rem 0.4rem; + border-bottom-color: #000015; +} + +.bs-tooltip-left, .bs-tooltip-auto[x-placement^="left"] { + padding: 0 0.4rem; +} + +.bs-tooltip-left .arrow, .bs-tooltip-auto[x-placement^="left"] .arrow { + right: 0; + width: 0.4rem; + height: 0.8rem; +} + +.bs-tooltip-left .arrow::before, .bs-tooltip-auto[x-placement^="left"] .arrow::before { + left: 0; + border-width: 0.4rem 0 0.4rem 0.4rem; + border-left-color: #000015; +} + +.tooltip-inner { + max-width: 200px; + padding: 0.25rem 0.5rem; + color: #fff; + text-align: center; + background-color: #000015; + border-radius: 0.25rem; +} + +.fade { + transition: opacity 0.15s linear; +} + +@media (prefers-reduced-motion: reduce) { + .fade { + transition: none; + } +} + +.fade:not(.show) { + opacity: 0; +} + +.collapse:not(.show) { + display: none; +} + +.collapsing { + position: relative; + height: 0; + overflow: hidden; + transition: height 0.35s ease; +} + +@media (prefers-reduced-motion: reduce) { + .collapsing { + transition: none; + } +} + +@-webkit-keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.fade-in { + -webkit-animation-name: fadeIn; + animation-name: fadeIn; + -webkit-animation-duration: 1s; + animation-duration: 1s; +} + +.c-wrapper { + will-change: auto; + transition: margin-left 0.25s, margin-right 0.25s, width 0.25s, flex 0.25s, -webkit-transform 0.25s; + transition: transform 0.25s, margin-left 0.25s, margin-right 0.25s, width 0.25s, flex 0.25s; + transition: transform 0.25s, margin-left 0.25s, margin-right 0.25s, width 0.25s, flex 0.25s, -webkit-transform 0.25s, -ms-flex 0.25s; + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; +} + +.c-sidebar.c-sidebar-unfoldable { + transition: margin-left 0.25s, margin-right 0.25s, width 0.25s, z-index 0s ease 0s, -webkit-transform 0.25s; + transition: transform 0.25s, margin-left 0.25s, margin-right 0.25s, width 0.25s, z-index 0s ease 0s; + transition: transform 0.25s, margin-left 0.25s, margin-right 0.25s, width 0.25s, z-index 0s ease 0s, -webkit-transform 0.25s; +} + +h1, h2, h3, h4, h5, h6, +.h1, .h2, .h3, .h4, .h5, .h6 { + margin-bottom: 0.5rem; + font-weight: 500; + line-height: 1.2; +} + +h1, .h1 { + font-size: 2.1875rem; +} + +h2, .h2 { + font-size: 1.75rem; +} + +h3, .h3 { + font-size: 1.53125rem; +} + +h4, .h4 { + font-size: 1.3125rem; +} + +h5, .h5 { + font-size: 1.09375rem; +} + +h6, .h6 { + font-size: 0.875rem; +} + +.lead { + font-size: 1.09375rem; + font-weight: 300; +} + +.display-1 { + font-size: 6rem; +} + +.display-1,.display-2 { + font-weight: 300; + line-height: 1.2; +} + +.display-2 { + font-size: 5.5rem; +} + +.display-3 { + font-size: 4.5rem; +} + +.display-3,.display-4 { + font-weight: 300; + line-height: 1.2; +} + +.display-4 { + font-size: 3.5rem; +} + +.c-vr { + width: 1px; + background-color: rgba(0, 0, 21, 0.2); +} + +small, +.small { + font-size: 80%; + font-weight: 400; +} + +mark, +.mark { + padding: 0.2em; + background-color: #fcf8e3; +} + +.list-unstyled { + list-style: none; +} + +html:not([dir="rtl"]) .list-unstyled { + padding-left: 0; +} + +*[dir="rtl"] .list-unstyled { + padding-right: 0; +} + +.list-inline { + list-style: none; +} + +html:not([dir="rtl"]) .list-inline { + padding-left: 0; +} + +*[dir="rtl"] .list-inline { + padding-right: 0; +} + +.list-inline-item { + display: inline-block; +} + +.list-inline-item:not(:last-child) { + margin-right: 0.5rem; +} + +.initialism { + font-size: 90%; + text-transform: uppercase; +} + +.blockquote { + margin-bottom: 1rem; + font-size: 1.09375rem; +} + +.blockquote-footer { + display: block; + font-size: 80%; + color: #8a93a2; +} + +.blockquote-footer::before { + content: "\2014\00A0"; +} + +@media all and (-ms-high-contrast: none) { + html { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; + } +} + +.c-wrapper:not(.c-wrapper-fluid) { + height: 100vh; +} + +.c-wrapper:not(.c-wrapper-fluid) .c-header-fixed, +.c-wrapper:not(.c-wrapper-fluid) .c-subheader-fixed, +.c-wrapper:not(.c-wrapper-fluid) .c-footer-fixed { + position: relative; +} + +.c-wrapper:not(.c-wrapper-fluid) .c-body { + -ms-flex-direction: column; + flex-direction: column; + overflow-y: auto; +} + +.c-wrapper.c-wrapper-fluid { + min-height: 100vh; +} + +.c-wrapper.c-wrapper-fluid .c-header-fixed { + margin: inherit; +} + +.c-body { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: row; + flex-direction: row; + -ms-flex-positive: 1; + flex-grow: 1; +} + +.c-main { + -ms-flex-preferred-size: auto; + flex-basis: auto; + -ms-flex-negative: 0; + flex-shrink: 0; + -ms-flex-positive: 1; + flex-grow: 1; + min-width: 0; + padding-top: 2rem; +} + +@media (min-width: 768px) { + .c-main > .container-fluid, .c-main > .container-sm, .c-main > .container-md, .c-main > .container-lg, .c-main > .container-xl { + padding-right: 30px; + padding-left: 30px; + } +} + +.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; +} + +.bg-primary { + background-color: #321fdb !important; +} + +a.bg-primary:hover, a.bg-primary:focus, +button.bg-primary:hover, +button.bg-primary:focus { + background-color: #2819ae !important; +} + +.bg-secondary { + background-color: #ced2d8 !important; +} + +a.bg-secondary:hover, a.bg-secondary:focus, +button.bg-secondary:hover, +button.bg-secondary:focus { + background-color: #b2b8c1 !important; +} + +.bg-success { + background-color: #2eb85c !important; +} + +a.bg-success:hover, a.bg-success:focus, +button.bg-success:hover, +button.bg-success:focus { + background-color: #248f48 !important; +} + +.bg-info { + background-color: #39f !important; +} + +a.bg-info:hover, a.bg-info:focus, +button.bg-info:hover, +button.bg-info:focus { + background-color: #0080ff !important; +} + +.bg-warning { + background-color: #f9b115 !important; +} + +a.bg-warning:hover, a.bg-warning:focus, +button.bg-warning:hover, +button.bg-warning:focus { + background-color: #d69405 !important; +} + +.bg-danger { + background-color: #e55353 !important; +} + +a.bg-danger:hover, a.bg-danger:focus, +button.bg-danger:hover, +button.bg-danger:focus { + background-color: #de2727 !important; +} + +.bg-light { + background-color: #ebedef !important; +} + +a.bg-light:hover, a.bg-light:focus, +button.bg-light:hover, +button.bg-light:focus { + background-color: #cfd4d8 !important; +} + +.bg-dark { + background-color: #636f83 !important; +} + +a.bg-dark:hover, a.bg-dark:focus, +button.bg-dark:hover, +button.bg-dark:focus { + background-color: #4d5666 !important; +} + +.bg-gradient-primary { + background: #1f1498 !important; + background: linear-gradient(45deg, #321fdb 0%, #1f1498 100%) !important; + border-color: #1f1498 !important; +} + +.c-dark-theme .bg-gradient-primary { + background: #2d2587 !important; + background: linear-gradient(45deg, #4638c2 0%, #2d2587 100%) !important; + border-color: #2d2587 !important; +} + +.bg-gradient-secondary { + background: #fff !important; + background: linear-gradient(45deg, #c8d2dc 0%, #fff 100%) !important; + border-color: #fff !important; +} + +.c-dark-theme .bg-gradient-secondary { + background: white !important; + background: linear-gradient(45deg, #d1d2d3 0%, white 100%) !important; + border-color: white !important; +} + +.bg-gradient-success { + background: #1b9e3e !important; + background: linear-gradient(45deg, #2eb85c 0%, #1b9e3e 100%) !important; + border-color: #1b9e3e !important; +} + +.c-dark-theme .bg-gradient-success { + background: #2e8c47 !important; + background: linear-gradient(45deg, #45a164 0%, #2e8c47 100%) !important; + border-color: #2e8c47 !important; +} + +.bg-gradient-info { + background: #2982cc !important; + background: linear-gradient(45deg, #39f 0%, #2982cc 100%) !important; + border-color: #2982cc !important; +} + +.c-dark-theme .bg-gradient-info { + background: #4280b4 !important; + background: linear-gradient(45deg, #4799eb 0%, #4280b4 100%) !important; + border-color: #4280b4 !important; +} + +.bg-gradient-warning { + background: #f6960b !important; + background: linear-gradient(45deg, #f9b115 0%, #f6960b 100%) !important; + border-color: #f6960b !important; +} + +.c-dark-theme .bg-gradient-warning { + background: #dd9124 !important; + background: linear-gradient(45deg, #e1a82d 0%, #dd9124 100%) !important; + border-color: #dd9124 !important; +} + +.bg-gradient-danger { + background: #d93737 !important; + background: linear-gradient(45deg, #e55353 0%, #d93737 100%) !important; + border-color: #d93737 !important; +} + +.c-dark-theme .bg-gradient-danger { + background: #c14f4f !important; + background: linear-gradient(45deg, #d16767 0%, #c14f4f 100%) !important; + border-color: #c14f4f !important; +} + +.bg-gradient-light { + background: #fff !important; + background: linear-gradient(45deg, #e3e8ed 0%, #fff 100%) !important; + border-color: #fff !important; +} + +.c-dark-theme .bg-gradient-light { + background: white !important; + background: linear-gradient(45deg, #e8e8e8 0%, white 100%) !important; + border-color: white !important; +} + +.bg-gradient-dark { + background: #212333 !important; + background: linear-gradient(45deg, #3c4b64 0%, #212333 100%) !important; + border-color: #212333 !important; +} + +.c-dark-theme .bg-gradient-dark { + background: #292a2b !important; + background: linear-gradient(45deg, #4c4f54 0%, #292a2b 100%) !important; + border-color: #292a2b !important; +} + +.bg-white { + background-color: #fff !important; +} + +.bg-transparent { + background-color: transparent !important; +} + +[class^="bg-"] { + color: #fff; +} + +.bg-facebook { + background-color: #3b5998 !important; +} + +a.bg-facebook:hover, a.bg-facebook:focus, +button.bg-facebook:hover, +button.bg-facebook:focus { + background-color: #2d4373 !important; +} + +.bg-twitter { + background-color: #00aced !important; +} + +a.bg-twitter:hover, a.bg-twitter:focus, +button.bg-twitter:hover, +button.bg-twitter:focus { + background-color: #0087ba !important; +} + +.bg-linkedin { + background-color: #4875b4 !important; +} + +a.bg-linkedin:hover, a.bg-linkedin:focus, +button.bg-linkedin:hover, +button.bg-linkedin:focus { + background-color: #395d90 !important; +} + +.bg-flickr { + background-color: #ff0084 !important; +} + +a.bg-flickr:hover, a.bg-flickr:focus, +button.bg-flickr:hover, +button.bg-flickr:focus { + background-color: #cc006a !important; +} + +.bg-tumblr { + background-color: #32506d !important; +} + +a.bg-tumblr:hover, a.bg-tumblr:focus, +button.bg-tumblr:hover, +button.bg-tumblr:focus { + background-color: #22364a !important; +} + +.bg-xing { + background-color: #026466 !important; +} + +a.bg-xing:hover, a.bg-xing:focus, +button.bg-xing:hover, +button.bg-xing:focus { + background-color: #013334 !important; +} + +.bg-github { + background-color: #4183c4 !important; +} + +a.bg-github:hover, a.bg-github:focus, +button.bg-github:hover, +button.bg-github:focus { + background-color: #3269a0 !important; +} + +.bg-stack-overflow { + background-color: #fe7a15 !important; +} + +a.bg-stack-overflow:hover, a.bg-stack-overflow:focus, +button.bg-stack-overflow:hover, +button.bg-stack-overflow:focus { + background-color: #df6101 !important; +} + +.bg-youtube { + background-color: #b00 !important; +} + +a.bg-youtube:hover, a.bg-youtube:focus, +button.bg-youtube:hover, +button.bg-youtube:focus { + background-color: #880000 !important; +} + +.bg-dribbble { + background-color: #ea4c89 !important; +} + +a.bg-dribbble:hover, a.bg-dribbble:focus, +button.bg-dribbble:hover, +button.bg-dribbble:focus { + background-color: #e51e6b !important; +} + +.bg-instagram { + background-color: #517fa4 !important; +} + +a.bg-instagram:hover, a.bg-instagram:focus, +button.bg-instagram:hover, +button.bg-instagram:focus { + background-color: #406582 !important; +} + +.bg-pinterest { + background-color: #cb2027 !important; +} + +a.bg-pinterest:hover, a.bg-pinterest:focus, +button.bg-pinterest:hover, +button.bg-pinterest:focus { + background-color: #9f191f !important; +} + +.bg-vk { + background-color: #45668e !important; +} + +a.bg-vk:hover, a.bg-vk:focus, +button.bg-vk:hover, +button.bg-vk:focus { + background-color: #344d6c !important; +} + +.bg-yahoo { + background-color: #400191 !important; +} + +a.bg-yahoo:hover, a.bg-yahoo:focus, +button.bg-yahoo:hover, +button.bg-yahoo:focus { + background-color: #2a015e !important; +} + +.bg-behance { + background-color: #1769ff !important; +} + +a.bg-behance:hover, a.bg-behance:focus, +button.bg-behance:hover, +button.bg-behance:focus { + background-color: #0050e3 !important; +} + +.bg-reddit { + background-color: #ff4500 !important; +} + +a.bg-reddit:hover, a.bg-reddit:focus, +button.bg-reddit:hover, +button.bg-reddit:focus { + background-color: #cc3700 !important; +} + +.bg-vimeo { + background-color: #aad450 !important; +} + +a.bg-vimeo:hover, a.bg-vimeo:focus, +button.bg-vimeo:hover, +button.bg-vimeo:focus { + background-color: #93c130 !important; +} + +.bg-gray-100 { + background-color: #ebedef !important; +} + +a.bg-gray-100:hover, a.bg-gray-100:focus, +button.bg-gray-100:hover, +button.bg-gray-100:focus { + background-color: #cfd4d8 !important; +} + +.bg-gray-200 { + background-color: #d8dbe0 !important; +} + +a.bg-gray-200:hover, a.bg-gray-200:focus, +button.bg-gray-200:hover, +button.bg-gray-200:focus { + background-color: #bcc1c9 !important; +} + +.bg-gray-300 { + background-color: #c4c9d0 !important; +} + +a.bg-gray-300:hover, a.bg-gray-300:focus, +button.bg-gray-300:hover, +button.bg-gray-300:focus { + background-color: #a8afb9 !important; +} + +.bg-gray-400 { + background-color: #b1b7c1 !important; +} + +a.bg-gray-400:hover, a.bg-gray-400:focus, +button.bg-gray-400:hover, +button.bg-gray-400:focus { + background-color: #959daa !important; +} + +.bg-gray-500 { + background-color: #9da5b1 !important; +} + +a.bg-gray-500:hover, a.bg-gray-500:focus, +button.bg-gray-500:hover, +button.bg-gray-500:focus { + background-color: #818b9a !important; +} + +.bg-gray-600 { + background-color: #8a93a2 !important; +} + +a.bg-gray-600:hover, a.bg-gray-600:focus, +button.bg-gray-600:hover, +button.bg-gray-600:focus { + background-color: #6e798b !important; +} + +.bg-gray-700 { + background-color: #768192 !important; +} + +a.bg-gray-700:hover, a.bg-gray-700:focus, +button.bg-gray-700:hover, +button.bg-gray-700:focus { + background-color: #5e6877 !important; +} + +.bg-gray-800 { + background-color: #636f83 !important; +} + +a.bg-gray-800:hover, a.bg-gray-800:focus, +button.bg-gray-800:hover, +button.bg-gray-800:focus { + background-color: #4d5666 !important; +} + +.bg-gray-900 { + background-color: #4f5d73 !important; +} + +a.bg-gray-900:hover, a.bg-gray-900:focus, +button.bg-gray-900:hover, +button.bg-gray-900:focus { + background-color: #3a4555 !important; +} + +.bg-box { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; +} + +.border { + border: 1px solid #d8dbe0 !important; +} + +.border-top { + border-top: 1px solid #d8dbe0 !important; +} + +.border-right { + border-right: 1px solid #d8dbe0 !important; +} + +.border-bottom { + border-bottom: 1px solid #d8dbe0 !important; +} + +.border-left { + border-left: 1px solid #d8dbe0 !important; +} + +.border-0 { + border: 0 !important; +} + +.border-top-0 { + border-top: 0 !important; +} + +.border-right-0 { + border-right: 0 !important; +} + +.border-bottom-0 { + border-bottom: 0 !important; +} + +.border-left-0 { + border-left: 0 !important; +} + +.border-primary { + border: 1px solid !important; + border-color: #321fdb !important; +} + +.border-secondary { + border: 1px solid !important; + border-color: #ced2d8 !important; +} + +.border-success { + border: 1px solid !important; + border-color: #2eb85c !important; +} + +.border-info { + border: 1px solid !important; + border-color: #39f !important; +} + +.border-warning { + border: 1px solid !important; + border-color: #f9b115 !important; +} + +.border-danger { + border: 1px solid !important; + border-color: #e55353 !important; +} + +.border-light { + border: 1px solid !important; + border-color: #ebedef !important; +} + +.border-dark { + border: 1px solid !important; + border-color: #636f83 !important; +} + +.border-white { + border-color: #fff !important; +} + +.rounded-sm { + border-radius: 0.2rem !important; +} + +.rounded { + border-radius: 0.25rem !important; +} + +.rounded-top { + border-top-left-radius: 0.25rem !important; +} + +.rounded-top,.rounded-right { + border-top-right-radius: 0.25rem !important; +} + +.rounded-right,.rounded-bottom { + border-bottom-right-radius: 0.25rem !important; +} + +.rounded-bottom,.rounded-left { + border-bottom-left-radius: 0.25rem !important; +} + +.rounded-left { + border-top-left-radius: 0.25rem !important; +} + +.rounded-lg { + border-radius: 0.3rem !important; +} + +.rounded-circle { + border-radius: 50% !important; +} + +.rounded-pill { + border-radius: 50rem !important; +} + +.rounded-0 { + border-radius: 0 !important; +} + +.b-a-0 { + border: 0 !important; +} + +.b-t-0 { + border-top: 0 !important; +} + +.b-r-0 { + border-right: 0 !important; +} + +.b-b-0 { + border-bottom: 0 !important; +} + +.b-l-0 { + border-left: 0 !important; +} + +.b-a-1 { + border: 1px solid #d8dbe0; +} + +.b-t-1 { + border-top: 1px solid #d8dbe0; +} + +.b-r-1 { + border-right: 1px solid #d8dbe0; +} + +.b-b-1 { + border-bottom: 1px solid #d8dbe0; +} + +.b-l-1 { + border-left: 1px solid #d8dbe0; +} + +.b-a-2 { + border: 2px solid #d8dbe0; +} + +.b-t-2 { + border-top: 2px solid #d8dbe0; +} + +.b-r-2 { + border-right: 2px solid #d8dbe0; +} + +.b-b-2 { + border-bottom: 2px solid #d8dbe0; +} + +.b-l-2 { + border-left: 2px solid #d8dbe0; +} + +.content-center { + position: relative; + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; + padding: 0; + text-align: center; +} + +.clearfix::after { + display: block; + clear: both; + content: ""; +} + +.d-none { + display: none !important; +} + +.d-inline { + display: inline !important; +} + +.d-inline-block { + display: inline-block !important; +} + +.d-block { + display: block !important; +} + +.d-table { + display: table !important; +} + +.d-table-row { + display: table-row !important; +} + +.d-table-cell { + display: table-cell !important; +} + +.d-flex { + display: -ms-flexbox !important; + display: flex !important; +} + +.d-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; +} + +@media (min-width: 576px) { + .d-sm-none { + display: none !important; + } + .d-sm-inline { + display: inline !important; + } + .d-sm-inline-block { + display: inline-block !important; + } + .d-sm-block { + display: block !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: -ms-flexbox !important; + display: flex !important; + } + .d-sm-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; + } +} + +@media (min-width: 768px) { + .d-md-none { + display: none !important; + } + .d-md-inline { + display: inline !important; + } + .d-md-inline-block { + display: inline-block !important; + } + .d-md-block { + display: block !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: -ms-flexbox !important; + display: flex !important; + } + .d-md-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; + } +} + +@media (min-width: 992px) { + .d-lg-none { + display: none !important; + } + .d-lg-inline { + display: inline !important; + } + .d-lg-inline-block { + display: inline-block !important; + } + .d-lg-block { + display: block !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: -ms-flexbox !important; + display: flex !important; + } + .d-lg-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; + } +} + +@media (min-width: 1200px) { + .d-xl-none { + display: none !important; + } + .d-xl-inline { + display: inline !important; + } + .d-xl-inline-block { + display: inline-block !important; + } + .d-xl-block { + display: block !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: -ms-flexbox !important; + display: flex !important; + } + .d-xl-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; + } +} + +@media (max-width: 575.98px) { + .d-down-none { + display: none !important; + } +} + +@media (max-width: 767.98px) { + .d-sm-down-none { + display: none !important; + } +} + +@media (max-width: 991.98px) { + .d-md-down-none { + display: none !important; + } +} + +@media (max-width: 1199.98px) { + .d-lg-down-none { + display: none !important; + } +} + +.d-xl-down-none,.c-default-theme .c-d-default-none { + display: none !important; +} + +@media print { + .d-print-none { + display: none !important; + } + .d-print-inline { + display: inline !important; + } + .d-print-inline-block { + display: inline-block !important; + } + .d-print-block { + display: block !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: -ms-flexbox !important; + display: flex !important; + } + .d-print-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; + } +} + +.embed-responsive { + position: relative; + display: block; + width: 100%; + padding: 0; + overflow: hidden; +} + +.embed-responsive::before { + display: block; + content: ""; +} + +.embed-responsive .embed-responsive-item, +.embed-responsive iframe, +.embed-responsive embed, +.embed-responsive object, +.embed-responsive video { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} + +.embed-responsive-21by9::before { + padding-top: 42.85714286%; +} + +.embed-responsive-16by9::before { + padding-top: 56.25%; +} + +.embed-responsive-4by3::before { + padding-top: 75%; +} + +.embed-responsive-1by1::before { + padding-top: 100%; +} + +.flex-row { + -ms-flex-direction: row !important; + flex-direction: row !important; +} + +.flex-column { + -ms-flex-direction: column !important; + flex-direction: column !important; +} + +.flex-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; +} + +.flex-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; +} + +.flex-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; +} + +.flex-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; +} + +.flex-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; +} + +.flex-fill { + -ms-flex: 1 1 auto !important; + flex: 1 1 auto !important; +} + +.flex-grow-0 { + -ms-flex-positive: 0 !important; + flex-grow: 0 !important; +} + +.flex-grow-1 { + -ms-flex-positive: 1 !important; + flex-grow: 1 !important; +} + +.flex-shrink-0 { + -ms-flex-negative: 0 !important; + flex-shrink: 0 !important; +} + +.flex-shrink-1 { + -ms-flex-negative: 1 !important; + flex-shrink: 1 !important; +} + +.justify-content-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; +} + +.justify-content-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; +} + +.justify-content-center { + -ms-flex-pack: center !important; + justify-content: center !important; +} + +.justify-content-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; +} + +.justify-content-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; +} + +.align-items-start { + -ms-flex-align: start !important; + align-items: flex-start !important; +} + +.align-items-end { + -ms-flex-align: end !important; + align-items: flex-end !important; +} + +.align-items-center { + -ms-flex-align: center !important; + align-items: center !important; +} + +.align-items-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; +} + +.align-items-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; +} + +.align-content-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; +} + +.align-content-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; +} + +.align-content-center { + -ms-flex-line-pack: center !important; + align-content: center !important; +} + +.align-content-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; +} + +.align-content-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; +} + +.align-content-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; +} + +.align-self-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; +} + +.align-self-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; +} + +.align-self-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; +} + +.align-self-center { + -ms-flex-item-align: center !important; + align-self: center !important; +} + +.align-self-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; +} + +.align-self-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; +} + +@media (min-width: 576px) { + .flex-sm-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-sm-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-sm-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-sm-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-sm-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-sm-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-sm-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .flex-sm-fill { + -ms-flex: 1 1 auto !important; + flex: 1 1 auto !important; + } + .flex-sm-grow-0 { + -ms-flex-positive: 0 !important; + flex-grow: 0 !important; + } + .flex-sm-grow-1 { + -ms-flex-positive: 1 !important; + flex-grow: 1 !important; + } + .flex-sm-shrink-0 { + -ms-flex-negative: 0 !important; + flex-shrink: 0 !important; + } + .flex-sm-shrink-1 { + -ms-flex-negative: 1 !important; + flex-shrink: 1 !important; + } + .justify-content-sm-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-sm-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-sm-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-sm-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-sm-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-sm-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-sm-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-sm-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-sm-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-sm-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-sm-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-sm-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-sm-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-sm-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-sm-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-sm-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-sm-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-sm-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-sm-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-sm-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-sm-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-sm-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} + +@media (min-width: 768px) { + .flex-md-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-md-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-md-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-md-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-md-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-md-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-md-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .flex-md-fill { + -ms-flex: 1 1 auto !important; + flex: 1 1 auto !important; + } + .flex-md-grow-0 { + -ms-flex-positive: 0 !important; + flex-grow: 0 !important; + } + .flex-md-grow-1 { + -ms-flex-positive: 1 !important; + flex-grow: 1 !important; + } + .flex-md-shrink-0 { + -ms-flex-negative: 0 !important; + flex-shrink: 0 !important; + } + .flex-md-shrink-1 { + -ms-flex-negative: 1 !important; + flex-shrink: 1 !important; + } + .justify-content-md-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-md-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-md-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-md-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-md-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-md-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-md-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-md-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-md-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-md-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-md-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-md-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-md-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-md-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-md-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-md-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-md-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-md-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-md-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-md-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-md-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-md-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} + +@media (min-width: 992px) { + .flex-lg-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-lg-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-lg-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-lg-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-lg-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-lg-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-lg-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .flex-lg-fill { + -ms-flex: 1 1 auto !important; + flex: 1 1 auto !important; + } + .flex-lg-grow-0 { + -ms-flex-positive: 0 !important; + flex-grow: 0 !important; + } + .flex-lg-grow-1 { + -ms-flex-positive: 1 !important; + flex-grow: 1 !important; + } + .flex-lg-shrink-0 { + -ms-flex-negative: 0 !important; + flex-shrink: 0 !important; + } + .flex-lg-shrink-1 { + -ms-flex-negative: 1 !important; + flex-shrink: 1 !important; + } + .justify-content-lg-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-lg-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-lg-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-lg-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-lg-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-lg-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-lg-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-lg-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-lg-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-lg-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-lg-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-lg-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-lg-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-lg-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-lg-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-lg-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-lg-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-lg-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-lg-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-lg-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-lg-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-lg-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} + +@media (min-width: 1200px) { + .flex-xl-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-xl-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-xl-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-xl-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-xl-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-xl-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-xl-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .flex-xl-fill { + -ms-flex: 1 1 auto !important; + flex: 1 1 auto !important; + } + .flex-xl-grow-0 { + -ms-flex-positive: 0 !important; + flex-grow: 0 !important; + } + .flex-xl-grow-1 { + -ms-flex-positive: 1 !important; + flex-grow: 1 !important; + } + .flex-xl-shrink-0 { + -ms-flex-negative: 0 !important; + flex-shrink: 0 !important; + } + .flex-xl-shrink-1 { + -ms-flex-negative: 1 !important; + flex-shrink: 1 !important; + } + .justify-content-xl-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-xl-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-xl-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-xl-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-xl-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-xl-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-xl-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-xl-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-xl-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-xl-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-xl-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-xl-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-xl-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-xl-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-xl-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-xl-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-xl-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-xl-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-xl-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-xl-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-xl-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-xl-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} + +.float-left { + float: left !important; +} + +.float-right { + float: right !important; +} + +.float-none { + float: none !important; +} + +@media (min-width: 576px) { + .float-sm-left { + float: left !important; + } + .float-sm-right { + float: right !important; + } + .float-sm-none { + float: none !important; + } +} + +@media (min-width: 768px) { + .float-md-left { + float: left !important; + } + .float-md-right { + float: right !important; + } + .float-md-none { + float: none !important; + } +} + +@media (min-width: 992px) { + .float-lg-left { + float: left !important; + } + .float-lg-right { + float: right !important; + } + .float-lg-none { + float: none !important; + } +} + +@media (min-width: 1200px) { + .float-xl-left { + float: left !important; + } + .float-xl-right { + float: right !important; + } + .float-xl-none { + float: none !important; + } +} + +.overflow-auto { + overflow: auto !important; +} + +.overflow-hidden { + overflow: hidden !important; +} + +.position-static { + position: static !important; +} + +.position-relative { + position: relative !important; +} + +.position-absolute { + position: absolute !important; +} + +.position-fixed { + position: fixed !important; +} + +.position-sticky { + position: -webkit-sticky !important; + position: sticky !important; +} + +.fixed-top { + top: 0; +} + +.fixed-top,.fixed-bottom { + position: fixed; + right: 0; + left: 0; + z-index: 1030; +} + +.fixed-bottom { + bottom: 0; +} + +@supports ((position: -webkit-sticky) or (position: sticky)) { + .sticky-top { + position: -webkit-sticky; + position: sticky; + top: 0; + z-index: 1020; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.sr-only-focusable:active, .sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + overflow: visible; + clip: auto; + white-space: normal; +} + +.shadow-sm { + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 21, 0.075) !important; +} + +.shadow { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 21, 0.15) !important; +} + +.shadow-lg { + box-shadow: 0 1rem 3rem rgba(0, 0, 21, 0.175) !important; +} + +.shadow-none { + box-shadow: none !important; +} + +.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; +} + +.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; +} + +.mw-100 { + max-width: 100% !important; +} + +.mh-100 { + max-height: 100% !important; +} + +.min-vw-100 { + min-width: 100vw !important; +} + +.min-vh-100 { + min-height: 100vh !important; +} + +.vw-100 { + width: 100vw !important; +} + +.vh-100 { + height: 100vh !important; +} + +.stretched-link::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + pointer-events: auto; + content: ""; + background-color: rgba(0, 0, 21, 0); +} + +.m-0 { + margin: 0 !important; +} + +.mt-0, +.my-0 { + margin-top: 0 !important; +} + +.mr-0, +.mx-0 { + margin-right: 0 !important; +} + +.mb-0, +.my-0 { + margin-bottom: 0 !important; +} + +.ml-0, +.mx-0,html:not([dir="rtl"]) .mfs-0 { + margin-left: 0 !important; +} + +*[dir="rtl"] .mfs-0,html:not([dir="rtl"]) .mfe-0 { + margin-right: 0 !important; +} + +*[dir="rtl"] .mfe-0 { + margin-left: 0 !important; +} + +.m-1 { + margin: 0.25rem !important; +} + +.mt-1, +.my-1 { + margin-top: 0.25rem !important; +} + +.mr-1, +.mx-1 { + margin-right: 0.25rem !important; +} + +.mb-1, +.my-1 { + margin-bottom: 0.25rem !important; +} + +.ml-1, +.mx-1,html:not([dir="rtl"]) .mfs-1 { + margin-left: 0.25rem !important; +} + +*[dir="rtl"] .mfs-1,html:not([dir="rtl"]) .mfe-1 { + margin-right: 0.25rem !important; +} + +*[dir="rtl"] .mfe-1 { + margin-left: 0.25rem !important; +} + +.m-2 { + margin: 0.5rem !important; +} + +.mt-2, +.my-2 { + margin-top: 0.5rem !important; +} + +.mr-2, +.mx-2 { + margin-right: 0.5rem !important; +} + +.mb-2, +.my-2 { + margin-bottom: 0.5rem !important; +} + +.ml-2, +.mx-2,html:not([dir="rtl"]) .mfs-2 { + margin-left: 0.5rem !important; +} + +*[dir="rtl"] .mfs-2,html:not([dir="rtl"]) .mfe-2 { + margin-right: 0.5rem !important; +} + +*[dir="rtl"] .mfe-2 { + margin-left: 0.5rem !important; +} + +.m-3 { + margin: 1rem !important; +} + +.mt-3, +.my-3 { + margin-top: 1rem !important; +} + +.mr-3, +.mx-3 { + margin-right: 1rem !important; +} + +.mb-3, +.my-3 { + margin-bottom: 1rem !important; +} + +.ml-3, +.mx-3,html:not([dir="rtl"]) .mfs-3 { + margin-left: 1rem !important; +} + +*[dir="rtl"] .mfs-3,html:not([dir="rtl"]) .mfe-3 { + margin-right: 1rem !important; +} + +*[dir="rtl"] .mfe-3 { + margin-left: 1rem !important; +} + +.m-4 { + margin: 1.5rem !important; +} + +.mt-4, +.my-4 { + margin-top: 1.5rem !important; +} + +.mr-4, +.mx-4 { + margin-right: 1.5rem !important; +} + +.mb-4, +.my-4 { + margin-bottom: 1.5rem !important; +} + +.ml-4, +.mx-4,html:not([dir="rtl"]) .mfs-4 { + margin-left: 1.5rem !important; +} + +*[dir="rtl"] .mfs-4,html:not([dir="rtl"]) .mfe-4 { + margin-right: 1.5rem !important; +} + +*[dir="rtl"] .mfe-4 { + margin-left: 1.5rem !important; +} + +.m-5 { + margin: 3rem !important; +} + +.mt-5, +.my-5 { + margin-top: 3rem !important; +} + +.mr-5, +.mx-5 { + margin-right: 3rem !important; +} + +.mb-5, +.my-5 { + margin-bottom: 3rem !important; +} + +.ml-5, +.mx-5,html:not([dir="rtl"]) .mfs-5 { + margin-left: 3rem !important; +} + +*[dir="rtl"] .mfs-5,html:not([dir="rtl"]) .mfe-5 { + margin-right: 3rem !important; +} + +*[dir="rtl"] .mfe-5 { + margin-left: 3rem !important; +} + +.p-0 { + padding: 0 !important; +} + +.pt-0, +.py-0 { + padding-top: 0 !important; +} + +.pr-0, +.px-0 { + padding-right: 0 !important; +} + +.pb-0, +.py-0 { + padding-bottom: 0 !important; +} + +.pl-0, +.px-0,html:not([dir="rtl"]) .pfs-0 { + padding-left: 0 !important; +} + +*[dir="rtl"] .pfs-0,html:not([dir="rtl"]) .pfe-0 { + padding-right: 0 !important; +} + +*[dir="rtl"] .pfe-0 { + padding-left: 0 !important; +} + +.p-1 { + padding: 0.25rem !important; +} + +.pt-1, +.py-1 { + padding-top: 0.25rem !important; +} + +.pr-1, +.px-1 { + padding-right: 0.25rem !important; +} + +.pb-1, +.py-1 { + padding-bottom: 0.25rem !important; +} + +.pl-1, +.px-1,html:not([dir="rtl"]) .pfs-1 { + padding-left: 0.25rem !important; +} + +*[dir="rtl"] .pfs-1,html:not([dir="rtl"]) .pfe-1 { + padding-right: 0.25rem !important; +} + +*[dir="rtl"] .pfe-1 { + padding-left: 0.25rem !important; +} + +.p-2 { + padding: 0.5rem !important; +} + +.pt-2, +.py-2 { + padding-top: 0.5rem !important; +} + +.pr-2, +.px-2 { + padding-right: 0.5rem !important; +} + +.pb-2, +.py-2 { + padding-bottom: 0.5rem !important; +} + +.pl-2, +.px-2,html:not([dir="rtl"]) .pfs-2 { + padding-left: 0.5rem !important; +} + +*[dir="rtl"] .pfs-2,html:not([dir="rtl"]) .pfe-2 { + padding-right: 0.5rem !important; +} + +*[dir="rtl"] .pfe-2 { + padding-left: 0.5rem !important; +} + +.p-3 { + padding: 1rem !important; +} + +.pt-3, +.py-3 { + padding-top: 1rem !important; +} + +.pr-3, +.px-3 { + padding-right: 1rem !important; +} + +.pb-3, +.py-3 { + padding-bottom: 1rem !important; +} + +.pl-3, +.px-3,html:not([dir="rtl"]) .pfs-3 { + padding-left: 1rem !important; +} + +*[dir="rtl"] .pfs-3,html:not([dir="rtl"]) .pfe-3 { + padding-right: 1rem !important; +} + +*[dir="rtl"] .pfe-3 { + padding-left: 1rem !important; +} + +.p-4 { + padding: 1.5rem !important; +} + +.pt-4, +.py-4 { + padding-top: 1.5rem !important; +} + +.pr-4, +.px-4 { + padding-right: 1.5rem !important; +} + +.pb-4, +.py-4 { + padding-bottom: 1.5rem !important; +} + +.pl-4, +.px-4,html:not([dir="rtl"]) .pfs-4 { + padding-left: 1.5rem !important; +} + +*[dir="rtl"] .pfs-4,html:not([dir="rtl"]) .pfe-4 { + padding-right: 1.5rem !important; +} + +*[dir="rtl"] .pfe-4 { + padding-left: 1.5rem !important; +} + +.p-5 { + padding: 3rem !important; +} + +.pt-5, +.py-5 { + padding-top: 3rem !important; +} + +.pr-5, +.px-5 { + padding-right: 3rem !important; +} + +.pb-5, +.py-5 { + padding-bottom: 3rem !important; +} + +.pl-5, +.px-5,html:not([dir="rtl"]) .pfs-5 { + padding-left: 3rem !important; +} + +*[dir="rtl"] .pfs-5,html:not([dir="rtl"]) .pfe-5 { + padding-right: 3rem !important; +} + +*[dir="rtl"] .pfe-5 { + padding-left: 3rem !important; +} + +.m-n1 { + margin: -0.25rem !important; +} + +.mt-n1, +.my-n1 { + margin-top: -0.25rem !important; +} + +.mr-n1, +.mx-n1 { + margin-right: -0.25rem !important; +} + +.mb-n1, +.my-n1 { + margin-bottom: -0.25rem !important; +} + +.ml-n1, +.mx-n1,html:not([dir="rtl"]) .mfs-n1 { + margin-left: -0.25rem !important; +} + +*[dir="rtl"] .mfs-n1,html:not([dir="rtl"]) .mfe-n1 { + margin-right: -0.25rem !important; +} + +*[dir="rtl"] .mfe-n1 { + margin-left: -0.25rem !important; +} + +.m-n2 { + margin: -0.5rem !important; +} + +.mt-n2, +.my-n2 { + margin-top: -0.5rem !important; +} + +.mr-n2, +.mx-n2 { + margin-right: -0.5rem !important; +} + +.mb-n2, +.my-n2 { + margin-bottom: -0.5rem !important; +} + +.ml-n2, +.mx-n2,html:not([dir="rtl"]) .mfs-n2 { + margin-left: -0.5rem !important; +} + +*[dir="rtl"] .mfs-n2,html:not([dir="rtl"]) .mfe-n2 { + margin-right: -0.5rem !important; +} + +*[dir="rtl"] .mfe-n2 { + margin-left: -0.5rem !important; +} + +.m-n3 { + margin: -1rem !important; +} + +.mt-n3, +.my-n3 { + margin-top: -1rem !important; +} + +.mr-n3, +.mx-n3 { + margin-right: -1rem !important; +} + +.mb-n3, +.my-n3 { + margin-bottom: -1rem !important; +} + +.ml-n3, +.mx-n3,html:not([dir="rtl"]) .mfs-n3 { + margin-left: -1rem !important; +} + +*[dir="rtl"] .mfs-n3,html:not([dir="rtl"]) .mfe-n3 { + margin-right: -1rem !important; +} + +*[dir="rtl"] .mfe-n3 { + margin-left: -1rem !important; +} + +.m-n4 { + margin: -1.5rem !important; +} + +.mt-n4, +.my-n4 { + margin-top: -1.5rem !important; +} + +.mr-n4, +.mx-n4 { + margin-right: -1.5rem !important; +} + +.mb-n4, +.my-n4 { + margin-bottom: -1.5rem !important; +} + +.ml-n4, +.mx-n4,html:not([dir="rtl"]) .mfs-n4 { + margin-left: -1.5rem !important; +} + +*[dir="rtl"] .mfs-n4,html:not([dir="rtl"]) .mfe-n4 { + margin-right: -1.5rem !important; +} + +*[dir="rtl"] .mfe-n4 { + margin-left: -1.5rem !important; +} + +.m-n5 { + margin: -3rem !important; +} + +.mt-n5, +.my-n5 { + margin-top: -3rem !important; +} + +.mr-n5, +.mx-n5 { + margin-right: -3rem !important; +} + +.mb-n5, +.my-n5 { + margin-bottom: -3rem !important; +} + +.ml-n5, +.mx-n5,html:not([dir="rtl"]) .mfs-n5 { + margin-left: -3rem !important; +} + +*[dir="rtl"] .mfs-n5,html:not([dir="rtl"]) .mfe-n5 { + margin-right: -3rem !important; +} + +*[dir="rtl"] .mfe-n5 { + margin-left: -3rem !important; +} + +.m-auto { + margin: auto !important; +} + +.mt-auto, +.my-auto { + margin-top: auto !important; +} + +.mr-auto, +.mx-auto { + margin-right: auto !important; +} + +.mb-auto, +.my-auto { + margin-bottom: auto !important; +} + +.ml-auto, +.mx-auto,html:not([dir="rtl"]) .mfs-auto { + margin-left: auto !important; +} + +*[dir="rtl"] .mfs-auto,html:not([dir="rtl"]) .mfe-auto { + margin-right: auto !important; +} + +*[dir="rtl"] .mfe-auto { + margin-left: auto !important; +} + +@media (min-width: 576px) { + .m-sm-0 { + margin: 0 !important; + } + .mt-sm-0, + .my-sm-0 { + margin-top: 0 !important; + } + .mr-sm-0, + .mx-sm-0 { + margin-right: 0 !important; + } + .mb-sm-0, + .my-sm-0 { + margin-bottom: 0 !important; + } + .ml-sm-0, + .mx-sm-0,html:not([dir="rtl"]) .mfs-sm-0 { + margin-left: 0 !important; + } + *[dir="rtl"] .mfs-sm-0,html:not([dir="rtl"]) .mfe-sm-0 { + margin-right: 0 !important; + } + *[dir="rtl"] .mfe-sm-0 { + margin-left: 0 !important; + } + .m-sm-1 { + margin: 0.25rem !important; + } + .mt-sm-1, + .my-sm-1 { + margin-top: 0.25rem !important; + } + .mr-sm-1, + .mx-sm-1 { + margin-right: 0.25rem !important; + } + .mb-sm-1, + .my-sm-1 { + margin-bottom: 0.25rem !important; + } + .ml-sm-1, + .mx-sm-1,html:not([dir="rtl"]) .mfs-sm-1 { + margin-left: 0.25rem !important; + } + *[dir="rtl"] .mfs-sm-1,html:not([dir="rtl"]) .mfe-sm-1 { + margin-right: 0.25rem !important; + } + *[dir="rtl"] .mfe-sm-1 { + margin-left: 0.25rem !important; + } + .m-sm-2 { + margin: 0.5rem !important; + } + .mt-sm-2, + .my-sm-2 { + margin-top: 0.5rem !important; + } + .mr-sm-2, + .mx-sm-2 { + margin-right: 0.5rem !important; + } + .mb-sm-2, + .my-sm-2 { + margin-bottom: 0.5rem !important; + } + .ml-sm-2, + .mx-sm-2,html:not([dir="rtl"]) .mfs-sm-2 { + margin-left: 0.5rem !important; + } + *[dir="rtl"] .mfs-sm-2,html:not([dir="rtl"]) .mfe-sm-2 { + margin-right: 0.5rem !important; + } + *[dir="rtl"] .mfe-sm-2 { + margin-left: 0.5rem !important; + } + .m-sm-3 { + margin: 1rem !important; + } + .mt-sm-3, + .my-sm-3 { + margin-top: 1rem !important; + } + .mr-sm-3, + .mx-sm-3 { + margin-right: 1rem !important; + } + .mb-sm-3, + .my-sm-3 { + margin-bottom: 1rem !important; + } + .ml-sm-3, + .mx-sm-3,html:not([dir="rtl"]) .mfs-sm-3 { + margin-left: 1rem !important; + } + *[dir="rtl"] .mfs-sm-3,html:not([dir="rtl"]) .mfe-sm-3 { + margin-right: 1rem !important; + } + *[dir="rtl"] .mfe-sm-3 { + margin-left: 1rem !important; + } + .m-sm-4 { + margin: 1.5rem !important; + } + .mt-sm-4, + .my-sm-4 { + margin-top: 1.5rem !important; + } + .mr-sm-4, + .mx-sm-4 { + margin-right: 1.5rem !important; + } + .mb-sm-4, + .my-sm-4 { + margin-bottom: 1.5rem !important; + } + .ml-sm-4, + .mx-sm-4,html:not([dir="rtl"]) .mfs-sm-4 { + margin-left: 1.5rem !important; + } + *[dir="rtl"] .mfs-sm-4,html:not([dir="rtl"]) .mfe-sm-4 { + margin-right: 1.5rem !important; + } + *[dir="rtl"] .mfe-sm-4 { + margin-left: 1.5rem !important; + } + .m-sm-5 { + margin: 3rem !important; + } + .mt-sm-5, + .my-sm-5 { + margin-top: 3rem !important; + } + .mr-sm-5, + .mx-sm-5 { + margin-right: 3rem !important; + } + .mb-sm-5, + .my-sm-5 { + margin-bottom: 3rem !important; + } + .ml-sm-5, + .mx-sm-5,html:not([dir="rtl"]) .mfs-sm-5 { + margin-left: 3rem !important; + } + *[dir="rtl"] .mfs-sm-5,html:not([dir="rtl"]) .mfe-sm-5 { + margin-right: 3rem !important; + } + *[dir="rtl"] .mfe-sm-5 { + margin-left: 3rem !important; + } + .p-sm-0 { + padding: 0 !important; + } + .pt-sm-0, + .py-sm-0 { + padding-top: 0 !important; + } + .pr-sm-0, + .px-sm-0 { + padding-right: 0 !important; + } + .pb-sm-0, + .py-sm-0 { + padding-bottom: 0 !important; + } + .pl-sm-0, + .px-sm-0,html:not([dir="rtl"]) .pfs-sm-0 { + padding-left: 0 !important; + } + *[dir="rtl"] .pfs-sm-0,html:not([dir="rtl"]) .pfe-sm-0 { + padding-right: 0 !important; + } + *[dir="rtl"] .pfe-sm-0 { + padding-left: 0 !important; + } + .p-sm-1 { + padding: 0.25rem !important; + } + .pt-sm-1, + .py-sm-1 { + padding-top: 0.25rem !important; + } + .pr-sm-1, + .px-sm-1 { + padding-right: 0.25rem !important; + } + .pb-sm-1, + .py-sm-1 { + padding-bottom: 0.25rem !important; + } + .pl-sm-1, + .px-sm-1,html:not([dir="rtl"]) .pfs-sm-1 { + padding-left: 0.25rem !important; + } + *[dir="rtl"] .pfs-sm-1,html:not([dir="rtl"]) .pfe-sm-1 { + padding-right: 0.25rem !important; + } + *[dir="rtl"] .pfe-sm-1 { + padding-left: 0.25rem !important; + } + .p-sm-2 { + padding: 0.5rem !important; + } + .pt-sm-2, + .py-sm-2 { + padding-top: 0.5rem !important; + } + .pr-sm-2, + .px-sm-2 { + padding-right: 0.5rem !important; + } + .pb-sm-2, + .py-sm-2 { + padding-bottom: 0.5rem !important; + } + .pl-sm-2, + .px-sm-2,html:not([dir="rtl"]) .pfs-sm-2 { + padding-left: 0.5rem !important; + } + *[dir="rtl"] .pfs-sm-2,html:not([dir="rtl"]) .pfe-sm-2 { + padding-right: 0.5rem !important; + } + *[dir="rtl"] .pfe-sm-2 { + padding-left: 0.5rem !important; + } + .p-sm-3 { + padding: 1rem !important; + } + .pt-sm-3, + .py-sm-3 { + padding-top: 1rem !important; + } + .pr-sm-3, + .px-sm-3 { + padding-right: 1rem !important; + } + .pb-sm-3, + .py-sm-3 { + padding-bottom: 1rem !important; + } + .pl-sm-3, + .px-sm-3,html:not([dir="rtl"]) .pfs-sm-3 { + padding-left: 1rem !important; + } + *[dir="rtl"] .pfs-sm-3,html:not([dir="rtl"]) .pfe-sm-3 { + padding-right: 1rem !important; + } + *[dir="rtl"] .pfe-sm-3 { + padding-left: 1rem !important; + } + .p-sm-4 { + padding: 1.5rem !important; + } + .pt-sm-4, + .py-sm-4 { + padding-top: 1.5rem !important; + } + .pr-sm-4, + .px-sm-4 { + padding-right: 1.5rem !important; + } + .pb-sm-4, + .py-sm-4 { + padding-bottom: 1.5rem !important; + } + .pl-sm-4, + .px-sm-4,html:not([dir="rtl"]) .pfs-sm-4 { + padding-left: 1.5rem !important; + } + *[dir="rtl"] .pfs-sm-4,html:not([dir="rtl"]) .pfe-sm-4 { + padding-right: 1.5rem !important; + } + *[dir="rtl"] .pfe-sm-4 { + padding-left: 1.5rem !important; + } + .p-sm-5 { + padding: 3rem !important; + } + .pt-sm-5, + .py-sm-5 { + padding-top: 3rem !important; + } + .pr-sm-5, + .px-sm-5 { + padding-right: 3rem !important; + } + .pb-sm-5, + .py-sm-5 { + padding-bottom: 3rem !important; + } + .pl-sm-5, + .px-sm-5,html:not([dir="rtl"]) .pfs-sm-5 { + padding-left: 3rem !important; + } + *[dir="rtl"] .pfs-sm-5,html:not([dir="rtl"]) .pfe-sm-5 { + padding-right: 3rem !important; + } + *[dir="rtl"] .pfe-sm-5 { + padding-left: 3rem !important; + } + .m-sm-n1 { + margin: -0.25rem !important; + } + .mt-sm-n1, + .my-sm-n1 { + margin-top: -0.25rem !important; + } + .mr-sm-n1, + .mx-sm-n1 { + margin-right: -0.25rem !important; + } + .mb-sm-n1, + .my-sm-n1 { + margin-bottom: -0.25rem !important; + } + .ml-sm-n1, + .mx-sm-n1,html:not([dir="rtl"]) .mfs-sm-n1 { + margin-left: -0.25rem !important; + } + *[dir="rtl"] .mfs-sm-n1,html:not([dir="rtl"]) .mfe-sm-n1 { + margin-right: -0.25rem !important; + } + *[dir="rtl"] .mfe-sm-n1 { + margin-left: -0.25rem !important; + } + .m-sm-n2 { + margin: -0.5rem !important; + } + .mt-sm-n2, + .my-sm-n2 { + margin-top: -0.5rem !important; + } + .mr-sm-n2, + .mx-sm-n2 { + margin-right: -0.5rem !important; + } + .mb-sm-n2, + .my-sm-n2 { + margin-bottom: -0.5rem !important; + } + .ml-sm-n2, + .mx-sm-n2,html:not([dir="rtl"]) .mfs-sm-n2 { + margin-left: -0.5rem !important; + } + *[dir="rtl"] .mfs-sm-n2,html:not([dir="rtl"]) .mfe-sm-n2 { + margin-right: -0.5rem !important; + } + *[dir="rtl"] .mfe-sm-n2 { + margin-left: -0.5rem !important; + } + .m-sm-n3 { + margin: -1rem !important; + } + .mt-sm-n3, + .my-sm-n3 { + margin-top: -1rem !important; + } + .mr-sm-n3, + .mx-sm-n3 { + margin-right: -1rem !important; + } + .mb-sm-n3, + .my-sm-n3 { + margin-bottom: -1rem !important; + } + .ml-sm-n3, + .mx-sm-n3,html:not([dir="rtl"]) .mfs-sm-n3 { + margin-left: -1rem !important; + } + *[dir="rtl"] .mfs-sm-n3,html:not([dir="rtl"]) .mfe-sm-n3 { + margin-right: -1rem !important; + } + *[dir="rtl"] .mfe-sm-n3 { + margin-left: -1rem !important; + } + .m-sm-n4 { + margin: -1.5rem !important; + } + .mt-sm-n4, + .my-sm-n4 { + margin-top: -1.5rem !important; + } + .mr-sm-n4, + .mx-sm-n4 { + margin-right: -1.5rem !important; + } + .mb-sm-n4, + .my-sm-n4 { + margin-bottom: -1.5rem !important; + } + .ml-sm-n4, + .mx-sm-n4,html:not([dir="rtl"]) .mfs-sm-n4 { + margin-left: -1.5rem !important; + } + *[dir="rtl"] .mfs-sm-n4,html:not([dir="rtl"]) .mfe-sm-n4 { + margin-right: -1.5rem !important; + } + *[dir="rtl"] .mfe-sm-n4 { + margin-left: -1.5rem !important; + } + .m-sm-n5 { + margin: -3rem !important; + } + .mt-sm-n5, + .my-sm-n5 { + margin-top: -3rem !important; + } + .mr-sm-n5, + .mx-sm-n5 { + margin-right: -3rem !important; + } + .mb-sm-n5, + .my-sm-n5 { + margin-bottom: -3rem !important; + } + .ml-sm-n5, + .mx-sm-n5,html:not([dir="rtl"]) .mfs-sm-n5 { + margin-left: -3rem !important; + } + *[dir="rtl"] .mfs-sm-n5,html:not([dir="rtl"]) .mfe-sm-n5 { + margin-right: -3rem !important; + } + *[dir="rtl"] .mfe-sm-n5 { + margin-left: -3rem !important; + } + .m-sm-auto { + margin: auto !important; + } + .mt-sm-auto, + .my-sm-auto { + margin-top: auto !important; + } + .mr-sm-auto, + .mx-sm-auto { + margin-right: auto !important; + } + .mb-sm-auto, + .my-sm-auto { + margin-bottom: auto !important; + } + .ml-sm-auto, + .mx-sm-auto,html:not([dir="rtl"]) .mfs-sm-auto { + margin-left: auto !important; + } + *[dir="rtl"] .mfs-sm-auto,html:not([dir="rtl"]) .mfe-sm-auto { + margin-right: auto !important; + } + *[dir="rtl"] .mfe-sm-auto { + margin-left: auto !important; + } +} + +@media (min-width: 768px) { + .m-md-0 { + margin: 0 !important; + } + .mt-md-0, + .my-md-0 { + margin-top: 0 !important; + } + .mr-md-0, + .mx-md-0 { + margin-right: 0 !important; + } + .mb-md-0, + .my-md-0 { + margin-bottom: 0 !important; + } + .ml-md-0, + .mx-md-0,html:not([dir="rtl"]) .mfs-md-0 { + margin-left: 0 !important; + } + *[dir="rtl"] .mfs-md-0,html:not([dir="rtl"]) .mfe-md-0 { + margin-right: 0 !important; + } + *[dir="rtl"] .mfe-md-0 { + margin-left: 0 !important; + } + .m-md-1 { + margin: 0.25rem !important; + } + .mt-md-1, + .my-md-1 { + margin-top: 0.25rem !important; + } + .mr-md-1, + .mx-md-1 { + margin-right: 0.25rem !important; + } + .mb-md-1, + .my-md-1 { + margin-bottom: 0.25rem !important; + } + .ml-md-1, + .mx-md-1,html:not([dir="rtl"]) .mfs-md-1 { + margin-left: 0.25rem !important; + } + *[dir="rtl"] .mfs-md-1,html:not([dir="rtl"]) .mfe-md-1 { + margin-right: 0.25rem !important; + } + *[dir="rtl"] .mfe-md-1 { + margin-left: 0.25rem !important; + } + .m-md-2 { + margin: 0.5rem !important; + } + .mt-md-2, + .my-md-2 { + margin-top: 0.5rem !important; + } + .mr-md-2, + .mx-md-2 { + margin-right: 0.5rem !important; + } + .mb-md-2, + .my-md-2 { + margin-bottom: 0.5rem !important; + } + .ml-md-2, + .mx-md-2,html:not([dir="rtl"]) .mfs-md-2 { + margin-left: 0.5rem !important; + } + *[dir="rtl"] .mfs-md-2,html:not([dir="rtl"]) .mfe-md-2 { + margin-right: 0.5rem !important; + } + *[dir="rtl"] .mfe-md-2 { + margin-left: 0.5rem !important; + } + .m-md-3 { + margin: 1rem !important; + } + .mt-md-3, + .my-md-3 { + margin-top: 1rem !important; + } + .mr-md-3, + .mx-md-3 { + margin-right: 1rem !important; + } + .mb-md-3, + .my-md-3 { + margin-bottom: 1rem !important; + } + .ml-md-3, + .mx-md-3,html:not([dir="rtl"]) .mfs-md-3 { + margin-left: 1rem !important; + } + *[dir="rtl"] .mfs-md-3,html:not([dir="rtl"]) .mfe-md-3 { + margin-right: 1rem !important; + } + *[dir="rtl"] .mfe-md-3 { + margin-left: 1rem !important; + } + .m-md-4 { + margin: 1.5rem !important; + } + .mt-md-4, + .my-md-4 { + margin-top: 1.5rem !important; + } + .mr-md-4, + .mx-md-4 { + margin-right: 1.5rem !important; + } + .mb-md-4, + .my-md-4 { + margin-bottom: 1.5rem !important; + } + .ml-md-4, + .mx-md-4,html:not([dir="rtl"]) .mfs-md-4 { + margin-left: 1.5rem !important; + } + *[dir="rtl"] .mfs-md-4,html:not([dir="rtl"]) .mfe-md-4 { + margin-right: 1.5rem !important; + } + *[dir="rtl"] .mfe-md-4 { + margin-left: 1.5rem !important; + } + .m-md-5 { + margin: 3rem !important; + } + .mt-md-5, + .my-md-5 { + margin-top: 3rem !important; + } + .mr-md-5, + .mx-md-5 { + margin-right: 3rem !important; + } + .mb-md-5, + .my-md-5 { + margin-bottom: 3rem !important; + } + .ml-md-5, + .mx-md-5,html:not([dir="rtl"]) .mfs-md-5 { + margin-left: 3rem !important; + } + *[dir="rtl"] .mfs-md-5,html:not([dir="rtl"]) .mfe-md-5 { + margin-right: 3rem !important; + } + *[dir="rtl"] .mfe-md-5 { + margin-left: 3rem !important; + } + .p-md-0 { + padding: 0 !important; + } + .pt-md-0, + .py-md-0 { + padding-top: 0 !important; + } + .pr-md-0, + .px-md-0 { + padding-right: 0 !important; + } + .pb-md-0, + .py-md-0 { + padding-bottom: 0 !important; + } + .pl-md-0, + .px-md-0,html:not([dir="rtl"]) .pfs-md-0 { + padding-left: 0 !important; + } + *[dir="rtl"] .pfs-md-0,html:not([dir="rtl"]) .pfe-md-0 { + padding-right: 0 !important; + } + *[dir="rtl"] .pfe-md-0 { + padding-left: 0 !important; + } + .p-md-1 { + padding: 0.25rem !important; + } + .pt-md-1, + .py-md-1 { + padding-top: 0.25rem !important; + } + .pr-md-1, + .px-md-1 { + padding-right: 0.25rem !important; + } + .pb-md-1, + .py-md-1 { + padding-bottom: 0.25rem !important; + } + .pl-md-1, + .px-md-1,html:not([dir="rtl"]) .pfs-md-1 { + padding-left: 0.25rem !important; + } + *[dir="rtl"] .pfs-md-1,html:not([dir="rtl"]) .pfe-md-1 { + padding-right: 0.25rem !important; + } + *[dir="rtl"] .pfe-md-1 { + padding-left: 0.25rem !important; + } + .p-md-2 { + padding: 0.5rem !important; + } + .pt-md-2, + .py-md-2 { + padding-top: 0.5rem !important; + } + .pr-md-2, + .px-md-2 { + padding-right: 0.5rem !important; + } + .pb-md-2, + .py-md-2 { + padding-bottom: 0.5rem !important; + } + .pl-md-2, + .px-md-2,html:not([dir="rtl"]) .pfs-md-2 { + padding-left: 0.5rem !important; + } + *[dir="rtl"] .pfs-md-2,html:not([dir="rtl"]) .pfe-md-2 { + padding-right: 0.5rem !important; + } + *[dir="rtl"] .pfe-md-2 { + padding-left: 0.5rem !important; + } + .p-md-3 { + padding: 1rem !important; + } + .pt-md-3, + .py-md-3 { + padding-top: 1rem !important; + } + .pr-md-3, + .px-md-3 { + padding-right: 1rem !important; + } + .pb-md-3, + .py-md-3 { + padding-bottom: 1rem !important; + } + .pl-md-3, + .px-md-3,html:not([dir="rtl"]) .pfs-md-3 { + padding-left: 1rem !important; + } + *[dir="rtl"] .pfs-md-3,html:not([dir="rtl"]) .pfe-md-3 { + padding-right: 1rem !important; + } + *[dir="rtl"] .pfe-md-3 { + padding-left: 1rem !important; + } + .p-md-4 { + padding: 1.5rem !important; + } + .pt-md-4, + .py-md-4 { + padding-top: 1.5rem !important; + } + .pr-md-4, + .px-md-4 { + padding-right: 1.5rem !important; + } + .pb-md-4, + .py-md-4 { + padding-bottom: 1.5rem !important; + } + .pl-md-4, + .px-md-4,html:not([dir="rtl"]) .pfs-md-4 { + padding-left: 1.5rem !important; + } + *[dir="rtl"] .pfs-md-4,html:not([dir="rtl"]) .pfe-md-4 { + padding-right: 1.5rem !important; + } + *[dir="rtl"] .pfe-md-4 { + padding-left: 1.5rem !important; + } + .p-md-5 { + padding: 3rem !important; + } + .pt-md-5, + .py-md-5 { + padding-top: 3rem !important; + } + .pr-md-5, + .px-md-5 { + padding-right: 3rem !important; + } + .pb-md-5, + .py-md-5 { + padding-bottom: 3rem !important; + } + .pl-md-5, + .px-md-5,html:not([dir="rtl"]) .pfs-md-5 { + padding-left: 3rem !important; + } + *[dir="rtl"] .pfs-md-5,html:not([dir="rtl"]) .pfe-md-5 { + padding-right: 3rem !important; + } + *[dir="rtl"] .pfe-md-5 { + padding-left: 3rem !important; + } + .m-md-n1 { + margin: -0.25rem !important; + } + .mt-md-n1, + .my-md-n1 { + margin-top: -0.25rem !important; + } + .mr-md-n1, + .mx-md-n1 { + margin-right: -0.25rem !important; + } + .mb-md-n1, + .my-md-n1 { + margin-bottom: -0.25rem !important; + } + .ml-md-n1, + .mx-md-n1,html:not([dir="rtl"]) .mfs-md-n1 { + margin-left: -0.25rem !important; + } + *[dir="rtl"] .mfs-md-n1,html:not([dir="rtl"]) .mfe-md-n1 { + margin-right: -0.25rem !important; + } + *[dir="rtl"] .mfe-md-n1 { + margin-left: -0.25rem !important; + } + .m-md-n2 { + margin: -0.5rem !important; + } + .mt-md-n2, + .my-md-n2 { + margin-top: -0.5rem !important; + } + .mr-md-n2, + .mx-md-n2 { + margin-right: -0.5rem !important; + } + .mb-md-n2, + .my-md-n2 { + margin-bottom: -0.5rem !important; + } + .ml-md-n2, + .mx-md-n2,html:not([dir="rtl"]) .mfs-md-n2 { + margin-left: -0.5rem !important; + } + *[dir="rtl"] .mfs-md-n2,html:not([dir="rtl"]) .mfe-md-n2 { + margin-right: -0.5rem !important; + } + *[dir="rtl"] .mfe-md-n2 { + margin-left: -0.5rem !important; + } + .m-md-n3 { + margin: -1rem !important; + } + .mt-md-n3, + .my-md-n3 { + margin-top: -1rem !important; + } + .mr-md-n3, + .mx-md-n3 { + margin-right: -1rem !important; + } + .mb-md-n3, + .my-md-n3 { + margin-bottom: -1rem !important; + } + .ml-md-n3, + .mx-md-n3,html:not([dir="rtl"]) .mfs-md-n3 { + margin-left: -1rem !important; + } + *[dir="rtl"] .mfs-md-n3,html:not([dir="rtl"]) .mfe-md-n3 { + margin-right: -1rem !important; + } + *[dir="rtl"] .mfe-md-n3 { + margin-left: -1rem !important; + } + .m-md-n4 { + margin: -1.5rem !important; + } + .mt-md-n4, + .my-md-n4 { + margin-top: -1.5rem !important; + } + .mr-md-n4, + .mx-md-n4 { + margin-right: -1.5rem !important; + } + .mb-md-n4, + .my-md-n4 { + margin-bottom: -1.5rem !important; + } + .ml-md-n4, + .mx-md-n4,html:not([dir="rtl"]) .mfs-md-n4 { + margin-left: -1.5rem !important; + } + *[dir="rtl"] .mfs-md-n4,html:not([dir="rtl"]) .mfe-md-n4 { + margin-right: -1.5rem !important; + } + *[dir="rtl"] .mfe-md-n4 { + margin-left: -1.5rem !important; + } + .m-md-n5 { + margin: -3rem !important; + } + .mt-md-n5, + .my-md-n5 { + margin-top: -3rem !important; + } + .mr-md-n5, + .mx-md-n5 { + margin-right: -3rem !important; + } + .mb-md-n5, + .my-md-n5 { + margin-bottom: -3rem !important; + } + .ml-md-n5, + .mx-md-n5,html:not([dir="rtl"]) .mfs-md-n5 { + margin-left: -3rem !important; + } + *[dir="rtl"] .mfs-md-n5,html:not([dir="rtl"]) .mfe-md-n5 { + margin-right: -3rem !important; + } + *[dir="rtl"] .mfe-md-n5 { + margin-left: -3rem !important; + } + .m-md-auto { + margin: auto !important; + } + .mt-md-auto, + .my-md-auto { + margin-top: auto !important; + } + .mr-md-auto, + .mx-md-auto { + margin-right: auto !important; + } + .mb-md-auto, + .my-md-auto { + margin-bottom: auto !important; + } + .ml-md-auto, + .mx-md-auto,html:not([dir="rtl"]) .mfs-md-auto { + margin-left: auto !important; + } + *[dir="rtl"] .mfs-md-auto,html:not([dir="rtl"]) .mfe-md-auto { + margin-right: auto !important; + } + *[dir="rtl"] .mfe-md-auto { + margin-left: auto !important; + } +} + +@media (min-width: 992px) { + .m-lg-0 { + margin: 0 !important; + } + .mt-lg-0, + .my-lg-0 { + margin-top: 0 !important; + } + .mr-lg-0, + .mx-lg-0 { + margin-right: 0 !important; + } + .mb-lg-0, + .my-lg-0 { + margin-bottom: 0 !important; + } + .ml-lg-0, + .mx-lg-0,html:not([dir="rtl"]) .mfs-lg-0 { + margin-left: 0 !important; + } + *[dir="rtl"] .mfs-lg-0,html:not([dir="rtl"]) .mfe-lg-0 { + margin-right: 0 !important; + } + *[dir="rtl"] .mfe-lg-0 { + margin-left: 0 !important; + } + .m-lg-1 { + margin: 0.25rem !important; + } + .mt-lg-1, + .my-lg-1 { + margin-top: 0.25rem !important; + } + .mr-lg-1, + .mx-lg-1 { + margin-right: 0.25rem !important; + } + .mb-lg-1, + .my-lg-1 { + margin-bottom: 0.25rem !important; + } + .ml-lg-1, + .mx-lg-1,html:not([dir="rtl"]) .mfs-lg-1 { + margin-left: 0.25rem !important; + } + *[dir="rtl"] .mfs-lg-1,html:not([dir="rtl"]) .mfe-lg-1 { + margin-right: 0.25rem !important; + } + *[dir="rtl"] .mfe-lg-1 { + margin-left: 0.25rem !important; + } + .m-lg-2 { + margin: 0.5rem !important; + } + .mt-lg-2, + .my-lg-2 { + margin-top: 0.5rem !important; + } + .mr-lg-2, + .mx-lg-2 { + margin-right: 0.5rem !important; + } + .mb-lg-2, + .my-lg-2 { + margin-bottom: 0.5rem !important; + } + .ml-lg-2, + .mx-lg-2,html:not([dir="rtl"]) .mfs-lg-2 { + margin-left: 0.5rem !important; + } + *[dir="rtl"] .mfs-lg-2,html:not([dir="rtl"]) .mfe-lg-2 { + margin-right: 0.5rem !important; + } + *[dir="rtl"] .mfe-lg-2 { + margin-left: 0.5rem !important; + } + .m-lg-3 { + margin: 1rem !important; + } + .mt-lg-3, + .my-lg-3 { + margin-top: 1rem !important; + } + .mr-lg-3, + .mx-lg-3 { + margin-right: 1rem !important; + } + .mb-lg-3, + .my-lg-3 { + margin-bottom: 1rem !important; + } + .ml-lg-3, + .mx-lg-3,html:not([dir="rtl"]) .mfs-lg-3 { + margin-left: 1rem !important; + } + *[dir="rtl"] .mfs-lg-3,html:not([dir="rtl"]) .mfe-lg-3 { + margin-right: 1rem !important; + } + *[dir="rtl"] .mfe-lg-3 { + margin-left: 1rem !important; + } + .m-lg-4 { + margin: 1.5rem !important; + } + .mt-lg-4, + .my-lg-4 { + margin-top: 1.5rem !important; + } + .mr-lg-4, + .mx-lg-4 { + margin-right: 1.5rem !important; + } + .mb-lg-4, + .my-lg-4 { + margin-bottom: 1.5rem !important; + } + .ml-lg-4, + .mx-lg-4,html:not([dir="rtl"]) .mfs-lg-4 { + margin-left: 1.5rem !important; + } + *[dir="rtl"] .mfs-lg-4,html:not([dir="rtl"]) .mfe-lg-4 { + margin-right: 1.5rem !important; + } + *[dir="rtl"] .mfe-lg-4 { + margin-left: 1.5rem !important; + } + .m-lg-5 { + margin: 3rem !important; + } + .mt-lg-5, + .my-lg-5 { + margin-top: 3rem !important; + } + .mr-lg-5, + .mx-lg-5 { + margin-right: 3rem !important; + } + .mb-lg-5, + .my-lg-5 { + margin-bottom: 3rem !important; + } + .ml-lg-5, + .mx-lg-5,html:not([dir="rtl"]) .mfs-lg-5 { + margin-left: 3rem !important; + } + *[dir="rtl"] .mfs-lg-5,html:not([dir="rtl"]) .mfe-lg-5 { + margin-right: 3rem !important; + } + *[dir="rtl"] .mfe-lg-5 { + margin-left: 3rem !important; + } + .p-lg-0 { + padding: 0 !important; + } + .pt-lg-0, + .py-lg-0 { + padding-top: 0 !important; + } + .pr-lg-0, + .px-lg-0 { + padding-right: 0 !important; + } + .pb-lg-0, + .py-lg-0 { + padding-bottom: 0 !important; + } + .pl-lg-0, + .px-lg-0,html:not([dir="rtl"]) .pfs-lg-0 { + padding-left: 0 !important; + } + *[dir="rtl"] .pfs-lg-0,html:not([dir="rtl"]) .pfe-lg-0 { + padding-right: 0 !important; + } + *[dir="rtl"] .pfe-lg-0 { + padding-left: 0 !important; + } + .p-lg-1 { + padding: 0.25rem !important; + } + .pt-lg-1, + .py-lg-1 { + padding-top: 0.25rem !important; + } + .pr-lg-1, + .px-lg-1 { + padding-right: 0.25rem !important; + } + .pb-lg-1, + .py-lg-1 { + padding-bottom: 0.25rem !important; + } + .pl-lg-1, + .px-lg-1,html:not([dir="rtl"]) .pfs-lg-1 { + padding-left: 0.25rem !important; + } + *[dir="rtl"] .pfs-lg-1,html:not([dir="rtl"]) .pfe-lg-1 { + padding-right: 0.25rem !important; + } + *[dir="rtl"] .pfe-lg-1 { + padding-left: 0.25rem !important; + } + .p-lg-2 { + padding: 0.5rem !important; + } + .pt-lg-2, + .py-lg-2 { + padding-top: 0.5rem !important; + } + .pr-lg-2, + .px-lg-2 { + padding-right: 0.5rem !important; + } + .pb-lg-2, + .py-lg-2 { + padding-bottom: 0.5rem !important; + } + .pl-lg-2, + .px-lg-2,html:not([dir="rtl"]) .pfs-lg-2 { + padding-left: 0.5rem !important; + } + *[dir="rtl"] .pfs-lg-2,html:not([dir="rtl"]) .pfe-lg-2 { + padding-right: 0.5rem !important; + } + *[dir="rtl"] .pfe-lg-2 { + padding-left: 0.5rem !important; + } + .p-lg-3 { + padding: 1rem !important; + } + .pt-lg-3, + .py-lg-3 { + padding-top: 1rem !important; + } + .pr-lg-3, + .px-lg-3 { + padding-right: 1rem !important; + } + .pb-lg-3, + .py-lg-3 { + padding-bottom: 1rem !important; + } + .pl-lg-3, + .px-lg-3,html:not([dir="rtl"]) .pfs-lg-3 { + padding-left: 1rem !important; + } + *[dir="rtl"] .pfs-lg-3,html:not([dir="rtl"]) .pfe-lg-3 { + padding-right: 1rem !important; + } + *[dir="rtl"] .pfe-lg-3 { + padding-left: 1rem !important; + } + .p-lg-4 { + padding: 1.5rem !important; + } + .pt-lg-4, + .py-lg-4 { + padding-top: 1.5rem !important; + } + .pr-lg-4, + .px-lg-4 { + padding-right: 1.5rem !important; + } + .pb-lg-4, + .py-lg-4 { + padding-bottom: 1.5rem !important; + } + .pl-lg-4, + .px-lg-4,html:not([dir="rtl"]) .pfs-lg-4 { + padding-left: 1.5rem !important; + } + *[dir="rtl"] .pfs-lg-4,html:not([dir="rtl"]) .pfe-lg-4 { + padding-right: 1.5rem !important; + } + *[dir="rtl"] .pfe-lg-4 { + padding-left: 1.5rem !important; + } + .p-lg-5 { + padding: 3rem !important; + } + .pt-lg-5, + .py-lg-5 { + padding-top: 3rem !important; + } + .pr-lg-5, + .px-lg-5 { + padding-right: 3rem !important; + } + .pb-lg-5, + .py-lg-5 { + padding-bottom: 3rem !important; + } + .pl-lg-5, + .px-lg-5,html:not([dir="rtl"]) .pfs-lg-5 { + padding-left: 3rem !important; + } + *[dir="rtl"] .pfs-lg-5,html:not([dir="rtl"]) .pfe-lg-5 { + padding-right: 3rem !important; + } + *[dir="rtl"] .pfe-lg-5 { + padding-left: 3rem !important; + } + .m-lg-n1 { + margin: -0.25rem !important; + } + .mt-lg-n1, + .my-lg-n1 { + margin-top: -0.25rem !important; + } + .mr-lg-n1, + .mx-lg-n1 { + margin-right: -0.25rem !important; + } + .mb-lg-n1, + .my-lg-n1 { + margin-bottom: -0.25rem !important; + } + .ml-lg-n1, + .mx-lg-n1,html:not([dir="rtl"]) .mfs-lg-n1 { + margin-left: -0.25rem !important; + } + *[dir="rtl"] .mfs-lg-n1,html:not([dir="rtl"]) .mfe-lg-n1 { + margin-right: -0.25rem !important; + } + *[dir="rtl"] .mfe-lg-n1 { + margin-left: -0.25rem !important; + } + .m-lg-n2 { + margin: -0.5rem !important; + } + .mt-lg-n2, + .my-lg-n2 { + margin-top: -0.5rem !important; + } + .mr-lg-n2, + .mx-lg-n2 { + margin-right: -0.5rem !important; + } + .mb-lg-n2, + .my-lg-n2 { + margin-bottom: -0.5rem !important; + } + .ml-lg-n2, + .mx-lg-n2,html:not([dir="rtl"]) .mfs-lg-n2 { + margin-left: -0.5rem !important; + } + *[dir="rtl"] .mfs-lg-n2,html:not([dir="rtl"]) .mfe-lg-n2 { + margin-right: -0.5rem !important; + } + *[dir="rtl"] .mfe-lg-n2 { + margin-left: -0.5rem !important; + } + .m-lg-n3 { + margin: -1rem !important; + } + .mt-lg-n3, + .my-lg-n3 { + margin-top: -1rem !important; + } + .mr-lg-n3, + .mx-lg-n3 { + margin-right: -1rem !important; + } + .mb-lg-n3, + .my-lg-n3 { + margin-bottom: -1rem !important; + } + .ml-lg-n3, + .mx-lg-n3,html:not([dir="rtl"]) .mfs-lg-n3 { + margin-left: -1rem !important; + } + *[dir="rtl"] .mfs-lg-n3,html:not([dir="rtl"]) .mfe-lg-n3 { + margin-right: -1rem !important; + } + *[dir="rtl"] .mfe-lg-n3 { + margin-left: -1rem !important; + } + .m-lg-n4 { + margin: -1.5rem !important; + } + .mt-lg-n4, + .my-lg-n4 { + margin-top: -1.5rem !important; + } + .mr-lg-n4, + .mx-lg-n4 { + margin-right: -1.5rem !important; + } + .mb-lg-n4, + .my-lg-n4 { + margin-bottom: -1.5rem !important; + } + .ml-lg-n4, + .mx-lg-n4,html:not([dir="rtl"]) .mfs-lg-n4 { + margin-left: -1.5rem !important; + } + *[dir="rtl"] .mfs-lg-n4,html:not([dir="rtl"]) .mfe-lg-n4 { + margin-right: -1.5rem !important; + } + *[dir="rtl"] .mfe-lg-n4 { + margin-left: -1.5rem !important; + } + .m-lg-n5 { + margin: -3rem !important; + } + .mt-lg-n5, + .my-lg-n5 { + margin-top: -3rem !important; + } + .mr-lg-n5, + .mx-lg-n5 { + margin-right: -3rem !important; + } + .mb-lg-n5, + .my-lg-n5 { + margin-bottom: -3rem !important; + } + .ml-lg-n5, + .mx-lg-n5,html:not([dir="rtl"]) .mfs-lg-n5 { + margin-left: -3rem !important; + } + *[dir="rtl"] .mfs-lg-n5,html:not([dir="rtl"]) .mfe-lg-n5 { + margin-right: -3rem !important; + } + *[dir="rtl"] .mfe-lg-n5 { + margin-left: -3rem !important; + } + .m-lg-auto { + margin: auto !important; + } + .mt-lg-auto, + .my-lg-auto { + margin-top: auto !important; + } + .mr-lg-auto, + .mx-lg-auto { + margin-right: auto !important; + } + .mb-lg-auto, + .my-lg-auto { + margin-bottom: auto !important; + } + .ml-lg-auto, + .mx-lg-auto,html:not([dir="rtl"]) .mfs-lg-auto { + margin-left: auto !important; + } + *[dir="rtl"] .mfs-lg-auto,html:not([dir="rtl"]) .mfe-lg-auto { + margin-right: auto !important; + } + *[dir="rtl"] .mfe-lg-auto { + margin-left: auto !important; + } +} + +@media (min-width: 1200px) { + .m-xl-0 { + margin: 0 !important; + } + .mt-xl-0, + .my-xl-0 { + margin-top: 0 !important; + } + .mr-xl-0, + .mx-xl-0 { + margin-right: 0 !important; + } + .mb-xl-0, + .my-xl-0 { + margin-bottom: 0 !important; + } + .ml-xl-0, + .mx-xl-0,html:not([dir="rtl"]) .mfs-xl-0 { + margin-left: 0 !important; + } + *[dir="rtl"] .mfs-xl-0,html:not([dir="rtl"]) .mfe-xl-0 { + margin-right: 0 !important; + } + *[dir="rtl"] .mfe-xl-0 { + margin-left: 0 !important; + } + .m-xl-1 { + margin: 0.25rem !important; + } + .mt-xl-1, + .my-xl-1 { + margin-top: 0.25rem !important; + } + .mr-xl-1, + .mx-xl-1 { + margin-right: 0.25rem !important; + } + .mb-xl-1, + .my-xl-1 { + margin-bottom: 0.25rem !important; + } + .ml-xl-1, + .mx-xl-1,html:not([dir="rtl"]) .mfs-xl-1 { + margin-left: 0.25rem !important; + } + *[dir="rtl"] .mfs-xl-1,html:not([dir="rtl"]) .mfe-xl-1 { + margin-right: 0.25rem !important; + } + *[dir="rtl"] .mfe-xl-1 { + margin-left: 0.25rem !important; + } + .m-xl-2 { + margin: 0.5rem !important; + } + .mt-xl-2, + .my-xl-2 { + margin-top: 0.5rem !important; + } + .mr-xl-2, + .mx-xl-2 { + margin-right: 0.5rem !important; + } + .mb-xl-2, + .my-xl-2 { + margin-bottom: 0.5rem !important; + } + .ml-xl-2, + .mx-xl-2,html:not([dir="rtl"]) .mfs-xl-2 { + margin-left: 0.5rem !important; + } + *[dir="rtl"] .mfs-xl-2,html:not([dir="rtl"]) .mfe-xl-2 { + margin-right: 0.5rem !important; + } + *[dir="rtl"] .mfe-xl-2 { + margin-left: 0.5rem !important; + } + .m-xl-3 { + margin: 1rem !important; + } + .mt-xl-3, + .my-xl-3 { + margin-top: 1rem !important; + } + .mr-xl-3, + .mx-xl-3 { + margin-right: 1rem !important; + } + .mb-xl-3, + .my-xl-3 { + margin-bottom: 1rem !important; + } + .ml-xl-3, + .mx-xl-3,html:not([dir="rtl"]) .mfs-xl-3 { + margin-left: 1rem !important; + } + *[dir="rtl"] .mfs-xl-3,html:not([dir="rtl"]) .mfe-xl-3 { + margin-right: 1rem !important; + } + *[dir="rtl"] .mfe-xl-3 { + margin-left: 1rem !important; + } + .m-xl-4 { + margin: 1.5rem !important; + } + .mt-xl-4, + .my-xl-4 { + margin-top: 1.5rem !important; + } + .mr-xl-4, + .mx-xl-4 { + margin-right: 1.5rem !important; + } + .mb-xl-4, + .my-xl-4 { + margin-bottom: 1.5rem !important; + } + .ml-xl-4, + .mx-xl-4,html:not([dir="rtl"]) .mfs-xl-4 { + margin-left: 1.5rem !important; + } + *[dir="rtl"] .mfs-xl-4,html:not([dir="rtl"]) .mfe-xl-4 { + margin-right: 1.5rem !important; + } + *[dir="rtl"] .mfe-xl-4 { + margin-left: 1.5rem !important; + } + .m-xl-5 { + margin: 3rem !important; + } + .mt-xl-5, + .my-xl-5 { + margin-top: 3rem !important; + } + .mr-xl-5, + .mx-xl-5 { + margin-right: 3rem !important; + } + .mb-xl-5, + .my-xl-5 { + margin-bottom: 3rem !important; + } + .ml-xl-5, + .mx-xl-5,html:not([dir="rtl"]) .mfs-xl-5 { + margin-left: 3rem !important; + } + *[dir="rtl"] .mfs-xl-5,html:not([dir="rtl"]) .mfe-xl-5 { + margin-right: 3rem !important; + } + *[dir="rtl"] .mfe-xl-5 { + margin-left: 3rem !important; + } + .p-xl-0 { + padding: 0 !important; + } + .pt-xl-0, + .py-xl-0 { + padding-top: 0 !important; + } + .pr-xl-0, + .px-xl-0 { + padding-right: 0 !important; + } + .pb-xl-0, + .py-xl-0 { + padding-bottom: 0 !important; + } + .pl-xl-0, + .px-xl-0,html:not([dir="rtl"]) .pfs-xl-0 { + padding-left: 0 !important; + } + *[dir="rtl"] .pfs-xl-0,html:not([dir="rtl"]) .pfe-xl-0 { + padding-right: 0 !important; + } + *[dir="rtl"] .pfe-xl-0 { + padding-left: 0 !important; + } + .p-xl-1 { + padding: 0.25rem !important; + } + .pt-xl-1, + .py-xl-1 { + padding-top: 0.25rem !important; + } + .pr-xl-1, + .px-xl-1 { + padding-right: 0.25rem !important; + } + .pb-xl-1, + .py-xl-1 { + padding-bottom: 0.25rem !important; + } + .pl-xl-1, + .px-xl-1,html:not([dir="rtl"]) .pfs-xl-1 { + padding-left: 0.25rem !important; + } + *[dir="rtl"] .pfs-xl-1,html:not([dir="rtl"]) .pfe-xl-1 { + padding-right: 0.25rem !important; + } + *[dir="rtl"] .pfe-xl-1 { + padding-left: 0.25rem !important; + } + .p-xl-2 { + padding: 0.5rem !important; + } + .pt-xl-2, + .py-xl-2 { + padding-top: 0.5rem !important; + } + .pr-xl-2, + .px-xl-2 { + padding-right: 0.5rem !important; + } + .pb-xl-2, + .py-xl-2 { + padding-bottom: 0.5rem !important; + } + .pl-xl-2, + .px-xl-2,html:not([dir="rtl"]) .pfs-xl-2 { + padding-left: 0.5rem !important; + } + *[dir="rtl"] .pfs-xl-2,html:not([dir="rtl"]) .pfe-xl-2 { + padding-right: 0.5rem !important; + } + *[dir="rtl"] .pfe-xl-2 { + padding-left: 0.5rem !important; + } + .p-xl-3 { + padding: 1rem !important; + } + .pt-xl-3, + .py-xl-3 { + padding-top: 1rem !important; + } + .pr-xl-3, + .px-xl-3 { + padding-right: 1rem !important; + } + .pb-xl-3, + .py-xl-3 { + padding-bottom: 1rem !important; + } + .pl-xl-3, + .px-xl-3,html:not([dir="rtl"]) .pfs-xl-3 { + padding-left: 1rem !important; + } + *[dir="rtl"] .pfs-xl-3,html:not([dir="rtl"]) .pfe-xl-3 { + padding-right: 1rem !important; + } + *[dir="rtl"] .pfe-xl-3 { + padding-left: 1rem !important; + } + .p-xl-4 { + padding: 1.5rem !important; + } + .pt-xl-4, + .py-xl-4 { + padding-top: 1.5rem !important; + } + .pr-xl-4, + .px-xl-4 { + padding-right: 1.5rem !important; + } + .pb-xl-4, + .py-xl-4 { + padding-bottom: 1.5rem !important; + } + .pl-xl-4, + .px-xl-4,html:not([dir="rtl"]) .pfs-xl-4 { + padding-left: 1.5rem !important; + } + *[dir="rtl"] .pfs-xl-4,html:not([dir="rtl"]) .pfe-xl-4 { + padding-right: 1.5rem !important; + } + *[dir="rtl"] .pfe-xl-4 { + padding-left: 1.5rem !important; + } + .p-xl-5 { + padding: 3rem !important; + } + .pt-xl-5, + .py-xl-5 { + padding-top: 3rem !important; + } + .pr-xl-5, + .px-xl-5 { + padding-right: 3rem !important; + } + .pb-xl-5, + .py-xl-5 { + padding-bottom: 3rem !important; + } + .pl-xl-5, + .px-xl-5,html:not([dir="rtl"]) .pfs-xl-5 { + padding-left: 3rem !important; + } + *[dir="rtl"] .pfs-xl-5,html:not([dir="rtl"]) .pfe-xl-5 { + padding-right: 3rem !important; + } + *[dir="rtl"] .pfe-xl-5 { + padding-left: 3rem !important; + } + .m-xl-n1 { + margin: -0.25rem !important; + } + .mt-xl-n1, + .my-xl-n1 { + margin-top: -0.25rem !important; + } + .mr-xl-n1, + .mx-xl-n1 { + margin-right: -0.25rem !important; + } + .mb-xl-n1, + .my-xl-n1 { + margin-bottom: -0.25rem !important; + } + .ml-xl-n1, + .mx-xl-n1,html:not([dir="rtl"]) .mfs-xl-n1 { + margin-left: -0.25rem !important; + } + *[dir="rtl"] .mfs-xl-n1,html:not([dir="rtl"]) .mfe-xl-n1 { + margin-right: -0.25rem !important; + } + *[dir="rtl"] .mfe-xl-n1 { + margin-left: -0.25rem !important; + } + .m-xl-n2 { + margin: -0.5rem !important; + } + .mt-xl-n2, + .my-xl-n2 { + margin-top: -0.5rem !important; + } + .mr-xl-n2, + .mx-xl-n2 { + margin-right: -0.5rem !important; + } + .mb-xl-n2, + .my-xl-n2 { + margin-bottom: -0.5rem !important; + } + .ml-xl-n2, + .mx-xl-n2,html:not([dir="rtl"]) .mfs-xl-n2 { + margin-left: -0.5rem !important; + } + *[dir="rtl"] .mfs-xl-n2,html:not([dir="rtl"]) .mfe-xl-n2 { + margin-right: -0.5rem !important; + } + *[dir="rtl"] .mfe-xl-n2 { + margin-left: -0.5rem !important; + } + .m-xl-n3 { + margin: -1rem !important; + } + .mt-xl-n3, + .my-xl-n3 { + margin-top: -1rem !important; + } + .mr-xl-n3, + .mx-xl-n3 { + margin-right: -1rem !important; + } + .mb-xl-n3, + .my-xl-n3 { + margin-bottom: -1rem !important; + } + .ml-xl-n3, + .mx-xl-n3,html:not([dir="rtl"]) .mfs-xl-n3 { + margin-left: -1rem !important; + } + *[dir="rtl"] .mfs-xl-n3,html:not([dir="rtl"]) .mfe-xl-n3 { + margin-right: -1rem !important; + } + *[dir="rtl"] .mfe-xl-n3 { + margin-left: -1rem !important; + } + .m-xl-n4 { + margin: -1.5rem !important; + } + .mt-xl-n4, + .my-xl-n4 { + margin-top: -1.5rem !important; + } + .mr-xl-n4, + .mx-xl-n4 { + margin-right: -1.5rem !important; + } + .mb-xl-n4, + .my-xl-n4 { + margin-bottom: -1.5rem !important; + } + .ml-xl-n4, + .mx-xl-n4,html:not([dir="rtl"]) .mfs-xl-n4 { + margin-left: -1.5rem !important; + } + *[dir="rtl"] .mfs-xl-n4,html:not([dir="rtl"]) .mfe-xl-n4 { + margin-right: -1.5rem !important; + } + *[dir="rtl"] .mfe-xl-n4 { + margin-left: -1.5rem !important; + } + .m-xl-n5 { + margin: -3rem !important; + } + .mt-xl-n5, + .my-xl-n5 { + margin-top: -3rem !important; + } + .mr-xl-n5, + .mx-xl-n5 { + margin-right: -3rem !important; + } + .mb-xl-n5, + .my-xl-n5 { + margin-bottom: -3rem !important; + } + .ml-xl-n5, + .mx-xl-n5,html:not([dir="rtl"]) .mfs-xl-n5 { + margin-left: -3rem !important; + } + *[dir="rtl"] .mfs-xl-n5,html:not([dir="rtl"]) .mfe-xl-n5 { + margin-right: -3rem !important; + } + *[dir="rtl"] .mfe-xl-n5 { + margin-left: -3rem !important; + } + .m-xl-auto { + margin: auto !important; + } + .mt-xl-auto, + .my-xl-auto { + margin-top: auto !important; + } + .mr-xl-auto, + .mx-xl-auto { + margin-right: auto !important; + } + .mb-xl-auto, + .my-xl-auto { + margin-bottom: auto !important; + } + .ml-xl-auto, + .mx-xl-auto,html:not([dir="rtl"]) .mfs-xl-auto { + margin-left: auto !important; + } + *[dir="rtl"] .mfs-xl-auto,html:not([dir="rtl"]) .mfe-xl-auto { + margin-right: auto !important; + } + *[dir="rtl"] .mfe-xl-auto { + margin-left: auto !important; + } +} + +.text-monospace { + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; +} + +.text-justify { + text-align: justify !important; +} + +.text-wrap { + white-space: normal !important; +} + +.text-nowrap { + white-space: nowrap !important; +} + +.text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-left { + text-align: left !important; +} + +.text-right { + text-align: right !important; +} + +.text-center { + text-align: center !important; +} + +@media (min-width: 576px) { + .text-sm-left { + text-align: left !important; + } + .text-sm-right { + text-align: right !important; + } + .text-sm-center { + text-align: center !important; + } +} + +@media (min-width: 768px) { + .text-md-left { + text-align: left !important; + } + .text-md-right { + text-align: right !important; + } + .text-md-center { + text-align: center !important; + } +} + +@media (min-width: 992px) { + .text-lg-left { + text-align: left !important; + } + .text-lg-right { + text-align: right !important; + } + .text-lg-center { + text-align: center !important; + } +} + +@media (min-width: 1200px) { + .text-xl-left { + text-align: left !important; + } + .text-xl-right { + text-align: right !important; + } + .text-xl-center { + text-align: center !important; + } +} + +.text-lowercase { + text-transform: lowercase !important; +} + +.text-uppercase { + text-transform: uppercase !important; +} + +.text-capitalize { + text-transform: capitalize !important; +} + +.font-weight-light { + font-weight: 300 !important; +} + +.font-weight-lighter { + font-weight: lighter !important; +} + +.font-weight-normal { + font-weight: 400 !important; +} + +.font-weight-bold { + font-weight: 700 !important; +} + +.font-weight-bolder { + font-weight: bolder !important; +} + +.font-italic { + font-style: italic !important; +} + +.text-white { + color: #fff !important; +} + +.text-primary { + color: #321fdb !important; +} + +a.text-primary:hover, a.text-primary:focus { + color: #231698 !important; +} + +.text-secondary { + color: #ced2d8 !important; +} + +a.text-secondary:hover, a.text-secondary:focus { + color: #a3abb6 !important; +} + +.text-success { + color: #2eb85c !important; +} + +a.text-success:hover, a.text-success:focus { + color: #1f7b3d !important; +} + +.text-info { + color: #39f !important; +} + +a.text-info:hover, a.text-info:focus { + color: #0073e6 !important; +} + +.text-warning { + color: #f9b115 !important; +} + +a.text-warning:hover, a.text-warning:focus { + color: #bd8305 !important; +} + +.text-danger { + color: #e55353 !important; +} + +a.text-danger:hover, a.text-danger:focus { + color: #cd1f1f !important; +} + +.text-light { + color: #ebedef !important; +} + +a.text-light:hover, a.text-light:focus { + color: #c1c7cd !important; +} + +.text-dark { + color: #636f83 !important; +} + +a.text-dark:hover, a.text-dark:focus { + color: #424a57 !important; +} + +.text-body { + color: #4f5d73 !important; +} + +.text-muted { + color: #768192 !important; +} + +.text-black-50 { + color: rgba(0, 0, 21, 0.5) !important; +} + +.text-white-50 { + color: rgba(255, 255, 255, 0.5) !important; +} + +.text-hide { + font: 0/0 a; + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0; +} + +.text-decoration-none { + text-decoration: none !important; +} + +.text-break { + word-break: break-word !important; + overflow-wrap: break-word !important; +} + +.text-reset { + color: inherit !important; +} + +.font-xs { + font-size: .75rem !important; +} + +.font-sm { + font-size: .85rem !important; +} + +.font-lg { + font-size: 1rem !important; +} + +.font-xl { + font-size: 1.25rem !important; +} + +.font-2xl { + font-size: 1.5rem !important; +} + +.font-3xl { + font-size: 1.75rem !important; +} + +.font-4xl { + font-size: 2rem !important; +} + +.font-5xl { + font-size: 2.5rem !important; +} + +[class^="text-value"] { + font-weight: 600; +} + +.text-value-xs { + font-size: 0.65625rem; +} + +.text-value-sm { + font-size: 0.74375rem; +} + +.text-value { + font-size: 0.875rem; +} + +.text-value-lg { + font-size: 1.3125rem; +} + +.text-value-xl { + font-size: 1.53125rem; +} + +.text-white .text-muted { + color: rgba(255, 255, 255, 0.6) !important; +} + +.visible { + visibility: visible !important; +} + +.invisible { + visibility: hidden !important; +} + +*[dir="rtl"] { + direction: rtl; + unicode-bidi: embed; +} + +*[dir="rtl"] body { + text-align: right; +} + +.ie-custom-properties { + primary: #321fdb; + secondary: #ced2d8; + success: #2eb85c; + info: #39f; + warning: #f9b115; + danger: #e55353; + light: #ebedef; + dark: #636f83; + breakpoint-xs: 0; + breakpoint-sm: 576px; + breakpoint-md: 768px; + breakpoint-lg: 992px; + breakpoint-xl: 1200px; +} + +@media print { + *, + *::before, + *::after { + text-shadow: none !important; + box-shadow: none !important; + } + a:not(.btn) { + text-decoration: underline; + } + abbr[title]::after { + content: " (" attr(title) ")"; + } + pre { + white-space: pre-wrap !important; + } + pre, + blockquote { + border: 1px solid #9da5b1; + page-break-inside: avoid; + } + thead { + display: table-header-group; + } + tr, + img { + page-break-inside: avoid; + } + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + h2, + h3 { + page-break-after: avoid; + } + @page { + size: a3; + } + body,.container { + min-width: 992px !important; + } + .navbar { + display: none; + } + .badge { + border: 1px solid #000015; + } + .table { + border-collapse: collapse !important; + } + .table td, + .table th { + background-color: #fff !important; + } + .table-bordered th, + .table-bordered td { + border: 1px solid #c4c9d0 !important; + } + .table-dark { + color: inherit; + } + .table-dark th, + .table-dark td, + .table-dark thead th, + .table-dark tbody + tbody,.table .thead-dark th { + border-color: #d8dbe0; + } + .table .thead-dark th { + color: inherit; + } +} +/*# sourceMappingURL=coreui.css.map */ \ No newline at end of file diff --git a/web/public/css/coreui.min.css b/web/public/css/coreui.min.css new file mode 100644 index 0000000..0443f89 --- /dev/null +++ b/web/public/css/coreui.min.css @@ -0,0 +1,8 @@ +@charset "UTF-8";/*! + * CoreUI - HTML, CSS, and JavaScript UI Components Library + * @version v3.0.0 + * @link https://coreui.io/ + * Copyright (c) 2020 creativeLabs Łukasz Holeczek + * License MIT (https://coreui.io/license/) + */:root{--primary:#321fdb;--secondary:#ced2d8;--success:#2eb85c;--info:#39f;--warning:#f9b115;--danger:#e55353;--light:#ebedef;--dark:#636f83;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,21,0)}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem;font-weight:400;line-height:1.5;text-align:left;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased}.c-app,body{color:#3c4b64;background-color:#ebedef}.c-app{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-height:100vh}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible;margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,21,.2)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{font-style:normal;line-height:inherit}address,dl,ol,ul{margin-bottom:1rem}dl,ol,ul{margin-top:0}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem}html:not([dir=rtl]) dd{margin-left:0}[dir=rtl] dd{margin-right:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{text-decoration:none;background-color:transparent}a,a:hover{color:#321fdb}a:hover{text-decoration:underline}a:not([href]),a:not([href]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;display:block;font-size:87.5%;color:#4f5d73}figure{margin:0 0 1rem}img{border-style:none}img,svg{vertical-align:middle}svg{overflow:hidden}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#768192;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}select{word-wrap:normal}[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}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.ps{overflow:hidden!important;-ms-touch-action:auto;touch-action:auto;-ms-overflow-style:none;overflow-anchor:none}.ps__rail-x{bottom:0;height:15px}.ps__rail-x,.ps__rail-y{position:absolute;display:none;opacity:0;transition:background-color .2s linear,opacity .2s linear}.ps__rail-y{width:15px}html:not([dir=rtl]) .ps__rail-y{right:0}[dir=rtl] .ps__rail-y{left:0}.ps--active-x>.ps__rail-x,.ps--active-y>.ps__rail-y{display:block;background-color:transparent}.ps--focus>.ps__rail-x,.ps--focus>.ps__rail-y,.ps--scrolling-x>.ps__rail-x,.ps--scrolling-y>.ps__rail-y,.ps:hover>.ps__rail-x,.ps:hover>.ps__rail-y{opacity:.6}.ps__rail-x:focus,.ps__rail-x:hover,.ps__rail-y:focus,.ps__rail-y:hover{background-color:#eee;opacity:.9}.ps__thumb-x{bottom:2px;height:6px;transition:background-color .2s linear,height .2s ease-in-out}.ps__thumb-x,.ps__thumb-y{position:absolute;background-color:#aaa;border-radius:6px}.ps__thumb-y{width:6px;transition:background-color .2s linear,width .2s ease-in-out}html:not([dir=rtl]) .ps__thumb-y{right:2px}[dir=rtl] .ps__thumb-y{left:2px}.ps__rail-x:focus>.ps__thumb-x,.ps__rail-x:hover>.ps__thumb-x{height:11px;background-color:#999}.ps__rail-y:focus>.ps__thumb-y,.ps__rail-y:hover>.ps__thumb-y{width:11px;background-color:#999}@supports (-ms-overflow-style:none){.ps{overflow:auto!important}}@media screen and (-ms-high-contrast:active),(-ms-high-contrast:none){.ps{overflow:auto!important}}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}html:not([dir=rtl]) .alert-dismissible{padding-right:3.8125rem}[dir=rtl] .alert-dismissible{padding-left:3.8125rem}.alert-dismissible .close{position:absolute;top:0;padding:.75rem 1.25rem;color:inherit}html:not([dir=rtl]) .alert-dismissible .close{right:0}[dir=rtl] .alert-dismissible .close{left:0}.alert-primary{color:#1a107c;background-color:#d6d2f8;border-color:#c6c0f5}.alert-primary hr{border-top-color:#b2aaf2}.alert-primary .alert-link{color:#110a4f}.alert-secondary{color:#6b6d7a;background-color:#f5f6f7;border-color:#f1f2f4}.alert-secondary hr{border-top-color:#e3e5e9}.alert-secondary .alert-link{color:#53555f}.alert-success{color:#18603a;background-color:#d5f1de;border-color:#c4ebd1}.alert-success hr{border-top-color:#b1e5c2}.alert-success .alert-link{color:#0e3721}.alert-info{color:#1b508f;background-color:#d6ebff;border-color:#c6e2ff}.alert-info hr{border-top-color:#add5ff}.alert-info .alert-link{color:#133864}.alert-warning{color:#815c15;background-color:#feefd0;border-color:#fde9bd}.alert-warning hr{border-top-color:#fce1a4}.alert-warning .alert-link{color:#553d0e}.alert-danger{color:#772b35;background-color:#fadddd;border-color:#f8cfcf}.alert-danger hr{border-top-color:#f5b9b9}.alert-danger .alert-link{color:#521d24}.alert-light{color:#7a7b86;background-color:#fbfbfc;border-color:#f9fafb}.alert-light hr{border-top-color:#eaedf1}.alert-light .alert-link{color:#62626b}.alert-dark{color:#333a4e;background-color:#e0e2e6;border-color:#d3d7dc}.alert-dark hr{border-top-color:#c5cad1}.alert-dark .alert-link{color:#1f232f}.c-avatar{position:relative;display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;border-radius:50em;width:36px;height:36px;font-size:14.4px}.c-avatar .c-avatar-status{width:10px;height:10px}.c-avatar-img{width:100%;height:auto;border-radius:50em}.c-avatar-status{position:absolute;bottom:0;display:block;border:1px solid #fff;border-radius:50em}html:not([dir=rtl]) .c-avatar-status{right:0}[dir=rtl] .c-avatar-status{left:0}.c-avatar-sm{width:24px;height:24px;font-size:9.6px}.c-avatar-sm .c-avatar-status{width:8px;height:8px}.c-avatar-lg{width:48px;height:48px;font-size:19.2px}.c-avatar-lg .c-avatar-status{width:12px;height:12px}.c-avatar-xl{width:64px;height:64px;font-size:25.6px}.c-avatar-xl .c-avatar-status{width:14px;height:14px}.c-avatars-stack{display:-ms-flexbox;display:flex}.c-avatars-stack .c-avatar{margin-right:-18px;transition:margin-right .25s}.c-avatars-stack .c-avatar:hover{margin-right:0}.c-avatars-stack .c-avatar-sm{margin-right:-12px}.c-avatars-stack .c-avatar-lg{margin-right:-24px}.c-avatars-stack .c-avatar-xl{margin-right:-32px}.c-avatar-rounded{border-radius:.25rem}.c-avatar-square{border-radius:0}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem;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){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#321fdb}a.badge-primary:focus,a.badge-primary:hover{color:#fff;background-color:#2819ae}a.badge-primary.focus,a.badge-primary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(50,31,219,.5)}.badge-secondary{color:#4f5d73;background-color:#ced2d8}a.badge-secondary:focus,a.badge-secondary:hover{color:#4f5d73;background-color:#b2b8c1}a.badge-secondary.focus,a.badge-secondary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(206,210,216,.5)}.badge-success{color:#fff;background-color:#2eb85c}a.badge-success:focus,a.badge-success:hover{color:#fff;background-color:#248f48}a.badge-success.focus,a.badge-success:focus{outline:0;box-shadow:0 0 0 .2rem rgba(46,184,92,.5)}.badge-info{color:#fff;background-color:#39f}a.badge-info:focus,a.badge-info:hover{color:#fff;background-color:#0080ff}a.badge-info.focus,a.badge-info:focus{outline:0;box-shadow:0 0 0 .2rem rgba(51,153,255,.5)}.badge-warning{color:#4f5d73;background-color:#f9b115}a.badge-warning:focus,a.badge-warning:hover{color:#4f5d73;background-color:#d69405}a.badge-warning.focus,a.badge-warning:focus{outline:0;box-shadow:0 0 0 .2rem rgba(249,177,21,.5)}.badge-danger{color:#fff;background-color:#e55353}a.badge-danger:focus,a.badge-danger:hover{color:#fff;background-color:#de2727}a.badge-danger.focus,a.badge-danger:focus{outline:0;box-shadow:0 0 0 .2rem rgba(229,83,83,.5)}.badge-light{color:#4f5d73;background-color:#ebedef}a.badge-light:focus,a.badge-light:hover{color:#4f5d73;background-color:#cfd4d8}a.badge-light.focus,a.badge-light:focus{outline:0;box-shadow:0 0 0 .2rem rgba(235,237,239,.5)}.badge-dark{color:#fff;background-color:#636f83}a.badge-dark:focus,a.badge-dark:hover{color:#fff;background-color:#4d5666}a.badge-dark.focus,a.badge-dark:focus{outline:0;box-shadow:0 0 0 .2rem rgba(99,111,131,.5)}html:not([dir=rtl]) .breadcrumb-menu{margin-left:auto;margin-right:auto}.breadcrumb-menu::before{display:none}.breadcrumb-menu .btn,.breadcrumb-menu .btn-group{vertical-align:top}.breadcrumb-menu .btn{padding:0 .75rem;color:#768192;border:0}.breadcrumb-menu .btn.active,.breadcrumb-menu .btn:hover,.breadcrumb-menu .show .btn{color:#4f5d73;background:0 0}.breadcrumb-menu .dropdown-menu{min-width:180px;line-height:1.5}.breadcrumb{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1.5rem;list-style:none;border-radius:0;border-bottom:1px solid;background-color:transparent;border-color:#d8dbe0}html:not([dir=rtl]) .breadcrumb-item+.breadcrumb-item{padding-left:.5rem}[dir=rtl] .breadcrumb-item+.breadcrumb-item{padding-right:.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;color:#8a93a2;content:"/"}html:not([dir=rtl]) .breadcrumb-item+.breadcrumb-item::before{padding-right:.5rem}[dir=rtl] .breadcrumb-item+.breadcrumb-item::before{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline;text-decoration:none}.breadcrumb-item.active{color:#8a93a2}.btn-group,.btn-group-vertical{position:relative;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}html:not([dir=rtl]) .btn-group>.btn-group:not(:first-child),html:not([dir=rtl]) .btn-group>.btn:not(:first-child){margin-left:-1px}[dir=rtl] .btn-group>.btn-group:not(:first-child),[dir=rtl] .btn-group>.btn:not(:first-child){margin-right:-1px}html:not([dir=rtl]) .btn-group>.btn-group:not(:last-child)>.btn,html:not([dir=rtl]) .btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}[dir=rtl] .btn-group>.btn-group:not(:last-child)>.btn,[dir=rtl] .btn-group>.btn:not(:last-child):not(.dropdown-toggle),html:not([dir=rtl]) .btn-group>.btn-group:not(:first-child)>.btn,html:not([dir=rtl]) .btn-group>.btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}[dir=rtl] .btn-group>.btn-group:not(:first-child)>.btn,[dir=rtl] .btn-group>.btn:not(:first-child){border-top-right-radius:0;border-bottom-right-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}html:not([dir=rtl]) .dropdown-toggle-split::after,html:not([dir=rtl]) .dropright .dropdown-toggle-split::after,html:not([dir=rtl]) .dropup .dropdown-toggle-split::after{margin-left:0}[dir=rtl] .dropdown-toggle-split::after,[dir=rtl] .dropright .dropdown-toggle-split::after,[dir=rtl] .dropup .dropdown-toggle-split::after,html:not([dir=rtl]) .dropleft .dropdown-toggle-split::before{margin-right:0}[dir=rtl] .dropleft .dropdown-toggle-split::before{margin-left: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-vertical{-ms-flex-direction:column;flex-direction:column;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:center;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:-1px}.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:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn{display:inline-block;font-weight:400;color:#4f5d73;text-align:center;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:.875rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.btn .c-icon,.btn i{width:.875rem;height:.875rem;margin:.21875rem 0;height:.875rem;margin:.21875rem 0}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#4f5d73;text-decoration:none}.btn.focus,.btn:focus{outline:0;box-shadow:0 0 0 .2rem rgba(50,31,219,.25)}.btn.disabled,.btn:disabled{opacity:.65}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#321fdb;border-color:#321fdb}.btn-primary.focus,.btn-primary:focus,.btn-primary:hover{color:#fff;background-color:#2a1ab9;border-color:#2819ae}.btn-primary.focus,.btn-primary:focus{box-shadow:0 0 0 .2rem rgba(81,65,224,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#321fdb;border-color:#321fdb}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#2819ae;border-color:#2517a3}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(81,65,224,.5)}.btn-secondary{color:#4f5d73;background-color:#ced2d8;border-color:#ced2d8}.btn-secondary.focus,.btn-secondary:focus,.btn-secondary:hover{color:#4f5d73;background-color:#b9bec7;border-color:#b2b8c1}.btn-secondary.focus,.btn-secondary:focus{box-shadow:0 0 0 .2rem rgba(187,192,201,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#4f5d73;background-color:#ced2d8;border-color:#ced2d8}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{color:#4f5d73;background-color:#b2b8c1;border-color:#abb1bc}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(187,192,201,.5)}.btn-success{color:#fff;background-color:#2eb85c;border-color:#2eb85c}.btn-success.focus,.btn-success:focus,.btn-success:hover{color:#fff;background-color:#26994d;border-color:#248f48}.btn-success.focus,.btn-success:focus{box-shadow:0 0 0 .2rem rgba(77,195,116,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#2eb85c;border-color:#2eb85c}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#248f48;border-color:#218543}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(77,195,116,.5)}.btn-info{color:#fff;background-color:#39f;border-color:#39f}.btn-info.focus,.btn-info:focus,.btn-info:hover{color:#fff;background-color:#0d86ff;border-color:#0080ff}.btn-info.focus,.btn-info:focus{box-shadow:0 0 0 .2rem rgba(82,168,255,.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#39f;border-color:#39f}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#0080ff;border-color:#0079f2}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(82,168,255,.5)}.btn-warning{color:#4f5d73;background-color:#f9b115;border-color:#f9b115}.btn-warning.focus,.btn-warning:focus,.btn-warning:hover{color:#4f5d73;background-color:#e29c06;border-color:#d69405}.btn-warning.focus,.btn-warning:focus{box-shadow:0 0 0 .2rem rgba(224,164,35,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#4f5d73;background-color:#f9b115;border-color:#f9b115}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{color:#4f5d73;background-color:#d69405;border-color:#c98b05}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(224,164,35,.5)}.btn-danger{color:#fff;background-color:#e55353;border-color:#e55353}.btn-danger.focus,.btn-danger:focus,.btn-danger:hover{color:#fff;background-color:#e03232;border-color:#de2727}.btn-danger.focus,.btn-danger:focus{box-shadow:0 0 0 .2rem rgba(233,109,109,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#e55353;border-color:#e55353}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#de2727;border-color:#d82121}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(233,109,109,.5)}.btn-light{color:#4f5d73;background-color:#ebedef;border-color:#ebedef}.btn-light.focus,.btn-light:focus,.btn-light:hover{color:#4f5d73;background-color:#d6dade;border-color:#cfd4d8}.btn-light.focus,.btn-light:focus{box-shadow:0 0 0 .2rem rgba(212,215,220,.5)}.btn-light.disabled,.btn-light:disabled{color:#4f5d73;background-color:#ebedef;border-color:#ebedef}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{color:#4f5d73;background-color:#cfd4d8;border-color:#c8cdd3}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(212,215,220,.5)}.btn-dark{color:#fff;background-color:#636f83;border-color:#636f83}.btn-dark.focus,.btn-dark:focus,.btn-dark:hover{color:#fff;background-color:#535d6d;border-color:#4d5666}.btn-dark.focus,.btn-dark:focus{box-shadow:0 0 0 .2rem rgba(122,133,150,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#636f83;border-color:#636f83}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#4d5666;border-color:#48505f}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(122,133,150,.5)}.btn-transparent{color:rgba(255,255,255,.8)}.btn-transparent:hover{color:#fff}.btn-outline-primary{color:#321fdb;border-color:#321fdb}.btn-outline-primary:hover{color:#fff;background-color:#321fdb;border-color:#321fdb}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(50,31,219,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#321fdb;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#321fdb;border-color:#321fdb}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(50,31,219,.5)}.btn-outline-secondary{color:#ced2d8;border-color:#ced2d8}.btn-outline-secondary:hover{color:#4f5d73;background-color:#ced2d8;border-color:#ced2d8}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(206,210,216,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#ced2d8;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{color:#4f5d73;background-color:#ced2d8;border-color:#ced2d8}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(206,210,216,.5)}.btn-outline-success{color:#2eb85c;border-color:#2eb85c}.btn-outline-success:hover{color:#fff;background-color:#2eb85c;border-color:#2eb85c}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(46,184,92,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#2eb85c;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#2eb85c;border-color:#2eb85c}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(46,184,92,.5)}.btn-outline-info{color:#39f;border-color:#39f}.btn-outline-info:hover{color:#fff;background-color:#39f;border-color:#39f}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(51,153,255,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#39f;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#39f;border-color:#39f}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(51,153,255,.5)}.btn-outline-warning{color:#f9b115;border-color:#f9b115}.btn-outline-warning:hover{color:#4f5d73;background-color:#f9b115;border-color:#f9b115}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(249,177,21,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#f9b115;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{color:#4f5d73;background-color:#f9b115;border-color:#f9b115}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(249,177,21,.5)}.btn-outline-danger{color:#e55353;border-color:#e55353}.btn-outline-danger:hover{color:#fff;background-color:#e55353;border-color:#e55353}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(229,83,83,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#e55353;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#e55353;border-color:#e55353}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(229,83,83,.5)}.btn-outline-light{color:#ebedef;border-color:#ebedef}.btn-outline-light:hover{color:#4f5d73;background-color:#ebedef;border-color:#ebedef}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(235,237,239,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#ebedef;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{color:#4f5d73;background-color:#ebedef;border-color:#ebedef}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(235,237,239,.5)}.btn-outline-dark{color:#636f83;border-color:#636f83}.btn-outline-dark:hover{color:#fff;background-color:#636f83;border-color:#636f83}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(99,111,131,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#636f83;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#636f83;border-color:#636f83}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(99,111,131,.5)}.btn-link{font-weight:400;color:#321fdb;text-decoration:none}.btn-link:hover{color:#231698;text-decoration:underline}.btn-link.focus,.btn-link:focus{text-decoration:underline;box-shadow:none}.btn-link.disabled,.btn-link:disabled{color:#8a93a2;pointer-events:none}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.09375rem;line-height:1.5;border-radius:.3rem}.btn-group-lg>.btn .c-icon,.btn-group-lg>.btn i,.btn-lg .c-icon,.btn-lg i{width:1.09375rem;height:1.09375rem;margin:.2734375rem 0}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.765625rem;line-height:1.5;border-radius:.2rem}.btn-group-sm>.btn .c-icon,.btn-group-sm>.btn i,.btn-sm .c-icon,.btn-sm i{width:.765625rem;height:.765625rem;margin:.19140625rem 0}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.btn-pill{border-radius:50em}.btn-square{border-radius:0}.btn-ghost-primary{color:#321fdb;background-color:transparent;background-image:none;border-color:transparent}.btn-ghost-primary:hover{color:#fff;background-color:#321fdb;border-color:#321fdb}.btn-ghost-primary.focus,.btn-ghost-primary:focus{box-shadow:0 0 0 .2rem rgba(50,31,219,.5)}.btn-ghost-primary.disabled,.btn-ghost-primary:disabled{color:#321fdb;background-color:transparent;border-color:transparent}.btn-ghost-primary:not(:disabled):not(.disabled).active,.btn-ghost-primary:not(:disabled):not(.disabled):active,.show>.btn-ghost-primary.dropdown-toggle{color:#fff;background-color:#321fdb;border-color:#321fdb}.btn-ghost-primary:not(:disabled):not(.disabled).active:focus,.btn-ghost-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-ghost-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(50,31,219,.5)}.btn-ghost-secondary{color:#ced2d8;background-color:transparent;background-image:none;border-color:transparent}.btn-ghost-secondary:hover{color:#4f5d73;background-color:#ced2d8;border-color:#ced2d8}.btn-ghost-secondary.focus,.btn-ghost-secondary:focus{box-shadow:0 0 0 .2rem rgba(206,210,216,.5)}.btn-ghost-secondary.disabled,.btn-ghost-secondary:disabled{color:#ced2d8;background-color:transparent;border-color:transparent}.btn-ghost-secondary:not(:disabled):not(.disabled).active,.btn-ghost-secondary:not(:disabled):not(.disabled):active,.show>.btn-ghost-secondary.dropdown-toggle{color:#4f5d73;background-color:#ced2d8;border-color:#ced2d8}.btn-ghost-secondary:not(:disabled):not(.disabled).active:focus,.btn-ghost-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-ghost-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(206,210,216,.5)}.btn-ghost-success{color:#2eb85c;background-color:transparent;background-image:none;border-color:transparent}.btn-ghost-success:hover{color:#fff;background-color:#2eb85c;border-color:#2eb85c}.btn-ghost-success.focus,.btn-ghost-success:focus{box-shadow:0 0 0 .2rem rgba(46,184,92,.5)}.btn-ghost-success.disabled,.btn-ghost-success:disabled{color:#2eb85c;background-color:transparent;border-color:transparent}.btn-ghost-success:not(:disabled):not(.disabled).active,.btn-ghost-success:not(:disabled):not(.disabled):active,.show>.btn-ghost-success.dropdown-toggle{color:#fff;background-color:#2eb85c;border-color:#2eb85c}.btn-ghost-success:not(:disabled):not(.disabled).active:focus,.btn-ghost-success:not(:disabled):not(.disabled):active:focus,.show>.btn-ghost-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(46,184,92,.5)}.btn-ghost-info{color:#39f;background-color:transparent;background-image:none;border-color:transparent}.btn-ghost-info:hover{color:#fff;background-color:#39f;border-color:#39f}.btn-ghost-info.focus,.btn-ghost-info:focus{box-shadow:0 0 0 .2rem rgba(51,153,255,.5)}.btn-ghost-info.disabled,.btn-ghost-info:disabled{color:#39f;background-color:transparent;border-color:transparent}.btn-ghost-info:not(:disabled):not(.disabled).active,.btn-ghost-info:not(:disabled):not(.disabled):active,.show>.btn-ghost-info.dropdown-toggle{color:#fff;background-color:#39f;border-color:#39f}.btn-ghost-info:not(:disabled):not(.disabled).active:focus,.btn-ghost-info:not(:disabled):not(.disabled):active:focus,.show>.btn-ghost-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(51,153,255,.5)}.btn-ghost-warning{color:#f9b115;background-color:transparent;background-image:none;border-color:transparent}.btn-ghost-warning:hover{color:#4f5d73;background-color:#f9b115;border-color:#f9b115}.btn-ghost-warning.focus,.btn-ghost-warning:focus{box-shadow:0 0 0 .2rem rgba(249,177,21,.5)}.btn-ghost-warning.disabled,.btn-ghost-warning:disabled{color:#f9b115;background-color:transparent;border-color:transparent}.btn-ghost-warning:not(:disabled):not(.disabled).active,.btn-ghost-warning:not(:disabled):not(.disabled):active,.show>.btn-ghost-warning.dropdown-toggle{color:#4f5d73;background-color:#f9b115;border-color:#f9b115}.btn-ghost-warning:not(:disabled):not(.disabled).active:focus,.btn-ghost-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-ghost-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(249,177,21,.5)}.btn-ghost-danger{color:#e55353;background-color:transparent;background-image:none;border-color:transparent}.btn-ghost-danger:hover{color:#fff;background-color:#e55353;border-color:#e55353}.btn-ghost-danger.focus,.btn-ghost-danger:focus{box-shadow:0 0 0 .2rem rgba(229,83,83,.5)}.btn-ghost-danger.disabled,.btn-ghost-danger:disabled{color:#e55353;background-color:transparent;border-color:transparent}.btn-ghost-danger:not(:disabled):not(.disabled).active,.btn-ghost-danger:not(:disabled):not(.disabled):active,.show>.btn-ghost-danger.dropdown-toggle{color:#fff;background-color:#e55353;border-color:#e55353}.btn-ghost-danger:not(:disabled):not(.disabled).active:focus,.btn-ghost-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-ghost-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(229,83,83,.5)}.btn-ghost-light{color:#ebedef;background-color:transparent;background-image:none;border-color:transparent}.btn-ghost-light:hover{color:#4f5d73;background-color:#ebedef;border-color:#ebedef}.btn-ghost-light.focus,.btn-ghost-light:focus{box-shadow:0 0 0 .2rem rgba(235,237,239,.5)}.btn-ghost-light.disabled,.btn-ghost-light:disabled{color:#ebedef;background-color:transparent;border-color:transparent}.btn-ghost-light:not(:disabled):not(.disabled).active,.btn-ghost-light:not(:disabled):not(.disabled):active,.show>.btn-ghost-light.dropdown-toggle{color:#4f5d73;background-color:#ebedef;border-color:#ebedef}.btn-ghost-light:not(:disabled):not(.disabled).active:focus,.btn-ghost-light:not(:disabled):not(.disabled):active:focus,.show>.btn-ghost-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(235,237,239,.5)}.btn-ghost-dark{color:#636f83;background-color:transparent;background-image:none;border-color:transparent}.btn-ghost-dark:hover{color:#fff;background-color:#636f83;border-color:#636f83}.btn-ghost-dark.focus,.btn-ghost-dark:focus{box-shadow:0 0 0 .2rem rgba(99,111,131,.5)}.btn-ghost-dark.disabled,.btn-ghost-dark:disabled{color:#636f83;background-color:transparent;border-color:transparent}.btn-ghost-dark:not(:disabled):not(.disabled).active,.btn-ghost-dark:not(:disabled):not(.disabled):active,.show>.btn-ghost-dark.dropdown-toggle{color:#fff;background-color:#636f83;border-color:#636f83}.btn-ghost-dark:not(:disabled):not(.disabled).active:focus,.btn-ghost-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-ghost-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(99,111,131,.5)}.btn-facebook{color:#fff;background-color:#3b5998;border-color:#3b5998}.btn-facebook.focus,.btn-facebook:focus,.btn-facebook:hover{color:#fff;background-color:#30497c;border-color:#2d4373}.btn-facebook.focus,.btn-facebook:focus{box-shadow:0 0 0 .2rem rgba(88,114,167,.5)}.btn-facebook.disabled,.btn-facebook:disabled{color:#fff;background-color:#3b5998;border-color:#3b5998}.btn-facebook:not(:disabled):not(.disabled).active,.btn-facebook:not(:disabled):not(.disabled):active,.show>.btn-facebook.dropdown-toggle{color:#fff;background-color:#2d4373;border-color:#293e6a}.btn-facebook:not(:disabled):not(.disabled).active:focus,.btn-facebook:not(:disabled):not(.disabled):active:focus,.show>.btn-facebook.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(88,114,167,.5)}.btn-twitter{color:#fff;background-color:#00aced;border-color:#00aced}.btn-twitter.focus,.btn-twitter:focus,.btn-twitter:hover{color:#fff;background-color:#0090c7;border-color:#0087ba}.btn-twitter.focus,.btn-twitter:focus{box-shadow:0 0 0 .2rem rgba(38,184,240,.5)}.btn-twitter.disabled,.btn-twitter:disabled{color:#fff;background-color:#00aced;border-color:#00aced}.btn-twitter:not(:disabled):not(.disabled).active,.btn-twitter:not(:disabled):not(.disabled):active,.show>.btn-twitter.dropdown-toggle{color:#fff;background-color:#0087ba;border-color:#007ead}.btn-twitter:not(:disabled):not(.disabled).active:focus,.btn-twitter:not(:disabled):not(.disabled):active:focus,.show>.btn-twitter.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(38,184,240,.5)}.btn-linkedin{color:#fff;background-color:#4875b4;border-color:#4875b4}.btn-linkedin.focus,.btn-linkedin:focus,.btn-linkedin:hover{color:#fff;background-color:#3d6399;border-color:#395d90}.btn-linkedin.focus,.btn-linkedin:focus{box-shadow:0 0 0 .2rem rgba(99,138,191,.5)}.btn-linkedin.disabled,.btn-linkedin:disabled{color:#fff;background-color:#4875b4;border-color:#4875b4}.btn-linkedin:not(:disabled):not(.disabled).active,.btn-linkedin:not(:disabled):not(.disabled):active,.show>.btn-linkedin.dropdown-toggle{color:#fff;background-color:#395d90;border-color:#365786}.btn-linkedin:not(:disabled):not(.disabled).active:focus,.btn-linkedin:not(:disabled):not(.disabled):active:focus,.show>.btn-linkedin.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(99,138,191,.5)}.btn-flickr{color:#fff;background-color:#ff0084;border-color:#ff0084}.btn-flickr.focus,.btn-flickr:focus,.btn-flickr:hover{color:#fff;background-color:#d90070;border-color:#cc006a}.btn-flickr.focus,.btn-flickr:focus{box-shadow:0 0 0 .2rem rgba(255,38,150,.5)}.btn-flickr.disabled,.btn-flickr:disabled{color:#fff;background-color:#ff0084;border-color:#ff0084}.btn-flickr:not(:disabled):not(.disabled).active,.btn-flickr:not(:disabled):not(.disabled):active,.show>.btn-flickr.dropdown-toggle{color:#fff;background-color:#cc006a;border-color:#bf0063}.btn-flickr:not(:disabled):not(.disabled).active:focus,.btn-flickr:not(:disabled):not(.disabled):active:focus,.show>.btn-flickr.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,38,150,.5)}.btn-tumblr{color:#fff;background-color:#32506d;border-color:#32506d}.btn-tumblr.focus,.btn-tumblr:focus,.btn-tumblr:hover{color:#fff;background-color:#263d53;border-color:#22364a}.btn-tumblr.focus,.btn-tumblr:focus{box-shadow:0 0 0 .2rem rgba(81,106,131,.5)}.btn-tumblr.disabled,.btn-tumblr:disabled{color:#fff;background-color:#32506d;border-color:#32506d}.btn-tumblr:not(:disabled):not(.disabled).active,.btn-tumblr:not(:disabled):not(.disabled):active,.show>.btn-tumblr.dropdown-toggle{color:#fff;background-color:#22364a;border-color:#1e3041}.btn-tumblr:not(:disabled):not(.disabled).active:focus,.btn-tumblr:not(:disabled):not(.disabled):active:focus,.show>.btn-tumblr.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(81,106,131,.5)}.btn-xing{color:#fff;background-color:#026466;border-color:#026466}.btn-xing.focus,.btn-xing:focus,.btn-xing:hover{color:#fff;background-color:#013f40;border-color:#013334}.btn-xing.focus,.btn-xing:focus{box-shadow:0 0 0 .2rem rgba(40,123,125,.5)}.btn-xing.disabled,.btn-xing:disabled{color:#fff;background-color:#026466;border-color:#026466}.btn-xing:not(:disabled):not(.disabled).active,.btn-xing:not(:disabled):not(.disabled):active,.show>.btn-xing.dropdown-toggle{color:#fff;background-color:#013334;border-color:#012727}.btn-xing:not(:disabled):not(.disabled).active:focus,.btn-xing:not(:disabled):not(.disabled):active:focus,.show>.btn-xing.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(40,123,125,.5)}.btn-github{color:#fff;background-color:#4183c4;border-color:#4183c4}.btn-github.focus,.btn-github:focus,.btn-github:hover{color:#fff;background-color:#3570aa;border-color:#3269a0}.btn-github.focus,.btn-github:focus{box-shadow:0 0 0 .2rem rgba(94,150,205,.5)}.btn-github.disabled,.btn-github:disabled{color:#fff;background-color:#4183c4;border-color:#4183c4}.btn-github:not(:disabled):not(.disabled).active,.btn-github:not(:disabled):not(.disabled):active,.show>.btn-github.dropdown-toggle{color:#fff;background-color:#3269a0;border-color:#2f6397}.btn-github:not(:disabled):not(.disabled).active:focus,.btn-github:not(:disabled):not(.disabled):active:focus,.show>.btn-github.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(94,150,205,.5)}.btn-stack-overflow{color:#fff;background-color:#fe7a15;border-color:#fe7a15}.btn-stack-overflow.focus,.btn-stack-overflow:focus,.btn-stack-overflow:hover{color:#fff;background-color:#ec6701;border-color:#df6101}.btn-stack-overflow.focus,.btn-stack-overflow:focus{box-shadow:0 0 0 .2rem rgba(254,142,56,.5)}.btn-stack-overflow.disabled,.btn-stack-overflow:disabled{color:#fff;background-color:#fe7a15;border-color:#fe7a15}.btn-stack-overflow:not(:disabled):not(.disabled).active,.btn-stack-overflow:not(:disabled):not(.disabled):active,.show>.btn-stack-overflow.dropdown-toggle{color:#fff;background-color:#df6101;border-color:#d25c01}.btn-stack-overflow:not(:disabled):not(.disabled).active:focus,.btn-stack-overflow:not(:disabled):not(.disabled):active:focus,.show>.btn-stack-overflow.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(254,142,56,.5)}.btn-youtube{color:#fff;background-color:#b00;border-color:#b00}.btn-youtube.focus,.btn-youtube:focus,.btn-youtube:hover{color:#fff;background-color:#950000;border-color:#800}.btn-youtube.focus,.btn-youtube:focus{box-shadow:0 0 0 .2rem rgba(197,38,38,.5)}.btn-youtube.disabled,.btn-youtube:disabled{color:#fff;background-color:#b00;border-color:#b00}.btn-youtube:not(:disabled):not(.disabled).active,.btn-youtube:not(:disabled):not(.disabled):active,.show>.btn-youtube.dropdown-toggle{color:#fff;background-color:#800;border-color:#7b0000}.btn-youtube:not(:disabled):not(.disabled).active:focus,.btn-youtube:not(:disabled):not(.disabled):active:focus,.show>.btn-youtube.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(197,38,38,.5)}.btn-dribbble{color:#fff;background-color:#ea4c89;border-color:#ea4c89}.btn-dribbble.focus,.btn-dribbble:focus,.btn-dribbble:hover{color:#fff;background-color:#e62a72;border-color:#e51e6b}.btn-dribbble.focus,.btn-dribbble:focus{box-shadow:0 0 0 .2rem rgba(237,103,155,.5)}.btn-dribbble.disabled,.btn-dribbble:disabled{color:#fff;background-color:#ea4c89;border-color:#ea4c89}.btn-dribbble:not(:disabled):not(.disabled).active,.btn-dribbble:not(:disabled):not(.disabled):active,.show>.btn-dribbble.dropdown-toggle{color:#fff;background-color:#e51e6b;border-color:#dc1a65}.btn-dribbble:not(:disabled):not(.disabled).active:focus,.btn-dribbble:not(:disabled):not(.disabled):active:focus,.show>.btn-dribbble.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(237,103,155,.5)}.btn-instagram{color:#fff;background-color:#517fa4;border-color:#517fa4}.btn-instagram.focus,.btn-instagram:focus,.btn-instagram:hover{color:#fff;background-color:#446b8a;border-color:#406582}.btn-instagram.focus,.btn-instagram:focus{box-shadow:0 0 0 .2rem rgba(107,146,178,.5)}.btn-instagram.disabled,.btn-instagram:disabled{color:#fff;background-color:#517fa4;border-color:#517fa4}.btn-instagram:not(:disabled):not(.disabled).active,.btn-instagram:not(:disabled):not(.disabled):active,.show>.btn-instagram.dropdown-toggle{color:#fff;background-color:#406582;border-color:#3c5e79}.btn-instagram:not(:disabled):not(.disabled).active:focus,.btn-instagram:not(:disabled):not(.disabled):active:focus,.show>.btn-instagram.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(107,146,178,.5)}.btn-pinterest{color:#fff;background-color:#cb2027;border-color:#cb2027}.btn-pinterest.focus,.btn-pinterest:focus,.btn-pinterest:hover{color:#fff;background-color:#aa1b21;border-color:#9f191f}.btn-pinterest.focus,.btn-pinterest:focus{box-shadow:0 0 0 .2rem rgba(211,65,71,.5)}.btn-pinterest.disabled,.btn-pinterest:disabled{color:#fff;background-color:#cb2027;border-color:#cb2027}.btn-pinterest:not(:disabled):not(.disabled).active,.btn-pinterest:not(:disabled):not(.disabled):active,.show>.btn-pinterest.dropdown-toggle{color:#fff;background-color:#9f191f;border-color:#94171c}.btn-pinterest:not(:disabled):not(.disabled).active:focus,.btn-pinterest:not(:disabled):not(.disabled):active:focus,.show>.btn-pinterest.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(211,65,71,.5)}.btn-vk{color:#fff;background-color:#45668e;border-color:#45668e}.btn-vk.focus,.btn-vk:focus,.btn-vk:hover{color:#fff;background-color:#385474;border-color:#344d6c}.btn-vk.focus,.btn-vk:focus{box-shadow:0 0 0 .2rem rgba(97,125,159,.5)}.btn-vk.disabled,.btn-vk:disabled{color:#fff;background-color:#45668e;border-color:#45668e}.btn-vk:not(:disabled):not(.disabled).active,.btn-vk:not(:disabled):not(.disabled):active,.show>.btn-vk.dropdown-toggle{color:#fff;background-color:#344d6c;border-color:#304763}.btn-vk:not(:disabled):not(.disabled).active:focus,.btn-vk:not(:disabled):not(.disabled):active:focus,.show>.btn-vk.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(97,125,159,.5)}.btn-yahoo{color:#fff;background-color:#400191;border-color:#400191}.btn-yahoo.focus,.btn-yahoo:focus,.btn-yahoo:hover{color:#fff;background-color:#2f016b;border-color:#2a015e}.btn-yahoo.focus,.btn-yahoo:focus{box-shadow:0 0 0 .2rem rgba(93,39,162,.5)}.btn-yahoo.disabled,.btn-yahoo:disabled{color:#fff;background-color:#400191;border-color:#400191}.btn-yahoo:not(:disabled):not(.disabled).active,.btn-yahoo:not(:disabled):not(.disabled):active,.show>.btn-yahoo.dropdown-toggle{color:#fff;background-color:#2a015e;border-color:#240152}.btn-yahoo:not(:disabled):not(.disabled).active:focus,.btn-yahoo:not(:disabled):not(.disabled):active:focus,.show>.btn-yahoo.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(93,39,162,.5)}.btn-behance{color:#fff;background-color:#1769ff;border-color:#1769ff}.btn-behance.focus,.btn-behance:focus,.btn-behance:hover{color:#fff;background-color:#0055f0;border-color:#0050e3}.btn-behance.focus,.btn-behance:focus{box-shadow:0 0 0 .2rem rgba(58,128,255,.5)}.btn-behance.disabled,.btn-behance:disabled{color:#fff;background-color:#1769ff;border-color:#1769ff}.btn-behance:not(:disabled):not(.disabled).active,.btn-behance:not(:disabled):not(.disabled):active,.show>.btn-behance.dropdown-toggle{color:#fff;background-color:#0050e3;border-color:#004cd6}.btn-behance:not(:disabled):not(.disabled).active:focus,.btn-behance:not(:disabled):not(.disabled):active:focus,.show>.btn-behance.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(58,128,255,.5)}.btn-reddit{color:#fff;background-color:#ff4500;border-color:#ff4500}.btn-reddit.focus,.btn-reddit:focus,.btn-reddit:hover{color:#fff;background-color:#d93b00;border-color:#cc3700}.btn-reddit.focus,.btn-reddit:focus{box-shadow:0 0 0 .2rem rgba(255,97,38,.5)}.btn-reddit.disabled,.btn-reddit:disabled{color:#fff;background-color:#ff4500;border-color:#ff4500}.btn-reddit:not(:disabled):not(.disabled).active,.btn-reddit:not(:disabled):not(.disabled):active,.show>.btn-reddit.dropdown-toggle{color:#fff;background-color:#cc3700;border-color:#bf3400}.btn-reddit:not(:disabled):not(.disabled).active:focus,.btn-reddit:not(:disabled):not(.disabled):active:focus,.show>.btn-reddit.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,97,38,.5)}.btn-vimeo{color:#4f5d73;background-color:#aad450;border-color:#aad450}.btn-vimeo.focus,.btn-vimeo:focus,.btn-vimeo:hover{color:#4f5d73;background-color:#9bcc32;border-color:#93c130}.btn-vimeo.focus,.btn-vimeo:focus{box-shadow:0 0 0 .2rem rgba(156,194,85,.5)}.btn-vimeo.disabled,.btn-vimeo:disabled{color:#4f5d73;background-color:#aad450;border-color:#aad450}.btn-vimeo:not(:disabled):not(.disabled).active,.btn-vimeo:not(:disabled):not(.disabled):active,.show>.btn-vimeo.dropdown-toggle{color:#4f5d73;background-color:#93c130;border-color:#8bb72d}.btn-vimeo:not(:disabled):not(.disabled).active:focus,.btn-vimeo:not(:disabled):not(.disabled):active:focus,.show>.btn-vimeo.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(156,194,85,.5)}.c-callout{position:relative;padding:0 1rem;margin:1rem 0;border-radius:.25rem}html:not([dir=rtl]) .c-callout{border-left:4px solid #d8dbe0}[dir=rtl] .c-callout{border-right:4px solid #d8dbe0}.c-callout-bordered{border:1px solid #d8dbe0;border-left-width:4px}.c-callout code{border-radius:.25rem}.c-callout h4{margin-top:0;margin-bottom:.25rem}.c-callout p:last-child{margin-bottom:0}.c-callout+.c-callout{margin-top:-.25rem}html:not([dir=rtl]) .c-callout-primary{border-left-color:#321fdb}[dir=rtl] .c-callout-primary{border-right-color:#321fdb}.c-callout-primary h4{color:#321fdb}html:not([dir=rtl]) .c-callout-secondary{border-left-color:#ced2d8}[dir=rtl] .c-callout-secondary{border-right-color:#ced2d8}.c-callout-secondary h4{color:#ced2d8}html:not([dir=rtl]) .c-callout-success{border-left-color:#2eb85c}[dir=rtl] .c-callout-success{border-right-color:#2eb85c}.c-callout-success h4{color:#2eb85c}html:not([dir=rtl]) .c-callout-info{border-left-color:#39f}[dir=rtl] .c-callout-info{border-right-color:#39f}.c-callout-info h4{color:#39f}html:not([dir=rtl]) .c-callout-warning{border-left-color:#f9b115}[dir=rtl] .c-callout-warning{border-right-color:#f9b115}.c-callout-warning h4{color:#f9b115}html:not([dir=rtl]) .c-callout-danger{border-left-color:#e55353}[dir=rtl] .c-callout-danger{border-right-color:#e55353}.c-callout-danger h4{color:#e55353}html:not([dir=rtl]) .c-callout-light{border-left-color:#ebedef}[dir=rtl] .c-callout-light{border-right-color:#ebedef}.c-callout-light h4{color:#ebedef}html:not([dir=rtl]) .c-callout-dark{border-left-color:#636f83}[dir=rtl] .c-callout-dark{border-right-color:#636f83}.c-callout-dark h4{color:#636f83}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;margin-bottom:1.5rem;word-wrap:break-word;background-clip:border-box;border:1px solid;border-radius:.25rem;background-color:#fff;border-color:#d8dbe0}.card>hr{margin-right:0;margin-left:0}.card>.list-group:first-child .list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card>.list-group:last-child .list-group-item:last-child{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.card .drag,.card.drag{cursor:move}.card.bg-primary{border-color:#2517a3}.card.bg-primary .card-header{background-color:#2f1dce;border-color:#2517a3}.card.bg-secondary{border-color:#abb1bc}.card.bg-secondary .card-header{background-color:#c5cad1;border-color:#abb1bc}.card.bg-success{border-color:#218543}.card.bg-success .card-header{background-color:#2bac56;border-color:#218543}.card.bg-info{border-color:#0079f2}.card.bg-info .card-header{background-color:#2491ff;border-color:#0079f2}.card.bg-warning{border-color:#c98b05}.card.bg-warning .card-header{background-color:#f8ac06;border-color:#c98b05}.card.bg-danger{border-color:#d82121}.card.bg-danger .card-header{background-color:#e34646;border-color:#d82121}.card.bg-light{border-color:#c8cdd3}.card.bg-light .card-header{background-color:#e3e5e8;border-color:#c8cdd3}.card.bg-dark{border-color:#48505f}.card.bg-dark .card-header{background-color:#5c687a;border-color:#48505f}.card-body{-ms-flex:1 1 auto;flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem}.card-subtitle,.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}html:not([dir=rtl]) .card-link+.card-link{margin-left:1.25rem}[dir=rtl] .card-link+.card-link{margin-right:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;border-bottom:1px solid;background-color:#fff;border-color:#d8dbe0}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-header+.list-group .list-group-item:first-child{border-top:0}.card-header .c-chart-wrapper{position:absolute;top:0;right:0;width:100%;height:100%}.card-footer{padding:.75rem 1.25rem;border-top:1px solid;background-color:#fff;border-color:#d8dbe0}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-bottom:-.75rem;border-bottom:0}.card-header-pills,.card-header-tabs{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem}.card-img,.card-img-bottom,.card-img-top{-ms-flex-negative:0;flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck .card{margin-bottom:15px}@media (min-width:576px){.card-deck{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{-ms-flex:1 0 0%;flex:1 0 0%;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group>.card{margin-bottom:15px}@media (min-width:576px){.card-group{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}html:not([dir=rtl]) .card-group>.card+.card{margin-left:0;border-left:0}[dir=rtl] .card-group>.card+.card{margin-right:0;border-right: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}}.card-columns .card{margin-bottom:.75rem}@media (min-width:576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.card-placeholder{background:rgba(0,0,21,.025);border:1px dashed #c4c9d0}.card-header-icon-bg{width:2.8125rem;padding:.75rem 0;margin:-.75rem 1.25rem -.75rem -1.25rem;line-height:inherit;color:#4f5d73;text-align:center;background:0 0;border-right:1px solid;border-right:#d8dbe0}.card-header-actions,.card-header-icon-bg{display:inline-block}html:not([dir=rtl]) .card-header-actions{float:right;margin-right:-.25rem}[dir=rtl] .card-header-actions{float:left;margin-left:-.25rem}.card-header-action{padding:0 .25rem;color:#8a93a2}.card-header-action:hover{color:#4f5d73;text-decoration:none}.card-accent-primary{border-top:2px solid #321fdb!important}.card-accent-secondary{border-top:2px solid #ced2d8!important}.card-accent-success{border-top:2px solid #2eb85c!important}.card-accent-info{border-top:2px solid #39f!important}.card-accent-warning{border-top:2px solid #f9b115!important}.card-accent-danger{border-top:2px solid #e55353!important}.card-accent-light{border-top:2px solid #ebedef!important}.card-accent-dark{border-top:2px solid #636f83!important}.card-full{margin-top:-1rem;margin-right:-15px;margin-left:-15px;border:0;border-bottom:1px solid #d8dbe0}@media (min-width:576px){.card-columns.cols-2{-webkit-column-count:2;-moz-column-count:2;column-count:2}}.carousel{position:relative}.carousel.pointer-event{-ms-touch-action:pan-y;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%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:-webkit-transform .6s ease-in-out;transition:transform .6s ease-in-out;transition:transform .6s ease-in-out,-webkit-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-right,.carousel-item-next:not(.carousel-item-left){-webkit-transform:translateX(100%);transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){-webkit-transform:translateX(-100%);transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;-webkit-transform:none;transform:none}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:15%;color:#fff;text-align:center;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:20px;height:20px;background:no-repeat 50%/100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:15;display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;margin-right:15%;margin-left:15%;list-style:none}html:not([dir=rtl]) .carousel-indicators{padding-left:0}[dir=rtl] .carousel-indicators{padding-right:0}.carousel-indicators li{box-sizing:content-box;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}.c-chart-wrapper canvas{width:100%}base-chart.chart{display:block}canvas{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.close{float:right;font-size:1.3125rem;font-weight:700;line-height:1;opacity:.5;color:#000015;text-shadow:0 1px 0 #fff}.close:hover{text-decoration:none;color:#000015}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{padding:0;background-color:transparent;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none}a.close.disabled{pointer-events:none}code{font-size:87.5%;color:#e83e8c;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#4f5d73;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.custom-control{position:relative;display:block;min-height:1.3125rem}html:not([dir=rtl]) .custom-control{padding-left:1.5rem}[dir=rtl] .custom-control{padding-right:1.5rem}.custom-control-inline{display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;z-index:-1;width:1rem;height:1.15625rem;opacity:0}html:not([dir=rtl]) .custom-control-input{left:0}[dir=rtl] .custom-control-input{right:0}.custom-control-input:checked~.custom-control-label::before{color:#fff;border-color:#321fdb;background-color:#321fdb}.custom-control-input:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(50,31,219,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label::before{border-color:#958bef}.custom-control-input:not(:disabled):active~.custom-control-label::before{color:#fff;background-color:#beb8f5;border-color:#beb8f5}.custom-control-input:disabled~.custom-control-label,.custom-control-input[disabled]~.custom-control-label{color:#8a93a2}.custom-control-input:disabled~.custom-control-label::before,.custom-control-input[disabled]~.custom-control-label::before{background-color:#d8dbe0}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:.15625rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";border:solid 1px;background-color:#fff;border-color:#9da5b1}html:not([dir=rtl]) .custom-control-label::before{left:-1.5rem}[dir=rtl] .custom-control-label::before{right:-1.5rem}.custom-control-label::after{position:absolute;top:.15625rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50%/50% 50%}html:not([dir=rtl]) .custom-control-label::after{left:-1.5rem}[dir=rtl] .custom-control-label::after{right:-1.5rem}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::before{border-color:#321fdb;background-color:#321fdb}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(50,31,219,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label::before{background-color:rgba(50,31,219,.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(50,31,219,.5)}html:not([dir=rtl]) .custom-switch{padding-left:2.25rem}[dir=rtl] .custom-switch{padding-right:2.25rem}.custom-switch .custom-control-label::before{width:1.75rem;pointer-events:all;border-radius:.5rem}html:not([dir=rtl]) .custom-switch .custom-control-label::before{left:-2.25rem}[dir=rtl] .custom-switch .custom-control-label::before{right:-2.25rem}.custom-switch .custom-control-label::after{top:calc(.15625rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#9da5b1;border-radius:.5rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out;background-color:#9da5b1}html:not([dir=rtl]) .custom-switch .custom-control-label::after{left:calc(-2.25rem + 2px)}[dir=rtl] .custom-switch .custom-control-label::after{right:calc(-2.25rem + 2px)}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label::after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label::after{background-color:#fff;-webkit-transform:translateX(.75rem);transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(50,31,219,.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem 1.75rem .375rem .75rem;font-size:.875rem;font-weight:400;line-height:1.5;vertical-align:middle;border:1px solid;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none;color:#768192;background:#fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23636f83' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;border-color:#d8dbe0}.custom-select:focus{border-color:#958bef;outline:0;box-shadow:0 0 0 .2rem rgba(50,31,219,.25)}.custom-select:focus::-ms-value{color:#768192;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;background-image:none}html:not([dir=rtl]) .custom-select[multiple],html:not([dir=rtl]) .custom-select[size]:not([size="1"]){padding-right:.75rem}[dir=rtl] .custom-select[multiple],[dir=rtl] .custom-select[size]:not([size="1"]){padding-left:.75rem}.custom-select:disabled{color:#8a93a2;background-color:#d8dbe0}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #768192}.custom-select-sm{height:calc(1.5em + .5rem + 2px);padding-top:.25rem;padding-bottom:.25rem;font-size:.765625rem}html:not([dir=rtl]) .custom-select-sm{padding-left:.5rem}[dir=rtl] .custom-select-sm{padding-right:.5rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:.5rem;padding-bottom:.5rem;font-size:1.09375rem}html:not([dir=rtl]) .custom-select-lg{padding-left:1rem}[dir=rtl] .custom-select-lg{padding-right:1rem}.custom-file{display:inline-block;margin-bottom:0}.custom-file,.custom-file-input{position:relative;width:100%;height:calc(1.5em + .75rem + 2px)}.custom-file-input{z-index:2;margin:0;opacity:0}.custom-file-input:focus~.custom-file-label{box-shadow:0 0 0 .2rem rgba(50,31,219,.25);border-color:#958bef}.custom-file-input:disabled~.custom-file-label,.custom-file-input[disabled]~.custom-file-label{background-color:#d8dbe0}.custom-file-input:lang(en)~.custom-file-label::after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{right:0;left:0;z-index:1;height:calc(1.5em + .75rem + 2px);font-weight:400;border:1px solid;border-radius:.25rem;background-color:#fff;border-color:#d8dbe0}.custom-file-label,.custom-file-label::after{position:absolute;top:0;padding:.375rem .75rem;line-height:1.5;color:#768192}.custom-file-label::after{bottom:0;z-index:3;display:block;height:calc(1.5em + .75rem);content:"Browse";border-left:inherit;border-radius:0 .25rem .25rem 0;background-color:#ebedef}html:not([dir=rtl]) .custom-file-label::after{right:0}[dir=rtl] .custom-file-label::after{left:0}.custom-range{width:100%;height:1.4rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #ebedef,0 0 0 .2rem rgba(50,31,219,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #ebedef,0 0 0 .2rem rgba(50,31,219,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #ebedef,0 0 0 .2rem rgba(50,31,219,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#321fdb;border:0;border-radius:1rem;-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;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:ghten(#321fdb,35%)}.custom-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;border-color:transparent;border-radius:1rem;background-color:#c4c9d0}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#321fdb;border:0;border-radius:1rem;-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;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{-moz-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:ghten(#321fdb,35%)}.custom-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#c4c9d0;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:.2rem;margin-left:.2rem;background-color:#321fdb;border:0;border-radius:1rem;-ms-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;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{-ms-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:ghten(#321fdb,35%)}.custom-range::-ms-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:.5rem}.custom-range::-ms-fill-lower,.custom-range::-ms-fill-upper{background-color:#c4c9d0;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px}.custom-range:disabled::-webkit-slider-thumb{background-color:#9da5b1}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#9da5b1}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#9da5b1}.custom-control-label::before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label::before,.custom-file-label,.custom-select{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}html:not([dir=rtl]) .dropdown-toggle::after{margin-left:.255em}[dir=rtl] .dropdown-toggle::after{margin-right:.255em}html:not([dir=rtl]) .dropdown-toggle:empty::after{margin-left:0}[dir=rtl] .dropdown-toggle:empty::after{margin-right:0}.dropdown-menu{position:absolute;top:100%;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:.875rem;text-align:left;list-style:none;background-clip:padding-box;border:1px solid;border-radius:.25rem;color:#4f5d73;background-color:#fff;border-color:#d8dbe0}html:not([dir=rtl]) .dropdown-menu{left:0}[dir=rtl] .dropdown-menu{right:0}html:not([dir=rtl]) .dropdown-menu-left{right:auto;left:0}[dir=rtl] .dropdown-menu-left,html:not([dir=rtl]) .dropdown-menu-right{right:0;left:auto}[dir=rtl] .dropdown-menu-right{right:auto;left:0}@media (min-width:576px){html:not([dir=rtl]) .dropdown-menu-sm-left{right:auto;left:0}[dir=rtl] .dropdown-menu-sm-left,html:not([dir=rtl]) .dropdown-menu-sm-right{right:0;left:auto}[dir=rtl] .dropdown-menu-sm-right{right:auto;left:0}}@media (min-width:768px){html:not([dir=rtl]) .dropdown-menu-md-left{right:auto;left:0}[dir=rtl] .dropdown-menu-md-left,html:not([dir=rtl]) .dropdown-menu-md-right{right:0;left:auto}[dir=rtl] .dropdown-menu-md-right{right:auto;left:0}}@media (min-width:992px){html:not([dir=rtl]) .dropdown-menu-lg-left{right:auto;left:0}[dir=rtl] .dropdown-menu-lg-left,html:not([dir=rtl]) .dropdown-menu-lg-right{right:0;left:auto}[dir=rtl] .dropdown-menu-lg-right{right:auto;left:0}}@media (min-width:1200px){html:not([dir=rtl]) .dropdown-menu-xl-left{right:auto;left:0}[dir=rtl] .dropdown-menu-xl-left,html:not([dir=rtl]) .dropdown-menu-xl-right{right:0;left:auto}[dir=rtl] .dropdown-menu-xl-right{right:auto;left:0}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}html:not([dir=rtl]) .dropup .dropdown-toggle::after{margin-left:.255em}[dir=rtl] .dropup .dropdown-toggle::after{margin-right:.255em}html:not([dir=rtl]) .dropup .dropdown-toggle:empty::after{margin-left:0}[dir=rtl] .dropup .dropdown-toggle:empty::after{margin-right:0}.dropright .dropdown-menu{top:0;margin-top:0}html:not([dir=rtl]) .dropright .dropdown-menu{right:auto;left:100%;margin-left:.125rem}[dir=rtl] .dropright .dropdown-menu{right:100%;left:auto;margin-right:.125rem}.dropright .dropdown-toggle::after{display:inline-block;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid;vertical-align:0}html:not([dir=rtl]) .dropright .dropdown-toggle::after{margin-left:.255em}[dir=rtl] .dropright .dropdown-toggle::after{margin-right:.255em}html:not([dir=rtl]) .dropright .dropdown-toggle:empty::after{margin-left:0}[dir=rtl] .dropright .dropdown-toggle:empty::after{margin-right:0}.dropleft .dropdown-menu{top:0;margin-top:0}html:not([dir=rtl]) .dropleft .dropdown-menu{right:100%;left:auto;margin-right:.125rem}[dir=rtl] .dropleft .dropdown-menu{right:auto;left:100%;margin-left:.125rem}.dropleft .dropdown-toggle::after{display:inline-block;vertical-align:.255em;content:"";display:none}html:not([dir=rtl]) .dropleft .dropdown-toggle::after{margin-left:.255em}[dir=rtl] .dropleft .dropdown-toggle::after{margin-right:.255em}.dropleft .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;vertical-align:0}html:not([dir=rtl]) .dropleft .dropdown-toggle:empty::after{margin-left:0}[dir=rtl] .dropleft .dropdown-toggle:empty::after{margin-right:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #d8dbe0}.dropdown-item{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;width:100%;padding:.5rem 1.25rem;clear:both;font-weight:400;text-align:inherit;white-space:nowrap;background-color:transparent;border:0;color:#4f5d73}.dropdown-item:focus,.dropdown-item:hover{text-decoration:none;color:#455164;background-color:#ebedef}.dropdown-item.active,.dropdown-item:active{text-decoration:none;color:#fff;background-color:#321fdb}.dropdown-item.disabled,.dropdown-item:disabled{pointer-events:none;background-color:transparent;color:#8a93a2}.dropdown-menu.show{display:block}.dropdown-header{margin-bottom:0;font-size:.765625rem;white-space:nowrap;color:#8a93a2}.dropdown-header,.dropdown-item-text{display:block;padding:.5rem 1.25rem}.c-footer,.dropdown-item-text{color:#4f5d73}.c-footer{display:-ms-flexbox;display:flex;-ms-flex:0 0 50px;flex:0 0 50px;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;height:50px;padding:0 1rem;background:#ebedef;border-top:1px solid #d8dbe0}.c-footer[class*=bg-]{border-color:rgba(0,0,21,.1)}.c-footer.c-footer-fixed{position:fixed;right:0;bottom:0;left:0;z-index:1030}.c-footer.c-footer-dark{color:#fff;background:#636f83}.form-control{display:block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-size:.875rem;font-weight:400;line-height:1.5;background-clip:padding-box;border:1px solid;color:#768192;background-color:#fff;border-color:#d8dbe0;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #768192}.form-control:focus{color:#768192;background-color:#fff;border-color:#958bef;outline:0;box-shadow:0 0 0 .2rem rgba(50,31,219,.25)}.form-control::-webkit-input-placeholder{color:#8a93a2;opacity:1}.form-control::-moz-placeholder{color:#8a93a2;opacity:1}.form-control:-ms-input-placeholder{color:#8a93a2;opacity:1}.form-control::-ms-input-placeholder{color:#8a93a2;opacity:1}.form-control::placeholder{color:#8a93a2;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#d8dbe0;opacity:1}select.form-control:focus::-ms-value{color:#768192;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.09375rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.765625rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;font-size:.875rem;line-height:1.5;background-color:transparent;border:solid transparent;border-width:1px 0;color:#4f5d73}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.765625rem;line-height:1.5;border-radius:.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.09375rem;line-height:1.5;border-radius:.3rem}select.form-control[multiple],select.form-control[size],textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*=col-]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block}html:not([dir=rtl]) .form-check{padding-left:1.25rem}[dir=rtl] .form-check{padding-right:1.25rem}.form-check-input{position:absolute;margin-top:.3rem}html:not([dir=rtl]) .form-check-input{margin-left:-1.25rem}[dir=rtl] .form-check-input{margin-right:-1.25rem}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{color:#768192}.form-check-label{margin-bottom:0}.form-check-inline{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center}html:not([dir=rtl]) .form-check-inline{padding-left:0;margin-right:.75rem}[dir=rtl] .form-check-inline{padding-right:0;margin-left:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0}html:not([dir=rtl]) .form-check-inline .form-check-input{margin-right:.3125rem;margin-left:0}[dir=rtl] .form-check-inline .form-check-input{margin-right:0;margin-left:.3125rem}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#2eb85c}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.765625rem;line-height:1.5;color:#fff;background-color:rgba(46,184,92,.9);border-radius:.25rem}.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:#2eb85c;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%232eb85c' d='M2.3 6.73L.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.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}html:not([dir=rtl]) .form-control.is-valid,html:not([dir=rtl]) .was-validated .form-control:valid{padding-right:calc(1.5em + .75rem);background-position:right calc(.375em + .1875rem) center}[dir=rtl] .form-control.is-valid,[dir=rtl] .was-validated .form-control:valid{padding-left:calc(1.5em + .75rem);background-position:left calc(.375em + .1875rem) center}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#2eb85c;box-shadow:0 0 0 .2rem rgba(46,184,92,.25)}html:not([dir=rtl]) .was-validated textarea.form-control:valid,html:not([dir=rtl]) textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}[dir=rtl] .was-validated textarea.form-control:valid,[dir=rtl] textarea.form-control.is-valid{padding-left:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) left calc(.375em + .1875rem)}.custom-select.is-valid,.was-validated .custom-select:valid{border-color:#2eb85c;background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23636f83' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%232eb85c' d='M2.3 6.73L.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.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}html:not([dir=rtl]) .custom-select.is-valid,html:not([dir=rtl]) .was-validated .custom-select:valid{padding-right:calc(.75em + 2.3125rem)}[dir=rtl] .custom-select.is-valid,[dir=rtl] .was-validated .custom-select:valid{padding-left:calc(.75em + 2.3125rem)}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#2eb85c;box-shadow:0 0 0 .2rem rgba(46,184,92,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#2eb85c}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#2eb85c}.custom-control-input.is-valid~.custom-control-label::before,.was-validated .custom-control-input:valid~.custom-control-label::before{border-color:#2eb85c}.custom-control-input.is-valid:checked~.custom-control-label::before,.was-validated .custom-control-input:valid:checked~.custom-control-label::before{border-color:#48d176;background-color:#48d176}.custom-control-input.is-valid:focus~.custom-control-label::before,.was-validated .custom-control-input:valid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(46,184,92,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label::before,.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#2eb85c}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#2eb85c;box-shadow:0 0 0 .2rem rgba(46,184,92,.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#e55353}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.765625rem;line-height:1.5;color:#fff;background-color:rgba(229,83,83,.9);border-radius:.25rem}.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:#e55353;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23e55353' viewBox='0 0 12 12'%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='%23e55353' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}html:not([dir=rtl]) .form-control.is-invalid,html:not([dir=rtl]) .was-validated .form-control:invalid{padding-right:calc(1.5em + .75rem);background-position:right calc(.375em + .1875rem) center}[dir=rtl] .form-control.is-invalid,[dir=rtl] .was-validated .form-control:invalid{padding-left:calc(1.5em + .75rem);background-position:left calc(.375em + .1875rem) center}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#e55353;box-shadow:0 0 0 .2rem rgba(229,83,83,.25)}html:not([dir=rtl]) .was-validated textarea.form-control:invalid,html:not([dir=rtl]) textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}[dir=rtl] .was-validated textarea.form-control:invalid,[dir=rtl] textarea.form-control.is-invalid{padding-left:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) left calc(.375em + .1875rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{border-color:#e55353;background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23636f83' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23e55353' viewBox='0 0 12 12'%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='%23e55353' stroke='none'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}html:not([dir=rtl]) .custom-select.is-invalid,html:not([dir=rtl]) .was-validated .custom-select:invalid{padding-right:calc(.75em + 2.3125rem)}[dir=rtl] .custom-select.is-invalid,[dir=rtl] .was-validated .custom-select:invalid{padding-left:calc(.75em + 2.3125rem)}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#e55353;box-shadow:0 0 0 .2rem rgba(229,83,83,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#e55353}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#e55353}.custom-control-input.is-invalid~.custom-control-label::before,.was-validated .custom-control-input:invalid~.custom-control-label::before{border-color:#e55353}.custom-control-input.is-invalid:checked~.custom-control-label::before,.was-validated .custom-control-input:invalid:checked~.custom-control-label::before{border-color:#ec7f7f;background-color:#ec7f7f}.custom-control-input.is-invalid:focus~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(229,83,83,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label::before,.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#e55353}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#e55353;box-shadow:0 0 0 .2rem rgba(229,83,83,.25)}.form-inline{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width:576px){.form-inline label{-ms-flex-align:center;-ms-flex-pack:center;justify-content:center}.form-inline .form-group,.form-inline label{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-group{-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto}html:not([dir=rtl]) .form-inline .form-check{padding-left:0}[dir=rtl] .form-inline .form-check{padding-right:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0}html:not([dir=rtl]) .form-inline .form-check-input{margin-right:.25rem;margin-left:0}[dir=rtl] .form-inline .form-check-input{margin-right:0;margin-left:.25rem}.form-inline .custom-control{-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1140px}}.container-fluid,.container-lg,.container-md,.container-sm,.container-xl{width:100%;padding-right:15px;padding-left:15px;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}}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-3>*{-ms-flex:0 0 33.33333333%;flex:0 0 33.33333333%;max-width:33.33333333%}.row-cols-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-6>*{-ms-flex:0 0 16.66666667%;flex:0 0 16.66666667%;max-width:16.66666667%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-ms-flex:0 0 8.33333333%;flex:0 0 8.33333333%;max-width:8.33333333%}.col-2{-ms-flex:0 0 16.66666667%;flex:0 0 16.66666667%;max-width:16.66666667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.33333333%;flex:0 0 33.33333333%;max-width:33.33333333%}.col-5{-ms-flex:0 0 41.66666667%;flex:0 0 41.66666667%;max-width:41.66666667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.33333333%;flex:0 0 58.33333333%;max-width:58.33333333%}.col-8{-ms-flex:0 0 66.66666667%;flex:0 0 66.66666667%;max-width:66.66666667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.33333333%;flex:0 0 83.33333333%;max-width:83.33333333%}.col-11{-ms-flex:0 0 91.66666667%;flex:0 0 91.66666667%;max-width:91.66666667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.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%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-sm-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{-ms-flex:0 0 33.33333333%;flex:0 0 33.33333333%;max-width:33.33333333%}.row-cols-sm-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{-ms-flex:0 0 16.66666667%;flex:0 0 16.66666667%;max-width:16.66666667%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-ms-flex:0 0 8.33333333%;flex:0 0 8.33333333%;max-width:8.33333333%}.col-sm-2{-ms-flex:0 0 16.66666667%;flex:0 0 16.66666667%;max-width:16.66666667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.33333333%;flex:0 0 33.33333333%;max-width:33.33333333%}.col-sm-5{-ms-flex:0 0 41.66666667%;flex:0 0 41.66666667%;max-width:41.66666667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.33333333%;flex:0 0 58.33333333%;max-width:58.33333333%}.col-sm-8{-ms-flex:0 0 66.66666667%;flex:0 0 66.66666667%;max-width:66.66666667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.33333333%;flex:0 0 83.33333333%;max-width:83.33333333%}.col-sm-11{-ms-flex:0 0 91.66666667%;flex:0 0 91.66666667%;max-width:91.66666667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.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%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-md-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-md-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-md-3>*{-ms-flex:0 0 33.33333333%;flex:0 0 33.33333333%;max-width:33.33333333%}.row-cols-md-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-md-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-md-6>*{-ms-flex:0 0 16.66666667%;flex:0 0 16.66666667%;max-width:16.66666667%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-ms-flex:0 0 8.33333333%;flex:0 0 8.33333333%;max-width:8.33333333%}.col-md-2{-ms-flex:0 0 16.66666667%;flex:0 0 16.66666667%;max-width:16.66666667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.33333333%;flex:0 0 33.33333333%;max-width:33.33333333%}.col-md-5{-ms-flex:0 0 41.66666667%;flex:0 0 41.66666667%;max-width:41.66666667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.33333333%;flex:0 0 58.33333333%;max-width:58.33333333%}.col-md-8{-ms-flex:0 0 66.66666667%;flex:0 0 66.66666667%;max-width:66.66666667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.33333333%;flex:0 0 83.33333333%;max-width:83.33333333%}.col-md-11{-ms-flex:0 0 91.66666667%;flex:0 0 91.66666667%;max-width:91.66666667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.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%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-lg-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{-ms-flex:0 0 33.33333333%;flex:0 0 33.33333333%;max-width:33.33333333%}.row-cols-lg-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{-ms-flex:0 0 16.66666667%;flex:0 0 16.66666667%;max-width:16.66666667%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-ms-flex:0 0 8.33333333%;flex:0 0 8.33333333%;max-width:8.33333333%}.col-lg-2{-ms-flex:0 0 16.66666667%;flex:0 0 16.66666667%;max-width:16.66666667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.33333333%;flex:0 0 33.33333333%;max-width:33.33333333%}.col-lg-5{-ms-flex:0 0 41.66666667%;flex:0 0 41.66666667%;max-width:41.66666667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.33333333%;flex:0 0 58.33333333%;max-width:58.33333333%}.col-lg-8{-ms-flex:0 0 66.66666667%;flex:0 0 66.66666667%;max-width:66.66666667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.33333333%;flex:0 0 83.33333333%;max-width:83.33333333%}.col-lg-11{-ms-flex:0 0 91.66666667%;flex:0 0 91.66666667%;max-width:91.66666667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.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%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-xl-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{-ms-flex:0 0 33.33333333%;flex:0 0 33.33333333%;max-width:33.33333333%}.row-cols-xl-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{-ms-flex:0 0 16.66666667%;flex:0 0 16.66666667%;max-width:16.66666667%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-ms-flex:0 0 8.33333333%;flex:0 0 8.33333333%;max-width:8.33333333%}.col-xl-2{-ms-flex:0 0 16.66666667%;flex:0 0 16.66666667%;max-width:16.66666667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.33333333%;flex:0 0 33.33333333%;max-width:33.33333333%}.col-xl-5{-ms-flex:0 0 41.66666667%;flex:0 0 41.66666667%;max-width:41.66666667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.33333333%;flex:0 0 58.33333333%;max-width:58.33333333%}.col-xl-8{-ms-flex:0 0 66.66666667%;flex:0 0 66.66666667%;max-width:66.66666667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.33333333%;flex:0 0 83.33333333%;max-width:83.33333333%}.col-xl-11{-ms-flex:0 0 91.66666667%;flex:0 0 91.66666667%;max-width:91.66666667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.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%}}.row.row-equal{margin-right:-15px;margin-left:-15px}.row.row-equal,.row.row-equal [class*=col-]{padding-right:7.5px;padding-left:7.5px}.main .container-fluid,.main .container-lg,.main .container-md,.main .container-sm,.main .container-xl{padding:0 30px}.c-header{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:row;flex-direction:row;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-negative:0;flex-shrink:0;min-height:56px;background:#fff;border-bottom:1px solid #d8dbe0}.c-header[class*=bg-]{border-color:rgba(0,0,21,.1)}.c-header.c-header-fixed{position:fixed;right:0;left:0;z-index:1030}.c-header .c-subheader{border-bottom:0;margin-top:-1px;border-top:1px solid #d8dbe0}.c-header-brand{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto;min-height:56px;transition:width .25s}.c-header-brand.c-header-brand-center{position:absolute;top:56px;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}html:not([dir=rtl]) .c-header-brand.c-header-brand-center{left:50%}[dir=rtl] .c-header-brand.c-header-brand-center{right:50%}@media (max-width:575.98px){.c-header-brand.c-header-brand-xs-down-center{position:absolute;top:56px;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}html:not([dir=rtl]) .c-header-brand.c-header-brand-xs-down-center{left:50%}[dir=rtl] .c-header-brand.c-header-brand-xs-down-center{right:50%}}.c-header-brand.c-header-brand-xs-up-center{position:absolute;top:56px;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}html:not([dir=rtl]) .c-header-brand.c-header-brand-xs-up-center{left:50%}[dir=rtl] .c-header-brand.c-header-brand-xs-up-center{right:50%}@media (max-width:767.98px){.c-header-brand.c-header-brand-sm-down-center{position:absolute;top:56px;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}html:not([dir=rtl]) .c-header-brand.c-header-brand-sm-down-center{left:50%}[dir=rtl] .c-header-brand.c-header-brand-sm-down-center{right:50%}}@media (min-width:576px){.c-header-brand.c-header-brand-sm-up-center{position:absolute;top:56px;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}html:not([dir=rtl]) .c-header-brand.c-header-brand-sm-up-center{left:50%}[dir=rtl] .c-header-brand.c-header-brand-sm-up-center{right:50%}}@media (max-width:991.98px){.c-header-brand.c-header-brand-md-down-center{position:absolute;top:56px;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}html:not([dir=rtl]) .c-header-brand.c-header-brand-md-down-center{left:50%}[dir=rtl] .c-header-brand.c-header-brand-md-down-center{right:50%}}@media (min-width:768px){.c-header-brand.c-header-brand-md-up-center{position:absolute;top:56px;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}html:not([dir=rtl]) .c-header-brand.c-header-brand-md-up-center{left:50%}[dir=rtl] .c-header-brand.c-header-brand-md-up-center{right:50%}}@media (max-width:1199.98px){.c-header-brand.c-header-brand-lg-down-center{position:absolute;top:56px;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}html:not([dir=rtl]) .c-header-brand.c-header-brand-lg-down-center{left:50%}[dir=rtl] .c-header-brand.c-header-brand-lg-down-center{right:50%}}@media (min-width:992px){.c-header-brand.c-header-brand-lg-up-center{position:absolute;top:56px;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}html:not([dir=rtl]) .c-header-brand.c-header-brand-lg-up-center{left:50%}[dir=rtl] .c-header-brand.c-header-brand-lg-up-center{right:50%}}.c-header-brand.c-header-brand-xl-down-center{position:absolute;top:56px;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}html:not([dir=rtl]) .c-header-brand.c-header-brand-xl-down-center{left:50%}[dir=rtl] .c-header-brand.c-header-brand-xl-down-center{right:50%}@media (min-width:1200px){.c-header-brand.c-header-brand-xl-up-center{position:absolute;top:56px;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}html:not([dir=rtl]) .c-header-brand.c-header-brand-xl-up-center{left:50%}[dir=rtl] .c-header-brand.c-header-brand-xl-up-center{right:50%}}.c-header-toggler{min-width:50px;font-size:1.09375rem;background-color:transparent;border:0;border-radius:.25rem}.c-header-toggler:focus,.c-header-toggler:hover{text-decoration:none}.c-header-toggler:not(:disabled):not(.c-disabled){cursor:pointer}.c-header-toggler-icon{display:block;height:1.3671875rem;background-repeat:no-repeat;background-position:center center;background-size:100% 100%}.c-header-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:row;flex-direction:row;-ms-flex-align:center;align-items:center;min-height:56px;padding:0;margin-bottom:0;list-style:none}.c-header-nav .c-header-nav-item{position:relative}.c-header-nav .c-header-nav-btn{background-color:transparent;border:1px solid transparent}.c-header-nav .c-header-nav-btn,.c-header-nav .c-header-nav-link{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding-right:.5rem;padding-left:.5rem}.c-header-nav .c-header-nav-btn .badge,.c-header-nav .c-header-nav-link .badge{position:absolute;top:50%;margin-top:-16px}html:not([dir=rtl]) .c-header-nav .c-header-nav-btn .badge,html:not([dir=rtl]) .c-header-nav .c-header-nav-link .badge{left:50%;margin-left:0}[dir=rtl] .c-header-nav .c-header-nav-btn .badge,[dir=rtl] .c-header-nav .c-header-nav-link .badge{right:50%;margin-right:0}.c-header-nav .c-header-nav-btn:hover,.c-header-nav .c-header-nav-link:hover{text-decoration:none}.c-header-nav .dropdown-item{min-width:180px}.c-header.c-header-dark{background:#3c4b64;border-bottom:1px solid #636f83}.c-header.c-header-dark .c-subheader{margin-top:-1px;border-top:1px solid #636f83}.c-header.c-header-dark .c-header-brand{color:#fff;background-color:transparent}.c-header.c-header-dark .c-header-brand:focus,.c-header.c-header-dark .c-header-brand:hover{color:#fff}.c-header.c-header-dark .c-header-nav .c-header-nav-btn,.c-header.c-header-dark .c-header-nav .c-header-nav-link{color:rgba(255,255,255,.75)}.c-header.c-header-dark .c-header-nav .c-header-nav-btn:focus,.c-header.c-header-dark .c-header-nav .c-header-nav-btn:hover,.c-header.c-header-dark .c-header-nav .c-header-nav-link:focus,.c-header.c-header-dark .c-header-nav .c-header-nav-link:hover{color:rgba(255,255,255,.9)}.c-header.c-header-dark .c-header-nav .c-header-nav-btn.c-disabled,.c-header.c-header-dark .c-header-nav .c-header-nav-link.c-disabled{color:rgba(255,255,255,.25)}.c-header.c-header-dark .c-header-nav .c-active>.c-header-nav-link,.c-header.c-header-dark .c-header-nav .c-header-nav-link.c-active,.c-header.c-header-dark .c-header-nav .c-header-nav-link.c-show,.c-header.c-header-dark .c-header-nav .c-show>.c-header-nav-link{color:#fff}.c-header.c-header-dark .c-header-toggler{color:rgba(255,255,255,.75);border-color:rgba(255,255,255,.1)}.c-header.c-header-dark .c-header-toggler-icon{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.75)' stroke-width='2.25' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.c-header.c-header-dark .c-header-toggler-icon:hover{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.9)' stroke-width='2.25' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.c-header.c-header-dark .c-header-text{color:rgba(255,255,255,.75)}.c-header.c-header-dark .c-header-text a,.c-header.c-header-dark .c-header-text a:focus,.c-header.c-header-dark .c-header-text a:hover{color:#fff}.c-header .c-header-brand{color:#4f5d73;background-color:transparent}.c-header .c-header-brand:focus,.c-header .c-header-brand:hover{color:#3a4555}.c-header .c-header-nav .c-header-nav-btn,.c-header .c-header-nav .c-header-nav-link{color:rgba(0,0,21,.5)}.c-header .c-header-nav .c-header-nav-btn:focus,.c-header .c-header-nav .c-header-nav-btn:hover,.c-header .c-header-nav .c-header-nav-link:focus,.c-header .c-header-nav .c-header-nav-link:hover{color:rgba(0,0,21,.7)}.c-header .c-header-nav .c-header-nav-btn.c-disabled,.c-header .c-header-nav .c-header-nav-link.c-disabled{color:rgba(0,0,21,.3)}.c-header .c-header-nav .c-active>.c-header-nav-link,.c-header .c-header-nav .c-header-nav-link.c-active,.c-header .c-header-nav .c-header-nav-link.c-show,.c-header .c-header-nav .c-show>.c-header-nav-link{color:rgba(0,0,21,.9)}.c-header .c-header-toggler{color:rgba(0,0,21,.5);border-color:rgba(0,0,21,.1)}.c-header .c-header-toggler-icon{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(0, 0, 21, 0.5)' stroke-width='2.25' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.c-header .c-header-toggler-icon:hover{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(0, 0, 21, 0.7)' stroke-width='2.25' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.c-header .c-header-text{color:rgba(0,0,21,.5)}.c-header .c-header-text a,.c-header .c-header-text a:focus,.c-header .c-header-text a:hover{color:rgba(0,0,21,.9)}.c-icon{display:inline-block;color:inherit;text-align:center;fill:currentColor}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size){width:1rem;height:1rem;font-size:1rem}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-2xl{width:2rem;height:2rem;font-size:2rem}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-3xl{width:3rem;height:3rem;font-size:3rem}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-4xl{width:4rem;height:4rem;font-size:4rem}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-5xl{width:5rem;height:5rem;font-size:5rem}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-6xl{width:6rem;height:6rem;font-size:6rem}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-7xl{width:7rem;height:7rem;font-size:7rem}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-8xl{width:8rem;height:8rem;font-size:8rem}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-9xl{width:9rem;height:9rem;font-size:9rem}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-xl{width:1.5rem;height:1.5rem;font-size:1.5rem}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-lg{width:1.25rem;height:1.25rem;font-size:1.25rem}.c-icon:not(.c-icon-c-s):not(.c-icon-custom-size).c-icon-sm{width:.875rem;height:.875rem;font-size:.875rem}.input-group{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{position:relative;-ms-flex:1 1 0%;flex:1 1 0%;min-width:0;margin-bottom:0}html:not([dir=rtl]) .input-group>.custom-file+.custom-file,html:not([dir=rtl]) .input-group>.custom-file+.custom-select,html:not([dir=rtl]) .input-group>.custom-file+.form-control,html:not([dir=rtl]) .input-group>.custom-select+.custom-file,html:not([dir=rtl]) .input-group>.custom-select+.custom-select,html:not([dir=rtl]) .input-group>.custom-select+.form-control,html:not([dir=rtl]) .input-group>.form-control+.custom-file,html:not([dir=rtl]) .input-group>.form-control+.custom-select,html:not([dir=rtl]) .input-group>.form-control+.form-control,html:not([dir=rtl]) .input-group>.form-control-plaintext+.custom-file,html:not([dir=rtl]) .input-group>.form-control-plaintext+.custom-select,html:not([dir=rtl]) .input-group>.form-control-plaintext+.form-control{margin-left:-1px}[dir=rtl] .input-group>.custom-file+.custom-file,[dir=rtl] .input-group>.custom-file+.custom-select,[dir=rtl] .input-group>.custom-file+.form-control,[dir=rtl] .input-group>.custom-select+.custom-file,[dir=rtl] .input-group>.custom-select+.custom-select,[dir=rtl] .input-group>.custom-select+.form-control,[dir=rtl] .input-group>.form-control+.custom-file,[dir=rtl] .input-group>.form-control+.custom-select,[dir=rtl] .input-group>.form-control+.form-control,[dir=rtl] .input-group>.form-control-plaintext+.custom-file,[dir=rtl] .input-group>.form-control-plaintext+.custom-select,[dir=rtl] .input-group>.form-control-plaintext+.form-control{margin-right:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}html:not([dir=rtl]) .input-group>.custom-select:not(:last-child),html:not([dir=rtl]) .input-group>.form-control:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[dir=rtl] .input-group>.custom-select:not(:last-child),[dir=rtl] .input-group>.form-control:not(:last-child),html:not([dir=rtl]) .input-group>.custom-select:not(:first-child),html:not([dir=rtl]) .input-group>.form-control:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}[dir=rtl] .input-group>.custom-select:not(:first-child),[dir=rtl] .input-group>.form-control:not(:first-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}html:not([dir=rtl]) .input-group>.custom-file:not(:last-child) .custom-file-label,html:not([dir=rtl]) .input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}[dir=rtl] .input-group>.custom-file:not(:last-child) .custom-file-label,[dir=rtl] .input-group>.custom-file:not(:last-child) .custom-file-label::after,html:not([dir=rtl]) .input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}[dir=rtl] .input-group>.custom-file:not(:first-child) .custom-file-label{border-top-right-radius:0;border-bottom-right-radius:0}.input-group-append,.input-group-prepend{display:-ms-flexbox;display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}html:not([dir=rtl]) .input-group-append .btn+.btn,html:not([dir=rtl]) .input-group-append .btn+.input-group-text,html:not([dir=rtl]) .input-group-append .input-group-text+.btn,html:not([dir=rtl]) .input-group-append .input-group-text+.input-group-text,html:not([dir=rtl]) .input-group-prepend .btn+.btn,html:not([dir=rtl]) .input-group-prepend .btn+.input-group-text,html:not([dir=rtl]) .input-group-prepend .input-group-text+.btn,html:not([dir=rtl]) .input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}[dir=rtl] .input-group-append .btn+.btn,[dir=rtl] .input-group-append .btn+.input-group-text,[dir=rtl] .input-group-append .input-group-text+.btn,[dir=rtl] .input-group-append .input-group-text+.input-group-text,[dir=rtl] .input-group-prepend .btn+.btn,[dir=rtl] .input-group-prepend .btn+.input-group-text,[dir=rtl] .input-group-prepend .input-group-text+.btn,[dir=rtl] .input-group-prepend .input-group-text+.input-group-text{margin-right:-1px}.input-group-prepend{white-space:nowrap;vertical-align:middle}html:not([dir=rtl]) .input-group-prepend{margin-right:-1px}[dir=rtl] .input-group-prepend{margin-left:-1px}.input-group-append{white-space:nowrap;vertical-align:middle}html:not([dir=rtl]) .input-group-append{margin-left:-1px}[dir=rtl] .input-group-append{margin-right:-1px}.input-group-text{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.375rem .75rem;margin-bottom:0;font-size:.875rem;font-weight:400;line-height:1.5;text-align:center;white-space:nowrap;border:1px solid;border-radius:.25rem;color:#768192;background-color:#ebedef;border-color:#d8dbe0}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{padding:.5rem 1rem;font-size:1.09375rem;line-height:1.5;border-radius:.3rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{padding:.25rem .5rem;font-size:.765625rem;line-height:1.5;border-radius:.2rem}html:not([dir=rtl]) .input-group-lg>.custom-select,html:not([dir=rtl]) .input-group-sm>.custom-select{padding-right:1.75rem}[dir=rtl] .input-group-lg>.custom-select,[dir=rtl] .input-group-sm>.custom-select{padding-left:1.75rem}html:not([dir=rtl]) .input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),html:not([dir=rtl]) .input-group>.input-group-append:last-child>.input-group-text:not(:last-child),html:not([dir=rtl]) .input-group>.input-group-append:not(:last-child)>.btn,html:not([dir=rtl]) .input-group>.input-group-append:not(:last-child)>.input-group-text,html:not([dir=rtl]) .input-group>.input-group-prepend>.btn,html:not([dir=rtl]) .input-group>.input-group-prepend>.input-group-text{border-top-right-radius:0;border-bottom-right-radius:0}[dir=rtl] .input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),[dir=rtl] .input-group>.input-group-append:last-child>.input-group-text:not(:last-child),[dir=rtl] .input-group>.input-group-append:not(:last-child)>.btn,[dir=rtl] .input-group>.input-group-append:not(:last-child)>.input-group-text,[dir=rtl] .input-group>.input-group-prepend>.btn,[dir=rtl] .input-group>.input-group-prepend>.input-group-text,html:not([dir=rtl]) .input-group>.input-group-append>.btn,html:not([dir=rtl]) .input-group>.input-group-append>.input-group-text,html:not([dir=rtl]) .input-group>.input-group-prepend:first-child>.btn:not(:first-child),html:not([dir=rtl]) .input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),html:not([dir=rtl]) .input-group>.input-group-prepend:not(:first-child)>.btn,html:not([dir=rtl]) .input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-top-left-radius:0;border-bottom-left-radius:0}[dir=rtl] .input-group>.input-group-append>.btn,[dir=rtl] .input-group>.input-group-append>.input-group-text,[dir=rtl] .input-group>.input-group-prepend:first-child>.btn:not(:first-child),[dir=rtl] .input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),[dir=rtl] .input-group>.input-group-prepend:not(:first-child)>.btn,[dir=rtl] .input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-top-right-radius:0;border-bottom-right-radius:0}.img-fluid,.img-thumbnail{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#ebedef;border:1px solid #c4c9d0;border-radius:.25rem}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#8a93a2}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;border-radius:.3rem;background-color:#d8dbe0}@media (min-width:576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;margin-bottom:0}html:not([dir=rtl]) .list-group{padding-left:0}[dir=rtl] .list-group{padding-right:0}.list-group-item-action{width:100%;text-align:inherit;color:#768192}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;text-decoration:none;color:#768192;background-color:#ebedef}.list-group-item-action:active{color:#4f5d73;background-color:#321fdb}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;border:1px solid;background-color:inherit;border-color:rgba(0,0,21,.125)}.list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.list-group-item:last-child{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.list-group-item.disabled,.list-group-item:disabled{pointer-events:none;color:#8a93a2;background-color:inherit}.list-group-item.active{z-index:2;color:#fff;background-color:#321fdb;border-color:#321fdb}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal .list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal .list-group-item:last-child{border-top-right-radius:.25rem;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:1px;border-left-width:0}.list-group-horizontal .list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm .list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm .list-group-item:last-child{border-top-right-radius:.25rem;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:1px;border-left-width:0}.list-group-horizontal-sm .list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md .list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md .list-group-item:last-child{border-top-right-radius:.25rem;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:1px;border-left-width:0}.list-group-horizontal-md .list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg .list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg .list-group-item:last-child{border-top-right-radius:.25rem;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:1px;border-left-width:0}.list-group-horizontal-lg .list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl .list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl .list-group-item:last-child{border-top-right-radius:.25rem;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:1px;border-left-width:0}.list-group-horizontal-xl .list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush .list-group-item{border-right-width:0;border-left-width:0;border-radius:0}.list-group-flush .list-group-item:first-child{border-top-width:0}.list-group-flush:last-child .list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#1a107c;background-color:#c6c0f5}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#1a107c;background-color:#b2aaf2}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#1a107c;border-color:#1a107c}.list-group-item-secondary{color:#6b6d7a;background-color:#f1f2f4}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#6b6d7a;background-color:#e3e5e9}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#6b6d7a;border-color:#6b6d7a}.list-group-item-success{color:#18603a;background-color:#c4ebd1}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#18603a;background-color:#b1e5c2}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#18603a;border-color:#18603a}.list-group-item-info{color:#1b508f;background-color:#c6e2ff}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#1b508f;background-color:#add5ff}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#1b508f;border-color:#1b508f}.list-group-item-warning{color:#815c15;background-color:#fde9bd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#815c15;background-color:#fce1a4}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#815c15;border-color:#815c15}.list-group-item-danger{color:#772b35;background-color:#f8cfcf}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#772b35;background-color:#f5b9b9}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#772b35;border-color:#772b35}.list-group-item-light{color:#7a7b86;background-color:#f9fafb}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#7a7b86;background-color:#eaedf1}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#7a7b86;border-color:#7a7b86}.list-group-item-dark{color:#333a4e;background-color:#d3d7dc}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#333a4e;background-color:#c5cad1}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#333a4e;border-color:#333a4e}.list-group-accent .list-group-item{margin-bottom:1px;border-top:0;border-right:0;border-bottom:0;border-radius:0}.list-group-accent .list-group-item.list-group-item-divider{position:relative}.list-group-accent .list-group-item.list-group-item-divider::before{position:absolute;bottom:-1px;width:90%;height:1px;content:"";background-color:rgba(0,0,21,.125)}html:not([dir=rtl]) .list-group-accent .list-group-item.list-group-item-divider::before{left:5%}[dir=rtl] .list-group-accent .list-group-item.list-group-item-divider::before{right:5%}.list-group-accent .list-group-item-accent-primary{border-left:4px solid #321fdb}.list-group-accent .list-group-item-accent-secondary{border-left:4px solid #ced2d8}.list-group-accent .list-group-item-accent-success{border-left:4px solid #2eb85c}.list-group-accent .list-group-item-accent-info{border-left:4px solid #39f}.list-group-accent .list-group-item-accent-warning{border-left:4px solid #f9b115}.list-group-accent .list-group-item-accent-danger{border-left:4px solid #e55353}.list-group-accent .list-group-item-accent-light{border-left:4px solid #ebedef}.list-group-accent .list-group-item-accent-dark{border-left:4px solid #636f83}.media{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.media-body{-ms-flex:1;flex:1}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out;-webkit-transform:translate(0,-50px);transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal.modal-static .modal-dialog{-webkit-transform:scale(1.02);transform:scale(1.02)}.modal-dialog-scrollable{display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);content:""}.modal-dialog-centered.modal-dialog-scrollable{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-clip:padding-box;border:1px solid;border-radius:.3rem;outline:0;background-color:#fff;border-color:rgba(0,0,21,.2)}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000015}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:justify;justify-content:space-between;border-bottom:1px solid;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px);border-color:#d8dbe0}.modal-header,.modal-header .close{padding:1rem 1rem}html:not([dir=rtl]) .modal-header .close{margin:-1rem -1rem -1rem auto}[dir=rtl] .modal-header .close{margin:-1rem auto -1rem -1rem}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:end;justify-content:flex-end;padding:.75rem;border-top:1px solid;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px);border-color:#d8dbe0}.modal-footer>*{margin:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-primary .modal-content{border-color:#321fdb}.modal-primary .modal-header{color:#fff;background-color:#321fdb}.modal-secondary .modal-content{border-color:#ced2d8}.modal-secondary .modal-header{color:#fff;background-color:#ced2d8}.modal-success .modal-content{border-color:#2eb85c}.modal-success .modal-header{color:#fff;background-color:#2eb85c}.modal-info .modal-content{border-color:#39f}.modal-info .modal-header{color:#fff;background-color:#39f}.modal-warning .modal-content{border-color:#f9b115}.modal-warning .modal-header{color:#fff;background-color:#f9b115}.modal-danger .modal-content{border-color:#e55353}.modal-danger .modal-header{color:#fff;background-color:#e55353}.modal-light .modal-content{border-color:#ebedef}.modal-light .modal-header{color:#fff;background-color:#ebedef}.modal-dark .modal-content{border-color:#636f83}.modal-dark .modal-header{color:#fff;background-color:#636f83}.nav{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-bottom:0;list-style:none}html:not([dir=rtl]) .nav{padding-left:0}[dir=rtl] .nav{padding-right:0}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#8a93a2;pointer-events:none;cursor:default;color:#8a93a2}.nav-tabs{border-bottom:1px solid;border-color:#c4c9d0}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#d8dbe0 #d8dbe0 #c4c9d0}.nav-tabs .nav-link.disabled{background-color:transparent;border-color:transparent;color:#8a93a2}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#768192;background-color:#ebedef;border-color:#c4c9d0 #c4c9d0 #ebedef}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-tabs-boxed .nav-tabs{border:0}.nav-tabs-boxed .nav-tabs .nav-link.active{background-color:#fff;border-bottom-color:#fff}.nav-tabs-boxed .tab-content{padding:.75rem 1.25rem;border:1px solid;border-radius:0 .25rem .25rem .25rem;color:#768192;background-color:#fff;border-color:#d8dbe0}.nav-tabs-boxed.nav-tabs-boxed-top-right .nav-tabs{-ms-flex-pack:end;justify-content:flex-end}.nav-tabs-boxed.nav-tabs-boxed-top-right .tab-content{border-radius:.25rem 0 .25rem .25rem}.nav-tabs-boxed.nav-tabs-boxed-left,.nav-tabs-boxed.nav-tabs-boxed-right{display:-ms-flexbox;display:flex}.nav-tabs-boxed.nav-tabs-boxed-left .nav-item,.nav-tabs-boxed.nav-tabs-boxed-right .nav-item{z-index:1;-ms-flex-positive:1;flex-grow:1;margin-bottom:0}[dir=rtl] .nav-tabs-boxed.nav-tabs-boxed-left{-ms-flex-direction:row-reverse;flex-direction:row-reverse}.nav-tabs-boxed.nav-tabs-boxed-left .nav-item{margin-right:-1px}.nav-tabs-boxed.nav-tabs-boxed-left .nav-link{border-radius:.25rem 0 0 .25rem}.nav-tabs-boxed.nav-tabs-boxed-left .nav-link.active{border-color:#d8dbe0 #fff #d8dbe0 #d8dbe0}html:not([dir=rtl]) .nav-tabs-boxed.nav-tabs-boxed-right{-ms-flex-direction:row-reverse;flex-direction:row-reverse}[dir=rtl] .nav-tabs-boxed.nav-tabs-boxed-right{-ms-flex-direction:row;flex-direction:row}html:not([dir=rtl]) .nav-tabs-boxed.nav-tabs-boxed-right .nav-item{margin-left:-1px}[dir=rtl] .nav-tabs-boxed.nav-tabs-boxed-right .nav-item{margin-right:-1px}.nav-tabs-boxed.nav-tabs-boxed-right .nav-link{border-radius:0 .25rem .25rem 0}.nav-tabs-boxed.nav-tabs-boxed-right .nav-link.active{border-color:#d8dbe0 #d8dbe0 #d8dbe0 #fff}.nav-tabs-boxed.nav-tabs-boxed-right .tab-content{border-radius:.25rem 0 .25rem .25rem}.nav-pills .nav-link{border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#321fdb}.nav-underline{border-bottom:2px solid;border-color:#c4c9d0}.nav-underline .nav-item{margin-bottom:-2px}.nav-underline .nav-link{border:0;border-bottom:2px solid transparent}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{background:0 0}.nav-underline.nav-underline-primary .nav-link.active,.nav-underline.nav-underline-primary .show>.nav-link{color:#321fdb;border-color:#321fdb}.nav-underline.nav-underline-secondary .nav-link.active,.nav-underline.nav-underline-secondary .show>.nav-link{color:#ced2d8;border-color:#ced2d8}.nav-underline.nav-underline-success .nav-link.active,.nav-underline.nav-underline-success .show>.nav-link{color:#2eb85c;border-color:#2eb85c}.nav-underline.nav-underline-info .nav-link.active,.nav-underline.nav-underline-info .show>.nav-link{color:#39f;border-color:#39f}.nav-underline.nav-underline-warning .nav-link.active,.nav-underline.nav-underline-warning .show>.nav-link{color:#f9b115;border-color:#f9b115}.nav-underline.nav-underline-danger .nav-link.active,.nav-underline.nav-underline-danger .show>.nav-link{color:#e55353;border-color:#e55353}.nav-underline.nav-underline-light .nav-link.active,.nav-underline.nav-underline-light .show>.nav-link{color:#ebedef;border-color:#ebedef}.nav-underline.nav-underline-dark .nav-link.active,.nav-underline.nav-underline-dark .show>.nav-link{color:#636f83;border-color:#636f83}.nav-fill .nav-item{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.c-sidebar .c-sidebar-close+.nav-tabs .nav-link,.c-sidebar .nav-tabs:first-child .nav-link{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:56px;padding-top:0;padding-bottom:0}.navbar{position:relative;padding:.5rem 1rem}.navbar,.navbar .container,.navbar .container-fluid,.navbar .container-lg,.navbar .container-md,.navbar .container-sm,.navbar .container-xl{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3359375rem;padding-bottom:.3359375rem;margin-right:1rem;font-size:1.09375rem;line-height:inherit;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;margin-bottom:0;list-style:none}html:not([dir=rtl]) .navbar-nav{padding-left:0}[dir=rtl] .navbar-nav{padding-right:0}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.09375rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width:575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{padding-right:0;padding-left:0}}@media (min-width:576px){.navbar-expand-sm{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{padding-right:0;padding-left:0}}@media (min-width:768px){.navbar-expand-md{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{padding-right:0;padding-left:0}}@media (min-width:992px){.navbar-expand-lg{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{padding-right:0;padding-left:0}}@media (min-width:1200px){.navbar-expand-xl{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{padding-right:0;padding-left:0;-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar.navbar-dark .navbar-brand,.navbar.navbar-dark .navbar-brand:focus,.navbar.navbar-dark .navbar-brand:hover{color:#fff}.navbar.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.5)}.navbar.navbar-dark .navbar-nav .nav-link:focus,.navbar.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar.navbar-dark .navbar-nav .active>.nav-link,.navbar.navbar-dark .navbar-nav .nav-link.active,.navbar.navbar-dark .navbar-nav .nav-link.show,.navbar.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar.navbar-dark .navbar-toggler{color:rgba(255,255,255,.5);border-color:rgba(255,255,255,.1)}.navbar.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba(255, 255, 255, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar.navbar-dark .navbar-text{color:rgba(255,255,255,.5)}.navbar.navbar-dark .navbar-text a,.navbar.navbar-dark .navbar-text a:focus,.navbar.navbar-dark .navbar-text a:hover{color:#fff}.navbar.navbar-light .navbar-brand,.navbar.navbar-light .navbar-brand:focus,.navbar.navbar-light .navbar-brand:hover{color:rgba(0,0,21,.9)}.navbar.navbar-light .navbar-nav .nav-link{color:rgba(0,0,21,.5)}.navbar.navbar-light .navbar-nav .nav-link:focus,.navbar.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,21,.7)}.navbar.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,21,.3)}.navbar.navbar-light .navbar-nav .active>.nav-link,.navbar.navbar-light .navbar-nav .nav-link.active,.navbar.navbar-light .navbar-nav .nav-link.show,.navbar.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,21,.9)}.navbar.navbar-light .navbar-toggler{color:rgba(0,0,21,.5);border-color:rgba(0,0,21,.1)}.navbar.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba(0, 0, 21, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar.navbar-light .navbar-text{color:rgba(0,0,21,.5)}.navbar.navbar-light .navbar-text a,.navbar.navbar-light .navbar-text a:focus,.navbar.navbar-light .navbar-text a:hover{color:rgba(0,0,21,.9)}.pagination{display:-ms-flexbox;display:flex;list-style:none;border-radius:.25rem}html:not([dir=rtl]) .pagination{padding-left:0}[dir=rtl] .pagination{padding-right:0}.page-link{position:relative;display:block;padding:.5rem .75rem;line-height:1.25;border:1px solid;color:#321fdb;background-color:#fff;border-color:#d8dbe0}html:not([dir=rtl]) .page-link{margin-left:-1px}[dir=rtl] .page-link{margin-right:-1px}.page-link:hover{z-index:2;text-decoration:none;color:#231698;background-color:#d8dbe0;border-color:#c4c9d0}.page-link:focus{z-index:3;outline:0;box-shadow:0 0 0 .2rem rgba(50,31,219,.25)}html:not([dir=rtl]) .page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}[dir=rtl] .page-item:first-child .page-link{margin-right:0}[dir=rtl] .page-item:first-child .page-link,html:not([dir=rtl]) .page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}[dir=rtl] .page-item:last-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item.active .page-link{z-index:3;color:#fff;background-color:#321fdb;border-color:#321fdb}.page-item.disabled .page-link{pointer-events:none;cursor:auto;color:#8a93a2;background-color:#fff;border-color:#c4c9d0}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.09375rem;line-height:1.5}html:not([dir=rtl]) .pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}[dir=rtl] .pagination-lg .page-item:first-child .page-link,html:not([dir=rtl]) .pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}[dir=rtl] .pagination-lg .page-item:last-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.765625rem;line-height:1.5}html:not([dir=rtl]) .pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}[dir=rtl] .pagination-sm .page-item:first-child .page-link,html:not([dir=rtl]) .pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}[dir=rtl] .pagination-sm .page-item:last-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.popover{top:0;left:0;z-index:1060;max-width:276px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";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;word-spacing:normal;white-space:normal;line-break:auto;font-size:.765625rem;word-wrap:break-word;background-clip:padding-box;border:1px solid;border-radius:.3rem;background-color:#fff;border-color:rgba(0,0,21,.2)}.popover,.popover .arrow{position:absolute;display:block}.popover .arrow{width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::after,.popover .arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=top]>.arrow::before,.bs-popover-top>.arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,21,.25)}.bs-popover-auto[x-placement^=top]>.arrow::after,.bs-popover-top>.arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=right]>.arrow::before,.bs-popover-right>.arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,21,.25)}.bs-popover-auto[x-placement^=right]>.arrow::after,.bs-popover-right>.arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=bottom]>.arrow::before,.bs-popover-bottom>.arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,21,.25)}.bs-popover-auto[x-placement^=bottom]>.arrow::after,.bs-popover-bottom>.arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[x-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid;border-bottom-color:#f7f7f7}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=left]>.arrow::before,.bs-popover-left>.arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,21,.25)}.bs-popover-auto[x-placement^=left]>.arrow::after,.bs-popover-left>.arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:.875rem;border-bottom:1px solid;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px);background-color:#f7f7f7;border-bottom-color:#ebebeb}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#4f5d73}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{height:1rem;font-size:.65625rem;border-radius:.25rem;background-color:#ebedef}.progress,.progress-bar{display:-ms-flexbox;display:flex;overflow:hidden}.progress-bar{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;text-align:center;white-space:nowrap;transition:width .6s ease;color:#fff;background-color:#321fdb}@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:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.progress-xs{height:4px}.progress-sm{height:8px}.progress.progress-white{background-color:rgba(255,255,255,.2)}.progress.progress-white .progress-bar{background-color:#fff}.progress-group{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;margin-bottom:1rem}.progress-group-prepend{-ms-flex:0 0 100px;flex:0 0 100px;-ms-flex-item-align:center;align-self:center}.progress-group-icon{font-size:1.09375rem}html:not([dir=rtl]) .progress-group-icon{margin:0 1rem 0 .25rem}[dir=rtl] .progress-group-icon{margin:0 .25rem 0 1rem}.progress-group-text{font-size:.765625rem;color:#768192}.progress-group-header{display:-ms-flexbox;display:flex;-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-align:end;align-items:flex-end;margin-bottom:.25rem}.progress-group-bars{-ms-flex-positive:1;flex-grow:1;-ms-flex-item-align:center;align-self:center}.progress-group-bars .progress:not(:last-child){margin-bottom:2px}.progress-group-header+.progress-group-bars{-ms-flex-preferred-size:100%;flex-basis:100%}.c-sidebar{display:-ms-flexbox;display:flex;-ms-flex:0 0 256px;flex:0 0 256px;-ms-flex-direction:column;flex-direction:column;-ms-flex-order:-1;order:-1;width:256px;padding:0;box-shadow:none;color:#fff;background:#3c4b64;will-change:auto;transition:box-shadow .25s,margin-left .25s,margin-right .25s,width .25s,z-index 0s ease .25s,-webkit-transform .25s;transition:box-shadow .25s,transform .25s,margin-left .25s,margin-right .25s,width .25s,z-index 0s ease .25s;transition:box-shadow .25s,transform .25s,margin-left .25s,margin-right .25s,width .25s,z-index 0s ease .25s,-webkit-transform .25s}@media (max-width:991.98px){.c-sidebar{--is-mobile:true;position:fixed;top:0;bottom:0;z-index:1031}}html:not([dir=rtl]) .c-sidebar:not(.c-sidebar-right){margin-left:-256px}html:not([dir=rtl]) .c-sidebar.c-sidebar-right{-ms-flex-order:99;order:99;margin-right:-256px}[dir=rtl] .c-sidebar:not(.c-sidebar-right){margin-right:-256px}[dir=rtl] .c-sidebar.c-sidebar-right{margin-left:-256px;border:0}.c-sidebar[class*=bg-]{border-color:rgba(0,0,21,.1)}.c-sidebar.c-sidebar-sm{width:192px}html:not([dir=rtl]) .c-sidebar.c-sidebar-sm:not(.c-sidebar-right){margin-left:-192px}[dir=rtl] .c-sidebar.c-sidebar-sm:not(.c-sidebar-right),html:not([dir=rtl]) .c-sidebar.c-sidebar-sm.c-sidebar-right{margin-right:-192px}[dir=rtl] .c-sidebar.c-sidebar-sm.c-sidebar-right{margin-left:-192px}.c-sidebar.c-sidebar-lg{width:320px}html:not([dir=rtl]) .c-sidebar.c-sidebar-lg:not(.c-sidebar-right){margin-left:-320px}[dir=rtl] .c-sidebar.c-sidebar-lg:not(.c-sidebar-right),html:not([dir=rtl]) .c-sidebar.c-sidebar-lg.c-sidebar-right{margin-right:-320px}[dir=rtl] .c-sidebar.c-sidebar-lg.c-sidebar-right{margin-left:-320px}.c-sidebar.c-sidebar-xl{width:384px}html:not([dir=rtl]) .c-sidebar.c-sidebar-xl:not(.c-sidebar-right){margin-left:-384px}[dir=rtl] .c-sidebar.c-sidebar-xl:not(.c-sidebar-right),html:not([dir=rtl]) .c-sidebar.c-sidebar-xl.c-sidebar-right{margin-right:-384px}[dir=rtl] .c-sidebar.c-sidebar-xl.c-sidebar-right{margin-left:-384px}@media (min-width:992px){.c-sidebar.c-sidebar-fixed{position:fixed;top:0;bottom:0;z-index:1030}html:not([dir=rtl]) .c-sidebar.c-sidebar-fixed:not(.c-sidebar-right){left:0}[dir=rtl] .c-sidebar.c-sidebar-fixed:not(.c-sidebar-right),html:not([dir=rtl]) .c-sidebar.c-sidebar-fixed.c-sidebar-right{right:0}[dir=rtl] .c-sidebar.c-sidebar-fixed.c-sidebar-right{left:0}}.c-sidebar.c-sidebar-overlaid{position:fixed;top:0;bottom:0;z-index:1032}html:not([dir=rtl]) .c-sidebar.c-sidebar-overlaid:not(.c-sidebar-right){left:0}[dir=rtl] .c-sidebar.c-sidebar-overlaid:not(.c-sidebar-right),html:not([dir=rtl]) .c-sidebar.c-sidebar-overlaid.c-sidebar-right{right:0}[dir=rtl] .c-sidebar.c-sidebar-overlaid.c-sidebar-right{left:0}.c-sidebar-close{position:absolute;width:56px;height:56px;background:0 0;border:0}html:not([dir=rtl]) .c-sidebar-close{right:0}[dir=rtl] .c-sidebar-close{left:0}.c-sidebar-brand{display:-ms-flexbox;display:flex;-ms-flex:0 0 56px;flex:0 0 56px;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.c-sidebar-brand .c-sidebar-brand-minimized{display:none}.c-sidebar-header{-ms-flex:0 0 auto;flex:0 0 auto;padding:.75rem 1rem;text-align:center;transition:.25s}.c-sidebar-nav{position:relative;display:-ms-flexbox;display:flex;-ms-flex:1;flex:1;-ms-flex-direction:column;flex-direction:column;padding:0;margin-bottom:0;overflow-x:hidden;overflow-y:auto;list-style:none}.c-sidebar-nav-title{padding:.75rem 1rem;margin-top:1rem;font-size:80%;font-weight:700;text-transform:uppercase;transition:.25s}.c-sidebar-nav-divider{height:10px;transition:height .25s}.c-sidebar-nav-item{width:inherit}.c-sidebar-nav-dropdown-toggle,.c-sidebar-nav-link{display:-ms-flexbox;display:flex;-ms-flex:1;flex:1;-ms-flex-align:center;align-items:center;padding:.8445rem 1rem;text-decoration:none;white-space:nowrap;transition:background .25s,color .25s}html:not([dir=rtl]) .c-sidebar-nav-dropdown-toggle .badge,html:not([dir=rtl]) .c-sidebar-nav-link .badge{margin-left:auto}[dir=rtl] .c-sidebar-nav-dropdown-toggle .badge,[dir=rtl] .c-sidebar-nav-link .badge{margin-right:auto}.c-disabled.c-sidebar-nav-dropdown-toggle,.c-sidebar-nav-link.c-disabled{cursor:default}.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar-nav-link:hover{text-decoration:none}.c-sidebar-nav-icon{-ms-flex:0 0 56px;flex:0 0 56px;height:1.09375rem;font-size:1.09375rem;text-align:center;transition:.25s;fill:currentColor}html:not([dir=rtl]) .c-sidebar-nav-icon:first-child{margin-left:-1rem}[dir=rtl] .c-sidebar-nav-icon:first-child{margin-right:-1rem}.c-sidebar-nav-dropdown{position:relative;transition:background .25s ease-in-out}.c-sidebar-nav-dropdown.c-show>.c-sidebar-nav-dropdown-items{max-height:1500px}html:not([dir=rtl]) .c-sidebar-nav-dropdown.c-show>.c-sidebar-nav-dropdown-toggle::after{-webkit-transform:rotate(-90deg);transform:rotate(-90deg)}[dir=rtl] .c-sidebar-nav-dropdown.c-show>.c-sidebar-nav-dropdown-toggle::after{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.c-sidebar-nav-dropdown.c-show+.c-sidebar-nav-dropdown.c-show{margin-top:1px}.c-sidebar-nav-dropdown-toggle{cursor:pointer}.c-sidebar-nav-dropdown-toggle::after{display:block;-ms-flex:0 8px;flex:0 8px;height:8px;content:"";background-repeat:no-repeat;background-position:center;transition:-webkit-transform .25s;transition:transform .25s;transition:transform .25s,-webkit-transform .25s}html:not([dir=rtl]) .c-sidebar-nav-dropdown-toggle::after{margin-left:auto}[dir=rtl] .c-sidebar-nav-dropdown-toggle::after{margin-right:auto;-webkit-transform:rotate(180deg);transform:rotate(180deg)}html:not([dir=rtl]) .c-sidebar-nav-dropdown-toggle .badge{margin-right:1rem}[dir=rtl] .c-sidebar-nav-dropdown-toggle .badge{margin-left:1rem}.c-sidebar-nav-dropdown-items{max-height:0;padding:0;overflow-y:hidden;transition:max-height .25s ease-in-out}html:not([dir=rtl]) .c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown-toggle,html:not([dir=rtl]) .c-sidebar-nav-dropdown-items .c-sidebar-nav-link{padding-left:56px}[dir=rtl] .c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown-toggle,[dir=rtl] .c-sidebar-nav-dropdown-items .c-sidebar-nav-link{padding-right:56px}html:not([dir=rtl]) .c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,html:not([dir=rtl]) .c-sidebar-nav-dropdown-items .c-sidebar-nav-link .c-sidebar-nav-icon{margin-left:-56px}[dir=rtl] .c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,[dir=rtl] .c-sidebar-nav-dropdown-items .c-sidebar-nav-link .c-sidebar-nav-icon{margin-right:-56px}.c-sidebar-nav-label{display:-ms-flexbox;display:flex;padding:.211125rem 1rem;transition:.25s}.c-sidebar-nav-label:hover{text-decoration:none}.c-sidebar-nav-label .c-sidebar-nav-icon{margin-top:1px}.c-sidebar-footer{-ms-flex:0 0 auto;flex:0 0 auto;padding:.75rem 1rem;transition:.25s}.c-sidebar-minimizer{display:-ms-flexbox;display:flex;-ms-flex:0 0 50px;flex:0 0 50px;-ms-flex-pack:end;justify-content:flex-end;width:inherit;padding:0;cursor:pointer;border:0}@media (max-width:991.98px){.c-sidebar-minimizer{display:none}}.c-sidebar-minimizer::before{display:block;width:50px;height:50px;content:"";background-repeat:no-repeat;background-position:center;background-size:12.5px;transition:.25s}[dir=rtl] .c-sidebar-minimizer::before{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.c-sidebar-minimizer.c-focus,.c-sidebar-minimizer:focus{outline:0}.c-sidebar-right .c-sidebar-minimizer{-ms-flex-pack:start;justify-content:flex-start}html:not([dir=rtl]) .c-sidebar-right .c-sidebar-minimizer::before{-webkit-transform:rotate(-180deg);transform:rotate(-180deg)}[dir=rtl] .c-sidebar-right .c-sidebar-minimizer::before{-webkit-transform:rotate(0);transform:rotate(0)}@media (max-width:991.98px){.c-sidebar-backdrop{position:fixed;top:0;left:0;z-index:1030;width:100vw;height:100vh;background-color:#000015;transition:.25s}.c-sidebar-backdrop.c-fade{opacity:0}.c-sidebar-backdrop.c-show{opacity:.5}}@media (min-width:992px){.c-sidebar-minimized{z-index:1031;-ms-flex:0 0 56px;flex:0 0 56px}.c-sidebar-minimized.c-sidebar-fixed{z-index:1031;width:56px}html:not([dir=rtl]) .c-sidebar-minimized:not(.c-sidebar-right){margin-left:-56px}[dir=rtl] .c-sidebar-minimized:not(.c-sidebar-right){margin-right:-56px}html:not([dir=rtl]) .c-sidebar-minimized.c-sidebar-right{margin-right:-56px;margin-left:-56px}.c-sidebar-minimized .c-sidebar-brand-full{display:none}.c-sidebar-minimized .c-sidebar-brand-minimized{display:block}.c-sidebar-minimized .c-sidebar-nav{padding-bottom:50px;overflow:visible}.c-sidebar-minimized .c-d-minimized-none,.c-sidebar-minimized .c-sidebar-footer,.c-sidebar-minimized .c-sidebar-form,.c-sidebar-minimized .c-sidebar-header,.c-sidebar-minimized .c-sidebar-nav-divider,.c-sidebar-minimized .c-sidebar-nav-label,.c-sidebar-minimized .c-sidebar-nav-title{height:0;padding:0;margin:0;visibility:hidden;opacity:0}.c-sidebar-minimized .c-sidebar-minimizer{position:fixed;bottom:0;width:inherit}html:not([dir=rtl]) .c-sidebar-minimized .c-sidebar-minimizer::before{-webkit-transform:rotate(-180deg);transform:rotate(-180deg)}[dir=rtl] .c-sidebar-minimized .c-sidebar-minimizer::before,html:not([dir=rtl]) .c-sidebar-minimized.c-sidebar-right .c-sidebar-minimizer::before{-webkit-transform:rotate(0);transform:rotate(0)}[dir=rtl] .c-sidebar-minimized.c-sidebar-right .c-sidebar-minimizer::before{-webkit-transform:rotate(180deg);transform:rotate(180deg)}html:not([dir=rtl]) .c-sidebar-minimized.c-sidebar-right .c-sidebar-nav>.c-sidebar-nav-dropdown:hover,html:not([dir=rtl]) .c-sidebar-minimized.c-sidebar-right .c-sidebar-nav>.c-sidebar-nav-item:hover{margin-left:-256px}[dir=rtl] .c-sidebar-minimized.c-sidebar-right .c-sidebar-nav>.c-sidebar-nav-dropdown:hover,[dir=rtl] .c-sidebar-minimized.c-sidebar-right .c-sidebar-nav>.c-sidebar-nav-item:hover{margin-right:-256px}.c-sidebar-minimized .c-sidebar-nav-dropdown-toggle,.c-sidebar-minimized .c-sidebar-nav-link{overflow:hidden;white-space:nowrap;border-left:0}.c-sidebar-minimized .c-sidebar-nav-dropdown-toggle:hover,.c-sidebar-minimized .c-sidebar-nav-link:hover{width:312px}.c-sidebar-minimized .c-sidebar-nav-dropdown-toggle::after{display:none}.c-sidebar-minimized .c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown-toggle,.c-sidebar-minimized .c-sidebar-nav-dropdown-items .c-sidebar-nav-link{width:256px}.c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown{position:relative}.c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown>.c-sidebar-nav-dropdown-items,.c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown>.c-sidebar-nav-dropdown-items .c-sidebar-nav-dropdown:not(.c-show)>.c-sidebar-nav-dropdown-items{display:none}.c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown .c-sidebar-nav-dropdown-items{max-height:1500px}.c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown:hover{width:312px;overflow:visible}.c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown:hover>.c-sidebar-nav-dropdown-items{position:absolute;display:inline}html:not([dir=rtl]) .c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown:hover>.c-sidebar-nav-dropdown-items{left:56px}[dir=rtl] .c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown:hover>.c-sidebar-nav-dropdown-items{right:56px}html:not([dir=rtl]) .c-sidebar-minimized.c-sidebar-right>.c-sidebar-nav-dropdown:hover>.c-sidebar-nav-dropdown-items{left:0}[dir=rtl] .c-sidebar-minimized.c-sidebar-right>.c-sidebar-nav-dropdown:hover>.c-sidebar-nav-dropdown-items{right:0}}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right){margin-left:0}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right){margin-right:0}@media (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper{margin-left:256px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper{margin-right:256px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-left:192px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-right:192px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-left:320px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-right:320px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-left:384px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-right:384px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-left:56px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-right:56px}}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right{margin-right:0}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right{margin-left:0}@media (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper{margin-right:256px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper{margin-left:256px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-right:192px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-left:192px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-right:320px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-left:320px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-right:384px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-left:384px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-right:56px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-left:56px}}@media (min-width:576px){html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right),html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right){margin-left:0}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right),[dir=rtl] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right){margin-right:0}}@media (min-width:576px) and (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper{margin-left:256px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper{margin-right:256px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-left:192px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-right:192px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-left:320px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-right:320px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-left:384px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-right:384px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-left:56px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-sm-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-right:56px}}@media (min-width:576px){html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right,html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right{margin-right:0}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right,[dir=rtl] .c-sidebar.c-sidebar-sm-show.c-sidebar-right{margin-left:0}}@media (min-width:576px) and (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper{margin-right:256px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper{margin-left:256px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-right:192px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-left:192px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-right:320px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-left:320px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-right:384px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-left:384px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-right:56px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-sm-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-left:56px}}@media (min-width:768px){html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right),html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right){margin-left:0}[dir=rtl] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right),[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right){margin-right:0}}@media (min-width:768px) and (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper{margin-left:256px}[dir=rtl] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper{margin-right:256px}html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-left:192px}[dir=rtl] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-right:192px}html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-left:320px}[dir=rtl] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-right:320px}html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-left:384px}[dir=rtl] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-right:384px}html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-left:56px}[dir=rtl] .c-sidebar.c-sidebar-md-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-right:56px}}@media (min-width:768px){html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show.c-sidebar-right,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right{margin-right:0}[dir=rtl] .c-sidebar.c-sidebar-md-show.c-sidebar-right,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right{margin-left:0}}@media (min-width:768px) and (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper{margin-right:256px}[dir=rtl] .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper{margin-left:256px}html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-right:192px}[dir=rtl] .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-left:192px}html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-right:320px}[dir=rtl] .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-left:320px}html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-right:384px}[dir=rtl] .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-left:384px}html:not([dir=rtl]) .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-right:56px}[dir=rtl] .c-sidebar.c-sidebar-md-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-left:56px}}@media (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right),html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right){margin-left:0}[dir=rtl] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right),[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right){margin-right:0}}@media (min-width:992px) and (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper{margin-left:256px}[dir=rtl] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper{margin-right:256px}html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-left:192px}[dir=rtl] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-right:192px}html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-left:320px}[dir=rtl] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-right:320px}html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-left:384px}[dir=rtl] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-right:384px}html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-left:56px}[dir=rtl] .c-sidebar.c-sidebar-lg-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-right:56px}}@media (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right{margin-right:0}[dir=rtl] .c-sidebar.c-sidebar-lg-show.c-sidebar-right,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right{margin-left:0}}@media (min-width:992px) and (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper{margin-right:256px}[dir=rtl] .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper{margin-left:256px}html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-right:192px}[dir=rtl] .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-left:192px}html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-right:320px}[dir=rtl] .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-left:320px}html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-right:384px}[dir=rtl] .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-left:384px}html:not([dir=rtl]) .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-right:56px}[dir=rtl] .c-sidebar.c-sidebar-lg-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-left:56px}}@media (min-width:1200px){html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right),html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right){margin-left:0}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right),[dir=rtl] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right){margin-right:0}}@media (min-width:1200px) and (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper{margin-left:256px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed~.c-wrapper{margin-right:256px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-left:192px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-right:192px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-left:320px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-right:320px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-left:384px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-right:384px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-left:56px}[dir=rtl] .c-sidebar.c-sidebar-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-xl-show:not(.c-sidebar-right).c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-right:56px}}@media (min-width:1200px){html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right,html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right{margin-right:0}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right,[dir=rtl] .c-sidebar.c-sidebar-xl-show.c-sidebar-right{margin-left:0}}@media (min-width:1200px) and (min-width:992px){html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper{margin-right:256px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed~.c-wrapper{margin-left:256px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-right:192px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-sm~.c-wrapper{margin-left:192px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-right:320px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-lg~.c-wrapper{margin-left:320px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-right:384px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-xl~.c-wrapper{margin-left:384px}html:not([dir=rtl]) .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,html:not([dir=rtl]) .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-right:56px}[dir=rtl] .c-sidebar.c-sidebar-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper,[dir=rtl] .c-sidebar.c-sidebar-xl-show.c-sidebar-right.c-sidebar-fixed.c-sidebar-minimized~.c-wrapper{margin-left:56px}}.c-sidebar .c-sidebar-brand,.c-sidebar .c-sidebar-close{color:#fff}.c-sidebar .c-sidebar-brand,.c-sidebar .c-sidebar-header{background:rgba(0,0,21,.2)}.c-sidebar .c-sidebar-form .c-form-control{color:#fff;background:rgba(0,0,21,.1);border:0}.c-sidebar .c-sidebar-form .c-form-control::-webkit-input-placeholder{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-form .c-form-control::-moz-placeholder{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-form .c-form-control:-ms-input-placeholder{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-form .c-form-control::-ms-input-placeholder{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-form .c-form-control::placeholder{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-nav-title{color:rgba(255,255,255,.6)}.c-sidebar .c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-link{color:rgba(255,255,255,.8);background:0 0}.c-sidebar .c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link .c-sidebar-nav-icon{color:rgba(255,255,255,.5)}.c-sidebar .c-active.c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-link.c-active{color:#fff;background:rgba(255,255,255,.05)}.c-sidebar .c-active.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-active .c-sidebar-nav-icon{color:#fff}.c-sidebar .c-sidebar-nav-dropdown-toggle:hover,.c-sidebar .c-sidebar-nav-link:hover{color:#fff;background:#321fdb}.c-sidebar .c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link:hover .c-sidebar-nav-icon{color:#fff}.c-sidebar .c-sidebar-nav-link:hover.c-sidebar-nav-dropdown-toggle::after,.c-sidebar :hover.c-sidebar-nav-dropdown-toggle::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23fff' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E")}.c-sidebar .c-disabled.c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-link.c-disabled{color:#b3b2b2;background:0 0}.c-sidebar .c-disabled.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-disabled .c-sidebar-nav-icon{color:rgba(255,255,255,.5)}.c-sidebar .c-disabled.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar .c-sidebar-nav-link.c-disabled:hover{color:#b3b2b2}.c-sidebar .c-disabled.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-disabled:hover .c-sidebar-nav-icon{color:rgba(255,255,255,.5)}.c-sidebar .c-disabled:hover.c-sidebar-nav-dropdown-toggle::after,.c-sidebar .c-sidebar-nav-link.c-disabled:hover.c-sidebar-nav-dropdown-toggle::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23fff' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E")}.c-sidebar .c-sidebar-nav-dropdown-toggle{position:relative}.c-sidebar .c-sidebar-nav-dropdown-toggle::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='rgba(255, 255, 255, 0.5)' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E")}.c-sidebar .c-sidebar-nav-dropdown.c-show{background:rgba(0,0,0,.2)}.c-sidebar .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link{color:#fff}.c-sidebar .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled{color:#b3b2b2;background:0 0}.c-sidebar .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled:hover{color:#b3b2b2}.c-sidebar .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled:hover .c-sidebar-nav-icon{color:rgba(255,255,255,.5)}.c-sidebar .c-sidebar-nav-label{color:rgba(255,255,255,.6)}.c-sidebar .c-sidebar-nav-label:hover{color:#fff}.c-sidebar .c-sidebar-nav-label .c-sidebar-nav-icon{color:rgba(255,255,255,.5)}.c-sidebar .c-progress{background-color:#596f94!important}.c-sidebar .c-sidebar-footer{background:rgba(0,0,21,.2)}.c-sidebar .c-sidebar-minimizer{background-color:rgba(0,0,21,.2)}.c-sidebar .c-sidebar-minimizer::before{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%238a93a2' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E")}.c-sidebar .c-sidebar-minimizer.c-focus,.c-sidebar .c-sidebar-minimizer:focus{outline:0}.c-sidebar .c-sidebar-minimizer:hover{background-color:rgba(0,0,0,.3)}.c-sidebar .c-sidebar-minimizer:hover::before{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23fff' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E")}.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-dropdown-toggle,.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-link{background:#321fdb}.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-link .c-sidebar-nav-icon{color:#fff}.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-disabled.c-sidebar-nav-dropdown-toggle,.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-link.c-disabled{background:#3c4b64}.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-disabled.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-link.c-disabled .c-sidebar-nav-icon{color:rgba(255,255,255,.5)}.c-sidebar.c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown>.c-sidebar-nav-dropdown-items{background:#3c4b64}.c-sidebar.c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown:hover{background:#321fdb}.c-sidebar.c-sidebar-light{color:#4f5d73;background:#fff;border-right:1px solid rgba(159,167,179,.5)}[dir=rtl] .c-sidebar.c-sidebar-light,html:not([dir=rtl]) .c-sidebar.c-sidebar-light.c-sidebar-right{border-right:0;border-left:1px solid rgba(159,167,179,.5)}[dir=rtl] .c-sidebar.c-sidebar-light.c-sidebar-right{border:0;border-right:1px solid rgba(159,167,179,.5)}.c-sidebar.c-sidebar-light .c-sidebar-close{color:#4f5d73}.c-sidebar.c-sidebar-light .c-sidebar-brand{color:#fff;background:#321fdb}.c-sidebar.c-sidebar-light .c-sidebar-header{background:rgba(0,0,21,.2)}.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control{color:#fff;background:rgba(0,0,21,.1);border:0}.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control::-webkit-input-placeholder{color:rgba(255,255,255,.7)}.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control::-moz-placeholder{color:rgba(255,255,255,.7)}.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control:-ms-input-placeholder{color:rgba(255,255,255,.7)}.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control::-ms-input-placeholder{color:rgba(255,255,255,.7)}.c-sidebar.c-sidebar-light .c-sidebar-form .c-form-control::placeholder{color:rgba(255,255,255,.7)}.c-sidebar.c-sidebar-light .c-sidebar-nav-title{color:rgba(0,0,21,.4)}.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle,.c-sidebar.c-sidebar-light .c-sidebar-nav-link{color:rgba(0,0,21,.8);background:0 0}.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar.c-sidebar-light .c-sidebar-nav-link .c-sidebar-nav-icon{color:rgba(0,0,21,.5)}.c-sidebar.c-sidebar-light .c-active.c-sidebar-nav-dropdown-toggle,.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-active{color:rgba(0,0,21,.8);background:rgba(0,0,21,.05)}.c-sidebar.c-sidebar-light .c-active.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-active .c-sidebar-nav-icon{color:#321fdb}.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle:hover,.c-sidebar.c-sidebar-light .c-sidebar-nav-link:hover{color:#fff;background:#321fdb}.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar.c-sidebar-light .c-sidebar-nav-link:hover .c-sidebar-nav-icon{color:#fff}.c-sidebar.c-sidebar-light .c-sidebar-nav-link:hover.c-sidebar-nav-dropdown-toggle::after,.c-sidebar.c-sidebar-light :hover.c-sidebar-nav-dropdown-toggle::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23fff' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E")}.c-sidebar.c-sidebar-light .c-disabled.c-sidebar-nav-dropdown-toggle,.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-disabled{color:#b3b2b2;background:0 0}.c-sidebar.c-sidebar-light .c-disabled.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-disabled .c-sidebar-nav-icon{color:rgba(0,0,21,.5)}.c-sidebar.c-sidebar-light .c-disabled.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-disabled:hover{color:#b3b2b2}.c-sidebar.c-sidebar-light .c-disabled.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-disabled:hover .c-sidebar-nav-icon{color:rgba(0,0,21,.5)}.c-sidebar.c-sidebar-light .c-disabled:hover.c-sidebar-nav-dropdown-toggle::after,.c-sidebar.c-sidebar-light .c-sidebar-nav-link.c-disabled:hover.c-sidebar-nav-dropdown-toggle::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23fff' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E")}.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle{position:relative}.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown-toggle::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='rgba(0, 0, 21, 0.5)' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E")}.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show{background:rgba(0,0,0,.05)}.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-dropdown-toggle,.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link{color:rgba(0,0,21,.8)}.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle,.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled{color:#b3b2b2;background:0 0}.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled:hover{color:#b3b2b2}.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-disabled.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar.c-sidebar-light .c-sidebar-nav-dropdown.c-show .c-sidebar-nav-link.c-disabled:hover .c-sidebar-nav-icon{color:rgba(0,0,21,.5)}.c-sidebar.c-sidebar-light .c-sidebar-nav-label{color:rgba(0,0,21,.4)}.c-sidebar.c-sidebar-light .c-sidebar-nav-label:hover{color:#4f5d73}.c-sidebar.c-sidebar-light .c-sidebar-nav-label .c-sidebar-nav-icon{color:rgba(0,0,21,.5)}.c-sidebar.c-sidebar-light .c-sidebar-footer{background:rgba(0,0,21,.2)}.c-sidebar.c-sidebar-light .c-sidebar-minimizer{background-color:rgba(0,0,0,.05)}.c-sidebar.c-sidebar-light .c-sidebar-minimizer::before{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%238a93a2' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E")}.c-sidebar.c-sidebar-light .c-sidebar-minimizer.c-focus,.c-sidebar.c-sidebar-light .c-sidebar-minimizer:focus{outline:0}.c-sidebar.c-sidebar-light .c-sidebar-minimizer:hover{background-color:rgba(0,0,0,.1)}.c-sidebar.c-sidebar-light .c-sidebar-minimizer:hover::before{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 14'%3E%3Cpath fill='%23768192' d='M9.148 2.352l-4.148 4.148 4.148 4.148q0.148 0.148 0.148 0.352t-0.148 0.352l-1.297 1.297q-0.148 0.148-0.352 0.148t-0.352-0.148l-5.797-5.797q-0.148-0.148-0.148-0.352t0.148-0.352l5.797-5.797q0.148-0.148 0.352-0.148t0.352 0.148l1.297 1.297q0.148 0.148 0.148 0.352t-0.148 0.352z'/%3E%3C/svg%3E")}.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-dropdown-toggle,.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-link{background:#321fdb}.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-link .c-sidebar-nav-icon{color:#fff}.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-disabled.c-sidebar-nav-dropdown-toggle,.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-link.c-disabled{background:#fff}.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-disabled.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav-item:hover>.c-sidebar-nav-link.c-disabled .c-sidebar-nav-icon{color:rgba(0,0,21,.5)}.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown>.c-sidebar-nav-dropdown-items{background:#fff}.c-sidebar .c-sidebar-nav-link-primary.c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-primary,.c-sidebar.c-sidebar-light.c-sidebar-minimized .c-sidebar-nav>.c-sidebar-nav-dropdown:hover{background:#321fdb}.c-sidebar .c-sidebar-nav-link-primary.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-primary .c-sidebar-nav-icon{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-nav-link-primary.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-primary:hover{background:#2d1cc5}.c-sidebar .c-sidebar-nav-link-primary.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-primary:hover .c-sidebar-nav-icon{color:#fff}.c-sidebar .c-sidebar-nav-link-secondary.c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-secondary{background:#ced2d8}.c-sidebar .c-sidebar-nav-link-secondary.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-secondary .c-sidebar-nav-icon{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-nav-link-secondary.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-secondary:hover{background:#c0c5cd}.c-sidebar .c-sidebar-nav-link-secondary.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-secondary:hover .c-sidebar-nav-icon{color:#fff}.c-sidebar .c-sidebar-nav-link-success.c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-success{background:#2eb85c}.c-sidebar .c-sidebar-nav-link-success.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-success .c-sidebar-nav-icon{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-nav-link-success.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-success:hover{background:#29a452}.c-sidebar .c-sidebar-nav-link-success.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-success:hover .c-sidebar-nav-icon{color:#fff}.c-sidebar .c-sidebar-nav-link-info.c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-info{background:#39f}.c-sidebar .c-sidebar-nav-link-info.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-info .c-sidebar-nav-icon{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-nav-link-info.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-info:hover{background:#1a8cff}.c-sidebar .c-sidebar-nav-link-info.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-info:hover .c-sidebar-nav-icon{color:#fff}.c-sidebar .c-sidebar-nav-link-warning.c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-warning{background:#f9b115}.c-sidebar .c-sidebar-nav-link-warning.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-warning .c-sidebar-nav-icon{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-nav-link-warning.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-warning:hover{background:#eea506}.c-sidebar .c-sidebar-nav-link-warning.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-warning:hover .c-sidebar-nav-icon{color:#fff}.c-sidebar .c-sidebar-nav-link-danger.c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-danger{background:#e55353}.c-sidebar .c-sidebar-nav-link-danger.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-danger .c-sidebar-nav-icon{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-nav-link-danger.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-danger:hover{background:#e23d3d}.c-sidebar .c-sidebar-nav-link-danger.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-danger:hover .c-sidebar-nav-icon{color:#fff}.c-sidebar .c-sidebar-nav-link-light.c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-light{background:#ebedef}.c-sidebar .c-sidebar-nav-link-light.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-light .c-sidebar-nav-icon{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-nav-link-light.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-light:hover{background:#dde0e4}.c-sidebar .c-sidebar-nav-link-light.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-light:hover .c-sidebar-nav-icon{color:#fff}.c-sidebar .c-sidebar-nav-link-dark.c-sidebar-nav-dropdown-toggle,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-dark{background:#636f83}.c-sidebar .c-sidebar-nav-link-dark.c-sidebar-nav-dropdown-toggle .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-dark .c-sidebar-nav-icon{color:rgba(255,255,255,.7)}.c-sidebar .c-sidebar-nav-link-dark.c-sidebar-nav-dropdown-toggle:hover,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-dark:hover{background:#586374}.c-sidebar .c-sidebar-nav-link-dark.c-sidebar-nav-dropdown-toggle:hover .c-sidebar-nav-icon,.c-sidebar .c-sidebar-nav-link.c-sidebar-nav-link-dark:hover .c-sidebar-nav-icon{color:#fff}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.spinner-grow-sm{width:1rem;height:1rem}.c-subheader{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:row;flex-direction:row;-ms-flex-wrap:wrap;flex-wrap:wrap;width:100%;min-height:48px;background:#fff;border-bottom:1px solid #d8dbe0}.c-subheader[class*=bg-]{border-color:rgba(0,0,21,.1)}.c-subheader.c-subheader-fixed{position:fixed;right:0;left:0;z-index:1030}.c-subheader-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:row;flex-direction:row;-ms-flex-align:center;align-items:center;min-height:48px;padding:0;margin-bottom:0;list-style:none}.c-subheader-nav .c-subheader-nav-item{position:relative}.c-subheader-nav .c-subheader-nav-btn{background-color:transparent;border:1px solid transparent}.c-subheader-nav .c-subheader-nav-btn,.c-subheader-nav .c-subheader-nav-link{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding-right:.5rem;padding-left:.5rem}.c-subheader-nav .c-subheader-nav-btn .badge,.c-subheader-nav .c-subheader-nav-link .badge{position:absolute;top:50%;margin-top:-16px}html:not([dir=rtl]) .c-subheader-nav .c-subheader-nav-btn .badge,html:not([dir=rtl]) .c-subheader-nav .c-subheader-nav-link .badge{left:50%;margin-left:0}[dir=rtl] .c-subheader-nav .c-subheader-nav-btn .badge,[dir=rtl] .c-subheader-nav .c-subheader-nav-link .badge{right:50%;margin-right:0}.c-subheader-nav .c-subheader-nav-btn:hover,.c-subheader-nav .c-subheader-nav-link:hover{text-decoration:none}.c-subheader.c-subheader-dark{background:#3c4b64;border-bottom:1px solid #636f83}.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-btn,.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link{color:rgba(255,255,255,.75)}.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-btn:focus,.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-btn:hover,.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link:focus,.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link:hover{color:rgba(255,255,255,.9)}.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-btn.c-disabled,.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link.c-disabled{color:rgba(255,255,255,.25)}.c-subheader.c-subheader-dark .c-subheader-nav .c-active>.c-subheader-nav-link,.c-subheader.c-subheader-dark .c-subheader-nav .c-show>.c-subheader-nav-link,.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link.c-active,.c-subheader.c-subheader-dark .c-subheader-nav .c-subheader-nav-link.c-show{color:#fff}.c-subheader.c-subheader-dark .c-subheader-text{color:rgba(255,255,255,.75)}.c-subheader.c-subheader-dark .c-subheader-text a,.c-subheader.c-subheader-dark .c-subheader-text a:focus,.c-subheader.c-subheader-dark .c-subheader-text a:hover{color:#fff}.c-subheader .c-subheader-nav .c-subheader-nav-btn,.c-subheader .c-subheader-nav .c-subheader-nav-link{color:rgba(0,0,21,.5)}.c-subheader .c-subheader-nav .c-subheader-nav-btn:focus,.c-subheader .c-subheader-nav .c-subheader-nav-btn:hover,.c-subheader .c-subheader-nav .c-subheader-nav-link:focus,.c-subheader .c-subheader-nav .c-subheader-nav-link:hover{color:rgba(0,0,21,.7)}.c-subheader .c-subheader-nav .c-subheader-nav-btn.c-disabled,.c-subheader .c-subheader-nav .c-subheader-nav-link.c-disabled{color:rgba(0,0,21,.3)}.c-subheader .c-subheader-nav .c-active>.c-subheader-nav-link,.c-subheader .c-subheader-nav .c-show>.c-subheader-nav-link,.c-subheader .c-subheader-nav .c-subheader-nav-link.c-active,.c-subheader .c-subheader-nav .c-subheader-nav-link.c-show{color:rgba(0,0,21,.9)}.c-subheader .c-subheader-text{color:rgba(0,0,21,.5)}.c-subheader .c-subheader-text a,.c-subheader .c-subheader-text a:focus,.c-subheader .c-subheader-text a:hover{color:rgba(0,0,21,.9)}.c-switch{display:inline-block;width:40px;height:26px}.c-switch-input{position:absolute;z-index:-1;opacity:0}.c-switch-slider{position:relative;display:block;height:inherit;cursor:pointer;border:1px solid #d8dbe0;border-radius:.25rem}.c-switch-slider,.c-switch-slider::before{background-color:#fff;transition:.15s ease-out}.c-switch-slider::before{position:absolute;top:2px;left:2px;box-sizing:border-box;width:20px;height:20px;content:"";border:1px solid #d8dbe0;border-radius:.125rem}.c-switch-input:checked~.c-switch-slider::before{-webkit-transform:translateX(14px);transform:translateX(14px)}.c-switch-input:focus~.c-switch-slider{color:#768192;background-color:#fff;border-color:#958bef;outline:0;box-shadow:0 0 0 .2rem rgba(50,31,219,.25)}.c-switch-input:disabled~.c-switch-slider{cursor:not-allowed;opacity:.5}.c-switch-lg{width:48px;height:30px}.c-switch-lg .c-switch-slider{font-size:12px}.c-switch-lg .c-switch-slider::before{width:24px;height:24px}.c-switch-lg .c-switch-slider::after{font-size:12px}.c-switch-lg .c-switch-input:checked~.c-switch-slider::before{-webkit-transform:translateX(18px);transform:translateX(18px)}.c-switch-sm{width:32px;height:22px}.c-switch-sm .c-switch-slider{font-size:8px}.c-switch-sm .c-switch-slider::before{width:16px;height:16px}.c-switch-sm .c-switch-slider::after{font-size:8px}.c-switch-sm .c-switch-input:checked~.c-switch-slider::before{-webkit-transform:translateX(10px);transform:translateX(10px)}.c-switch-label{width:48px}.c-switch-label .c-switch-slider::before{z-index:2}.c-switch-label .c-switch-slider::after{position:absolute;top:50%;z-index:1;width:50%;margin-top:-.5em;font-size:10px;font-weight:600;line-height:1;color:#c4c9d0;text-align:center;text-transform:uppercase;content:attr(data-unchecked);transition:inherit}html:not([dir=rtl]) .c-switch-label .c-switch-slider::after{right:1px}.c-switch-label .c-switch-input:checked~.c-switch-slider::before{-webkit-transform:translateX(22px);transform:translateX(22px)}.c-switch-label .c-switch-input:checked~.c-switch-slider::after{left:1px;color:#fff;content:attr(data-checked)}.c-switch-label.c-switch-lg{width:56px;height:30px}.c-switch-label.c-switch-lg .c-switch-slider{font-size:12px}.c-switch-label.c-switch-lg .c-switch-slider::before{width:24px;height:24px}.c-switch-label.c-switch-lg .c-switch-slider::after{font-size:12px}.c-switch-label.c-switch-lg .c-switch-input:checked~.c-switch-slider::before{-webkit-transform:translateX(26px);transform:translateX(26px)}.c-switch-label.c-switch-sm{width:40px;height:22px}.c-switch-label.c-switch-sm .c-switch-slider{font-size:8px}.c-switch-label.c-switch-sm .c-switch-slider::before{width:16px;height:16px}.c-switch-label.c-switch-sm .c-switch-slider::after{font-size:8px}.c-switch-label.c-switch-sm .c-switch-input:checked~.c-switch-slider::before{-webkit-transform:translateX(18px);transform:translateX(18px)}.c-switch[class*="-3d"] .c-switch-slider{background-color:#ebedef;border-radius:50em}.c-switch[class*="-3d"] .c-switch-slider::before{top:-1px;left:-1px;width:26px;height:26px;border:0;border-radius:50em;box-shadow:0 2px 5px rgba(0,0,21,.3)}.c-switch[class*="-3d"].c-switch-lg{width:48px;height:30px}.c-switch[class*="-3d"].c-switch-lg .c-switch-slider::before{width:30px;height:30px}.c-switch[class*="-3d"].c-switch-lg .c-switch-input:checked~.c-switch-slider::before{-webkit-transform:translateX(18px);transform:translateX(18px)}.c-switch[class*="-3d"].c-switch-sm{width:32px;height:22px}.c-switch[class*="-3d"].c-switch-sm .c-switch-slider::before{width:22px;height:22px}.c-switch[class*="-3d"].c-switch-sm .c-switch-input:checked~.c-switch-slider::before{-webkit-transform:translateX(10px);transform:translateX(10px)}.c-switch-primary .c-switch-input:checked+.c-switch-slider{background-color:#321fdb;border-color:#2819ae}.c-switch-primary .c-switch-input:checked+.c-switch-slider::before{border-color:#2819ae}.c-switch-3d-primary .c-switch-input:checked+.c-switch-slider{background-color:#321fdb}.c-switch-outline-primary .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#321fdb}.c-switch-outline-primary .c-switch-input:checked+.c-switch-slider::before{border-color:#321fdb}.c-switch-outline-primary .c-switch-input:checked+.c-switch-slider::after{color:#321fdb}.c-switch-opposite-primary .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#321fdb}.c-switch-opposite-primary .c-switch-input:checked+.c-switch-slider::before{background-color:#321fdb;border-color:#321fdb}.c-switch-opposite-primary .c-switch-input:checked+.c-switch-slider::after{color:#321fdb}.c-switch-secondary .c-switch-input:checked+.c-switch-slider{background-color:#ced2d8;border-color:#b2b8c1}.c-switch-secondary .c-switch-input:checked+.c-switch-slider::before{border-color:#b2b8c1}.c-switch-3d-secondary .c-switch-input:checked+.c-switch-slider{background-color:#ced2d8}.c-switch-outline-secondary .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#ced2d8}.c-switch-outline-secondary .c-switch-input:checked+.c-switch-slider::before{border-color:#ced2d8}.c-switch-outline-secondary .c-switch-input:checked+.c-switch-slider::after{color:#ced2d8}.c-switch-opposite-secondary .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#ced2d8}.c-switch-opposite-secondary .c-switch-input:checked+.c-switch-slider::before{background-color:#ced2d8;border-color:#ced2d8}.c-switch-opposite-secondary .c-switch-input:checked+.c-switch-slider::after{color:#ced2d8}.c-switch-success .c-switch-input:checked+.c-switch-slider{background-color:#2eb85c;border-color:#248f48}.c-switch-success .c-switch-input:checked+.c-switch-slider::before{border-color:#248f48}.c-switch-3d-success .c-switch-input:checked+.c-switch-slider{background-color:#2eb85c}.c-switch-outline-success .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#2eb85c}.c-switch-outline-success .c-switch-input:checked+.c-switch-slider::before{border-color:#2eb85c}.c-switch-outline-success .c-switch-input:checked+.c-switch-slider::after{color:#2eb85c}.c-switch-opposite-success .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#2eb85c}.c-switch-opposite-success .c-switch-input:checked+.c-switch-slider::before{background-color:#2eb85c;border-color:#2eb85c}.c-switch-opposite-success .c-switch-input:checked+.c-switch-slider::after{color:#2eb85c}.c-switch-info .c-switch-input:checked+.c-switch-slider{background-color:#39f;border-color:#0080ff}.c-switch-info .c-switch-input:checked+.c-switch-slider::before{border-color:#0080ff}.c-switch-3d-info .c-switch-input:checked+.c-switch-slider{background-color:#39f}.c-switch-outline-info .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#39f}.c-switch-outline-info .c-switch-input:checked+.c-switch-slider::before{border-color:#39f}.c-switch-outline-info .c-switch-input:checked+.c-switch-slider::after{color:#39f}.c-switch-opposite-info .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#39f}.c-switch-opposite-info .c-switch-input:checked+.c-switch-slider::before{background-color:#39f;border-color:#39f}.c-switch-opposite-info .c-switch-input:checked+.c-switch-slider::after{color:#39f}.c-switch-warning .c-switch-input:checked+.c-switch-slider{background-color:#f9b115;border-color:#d69405}.c-switch-warning .c-switch-input:checked+.c-switch-slider::before{border-color:#d69405}.c-switch-3d-warning .c-switch-input:checked+.c-switch-slider{background-color:#f9b115}.c-switch-outline-warning .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#f9b115}.c-switch-outline-warning .c-switch-input:checked+.c-switch-slider::before{border-color:#f9b115}.c-switch-outline-warning .c-switch-input:checked+.c-switch-slider::after{color:#f9b115}.c-switch-opposite-warning .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#f9b115}.c-switch-opposite-warning .c-switch-input:checked+.c-switch-slider::before{background-color:#f9b115;border-color:#f9b115}.c-switch-opposite-warning .c-switch-input:checked+.c-switch-slider::after{color:#f9b115}.c-switch-danger .c-switch-input:checked+.c-switch-slider{background-color:#e55353;border-color:#de2727}.c-switch-danger .c-switch-input:checked+.c-switch-slider::before{border-color:#de2727}.c-switch-3d-danger .c-switch-input:checked+.c-switch-slider{background-color:#e55353}.c-switch-outline-danger .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#e55353}.c-switch-outline-danger .c-switch-input:checked+.c-switch-slider::before{border-color:#e55353}.c-switch-outline-danger .c-switch-input:checked+.c-switch-slider::after{color:#e55353}.c-switch-opposite-danger .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#e55353}.c-switch-opposite-danger .c-switch-input:checked+.c-switch-slider::before{background-color:#e55353;border-color:#e55353}.c-switch-opposite-danger .c-switch-input:checked+.c-switch-slider::after{color:#e55353}.c-switch-light .c-switch-input:checked+.c-switch-slider{background-color:#ebedef;border-color:#cfd4d8}.c-switch-light .c-switch-input:checked+.c-switch-slider::before{border-color:#cfd4d8}.c-switch-3d-light .c-switch-input:checked+.c-switch-slider{background-color:#ebedef}.c-switch-outline-light .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#ebedef}.c-switch-outline-light .c-switch-input:checked+.c-switch-slider::before{border-color:#ebedef}.c-switch-outline-light .c-switch-input:checked+.c-switch-slider::after{color:#ebedef}.c-switch-opposite-light .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#ebedef}.c-switch-opposite-light .c-switch-input:checked+.c-switch-slider::before{background-color:#ebedef;border-color:#ebedef}.c-switch-opposite-light .c-switch-input:checked+.c-switch-slider::after{color:#ebedef}.c-switch-dark .c-switch-input:checked+.c-switch-slider{background-color:#636f83;border-color:#4d5666}.c-switch-dark .c-switch-input:checked+.c-switch-slider::before{border-color:#4d5666}.c-switch-3d-dark .c-switch-input:checked+.c-switch-slider{background-color:#636f83}.c-switch-outline-dark .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#636f83}.c-switch-outline-dark .c-switch-input:checked+.c-switch-slider::before{border-color:#636f83}.c-switch-outline-dark .c-switch-input:checked+.c-switch-slider::after{color:#636f83}.c-switch-opposite-dark .c-switch-input:checked+.c-switch-slider{background-color:#fff;border-color:#636f83}.c-switch-opposite-dark .c-switch-input:checked+.c-switch-slider::before{background-color:#636f83;border-color:#636f83}.c-switch-opposite-dark .c-switch-input:checked+.c-switch-slider::after{color:#636f83}.c-switch-pill .c-switch-slider,.c-switch-pill .c-switch-slider::before{border-radius:50em}.c-switch-square .c-switch-slider,.c-switch-square .c-switch-slider::before{border-radius:0}.table{width:100%;margin-bottom:1rem;color:#4f5d73}.table td,.table th{padding:.75rem;vertical-align:top;border-top:1px solid;border-top-color:#d8dbe0}.table thead th{vertical-align:bottom;border-bottom:2px solid;border-bottom-color:#d8dbe0}.table tbody+tbody{border-top:2px solid;border-top-color:#d8dbe0}.table-sm td,.table-sm th{padding:.3rem}.table-bordered,.table-bordered td,.table-bordered th{border:1px solid;border-color:#d8dbe0}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,21,.05)}.table-hover tbody tr:hover{color:#4f5d73;background-color:rgba(0,0,21,.075)}.table-primary,.table-primary>td,.table-primary>th{color:#4f5d73;background-color:#c6c0f5}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#948bec}.table-hover .table-primary:hover,.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#b2aaf2}.table-secondary,.table-secondary>td,.table-secondary>th{color:#4f5d73;background-color:#f1f2f4}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#e6e8eb}.table-hover .table-secondary:hover,.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#e3e5e9}.table-success,.table-success>td,.table-success>th{color:#4f5d73;background-color:#c4ebd1}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#92daaa}.table-hover .table-success:hover,.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#b1e5c2}.table-info,.table-info>td,.table-info>th{color:#4f5d73;background-color:#c6e2ff}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#95caff}.table-hover .table-info:hover,.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#add5ff}.table-warning,.table-warning>td,.table-warning>th{color:#4f5d73;background-color:#fde9bd}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#fcd685}.table-hover .table-warning:hover,.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#fce1a4}.table-danger,.table-danger>td,.table-danger>th{color:#4f5d73;background-color:#f8cfcf}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#f1a6a6}.table-hover .table-danger:hover,.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f5b9b9}.table-light,.table-light>td,.table-light>th{color:#4f5d73;background-color:#f9fafb}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#f5f6f7}.table-hover .table-light:hover,.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#eaedf1}.table-dark,.table-dark>td,.table-dark>th{color:#4f5d73;background-color:#d3d7dc}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#aeb4bf}.table-hover .table-dark:hover,.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#c5cad1}.table-active,.table-active>td,.table-active>th{color:#fff;background-color:rgba(0,0,21,.075)}.table-hover .table-active:hover,.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(0,0,0,.075)}.table .thead-dark th{color:#fff;background-color:#636f83;border-color:#758297}.table .thead-light th{color:#768192;background-color:#d8dbe0;border-color:#d8dbe0}.table-dark{color:#fff;background-color:#636f83}.table-dark td,.table-dark th,.table-dark thead th{border-color:#758297}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,.075)}@media (max-width:575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width:991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.table-outline{border:1px solid;border-color:#d8dbe0}.table-align-middle td,.table-outline td{vertical-align:middle}.table-clear td{border:0}.toast{width:350px;max-width:350px;overflow:hidden;font-size:.875rem;background-clip:padding-box;border:1px solid;box-shadow:0 .25rem .75rem rgba(0,0,21,.1);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);opacity:0;border-radius:.25rem;background-color:rgba(255,255,255,.85);border-color:rgba(0,0,21,.1)}.toast:not(:last-child){margin-bottom:.75rem}.toast.show,.toast.showing{opacity:1}.toast.show{display:block}.toast.hide{display:none}.toast-full{width:100%;max-width:100%}.toast-header{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.25rem .75rem;background-clip:padding-box;border-bottom:1px solid;color:#8a93a2;background-color:rgba(255,255,255,.85);border-color:rgba(0,0,21,.05)}.toast-body{padding:.75rem}.toaster{display:-ms-flexbox;display:flex;-ms-flex-direction:column-reverse;flex-direction:column-reverse;width:100%;padding:.25rem .5rem}.toaster-bottom-center,.toaster-bottom-full,.toaster-bottom-left,.toaster-bottom-right,.toaster-top-center,.toaster-top-full,.toaster-top-left,.toaster-top-right{position:fixed;z-index:1080;width:350px}.toaster-top-center,.toaster-top-full,.toaster-top-left,.toaster-top-right{top:0}.toaster-bottom-center,.toaster-bottom-full,.toaster-bottom-left,.toaster-bottom-right{bottom:0;-ms-flex-direction:column;flex-direction:column}.toaster-bottom-full,.toaster-top-full{width:auto}.toaster-bottom-center,.toaster-top-center{left:50%;-webkit-transform:translateX(-50%);transform:translateX(-50%)}.toaster-bottom-full,.toaster-bottom-right,.toaster-top-full,.toaster-top-right{right:0}.toaster-bottom-full,.toaster-bottom-left,.toaster-top-full,.toaster-top-left{left:0}.toaster .toast{width:100%;max-width:100%;margin-top:.125rem;margin-bottom:.125rem}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";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;word-spacing:normal;white-space:normal;line-break:auto;font-size:.765625rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow::before,.bs-tooltip-top .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#000015}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=right] .arrow::before,.bs-tooltip-right .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#000015}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow::before,.bs-tooltip-bottom .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#000015}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=left] .arrow::before,.bs-tooltip-left .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#000015}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000015;border-radius:.25rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}@-webkit-keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}.fade-in{-webkit-animation-name:fadeIn;animation-name:fadeIn;-webkit-animation-duration:1s;animation-duration:1s}.c-wrapper{will-change:auto;transition:margin-left .25s,margin-right .25s,width .25s,flex .25s,-webkit-transform .25s;transition:transform .25s,margin-left .25s,margin-right .25s,width .25s,flex .25s;transition:transform .25s,margin-left .25s,margin-right .25s,width .25s,flex .25s,-webkit-transform .25s,-ms-flex .25s;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.c-sidebar.c-sidebar-unfoldable{transition:margin-left .25s,margin-right .25s,width .25s,z-index 0s ease 0s,-webkit-transform .25s;transition:transform .25s,margin-left .25s,margin-right .25s,width .25s,z-index 0s ease 0s;transition:transform .25s,margin-left .25s,margin-right .25s,width .25s,z-index 0s ease 0s,-webkit-transform .25s}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:2.1875rem}.h2,h2{font-size:1.75rem}.h3,h3{font-size:1.53125rem}.h4,h4{font-size:1.3125rem}.h5,h5{font-size:1.09375rem}.h6,h6{font-size:.875rem}.lead{font-size:1.09375rem;font-weight:300}.display-1{font-size:6rem}.display-1,.display-2{font-weight:300;line-height:1.2}.display-2{font-size:5.5rem}.display-3{font-size:4.5rem}.display-3,.display-4{font-weight:300;line-height:1.2}.display-4{font-size:3.5rem}.c-vr{width:1px;background-color:rgba(0,0,21,.2)}.small,small{font-size:80%;font-weight:400}.mark,mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{list-style:none}html:not([dir=rtl]) .list-unstyled{padding-left:0}[dir=rtl] .list-unstyled{padding-right:0}.list-inline{list-style:none}html:not([dir=rtl]) .list-inline{padding-left:0}[dir=rtl] .list-inline{padding-right:0}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.09375rem}.blockquote-footer{display:block;font-size:80%;color:#8a93a2}.blockquote-footer::before{content:"\2014\00A0"}@media all and (-ms-high-contrast:none){html{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}}.c-wrapper:not(.c-wrapper-fluid){height:100vh}.c-wrapper:not(.c-wrapper-fluid) .c-footer-fixed,.c-wrapper:not(.c-wrapper-fluid) .c-header-fixed,.c-wrapper:not(.c-wrapper-fluid) .c-subheader-fixed{position:relative}.c-wrapper:not(.c-wrapper-fluid) .c-body{-ms-flex-direction:column;flex-direction:column;overflow-y:auto}.c-wrapper.c-wrapper-fluid{min-height:100vh}.c-wrapper.c-wrapper-fluid .c-header-fixed{margin:inherit}.c-body{display:-ms-flexbox;display:flex;-ms-flex-direction:row;flex-direction:row;-ms-flex-positive:1;flex-grow:1}.c-main{-ms-flex-preferred-size:auto;flex-basis:auto;-ms-flex-negative:0;flex-shrink:0;-ms-flex-positive:1;flex-grow:1;min-width:0;padding-top:2rem}@media (min-width:768px){.c-main>.container-fluid,.c-main>.container-lg,.c-main>.container-md,.c-main>.container-sm,.c-main>.container-xl{padding-right:30px;padding-left:30px}}.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}.bg-primary{background-color:#321fdb!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#2819ae!important}.bg-secondary{background-color:#ced2d8!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#b2b8c1!important}.bg-success{background-color:#2eb85c!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#248f48!important}.bg-info{background-color:#39f!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#0080ff!important}.bg-warning{background-color:#f9b115!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#d69405!important}.bg-danger{background-color:#e55353!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#de2727!important}.bg-light{background-color:#ebedef!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#cfd4d8!important}.bg-dark{background-color:#636f83!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#4d5666!important}.bg-gradient-primary{background:#1f1498!important;background:linear-gradient(45deg,#321fdb 0,#1f1498 100%)!important;border-color:#1f1498!important}.c-dark-theme .bg-gradient-primary{background:#2d2587!important;background:linear-gradient(45deg,#4638c2 0,#2d2587 100%)!important;border-color:#2d2587!important}.bg-gradient-secondary{background:#fff!important;background:linear-gradient(45deg,#c8d2dc 0,#fff 100%)!important;border-color:#fff!important}.c-dark-theme .bg-gradient-secondary{background:#fff!important;background:linear-gradient(45deg,#d1d2d3 0,#fff 100%)!important;border-color:#fff!important}.bg-gradient-success{background:#1b9e3e!important;background:linear-gradient(45deg,#2eb85c 0,#1b9e3e 100%)!important;border-color:#1b9e3e!important}.c-dark-theme .bg-gradient-success{background:#2e8c47!important;background:linear-gradient(45deg,#45a164 0,#2e8c47 100%)!important;border-color:#2e8c47!important}.bg-gradient-info{background:#2982cc!important;background:linear-gradient(45deg,#39f 0,#2982cc 100%)!important;border-color:#2982cc!important}.c-dark-theme .bg-gradient-info{background:#4280b4!important;background:linear-gradient(45deg,#4799eb 0,#4280b4 100%)!important;border-color:#4280b4!important}.bg-gradient-warning{background:#f6960b!important;background:linear-gradient(45deg,#f9b115 0,#f6960b 100%)!important;border-color:#f6960b!important}.c-dark-theme .bg-gradient-warning{background:#dd9124!important;background:linear-gradient(45deg,#e1a82d 0,#dd9124 100%)!important;border-color:#dd9124!important}.bg-gradient-danger{background:#d93737!important;background:linear-gradient(45deg,#e55353 0,#d93737 100%)!important;border-color:#d93737!important}.c-dark-theme .bg-gradient-danger{background:#c14f4f!important;background:linear-gradient(45deg,#d16767 0,#c14f4f 100%)!important;border-color:#c14f4f!important}.bg-gradient-light{background:#fff!important;background:linear-gradient(45deg,#e3e8ed 0,#fff 100%)!important;border-color:#fff!important}.c-dark-theme .bg-gradient-light{background:#fff!important;background:linear-gradient(45deg,#e8e8e8 0,#fff 100%)!important;border-color:#fff!important}.bg-gradient-dark{background:#212333!important;background:linear-gradient(45deg,#3c4b64 0,#212333 100%)!important;border-color:#212333!important}.c-dark-theme .bg-gradient-dark{background:#292a2b!important;background:linear-gradient(45deg,#4c4f54 0,#292a2b 100%)!important;border-color:#292a2b!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}[class^=bg-]{color:#fff}.bg-facebook{background-color:#3b5998!important}a.bg-facebook:focus,a.bg-facebook:hover,button.bg-facebook:focus,button.bg-facebook:hover{background-color:#2d4373!important}.bg-twitter{background-color:#00aced!important}a.bg-twitter:focus,a.bg-twitter:hover,button.bg-twitter:focus,button.bg-twitter:hover{background-color:#0087ba!important}.bg-linkedin{background-color:#4875b4!important}a.bg-linkedin:focus,a.bg-linkedin:hover,button.bg-linkedin:focus,button.bg-linkedin:hover{background-color:#395d90!important}.bg-flickr{background-color:#ff0084!important}a.bg-flickr:focus,a.bg-flickr:hover,button.bg-flickr:focus,button.bg-flickr:hover{background-color:#cc006a!important}.bg-tumblr{background-color:#32506d!important}a.bg-tumblr:focus,a.bg-tumblr:hover,button.bg-tumblr:focus,button.bg-tumblr:hover{background-color:#22364a!important}.bg-xing{background-color:#026466!important}a.bg-xing:focus,a.bg-xing:hover,button.bg-xing:focus,button.bg-xing:hover{background-color:#013334!important}.bg-github{background-color:#4183c4!important}a.bg-github:focus,a.bg-github:hover,button.bg-github:focus,button.bg-github:hover{background-color:#3269a0!important}.bg-stack-overflow{background-color:#fe7a15!important}a.bg-stack-overflow:focus,a.bg-stack-overflow:hover,button.bg-stack-overflow:focus,button.bg-stack-overflow:hover{background-color:#df6101!important}.bg-youtube{background-color:#b00!important}a.bg-youtube:focus,a.bg-youtube:hover,button.bg-youtube:focus,button.bg-youtube:hover{background-color:#800!important}.bg-dribbble{background-color:#ea4c89!important}a.bg-dribbble:focus,a.bg-dribbble:hover,button.bg-dribbble:focus,button.bg-dribbble:hover{background-color:#e51e6b!important}.bg-instagram{background-color:#517fa4!important}a.bg-instagram:focus,a.bg-instagram:hover,button.bg-instagram:focus,button.bg-instagram:hover{background-color:#406582!important}.bg-pinterest{background-color:#cb2027!important}a.bg-pinterest:focus,a.bg-pinterest:hover,button.bg-pinterest:focus,button.bg-pinterest:hover{background-color:#9f191f!important}.bg-vk{background-color:#45668e!important}a.bg-vk:focus,a.bg-vk:hover,button.bg-vk:focus,button.bg-vk:hover{background-color:#344d6c!important}.bg-yahoo{background-color:#400191!important}a.bg-yahoo:focus,a.bg-yahoo:hover,button.bg-yahoo:focus,button.bg-yahoo:hover{background-color:#2a015e!important}.bg-behance{background-color:#1769ff!important}a.bg-behance:focus,a.bg-behance:hover,button.bg-behance:focus,button.bg-behance:hover{background-color:#0050e3!important}.bg-reddit{background-color:#ff4500!important}a.bg-reddit:focus,a.bg-reddit:hover,button.bg-reddit:focus,button.bg-reddit:hover{background-color:#cc3700!important}.bg-vimeo{background-color:#aad450!important}a.bg-vimeo:focus,a.bg-vimeo:hover,button.bg-vimeo:focus,button.bg-vimeo:hover{background-color:#93c130!important}.bg-gray-100{background-color:#ebedef!important}a.bg-gray-100:focus,a.bg-gray-100:hover,button.bg-gray-100:focus,button.bg-gray-100:hover{background-color:#cfd4d8!important}.bg-gray-200{background-color:#d8dbe0!important}a.bg-gray-200:focus,a.bg-gray-200:hover,button.bg-gray-200:focus,button.bg-gray-200:hover{background-color:#bcc1c9!important}.bg-gray-300{background-color:#c4c9d0!important}a.bg-gray-300:focus,a.bg-gray-300:hover,button.bg-gray-300:focus,button.bg-gray-300:hover{background-color:#a8afb9!important}.bg-gray-400{background-color:#b1b7c1!important}a.bg-gray-400:focus,a.bg-gray-400:hover,button.bg-gray-400:focus,button.bg-gray-400:hover{background-color:#959daa!important}.bg-gray-500{background-color:#9da5b1!important}a.bg-gray-500:focus,a.bg-gray-500:hover,button.bg-gray-500:focus,button.bg-gray-500:hover{background-color:#818b9a!important}.bg-gray-600{background-color:#8a93a2!important}a.bg-gray-600:focus,a.bg-gray-600:hover,button.bg-gray-600:focus,button.bg-gray-600:hover{background-color:#6e798b!important}.bg-gray-700{background-color:#768192!important}a.bg-gray-700:focus,a.bg-gray-700:hover,button.bg-gray-700:focus,button.bg-gray-700:hover{background-color:#5e6877!important}.bg-gray-800{background-color:#636f83!important}a.bg-gray-800:focus,a.bg-gray-800:hover,button.bg-gray-800:focus,button.bg-gray-800:hover{background-color:#4d5666!important}.bg-gray-900{background-color:#4f5d73!important}a.bg-gray-900:focus,a.bg-gray-900:hover,button.bg-gray-900:focus,button.bg-gray-900:hover{background-color:#3a4555!important}.bg-box{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:2.5rem;height:2.5rem}.border{border:1px solid #d8dbe0!important}.border-top{border-top:1px solid #d8dbe0!important}.border-right{border-right:1px solid #d8dbe0!important}.border-bottom{border-bottom:1px solid #d8dbe0!important}.border-left{border-left:1px solid #d8dbe0!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border:1px solid!important;border-color:#321fdb!important}.border-secondary{border:1px solid!important;border-color:#ced2d8!important}.border-success{border:1px solid!important;border-color:#2eb85c!important}.border-info{border:1px solid!important;border-color:#39f!important}.border-warning{border:1px solid!important;border-color:#f9b115!important}.border-danger{border:1px solid!important;border-color:#e55353!important}.border-light{border:1px solid!important;border-color:#ebedef!important}.border-dark{border:1px solid!important;border-color:#636f83!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important}.rounded-right,.rounded-top{border-top-right-radius:.25rem!important}.rounded-bottom,.rounded-right{border-bottom-right-radius:.25rem!important}.rounded-bottom,.rounded-left{border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important}.rounded-lg{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.b-a-0{border:0!important}.b-t-0{border-top:0!important}.b-r-0{border-right:0!important}.b-b-0{border-bottom:0!important}.b-l-0{border-left:0!important}.b-a-1{border:1px solid #d8dbe0}.b-t-1{border-top:1px solid #d8dbe0}.b-r-1{border-right:1px solid #d8dbe0}.b-b-1{border-bottom:1px solid #d8dbe0}.b-l-1{border-left:1px solid #d8dbe0}.b-a-2{border:2px solid #d8dbe0}.b-t-2{border-top:2px solid #d8dbe0}.b-r-2{border-right:2px solid #d8dbe0}.b-b-2{border-bottom:2px solid #d8dbe0}.b-l-2{border-left:2px solid #d8dbe0}.content-center{position:relative;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;padding:0;text-align:center}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:-ms-flexbox!important;display:flex!important}.d-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}@media (min-width:576px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!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:-ms-flexbox!important;display:flex!important}.d-sm-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:768px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!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:-ms-flexbox!important;display:flex!important}.d-md-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:992px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!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:-ms-flexbox!important;display:flex!important}.d-lg-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:1200px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!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:-ms-flexbox!important;display:flex!important}.d-xl-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (max-width:575.98px){.d-down-none{display:none!important}}@media (max-width:767.98px){.d-sm-down-none{display:none!important}}@media (max-width:991.98px){.d-md-down-none{display:none!important}}@media (max-width:1199.98px){.d-lg-down-none{display:none!important}}.c-default-theme .c-d-default-none,.d-xl-down-none{display:none!important}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!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:-ms-flexbox!important;display:flex!important}.d-print-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.85714286%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-center{-ms-flex-align:center!important;align-items:center!important}.align-items-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}@media (min-width:576px){.flex-sm-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-sm-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-sm-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-sm-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-sm-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-sm-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-sm-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-sm-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-sm-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-sm-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-sm-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-sm-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-sm-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-sm-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-sm-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-sm-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-sm-center{-ms-flex-align:center!important;align-items:center!important}.align-items-sm-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-sm-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-sm-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-sm-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-sm-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-sm-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-sm-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-sm-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-sm-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-sm-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-sm-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-sm-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-sm-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-sm-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:768px){.flex-md-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-md-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-md-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-md-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-md-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-md-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-md-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-md-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-md-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-md-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-md-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-md-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-md-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-md-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-md-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-md-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-md-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-md-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-md-center{-ms-flex-align:center!important;align-items:center!important}.align-items-md-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-md-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-md-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-md-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-md-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-md-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-md-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-md-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-md-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-md-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-md-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-md-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-md-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-md-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:992px){.flex-lg-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-lg-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-lg-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-lg-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-lg-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-lg-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-lg-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-lg-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-lg-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-lg-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-lg-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-lg-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-lg-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-lg-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-lg-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-lg-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-lg-center{-ms-flex-align:center!important;align-items:center!important}.align-items-lg-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-lg-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-lg-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-lg-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-lg-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-lg-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-lg-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-lg-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-lg-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-lg-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-lg-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-lg-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-lg-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-lg-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:1200px){.flex-xl-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-xl-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-xl-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-xl-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-xl-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-xl-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-xl-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-xl-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-xl-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-xl-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-xl-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-xl-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-xl-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-xl-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-xl-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-xl-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-xl-center{-ms-flex-align:center!important;align-items:center!important}.align-items-xl-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-xl-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-xl-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-xl-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-xl-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-xl-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-xl-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-xl-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-xl-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-xl-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-xl-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-xl-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-xl-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-xl-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:576px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:768px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:992px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:1200px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.fixed-top{top:0}.fixed-bottom,.fixed-top{position:fixed;right:0;left:0;z-index:1030}.fixed-bottom{bottom:0}@supports ((position:-webkit-sticky) or (position:sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,21,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,21,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,21,.175)!important}.shadow-none{box-shadow:none!important}.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}.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}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,21,0)}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0,html:not([dir=rtl]) .mfs-0{margin-left:0!important}[dir=rtl] .mfs-0,html:not([dir=rtl]) .mfe-0{margin-right:0!important}[dir=rtl] .mfe-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1,html:not([dir=rtl]) .mfs-1{margin-left:.25rem!important}[dir=rtl] .mfs-1,html:not([dir=rtl]) .mfe-1{margin-right:.25rem!important}[dir=rtl] .mfe-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2,html:not([dir=rtl]) .mfs-2{margin-left:.5rem!important}[dir=rtl] .mfs-2,html:not([dir=rtl]) .mfe-2{margin-right:.5rem!important}[dir=rtl] .mfe-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3,html:not([dir=rtl]) .mfs-3{margin-left:1rem!important}[dir=rtl] .mfs-3,html:not([dir=rtl]) .mfe-3{margin-right:1rem!important}[dir=rtl] .mfe-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4,html:not([dir=rtl]) .mfs-4{margin-left:1.5rem!important}[dir=rtl] .mfs-4,html:not([dir=rtl]) .mfe-4{margin-right:1.5rem!important}[dir=rtl] .mfe-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5,html:not([dir=rtl]) .mfs-5{margin-left:3rem!important}[dir=rtl] .mfs-5,html:not([dir=rtl]) .mfe-5{margin-right:3rem!important}[dir=rtl] .mfe-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0,html:not([dir=rtl]) .pfs-0{padding-left:0!important}[dir=rtl] .pfs-0,html:not([dir=rtl]) .pfe-0{padding-right:0!important}[dir=rtl] .pfe-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1,html:not([dir=rtl]) .pfs-1{padding-left:.25rem!important}[dir=rtl] .pfs-1,html:not([dir=rtl]) .pfe-1{padding-right:.25rem!important}[dir=rtl] .pfe-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2,html:not([dir=rtl]) .pfs-2{padding-left:.5rem!important}[dir=rtl] .pfs-2,html:not([dir=rtl]) .pfe-2{padding-right:.5rem!important}[dir=rtl] .pfe-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3,html:not([dir=rtl]) .pfs-3{padding-left:1rem!important}[dir=rtl] .pfs-3,html:not([dir=rtl]) .pfe-3{padding-right:1rem!important}[dir=rtl] .pfe-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4,html:not([dir=rtl]) .pfs-4{padding-left:1.5rem!important}[dir=rtl] .pfs-4,html:not([dir=rtl]) .pfe-4{padding-right:1.5rem!important}[dir=rtl] .pfe-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5,html:not([dir=rtl]) .pfs-5{padding-left:3rem!important}[dir=rtl] .pfs-5,html:not([dir=rtl]) .pfe-5{padding-right:3rem!important}[dir=rtl] .pfe-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1,html:not([dir=rtl]) .mfs-n1{margin-left:-.25rem!important}[dir=rtl] .mfs-n1,html:not([dir=rtl]) .mfe-n1{margin-right:-.25rem!important}[dir=rtl] .mfe-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2,html:not([dir=rtl]) .mfs-n2{margin-left:-.5rem!important}[dir=rtl] .mfs-n2,html:not([dir=rtl]) .mfe-n2{margin-right:-.5rem!important}[dir=rtl] .mfe-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3,html:not([dir=rtl]) .mfs-n3{margin-left:-1rem!important}[dir=rtl] .mfs-n3,html:not([dir=rtl]) .mfe-n3{margin-right:-1rem!important}[dir=rtl] .mfe-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4,html:not([dir=rtl]) .mfs-n4{margin-left:-1.5rem!important}[dir=rtl] .mfs-n4,html:not([dir=rtl]) .mfe-n4{margin-right:-1.5rem!important}[dir=rtl] .mfe-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5,html:not([dir=rtl]) .mfs-n5{margin-left:-3rem!important}[dir=rtl] .mfs-n5,html:not([dir=rtl]) .mfe-n5{margin-right:-3rem!important}[dir=rtl] .mfe-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto,html:not([dir=rtl]) .mfs-auto{margin-left:auto!important}[dir=rtl] .mfs-auto,html:not([dir=rtl]) .mfe-auto{margin-right:auto!important}[dir=rtl] .mfe-auto{margin-left:auto!important}@media (min-width:576px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0,html:not([dir=rtl]) .mfs-sm-0{margin-left:0!important}[dir=rtl] .mfs-sm-0,html:not([dir=rtl]) .mfe-sm-0{margin-right:0!important}[dir=rtl] .mfe-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1,html:not([dir=rtl]) .mfs-sm-1{margin-left:.25rem!important}[dir=rtl] .mfs-sm-1,html:not([dir=rtl]) .mfe-sm-1{margin-right:.25rem!important}[dir=rtl] .mfe-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2,html:not([dir=rtl]) .mfs-sm-2{margin-left:.5rem!important}[dir=rtl] .mfs-sm-2,html:not([dir=rtl]) .mfe-sm-2{margin-right:.5rem!important}[dir=rtl] .mfe-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3,html:not([dir=rtl]) .mfs-sm-3{margin-left:1rem!important}[dir=rtl] .mfs-sm-3,html:not([dir=rtl]) .mfe-sm-3{margin-right:1rem!important}[dir=rtl] .mfe-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4,html:not([dir=rtl]) .mfs-sm-4{margin-left:1.5rem!important}[dir=rtl] .mfs-sm-4,html:not([dir=rtl]) .mfe-sm-4{margin-right:1.5rem!important}[dir=rtl] .mfe-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5,html:not([dir=rtl]) .mfs-sm-5{margin-left:3rem!important}[dir=rtl] .mfs-sm-5,html:not([dir=rtl]) .mfe-sm-5{margin-right:3rem!important}[dir=rtl] .mfe-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0,html:not([dir=rtl]) .pfs-sm-0{padding-left:0!important}[dir=rtl] .pfs-sm-0,html:not([dir=rtl]) .pfe-sm-0{padding-right:0!important}[dir=rtl] .pfe-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1,html:not([dir=rtl]) .pfs-sm-1{padding-left:.25rem!important}[dir=rtl] .pfs-sm-1,html:not([dir=rtl]) .pfe-sm-1{padding-right:.25rem!important}[dir=rtl] .pfe-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2,html:not([dir=rtl]) .pfs-sm-2{padding-left:.5rem!important}[dir=rtl] .pfs-sm-2,html:not([dir=rtl]) .pfe-sm-2{padding-right:.5rem!important}[dir=rtl] .pfe-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3,html:not([dir=rtl]) .pfs-sm-3{padding-left:1rem!important}[dir=rtl] .pfs-sm-3,html:not([dir=rtl]) .pfe-sm-3{padding-right:1rem!important}[dir=rtl] .pfe-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4,html:not([dir=rtl]) .pfs-sm-4{padding-left:1.5rem!important}[dir=rtl] .pfs-sm-4,html:not([dir=rtl]) .pfe-sm-4{padding-right:1.5rem!important}[dir=rtl] .pfe-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5,html:not([dir=rtl]) .pfs-sm-5{padding-left:3rem!important}[dir=rtl] .pfs-sm-5,html:not([dir=rtl]) .pfe-sm-5{padding-right:3rem!important}[dir=rtl] .pfe-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1,html:not([dir=rtl]) .mfs-sm-n1{margin-left:-.25rem!important}[dir=rtl] .mfs-sm-n1,html:not([dir=rtl]) .mfe-sm-n1{margin-right:-.25rem!important}[dir=rtl] .mfe-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2,html:not([dir=rtl]) .mfs-sm-n2{margin-left:-.5rem!important}[dir=rtl] .mfs-sm-n2,html:not([dir=rtl]) .mfe-sm-n2{margin-right:-.5rem!important}[dir=rtl] .mfe-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3,html:not([dir=rtl]) .mfs-sm-n3{margin-left:-1rem!important}[dir=rtl] .mfs-sm-n3,html:not([dir=rtl]) .mfe-sm-n3{margin-right:-1rem!important}[dir=rtl] .mfe-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4,html:not([dir=rtl]) .mfs-sm-n4{margin-left:-1.5rem!important}[dir=rtl] .mfs-sm-n4,html:not([dir=rtl]) .mfe-sm-n4{margin-right:-1.5rem!important}[dir=rtl] .mfe-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5,html:not([dir=rtl]) .mfs-sm-n5{margin-left:-3rem!important}[dir=rtl] .mfs-sm-n5,html:not([dir=rtl]) .mfe-sm-n5{margin-right:-3rem!important}[dir=rtl] .mfe-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto,html:not([dir=rtl]) .mfs-sm-auto{margin-left:auto!important}[dir=rtl] .mfs-sm-auto,html:not([dir=rtl]) .mfe-sm-auto{margin-right:auto!important}[dir=rtl] .mfe-sm-auto{margin-left:auto!important}}@media (min-width:768px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0,html:not([dir=rtl]) .mfs-md-0{margin-left:0!important}[dir=rtl] .mfs-md-0,html:not([dir=rtl]) .mfe-md-0{margin-right:0!important}[dir=rtl] .mfe-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1,html:not([dir=rtl]) .mfs-md-1{margin-left:.25rem!important}[dir=rtl] .mfs-md-1,html:not([dir=rtl]) .mfe-md-1{margin-right:.25rem!important}[dir=rtl] .mfe-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2,html:not([dir=rtl]) .mfs-md-2{margin-left:.5rem!important}[dir=rtl] .mfs-md-2,html:not([dir=rtl]) .mfe-md-2{margin-right:.5rem!important}[dir=rtl] .mfe-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3,html:not([dir=rtl]) .mfs-md-3{margin-left:1rem!important}[dir=rtl] .mfs-md-3,html:not([dir=rtl]) .mfe-md-3{margin-right:1rem!important}[dir=rtl] .mfe-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4,html:not([dir=rtl]) .mfs-md-4{margin-left:1.5rem!important}[dir=rtl] .mfs-md-4,html:not([dir=rtl]) .mfe-md-4{margin-right:1.5rem!important}[dir=rtl] .mfe-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5,html:not([dir=rtl]) .mfs-md-5{margin-left:3rem!important}[dir=rtl] .mfs-md-5,html:not([dir=rtl]) .mfe-md-5{margin-right:3rem!important}[dir=rtl] .mfe-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0,html:not([dir=rtl]) .pfs-md-0{padding-left:0!important}[dir=rtl] .pfs-md-0,html:not([dir=rtl]) .pfe-md-0{padding-right:0!important}[dir=rtl] .pfe-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1,html:not([dir=rtl]) .pfs-md-1{padding-left:.25rem!important}[dir=rtl] .pfs-md-1,html:not([dir=rtl]) .pfe-md-1{padding-right:.25rem!important}[dir=rtl] .pfe-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2,html:not([dir=rtl]) .pfs-md-2{padding-left:.5rem!important}[dir=rtl] .pfs-md-2,html:not([dir=rtl]) .pfe-md-2{padding-right:.5rem!important}[dir=rtl] .pfe-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3,html:not([dir=rtl]) .pfs-md-3{padding-left:1rem!important}[dir=rtl] .pfs-md-3,html:not([dir=rtl]) .pfe-md-3{padding-right:1rem!important}[dir=rtl] .pfe-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4,html:not([dir=rtl]) .pfs-md-4{padding-left:1.5rem!important}[dir=rtl] .pfs-md-4,html:not([dir=rtl]) .pfe-md-4{padding-right:1.5rem!important}[dir=rtl] .pfe-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5,html:not([dir=rtl]) .pfs-md-5{padding-left:3rem!important}[dir=rtl] .pfs-md-5,html:not([dir=rtl]) .pfe-md-5{padding-right:3rem!important}[dir=rtl] .pfe-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1,html:not([dir=rtl]) .mfs-md-n1{margin-left:-.25rem!important}[dir=rtl] .mfs-md-n1,html:not([dir=rtl]) .mfe-md-n1{margin-right:-.25rem!important}[dir=rtl] .mfe-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2,html:not([dir=rtl]) .mfs-md-n2{margin-left:-.5rem!important}[dir=rtl] .mfs-md-n2,html:not([dir=rtl]) .mfe-md-n2{margin-right:-.5rem!important}[dir=rtl] .mfe-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3,html:not([dir=rtl]) .mfs-md-n3{margin-left:-1rem!important}[dir=rtl] .mfs-md-n3,html:not([dir=rtl]) .mfe-md-n3{margin-right:-1rem!important}[dir=rtl] .mfe-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4,html:not([dir=rtl]) .mfs-md-n4{margin-left:-1.5rem!important}[dir=rtl] .mfs-md-n4,html:not([dir=rtl]) .mfe-md-n4{margin-right:-1.5rem!important}[dir=rtl] .mfe-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5,html:not([dir=rtl]) .mfs-md-n5{margin-left:-3rem!important}[dir=rtl] .mfs-md-n5,html:not([dir=rtl]) .mfe-md-n5{margin-right:-3rem!important}[dir=rtl] .mfe-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto,html:not([dir=rtl]) .mfs-md-auto{margin-left:auto!important}[dir=rtl] .mfs-md-auto,html:not([dir=rtl]) .mfe-md-auto{margin-right:auto!important}[dir=rtl] .mfe-md-auto{margin-left:auto!important}}@media (min-width:992px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0,html:not([dir=rtl]) .mfs-lg-0{margin-left:0!important}[dir=rtl] .mfs-lg-0,html:not([dir=rtl]) .mfe-lg-0{margin-right:0!important}[dir=rtl] .mfe-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1,html:not([dir=rtl]) .mfs-lg-1{margin-left:.25rem!important}[dir=rtl] .mfs-lg-1,html:not([dir=rtl]) .mfe-lg-1{margin-right:.25rem!important}[dir=rtl] .mfe-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2,html:not([dir=rtl]) .mfs-lg-2{margin-left:.5rem!important}[dir=rtl] .mfs-lg-2,html:not([dir=rtl]) .mfe-lg-2{margin-right:.5rem!important}[dir=rtl] .mfe-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3,html:not([dir=rtl]) .mfs-lg-3{margin-left:1rem!important}[dir=rtl] .mfs-lg-3,html:not([dir=rtl]) .mfe-lg-3{margin-right:1rem!important}[dir=rtl] .mfe-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4,html:not([dir=rtl]) .mfs-lg-4{margin-left:1.5rem!important}[dir=rtl] .mfs-lg-4,html:not([dir=rtl]) .mfe-lg-4{margin-right:1.5rem!important}[dir=rtl] .mfe-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5,html:not([dir=rtl]) .mfs-lg-5{margin-left:3rem!important}[dir=rtl] .mfs-lg-5,html:not([dir=rtl]) .mfe-lg-5{margin-right:3rem!important}[dir=rtl] .mfe-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0,html:not([dir=rtl]) .pfs-lg-0{padding-left:0!important}[dir=rtl] .pfs-lg-0,html:not([dir=rtl]) .pfe-lg-0{padding-right:0!important}[dir=rtl] .pfe-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1,html:not([dir=rtl]) .pfs-lg-1{padding-left:.25rem!important}[dir=rtl] .pfs-lg-1,html:not([dir=rtl]) .pfe-lg-1{padding-right:.25rem!important}[dir=rtl] .pfe-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2,html:not([dir=rtl]) .pfs-lg-2{padding-left:.5rem!important}[dir=rtl] .pfs-lg-2,html:not([dir=rtl]) .pfe-lg-2{padding-right:.5rem!important}[dir=rtl] .pfe-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3,html:not([dir=rtl]) .pfs-lg-3{padding-left:1rem!important}[dir=rtl] .pfs-lg-3,html:not([dir=rtl]) .pfe-lg-3{padding-right:1rem!important}[dir=rtl] .pfe-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4,html:not([dir=rtl]) .pfs-lg-4{padding-left:1.5rem!important}[dir=rtl] .pfs-lg-4,html:not([dir=rtl]) .pfe-lg-4{padding-right:1.5rem!important}[dir=rtl] .pfe-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5,html:not([dir=rtl]) .pfs-lg-5{padding-left:3rem!important}[dir=rtl] .pfs-lg-5,html:not([dir=rtl]) .pfe-lg-5{padding-right:3rem!important}[dir=rtl] .pfe-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1,html:not([dir=rtl]) .mfs-lg-n1{margin-left:-.25rem!important}[dir=rtl] .mfs-lg-n1,html:not([dir=rtl]) .mfe-lg-n1{margin-right:-.25rem!important}[dir=rtl] .mfe-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2,html:not([dir=rtl]) .mfs-lg-n2{margin-left:-.5rem!important}[dir=rtl] .mfs-lg-n2,html:not([dir=rtl]) .mfe-lg-n2{margin-right:-.5rem!important}[dir=rtl] .mfe-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3,html:not([dir=rtl]) .mfs-lg-n3{margin-left:-1rem!important}[dir=rtl] .mfs-lg-n3,html:not([dir=rtl]) .mfe-lg-n3{margin-right:-1rem!important}[dir=rtl] .mfe-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4,html:not([dir=rtl]) .mfs-lg-n4{margin-left:-1.5rem!important}[dir=rtl] .mfs-lg-n4,html:not([dir=rtl]) .mfe-lg-n4{margin-right:-1.5rem!important}[dir=rtl] .mfe-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5,html:not([dir=rtl]) .mfs-lg-n5{margin-left:-3rem!important}[dir=rtl] .mfs-lg-n5,html:not([dir=rtl]) .mfe-lg-n5{margin-right:-3rem!important}[dir=rtl] .mfe-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto,html:not([dir=rtl]) .mfs-lg-auto{margin-left:auto!important}[dir=rtl] .mfs-lg-auto,html:not([dir=rtl]) .mfe-lg-auto{margin-right:auto!important}[dir=rtl] .mfe-lg-auto{margin-left:auto!important}}@media (min-width:1200px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0,html:not([dir=rtl]) .mfs-xl-0{margin-left:0!important}[dir=rtl] .mfs-xl-0,html:not([dir=rtl]) .mfe-xl-0{margin-right:0!important}[dir=rtl] .mfe-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1,html:not([dir=rtl]) .mfs-xl-1{margin-left:.25rem!important}[dir=rtl] .mfs-xl-1,html:not([dir=rtl]) .mfe-xl-1{margin-right:.25rem!important}[dir=rtl] .mfe-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2,html:not([dir=rtl]) .mfs-xl-2{margin-left:.5rem!important}[dir=rtl] .mfs-xl-2,html:not([dir=rtl]) .mfe-xl-2{margin-right:.5rem!important}[dir=rtl] .mfe-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3,html:not([dir=rtl]) .mfs-xl-3{margin-left:1rem!important}[dir=rtl] .mfs-xl-3,html:not([dir=rtl]) .mfe-xl-3{margin-right:1rem!important}[dir=rtl] .mfe-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4,html:not([dir=rtl]) .mfs-xl-4{margin-left:1.5rem!important}[dir=rtl] .mfs-xl-4,html:not([dir=rtl]) .mfe-xl-4{margin-right:1.5rem!important}[dir=rtl] .mfe-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5,html:not([dir=rtl]) .mfs-xl-5{margin-left:3rem!important}[dir=rtl] .mfs-xl-5,html:not([dir=rtl]) .mfe-xl-5{margin-right:3rem!important}[dir=rtl] .mfe-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0,html:not([dir=rtl]) .pfs-xl-0{padding-left:0!important}[dir=rtl] .pfs-xl-0,html:not([dir=rtl]) .pfe-xl-0{padding-right:0!important}[dir=rtl] .pfe-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1,html:not([dir=rtl]) .pfs-xl-1{padding-left:.25rem!important}[dir=rtl] .pfs-xl-1,html:not([dir=rtl]) .pfe-xl-1{padding-right:.25rem!important}[dir=rtl] .pfe-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2,html:not([dir=rtl]) .pfs-xl-2{padding-left:.5rem!important}[dir=rtl] .pfs-xl-2,html:not([dir=rtl]) .pfe-xl-2{padding-right:.5rem!important}[dir=rtl] .pfe-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3,html:not([dir=rtl]) .pfs-xl-3{padding-left:1rem!important}[dir=rtl] .pfs-xl-3,html:not([dir=rtl]) .pfe-xl-3{padding-right:1rem!important}[dir=rtl] .pfe-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4,html:not([dir=rtl]) .pfs-xl-4{padding-left:1.5rem!important}[dir=rtl] .pfs-xl-4,html:not([dir=rtl]) .pfe-xl-4{padding-right:1.5rem!important}[dir=rtl] .pfe-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5,html:not([dir=rtl]) .pfs-xl-5{padding-left:3rem!important}[dir=rtl] .pfs-xl-5,html:not([dir=rtl]) .pfe-xl-5{padding-right:3rem!important}[dir=rtl] .pfe-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1,html:not([dir=rtl]) .mfs-xl-n1{margin-left:-.25rem!important}[dir=rtl] .mfs-xl-n1,html:not([dir=rtl]) .mfe-xl-n1{margin-right:-.25rem!important}[dir=rtl] .mfe-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2,html:not([dir=rtl]) .mfs-xl-n2{margin-left:-.5rem!important}[dir=rtl] .mfs-xl-n2,html:not([dir=rtl]) .mfe-xl-n2{margin-right:-.5rem!important}[dir=rtl] .mfe-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3,html:not([dir=rtl]) .mfs-xl-n3{margin-left:-1rem!important}[dir=rtl] .mfs-xl-n3,html:not([dir=rtl]) .mfe-xl-n3{margin-right:-1rem!important}[dir=rtl] .mfe-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4,html:not([dir=rtl]) .mfs-xl-n4{margin-left:-1.5rem!important}[dir=rtl] .mfs-xl-n4,html:not([dir=rtl]) .mfe-xl-n4{margin-right:-1.5rem!important}[dir=rtl] .mfe-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5,html:not([dir=rtl]) .mfs-xl-n5{margin-left:-3rem!important}[dir=rtl] .mfs-xl-n5,html:not([dir=rtl]) .mfe-xl-n5{margin-right:-3rem!important}[dir=rtl] .mfe-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto,html:not([dir=rtl]) .mfs-xl-auto{margin-left:auto!important}[dir=rtl] .mfs-xl-auto,html:not([dir=rtl]) .mfe-xl-auto{margin-right:auto!important}[dir=rtl] .mfe-xl-auto{margin-left:auto!important}}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:576px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:700!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#321fdb!important}a.text-primary:focus,a.text-primary:hover{color:#231698!important}.text-secondary{color:#ced2d8!important}a.text-secondary:focus,a.text-secondary:hover{color:#a3abb6!important}.text-success{color:#2eb85c!important}a.text-success:focus,a.text-success:hover{color:#1f7b3d!important}.text-info{color:#39f!important}a.text-info:focus,a.text-info:hover{color:#0073e6!important}.text-warning{color:#f9b115!important}a.text-warning:focus,a.text-warning:hover{color:#bd8305!important}.text-danger{color:#e55353!important}a.text-danger:focus,a.text-danger:hover{color:#cd1f1f!important}.text-light{color:#ebedef!important}a.text-light:focus,a.text-light:hover{color:#c1c7cd!important}.text-dark{color:#636f83!important}a.text-dark:focus,a.text-dark:hover{color:#424a57!important}.text-body{color:#4f5d73!important}.text-muted{color:#768192!important}.text-black-50{color:rgba(0,0,21,.5)!important}.text-white-50{color:rgba(255,255,255,.5)!important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none!important}.text-break{word-break:break-word!important;overflow-wrap:break-word!important}.text-reset{color:inherit!important}.font-xs{font-size:.75rem!important}.font-sm{font-size:.85rem!important}.font-lg{font-size:1rem!important}.font-xl{font-size:1.25rem!important}.font-2xl{font-size:1.5rem!important}.font-3xl{font-size:1.75rem!important}.font-4xl{font-size:2rem!important}.font-5xl{font-size:2.5rem!important}[class^=text-value]{font-weight:600}.text-value-xs{font-size:.65625rem}.text-value-sm{font-size:.74375rem}.text-value{font-size:.875rem}.text-value-lg{font-size:1.3125rem}.text-value-xl{font-size:1.53125rem}.text-white .text-muted{color:rgba(255,255,255,.6)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}[dir=rtl]{direction:rtl;unicode-bidi:embed}[dir=rtl] body{text-align:right}.ie-custom-properties{primary:#321fdb;secondary:#ced2d8;success:#2eb85c;info:#39f;warning:#f9b115;danger:#e55353;light:#ebedef;dark:#636f83;breakpoint-xs:0;breakpoint-sm:576px;breakpoint-md:768px;breakpoint-lg:992px;breakpoint-xl:1200px}@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #9da5b1;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}.container,body{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000015}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #c4c9d0!important}.table-dark{color:inherit}.table .thead-dark th,.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#d8dbe0}.table .thead-dark th{color:inherit}} +/*# sourceMappingURL=coreui.min.css.map */ \ No newline at end of file diff --git a/web/public/css/dark.css b/web/public/css/dark.css new file mode 100644 index 0000000..d25963b --- /dev/null +++ b/web/public/css/dark.css @@ -0,0 +1,323 @@ +:root { + --primary: #00bf8f !important; + --secondary: #3ab8cd !important; + --danger: #ff148f !important; +} + +.card:first-child, +.card-header:first-child, +.card-body { + border-radius: 0.5rem; + background-color: #F0F1F4; +} + +/** + * A dark theme for all, by GoatGeek#0001 + * + * _)) + * > *\ _~ + * `;'\\__-' \_ + * | ) _ \ \ + * / / `` w w + * w w + */ +body.theme--dark { + /************************* + * CORE UI MODIFICATIONS + *************************/ + /*********************** + * TXADMIN ELEMENTS + ***********************/ + /** + * Deployment things + */ + /** + * txAdmin player list + */ +} +body.theme--dark, body.theme--dark .c-app, body.theme--dark .c-main, body.theme--dark .c-footer, body.theme--dark .c-header { + background: rgba(0, 0, 0, 0); + color: #FFFFFF; +} +body.theme--dark ul.stepper .step:not(:last-of-type)::after { + background-color: rgb(75, 75, 75); +} +body.theme--dark .list-group-accent .list-group-item-accent-secondary { + border-left: 4px solid #5c5f67; +} +body.theme--dark .progress { + background-color: #35373c; +} +body.theme--dark .c-switch-slider, body.theme--dark .c-switch-slider::before { + background-color: #35373c; + border-color: #494b51; + transition: 0.15s ease-out; +} +body.theme--dark .c-switch-input:focus ~ .c-switch-slider { + color: #FFFFFF; + background-color: #494b51; + border-color: #747782 !important; + outline: 0; + box-shadow: none; +} +body.theme--dark .c-switch-success .c-switch-input:checked + .c-switch-slider { + background-color: #2eb85c; + border-color: #248f48; +} +body.theme--dark a:not(.btn):not(.c-sidebar-nav-link):not(.alert-link), body.theme--dark a:not(.btn):not(.c-sidebar-nav-link):not(.alert-link):hover { + color: #00bf8f; +} +body.theme--dark a:not(.btn):not(.c-sidebar-nav-link):not(.alert-link).nav-link-red { + color: #ff148f; +} +body.theme--dark .c-sidebar-nav-link.c-active { + border-left: #00bf8f solid 4px; +} +body.theme--dark .c-sidebar .c-sidebar-nav-dropdown-toggle:hover, +body.theme--dark .c-sidebar .c-sidebar-nav-link:hover { + color: #fff; + background: #00bf8f !important; +} +body.theme--dark .text-body { + color: #FFFFFF !important; +} +body.theme--dark .text-danger { + color: #ff148f !important; +} +body.theme--dark .text-danger:hover { + color: #c70068 !important; +} +body.theme--dark .text-muted { + color: #969696 !important; +} +body.theme--dark .text-primary { + color: #00bf8f !important; +} +body.theme--dark .border-primary { + border-color: #00bf8f !important; +} +body.theme--dark .btn.btn-primary, body.theme--dark .nav-pills .btn-primary.nav-link.active, +body.theme--dark .nav-tabs .btn-primary.nav-link.active, +body.theme--dark .nav-pills .show > .btn-primary.nav-link { + background: #00bf8f; + border-color: #00bf8f; +} +body.theme--dark .btn.btn-outline-primary, body.theme--dark .nav-pills .btn-outline-primary.nav-link.active, +body.theme--dark .nav-tabs .btn-outline-primary.nav-link.active, +body.theme--dark .nav-pills .show > .btn-outline-primary.nav-link { + color: #00bf8f; + border-color: #00bf8f; +} +body.theme--dark .btn.btn-outline-dark, body.theme--dark .nav-pills .btn-outline-dark.nav-link.active, +body.theme--dark .nav-tabs .btn-outline-dark.nav-link.active, +body.theme--dark .nav-pills .show > .btn-outline-dark.nav-link { + color: #ebedef; + border-color: #ebedef; +} +body.theme--dark .btn.btn-outline-dark:hover, body.theme--dark .nav-pills .btn-outline-dark.nav-link.active:hover, +body.theme--dark .nav-tabs .btn-outline-dark.nav-link.active:hover, +body.theme--dark .nav-pills .show > .btn-outline-dark.nav-link:hover { + background-color: #ebedef; + color: #494b51; +} +body.theme--dark .btn.btn-primary:hover, body.theme--dark .nav-pills .btn-primary.nav-link.active:hover, +body.theme--dark .nav-tabs .btn-primary.nav-link.active:hover, +body.theme--dark .nav-pills .show > .btn-primary.nav-link:hover, body.theme--dark .btn.btn-outline-primary:hover, body.theme--dark .nav-pills .btn-outline-primary.nav-link.active:hover, +body.theme--dark .nav-tabs .btn-outline-primary.nav-link.active:hover, +body.theme--dark .nav-pills .show > .btn-outline-primary.nav-link:hover { + background-color: #00bf8f; + color: black; +} +body.theme--dark .c-footer, body.theme--dark .c-header, body.theme--dark .card-footer, body.theme--dark .card-header, +body.theme--dark .modal-body, body.theme--dark .modal-header, body.theme--dark .modal-footer, +body.theme--dark .border-right { + border-color: hsl(224, 5.6%, 21.2%) !important; +} +body.theme--dark .c-sidebar, body.theme--dark .c-callout, body.theme--dark .modal-content, body.theme--dark .modal-body { + background: #222326; +} +body.theme--dark .alert.alert-secondary { + background: #35373c; + color: #FFFFFF; + border-color: #494b51; +} +body.theme--dark ::-webkit-scrollbar-corner { + background: #2b2c30; +} +body.theme--dark .thin-scroll::-webkit-scrollbar-track { + background-color: #2b2c30; + border-right: 1px solid #2b2c30; + border-left: 1px solid #2b2c30; +} +body.theme--dark .thin-scroll::-webkit-scrollbar-thumb { + background-color: #333; + background-clip: content-box; + border-color: transparent; + border-radius: 6px; +} +body.theme--dark .thin-scroll:hover::-webkit-scrollbar-thumb { + background-color: #444; +} +body.theme--dark .nav-tabs { + border: 0; +} +body.theme--dark .nav-tabs .nav-link { + border-color: #494b51; + border-radius: calc(0.5rem - 2px); + transition: background-color 300ms ease-in-out; +} +body.theme--dark .nav-tabs .nav-link.active { + color: #222326 !important; + font-weight: 600; + border: 1px solid #00bf8f !important; + border-bottom-width: 0 !important; +} +body.theme--dark .nav-link:hover { + background-color: #28292d; +} +body.theme--dark .nav-link.nav-link-disabled { + color: #5c5f67 !important; +} +body.theme--dark .nav-link.active { + color: #222326 !important; + font-weight: 600; +} +body.theme--dark .nav-link.active.nav-link-red { + background-color: #e55353 !important; +} +body.theme--dark .nav-pills .nav-link.active, +body.theme--dark .nav-tabs .nav-link.active, +body.theme--dark .nav-pills .show > .nav-link { + background: #00bf8f; + color: #111111; +} +body.theme--dark .c-header .c-header-nav .c-header-nav-btn, body.theme--dark .c-header .c-header-nav .c-header-nav-link { + color: #00bf8f; +} +body.theme--dark .dropdown-menu { + border-color: #494b51; +} +body.theme--dark .dropdown-menu .dropdown-item, body.theme--dark .dropdown-menu { + background-color: #35373c; +} +body.theme--dark .dropdown-menu .dropdown-item:hover { + background-color: #222326; +} +body.theme--dark .dropdown-header { + background-color: #35373c; +} +body.theme--dark .c-header .c-header-toggler { + color: white; +} +body.theme--dark pre, body.theme--dark .close { + color: #fff; +} +body.theme--dark [data-notify=message] > pre { + color: #222326; +} +body.theme--dark button[data-notify=dismiss] { + color: #222326; +} +body.theme--dark .form-control, body.theme--dark .btn, body.theme--dark .nav-pills .nav-link.active, +body.theme--dark .nav-tabs .nav-link.active, +body.theme--dark .nav-pills .show > .nav-link, body.theme--dark .c-callout { + border-radius: calc(0.5rem - 2px); +} +body.theme--dark .form-control, body.theme--dark .input-group-text { + background: #35373c; + border-color: #494b51; + color: #FFFFFF; +} +body.theme--dark .form-control[disabled], body.theme--dark .form-control[readonly] { + background: #222326; + border-color: #494b51; + color: #FFFFFF; + cursor: not-allowed; +} +body.theme--dark .form-control:focus { + box-shadow: 0 0 0 0.2rem rgba(0, 191, 143, 0.15); +} +body.theme--dark .form-control.is-valid, body.theme--dark .was-validated .form-control:valid { + border-color: #2eb85c; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%232eb85c' d='M2.3 6.73L.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.1z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +body.theme--dark .table, body.theme--dark .table td { + border-color: #494b51 !important; +} +body.theme--dark .table tr { + color: #FFFFFF; +} +body.theme--dark .table thead th { + border-top-color: #494b51; + border-bottom-color: #494b51; +} +body.theme--dark .table strong, body.theme--dark .table .table-hover tbody tr:hover { + color: #00bf8f; +} +body.theme--dark .table .thead-light th { + background-color: #35373c !important; + color: #FFFFFF; + font-size: 1.175em; + font-family: Consolas, Courier, Droid Sans Mono, monospace; +} +body.theme--dark .card { + border: 1px solid hsl(224, 5.6%, 21.2%); + border-radius: 0.5rem !important; +} +body.theme--dark .card, body.theme--dark .card .card-header, body.theme--dark .card .card-body, body.theme--dark .card .card-footer { + background-color: #222326; +} +body.theme--dark .card .card-header, body.theme--dark .card .card-header p, body.theme--dark .card .card-body, body.theme--dark .card .card-body p { + color: #FFFFFF; +} +body.theme--dark .card .card-header > h5, body.theme--dark .card .card-header .card-title { + font-size: 1.05rem; + border: 0 solid transparent; +} +body.theme--dark .pagination .page-link { + border-color: #494b51 !important; + background-color: #222326; +} +body.theme--dark .pagination .page-link:hover { + background-color: #494b51; +} +body.theme--dark .pagination > .page-item.disabled > a { + color: #5c5f67 !important; +} +body.theme--dark #modPlayerMain-notes { + background-color: #222326; +} +body.theme--dark .attentionText { + color: #FFFFFF; +} +body.theme--dark .logEntry { + color: #FFFFFF; +} +body.theme--dark .logEntry:hover { + background-color: #35373c !important; +} +body.theme--dark .bigbutton { + background: #35373c; +} +body.theme--dark .bigbutton:hover { + background-color: #00bf8f; +} +body.theme--dark .blur-input:not(:focus):not(:-moz-placeholder-shown) { + color: transparent !important; + text-shadow: 0 0 5px rgba(235, 235, 235, 0.5) !important; +} +body.theme--dark .blur-input:not(:focus):not(:placeholder-shown) { + color: transparent !important; + text-shadow: 0 0 5px rgba(235, 235, 235, 0.5) !important; +} +body.theme--dark .playerlist .player:hover { + background-color: #35373c; +} +body.theme--dark .playerlist .player .pname { + color: #e6e6e6; +} +body.theme--dark .plheader { + background-color: #35373c !important; +}/*# sourceMappingURL=dark.css.map */ \ No newline at end of file diff --git a/web/public/css/dark.css.map b/web/public/css/dark.css.map new file mode 100644 index 0000000..e4a8e49 --- /dev/null +++ b/web/public/css/dark.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["dark.scss","dark.css"],"names":[],"mappings":"AAmBA;EACE,6BAAA;EACA,+BAAA;EACA,4BAAA;AClBF;;AD8BA;;;EAGI,qBAVK;EAWL,yBAAA;AC3BJ;;AD+BA;;;;;;;;;EAAA;AAUA;EAEE;;4BAAA;EAiWA;;0BAAA;EAmBA;;IAAA;EAeA;;IAAA;ACrZF;ADwBE;EACE,4BAAA;EACA,cAnDiB;AC6BrB;AD0BE;EACE,iCAAA;ACxBJ;AD2BE;EACE,8BAAA;ACzBJ;AD6BE;EACE,yBA1Ee;AC+CnB;AD+BE;EACE,yBA/Ee;EAgFf,qBA/Ee;EAiFf,0BAAA;AC9BJ;ADgCE;EACE,cA5EiB;EA6EjB,yBArFe;EAsFf,gCAAA;EACA,UAAA;EACA,gBAAA;AC9BJ;ADgCE;EACE,yBAAA;EACA,qBAAA;AC9BJ;ADiDI;EACE,cApGY;ACqDlB;ADkDI;EACE,cAtGW;ACsDjB;ADoDE;EACE,8BAAA;AClDJ;ADqDE;;EAEE,WAAA;EACA,8BAAA;ACnDJ;ADsDE;EACE,yBAAA;ACpDJ;ADuDE;EACE,yBAAA;ACrDJ;ADuDE;EACE,yBAAA;ACrDJ;ADwDE;EACE,yBAAA;ACtDJ;AD0DE;EACE,yBAAA;ACxDJ;AD2DE;EACE,gCAAA;ACzDJ;AD6DI;;;EACE,mBAhJY;EAiJZ,qBAjJY;ACwFlB;AD4DI;;;EACE,cArJY;EAsJZ,qBAtJY;AC8FlB;AD2DI;;;EACE,cApKmB;EAqKnB,qBArKmB;AC8GzB;ADwDM;;;EACE,yBAvKiB;EAwKjB,cA1KW;ACsHnB;ADyDM;;;;;EACE,yBApKU;EAqKV,YAAA;ACnDR;AD0DE;;;EAGE,8CAAA;ACxDJ;AD4DE;EACE,mBAnMe;ACyInB;AD+DI;EACE,mBAvMa;EAwMb,cA/Le;EAgMf,qBAxMa;AC2InB;ADoEE;EACE,mBAHa;AC/DjB;ADqEE;EACE,yBAPa;EAQb,+BAAA;EACA,8BAAA;ACnEJ;ADsEE;EACE,sBAAA;EACA,4BAAA;EACA,yBAAA;EACA,kBAAA;ACpEJ;ADuEE;EACE,sBAAA;ACrEJ;ADyEE;EACE,SAAA;ACvEJ;ADyEI;EACE,qBAzOa;EA0Ob,iCAhNM;EAiNN,8CAAA;ACvEN;ADyEM;EACE,yBAAA;EACA,gBAAA;EACA,oCAAA;EACA,iCAAA;ACvER;AD6EI;EACE,yBAAA;AC3EN;AD8EI;EACE,yBAAA;AC5EN;AD+EI;EACE,yBAAA;EACA,gBAAA;AC7EN;AD+EM;EACE,oCAAA;AC7ER;ADkFE;;;EAIE,mBAjQc;EAkQd,cArQyB;ACoL7B;ADoFE;EACE,cAtQc;ACoLlB;ADqFE;EACE,qBAtRe;ACmMnB;ADqFI;EACE,yBA1Ra;ACuMnB;ADsFI;EACE,yBAhSa;AC4MnB;ADwFE;EACE,yBAnSe;AC6MnB;ADyFE;EACE,YAAA;ACvFJ;AD2FE;EACE,WAAA;ACzFJ;AD2FE;EACE,cAjTe;ACwNnB;AD2FE;EACE,cApTe;AC2NnB;AD6FE;;;EACE,iCA5RQ;ACmMZ;AD4FE;EACE,mBA3Te;EA4Tf,qBA3Te;EA4Tf,cApTiB;AC0NrB;AD6FE;EACE,mBAnUe;EAoUf,qBAjUe;EAkUf,cA1TiB;EA2TjB,mBAAA;AC3FJ;AD8FE;EACE,gDAAA;AC5FJ;AD+FE;EACE,qBAAA;EACA,iRAAA;EACA,4BAAA;EACA,gEAAA;AC7FJ;ADkGI;EACE,gCAAA;AChGN;ADmGI;EACE,cAhVe;AC+OrB;ADoGI;EACE,yBA5Va;EA6Vb,4BA7Va;AC2PnB;ADqGI;EACE,cArVY;ACkPlB;ADsGI;EACE,oCAAA;EACA,cA9Ve;EA+Vf,kBAAA;EACA,0DAAA;ACpGN;ADyGE;EACE,uCAAA;EACA,gCAAA;ACvGJ;ADyGI;EACE,yBApXa;AC6QnB;AD2GM;EACE,cA/Wa;ACsQrB;AD8GM;EACE,kBAAA;EAEA,2BAAA;AC7GR;ADoHI;EACE,gCAAA;EACA,yBA3Ya;ACyRnB;ADmHM;EACE,yBA1YW;ACyRnB;ADqHI;EACE,yBAAA;ACnHN;AD2HE;EACE,yBA3Ze;ACkSnB;AD2HE;EACI,cAnZe;AC0RrB;AD6HE;EACE,cAxZiB;AC6RrB;AD6HI;EACE,oCAAA;AC3HN;ADkIE;EACE,mBA5ae;AC4SnB;ADkII;EACE,yBAlaY;ACkSlB;ADmIE;EACE,6BAAA;EACA,wDAAA;ACjIJ;AD+HE;EACE,6BAAA;EACA,wDAAA;ACjIJ;ADyIM;EACE,yBA7bW;ACsTnB;AD0IM;EACE,cAAA;ACxIR;AD6IE;EACE,oCAAA;AC3IJ","file":"dark.css"} \ No newline at end of file diff --git a/web/public/css/dark.scss b/web/public/css/dark.scss new file mode 100644 index 0000000..b0c812c --- /dev/null +++ b/web/public/css/dark.scss @@ -0,0 +1,460 @@ +$txadmin-dark-900: #222326; +$txadmin-dark-950: #222326; +$txadmin-dark-800: lighten($txadmin-dark-900, 8%); +$txadmin-dark-700: lighten($txadmin-dark-800, 8%); +$txadmin-dark-600: lighten($txadmin-dark-700, 8%); +$txadmin-dark-secondary: #ebedef; +// Default: +// #636f83 dark +// #ced2d8 secondary +// #ebedef light + +$txadmin-text-color: #FFFFFF; +$txadmin-text-color-inverse: #111111; +$txadmin-border-color: $txadmin-dark-700; + +$txadmin-primary: #00bf8f; +$txadmin-secondary: #3ab8cd; +$txadmin-danger: #ff148f; + +:root { + --primary: #{$txadmin-primary} !important; + --secondary: #{$txadmin-secondary} !important; + --danger: #{$txadmin-danger} !important; +} + +//Adapting to the new UI +//shadcn's default +$radius: 0.5rem; +$radius-lg: $radius; +$radius-md: calc($radius - 2px); +$radius-sm: calc($radius - 4px); +$border-color: hsl(224 5.6% 21.2%); + + +.card:first-child, +.card-header:first-child, +.card-body { + border-radius: $radius-lg; + background-color: #F0F1F4; +} + + +/** + * A dark theme for all, by GoatGeek#0001 + * + * _)) + * > *\ _~ + * `;'\\__-' \_ + * | ) _ \ \ + * / / `` w w + * w w + */ +body.theme--dark { + + /************************* + * CORE UI MODIFICATIONS + *************************/ + + // ----- Core Styles + // Shape CoreUI in the darkness + &, .c-app, .c-main, .c-footer, .c-header { + background: #0000; + color: $txadmin-text-color; + } + + // Stepper for the setup pages + ul.stepper .step:not(:last-of-type)::after { + background-color: rgba(75, 75, 75, 1); + } + + .list-group-accent .list-group-item-accent-secondary { + border-left: 4px solid $txadmin-dark-600; + } + + // ----- Progress bar + .progress { + background-color: $txadmin-dark-800; + } + + // ----- Switches + .c-switch-slider, .c-switch-slider::before { + background-color: $txadmin-dark-800; + border-color: $txadmin-dark-700; + // color: $txadmin-text-color; + transition: .15s ease-out; + } + .c-switch-input:focus ~ .c-switch-slider { + color: $txadmin-text-color; + background-color: $txadmin-dark-700; + border-color: lighten($txadmin-dark-600, 10%) !important; + outline: 0; + box-shadow: none; + } + .c-switch-success .c-switch-input:checked + .c-switch-slider { + background-color: #2eb85c; + border-color: #248f48; + } + + // ----- Buttons + // FIXME: this specific instruction was breaking lots of things + // .btn:not(.btn-outline-danger):not(.btn-outline-primary):not(.btn-outline-warning):not(.btn-stack-overflow):not(.btn-linkedin) { + // font-weight: 700; + // background: $txadmin-dark-800; + // border-color: transparent; + // color: $txadmin-text-color !important; + // transition: color 300ms ease-in-out, background 300ms ease-in-out; + // + // &:hover { + // background: opacify($txadmin-primary, 0.1); + // } + // } + + // ----- Text color styles + a:not(.btn):not(.c-sidebar-nav-link):not(.alert-link) { + &, &:hover { + color: $txadmin-primary; + } + + &.nav-link-red { + color: $txadmin-danger; + } + } + + .c-sidebar-nav-link.c-active { + border-left: $txadmin-primary solid 4px; + } + + .c-sidebar .c-sidebar-nav-dropdown-toggle:hover, + .c-sidebar .c-sidebar-nav-link:hover { + color: #fff; + background: $txadmin-primary !important; + } + + .text-body { + color: $txadmin-text-color !important; + } + + .text-danger { + color: $txadmin-danger !important; + } + .text-danger:hover { + color: darken($txadmin-danger, 15%) !important; + } + + .text-muted { + color: #969696 !important; + } + + // ----- Primary color overrides + .text-primary { + color: $txadmin-primary !important; + } + + .border-primary { + border-color: $txadmin-primary !important; + } + + .btn { + &.btn-primary { + background: $txadmin-primary; + border-color: $txadmin-primary; + } + + &.btn-outline-primary { + color: $txadmin-primary; + border-color: $txadmin-primary; + } + + &.btn-outline-dark { + color: $txadmin-dark-secondary; + border-color: $txadmin-dark-secondary; + &:hover { + background-color: $txadmin-dark-secondary; + color: $txadmin-dark-700; + } + } + + &.btn-primary, &.btn-outline-primary { + &:hover { + background-color: $txadmin-primary; + color: black; + } + } + } + + // ----- Body Element Changes + // Darken borders + .c-footer, .c-header, .card-footer, .card-header, + .modal-body, .modal-header, .modal-footer, + .border-right { + border-color: $border-color !important; + } + + // Darken backgrounds + .c-sidebar, .c-callout, .modal-content, .modal-body { + background: $txadmin-dark-900; + } + + // ----- Alert elements + .alert { + &.alert-secondary { + background: $txadmin-dark-800; + color: $txadmin-text-color; + border-color: $txadmin-dark-700; + } + } + + // ------ Nice scroll bars + $tx-scroll-bg: lighten($txadmin-dark-900, 3.75%); + + ::-webkit-scrollbar-corner { + background: $tx-scroll-bg; + } + + .thin-scroll::-webkit-scrollbar-track { + background-color: $tx-scroll-bg; + border-right: 1px solid $tx-scroll-bg; + border-left: 1px solid $tx-scroll-bg; + } + + .thin-scroll::-webkit-scrollbar-thumb { + background-color: #333; + background-clip: content-box; + border-color: transparent; + border-radius: 6px; + } + + .thin-scroll:hover::-webkit-scrollbar-thumb { + background-color: #444; + } + + // ----- Tabs & Navs + .nav-tabs { + border: 0; + + .nav-link { + border-color: $txadmin-dark-700; + border-radius: $radius-md; + transition: background-color 300ms ease-in-out; + + &.active { + color: $txadmin-dark-900 !important; + font-weight: 600; + border: 1px solid $txadmin-primary !important; + border-bottom-width: 0 !important; + } + } + } + + .nav-link { + &:hover { + background-color: lighten($txadmin-dark-900, 2.5%); + } + + &.nav-link-disabled { + color: $txadmin-dark-600 !important; + } + + &.active { + color: $txadmin-dark-900 !important; + font-weight: 600; + + &.nav-link-red{ + background-color: #e55353 !important; + } + } + } + + .nav-pills .nav-link.active, + .nav-tabs .nav-link.active, + .nav-pills .show > .nav-link { + @extend .btn; + background: $txadmin-primary; + color: $txadmin-text-color-inverse; + } + + .c-header .c-header-nav .c-header-nav-btn, .c-header .c-header-nav .c-header-nav-link { + color: $txadmin-primary; + } + + .dropdown-menu { + border-color: $txadmin-border-color; + + .dropdown-item, & { + background-color: $txadmin-dark-800; + } + + .dropdown-item:hover { + background-color: $txadmin-dark-900; + } + } + + .dropdown-header { + background-color: $txadmin-dark-800; + } + + .c-header .c-header-toggler { + color: white; + } + + // ----- Modals / Popups / Notifications + pre, .close { + color: #fff; + } + [data-notify="message"] > pre{ + color: $txadmin-dark-900; + } + button[data-notify="dismiss"]{ + color: $txadmin-dark-900; + } + + // ----- Forms + .form-control, .btn, .c-callout { + border-radius: $radius-md; + } + + .form-control, .input-group-text { + background: $txadmin-dark-800; + border-color: $txadmin-dark-700; + color: $txadmin-text-color; + } + + .form-control[disabled], .form-control[readonly]{ + background: $txadmin-dark-900; + border-color: $txadmin-dark-700; + color: $txadmin-text-color; + cursor: not-allowed; + } + + .form-control:focus { + box-shadow: 0 0 0 0.2rem rgba($txadmin-primary, .15); + } + + .form-control.is-valid, .was-validated .form-control:valid { + border-color: #2eb85c; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%232eb85c' d='M2.3 6.73L.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.1z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-size: calc(.75em + .375rem) calc(.75em + .375rem); +} + + // ----- Tables + .table { + &, td { + border-color: $txadmin-border-color !important; + } + + tr { + color: $txadmin-text-color; + } + + thead th { + border-top-color: $txadmin-dark-700; + border-bottom-color: $txadmin-dark-700; + } + + strong, .table-hover tbody tr:hover { + color: $txadmin-primary; + } + + .thead-light th { + background-color: $txadmin-dark-800 !important; + color: $txadmin-text-color; + font-size: 1.175em; + font-family: Consolas, Courier, Droid Sans Mono, monospace; + } + } + + // ----- Cards + .card { + border: 1px solid $border-color; + border-radius: $radius-lg !important; + + &, .card-header, .card-body, .card-footer { + background-color: $txadmin-dark-950; + } + + .card-header, .card-body { + &, p { + color: $txadmin-text-color; + } + } + + .card-header { + & > h5, .card-title { + font-size: 1.05rem; + // border-radius: 0; + border: 0 solid transparent; + } + } + } + + // ----- Pagination + .pagination { + & .page-link { + border-color: $txadmin-border-color !important; + background-color: $txadmin-dark-900; + &:hover { + background-color: $txadmin-dark-700; + } + } + + & > .page-item.disabled > a{ + color: $txadmin-dark-600 !important; + } + } + + + /*********************** + * TXADMIN ELEMENTS + ***********************/ + #modPlayerMain-notes { + background-color: $txadmin-dark-900; + } + .attentionText { + color: $txadmin-text-color; + } + + // Player log entries + .logEntry { + color: $txadmin-text-color; + + &:hover { + background-color: $txadmin-dark-800 !important; + } + } + + /** + * Deployment things + */ + .bigbutton { + background: $txadmin-dark-800; + + &:hover { + background-color: $txadmin-primary; + } + } + .blur-input:not(:focus):not(:placeholder-shown) { + color: transparent !important; + text-shadow: 0 0 5px rgba(235, 235, 235, 0.5) !important; + } + + /** + * txAdmin player list + */ + .playerlist { + .player { + &:hover { + background-color: $txadmin-dark-800; + } + + .pname { + color: #e6e6e6; + } + } + } + + .plheader { + background-color: $txadmin-dark-800 !important; + } +} diff --git a/web/public/css/foldgutter.css b/web/public/css/foldgutter.css new file mode 100644 index 0000000..ad19ae2 --- /dev/null +++ b/web/public/css/foldgutter.css @@ -0,0 +1,20 @@ +.CodeMirror-foldmarker { + color: blue; + text-shadow: #b9f 1px 1px 2px, #b9f -1px -1px 2px, #b9f 1px -1px 2px, #b9f -1px 1px 2px; + font-family: arial; + line-height: .3; + cursor: pointer; +} +.CodeMirror-foldgutter { + width: .7em; +} +.CodeMirror-foldgutter-open, +.CodeMirror-foldgutter-folded { + cursor: pointer; +} +.CodeMirror-foldgutter-open:after { + content: "\25BE"; +} +.CodeMirror-foldgutter-folded:after { + content: "\25B8"; +} diff --git a/web/public/css/jquery-confirm.min.css b/web/public/css/jquery-confirm.min.css new file mode 100644 index 0000000..b66d205 --- /dev/null +++ b/web/public/css/jquery-confirm.min.css @@ -0,0 +1,9 @@ +/*! + * jquery-confirm v3.3.4 (http://craftpip.github.io/jquery-confirm/) + * Author: boniface pereira + * Website: www.craftpip.com + * Contact: hey@craftpip.com + * + * Copyright 2013-2019 jquery-confirm + * Licensed under MIT (https://github.com/craftpip/jquery-confirm/blob/master/LICENSE) + */@-webkit-keyframes jconfirm-spin{from{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes jconfirm-spin{from{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}body[class*=jconfirm-no-scroll-]{overflow:hidden!important}.jconfirm{position:fixed;top:0;left:0;right:0;bottom:0;z-index:99999999;font-family:inherit;overflow:hidden}.jconfirm .jconfirm-bg{position:fixed;top:0;left:0;right:0;bottom:0;-webkit-transition:opacity .4s;transition:opacity .4s}.jconfirm .jconfirm-bg.jconfirm-bg-h{opacity:0!important}.jconfirm .jconfirm-scrollpane{-webkit-perspective:500px;perspective:500px;-webkit-perspective-origin:center;perspective-origin:center;display:table;width:100%;height:100%}.jconfirm .jconfirm-row{display:table-row;width:100%}.jconfirm .jconfirm-cell{display:table-cell;vertical-align:middle}.jconfirm .jconfirm-holder{max-height:100%;padding:50px 0}.jconfirm .jconfirm-box-container{-webkit-transition:-webkit-transform;transition:-webkit-transform;transition:transform;transition:transform,-webkit-transform}.jconfirm .jconfirm-box-container.jconfirm-no-transition{-webkit-transition:none!important;transition:none!important}.jconfirm .jconfirm-box{background:white;border-radius:4px;position:relative;outline:0;padding:15px 15px 0;overflow:hidden;margin-left:auto;margin-right:auto}@-webkit-keyframes type-blue{1%,100%{border-color:#3498db}50%{border-color:#5faee3}}@keyframes type-blue{1%,100%{border-color:#3498db}50%{border-color:#5faee3}}@-webkit-keyframes type-green{1%,100%{border-color:#2ecc71}50%{border-color:#54d98c}}@keyframes type-green{1%,100%{border-color:#2ecc71}50%{border-color:#54d98c}}@-webkit-keyframes type-red{1%,100%{border-color:#e74c3c}50%{border-color:#ed7669}}@keyframes type-red{1%,100%{border-color:#e74c3c}50%{border-color:#ed7669}}@-webkit-keyframes type-orange{1%,100%{border-color:#f1c40f}50%{border-color:#f4d03f}}@keyframes type-orange{1%,100%{border-color:#f1c40f}50%{border-color:#f4d03f}}@-webkit-keyframes type-purple{1%,100%{border-color:#9b59b6}50%{border-color:#b07cc6}}@keyframes type-purple{1%,100%{border-color:#9b59b6}50%{border-color:#b07cc6}}@-webkit-keyframes type-dark{1%,100%{border-color:#34495e}50%{border-color:#46627f}}@keyframes type-dark{1%,100%{border-color:#34495e}50%{border-color:#46627f}}.jconfirm .jconfirm-box.jconfirm-type-animated{-webkit-animation-duration:2s;animation-duration:2s;-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite}.jconfirm .jconfirm-box.jconfirm-type-blue{border-top:solid 7px #3498db;-webkit-animation-name:type-blue;animation-name:type-blue}.jconfirm .jconfirm-box.jconfirm-type-green{border-top:solid 7px #2ecc71;-webkit-animation-name:type-green;animation-name:type-green}.jconfirm .jconfirm-box.jconfirm-type-red{border-top:solid 7px #e74c3c;-webkit-animation-name:type-red;animation-name:type-red}.jconfirm .jconfirm-box.jconfirm-type-orange{border-top:solid 7px #f1c40f;-webkit-animation-name:type-orange;animation-name:type-orange}.jconfirm .jconfirm-box.jconfirm-type-purple{border-top:solid 7px #9b59b6;-webkit-animation-name:type-purple;animation-name:type-purple}.jconfirm .jconfirm-box.jconfirm-type-dark{border-top:solid 7px #34495e;-webkit-animation-name:type-dark;animation-name:type-dark}.jconfirm .jconfirm-box.loading{height:120px}.jconfirm .jconfirm-box.loading:before{content:'';position:absolute;left:0;background:white;right:0;top:0;bottom:0;border-radius:10px;z-index:1}.jconfirm .jconfirm-box.loading:after{opacity:.6;content:'';height:30px;width:30px;border:solid 3px transparent;position:absolute;left:50%;margin-left:-15px;border-radius:50%;-webkit-animation:jconfirm-spin 1s infinite linear;animation:jconfirm-spin 1s infinite linear;border-bottom-color:dodgerblue;top:50%;margin-top:-15px;z-index:2}.jconfirm .jconfirm-box div.jconfirm-closeIcon{height:20px;width:20px;position:absolute;top:10px;right:10px;cursor:pointer;opacity:.6;text-align:center;font-size:27px!important;line-height:14px!important;display:none;z-index:1}.jconfirm .jconfirm-box div.jconfirm-closeIcon:empty{display:none}.jconfirm .jconfirm-box div.jconfirm-closeIcon .fa{font-size:16px}.jconfirm .jconfirm-box div.jconfirm-closeIcon .glyphicon{font-size:16px}.jconfirm .jconfirm-box div.jconfirm-closeIcon .zmdi{font-size:16px}.jconfirm .jconfirm-box div.jconfirm-closeIcon:hover{opacity:1}.jconfirm .jconfirm-box div.jconfirm-title-c{display:block;font-size:22px;line-height:20px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;cursor:default;padding-bottom:15px}.jconfirm .jconfirm-box div.jconfirm-title-c.jconfirm-hand{cursor:move}.jconfirm .jconfirm-box div.jconfirm-title-c .jconfirm-icon-c{font-size:inherit;display:inline-block;vertical-align:middle}.jconfirm .jconfirm-box div.jconfirm-title-c .jconfirm-icon-c i{vertical-align:middle}.jconfirm .jconfirm-box div.jconfirm-title-c .jconfirm-icon-c:empty{display:none}.jconfirm .jconfirm-box div.jconfirm-title-c .jconfirm-title{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;font-size:inherit;font-family:inherit;display:inline-block;vertical-align:middle}.jconfirm .jconfirm-box div.jconfirm-title-c .jconfirm-title:empty{display:none}.jconfirm .jconfirm-box div.jconfirm-content-pane{margin-bottom:15px;height:auto;-webkit-transition:height .4s ease-in;transition:height .4s ease-in;display:inline-block;width:100%;position:relative;overflow-x:hidden;overflow-y:auto}.jconfirm .jconfirm-box div.jconfirm-content-pane.no-scroll{overflow-y:hidden}.jconfirm .jconfirm-box div.jconfirm-content-pane::-webkit-scrollbar{width:3px}.jconfirm .jconfirm-box div.jconfirm-content-pane::-webkit-scrollbar-track{background:rgba(0,0,0,0.1)}.jconfirm .jconfirm-box div.jconfirm-content-pane::-webkit-scrollbar-thumb{background:#666;border-radius:3px}.jconfirm .jconfirm-box div.jconfirm-content-pane .jconfirm-content{overflow:auto}.jconfirm .jconfirm-box div.jconfirm-content-pane .jconfirm-content img{max-width:100%;height:auto}.jconfirm .jconfirm-box div.jconfirm-content-pane .jconfirm-content:empty{display:none}.jconfirm .jconfirm-box .jconfirm-buttons{padding-bottom:11px}.jconfirm .jconfirm-box .jconfirm-buttons>button{margin-bottom:4px;margin-left:2px;margin-right:2px}.jconfirm .jconfirm-box .jconfirm-buttons button{display:inline-block;padding:6px 12px;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;border-radius:4px;min-height:1em;-webkit-transition:opacity .1s ease,background-color .1s ease,color .1s ease,background .1s ease,-webkit-box-shadow .1s ease;transition:opacity .1s ease,background-color .1s ease,color .1s ease,background .1s ease,-webkit-box-shadow .1s ease;transition:opacity .1s ease,background-color .1s ease,color .1s ease,box-shadow .1s ease,background .1s ease;transition:opacity .1s ease,background-color .1s ease,color .1s ease,box-shadow .1s ease,background .1s ease,-webkit-box-shadow .1s ease;-webkit-tap-highlight-color:transparent;border:0;background-image:none}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-blue{background-color:#3498db;color:#FFF;text-shadow:none;-webkit-transition:background .2s;transition:background .2s}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-blue:hover{background-color:#2980b9;color:#FFF}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-green{background-color:#2ecc71;color:#FFF;text-shadow:none;-webkit-transition:background .2s;transition:background .2s}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-green:hover{background-color:#27ae60;color:#FFF}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-red{background-color:#e74c3c;color:#FFF;text-shadow:none;-webkit-transition:background .2s;transition:background .2s}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-red:hover{background-color:#c0392b;color:#FFF}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-orange{background-color:#f1c40f;color:#FFF;text-shadow:none;-webkit-transition:background .2s;transition:background .2s}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-orange:hover{background-color:#f39c12;color:#FFF}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-default{background-color:#ecf0f1;color:#000;text-shadow:none;-webkit-transition:background .2s;transition:background .2s}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-default:hover{background-color:#bdc3c7;color:#000}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-purple{background-color:#9b59b6;color:#FFF;text-shadow:none;-webkit-transition:background .2s;transition:background .2s}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-purple:hover{background-color:#8e44ad;color:#FFF}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-dark{background-color:#34495e;color:#FFF;text-shadow:none;-webkit-transition:background .2s;transition:background .2s}.jconfirm .jconfirm-box .jconfirm-buttons button.btn-dark:hover{background-color:#2c3e50;color:#FFF}.jconfirm .jconfirm-box.jconfirm-type-red .jconfirm-title-c .jconfirm-icon-c{color:#e74c3c!important}.jconfirm .jconfirm-box.jconfirm-type-blue .jconfirm-title-c .jconfirm-icon-c{color:#3498db!important}.jconfirm .jconfirm-box.jconfirm-type-green .jconfirm-title-c .jconfirm-icon-c{color:#2ecc71!important}.jconfirm .jconfirm-box.jconfirm-type-purple .jconfirm-title-c .jconfirm-icon-c{color:#9b59b6!important}.jconfirm .jconfirm-box.jconfirm-type-orange .jconfirm-title-c .jconfirm-icon-c{color:#f1c40f!important}.jconfirm .jconfirm-box.jconfirm-type-dark .jconfirm-title-c .jconfirm-icon-c{color:#34495e!important}.jconfirm .jconfirm-clear{clear:both}.jconfirm.jconfirm-rtl{direction:rtl}.jconfirm.jconfirm-rtl div.jconfirm-closeIcon{left:5px;right:auto}.jconfirm.jconfirm-white .jconfirm-bg,.jconfirm.jconfirm-light .jconfirm-bg{background-color:#444;opacity:.2}.jconfirm.jconfirm-white .jconfirm-box,.jconfirm.jconfirm-light .jconfirm-box{-webkit-box-shadow:0 2px 6px rgba(0,0,0,0.2);box-shadow:0 2px 6px rgba(0,0,0,0.2);border-radius:5px}.jconfirm.jconfirm-white .jconfirm-box .jconfirm-title-c .jconfirm-icon-c,.jconfirm.jconfirm-light .jconfirm-box .jconfirm-title-c .jconfirm-icon-c{margin-right:8px;margin-left:0}.jconfirm.jconfirm-white .jconfirm-box .jconfirm-buttons,.jconfirm.jconfirm-light .jconfirm-box .jconfirm-buttons{float:right}.jconfirm.jconfirm-white .jconfirm-box .jconfirm-buttons button,.jconfirm.jconfirm-light .jconfirm-box .jconfirm-buttons button{text-transform:uppercase;font-size:14px;font-weight:bold;text-shadow:none}.jconfirm.jconfirm-white .jconfirm-box .jconfirm-buttons button.btn-default,.jconfirm.jconfirm-light .jconfirm-box .jconfirm-buttons button.btn-default{-webkit-box-shadow:none;box-shadow:none;color:#333}.jconfirm.jconfirm-white .jconfirm-box .jconfirm-buttons button.btn-default:hover,.jconfirm.jconfirm-light .jconfirm-box .jconfirm-buttons button.btn-default:hover{background:#ddd}.jconfirm.jconfirm-white.jconfirm-rtl .jconfirm-title-c .jconfirm-icon-c,.jconfirm.jconfirm-light.jconfirm-rtl .jconfirm-title-c .jconfirm-icon-c{margin-left:8px;margin-right:0}.jconfirm.jconfirm-black .jconfirm-bg,.jconfirm.jconfirm-dark .jconfirm-bg{background-color:darkslategray;opacity:.4}.jconfirm.jconfirm-black .jconfirm-box,.jconfirm.jconfirm-dark .jconfirm-box{-webkit-box-shadow:0 2px 6px rgba(0,0,0,0.2);box-shadow:0 2px 6px rgba(0,0,0,0.2);background:#444;border-radius:5px;color:white}.jconfirm.jconfirm-black .jconfirm-box .jconfirm-title-c .jconfirm-icon-c,.jconfirm.jconfirm-dark .jconfirm-box .jconfirm-title-c .jconfirm-icon-c{margin-right:8px;margin-left:0}.jconfirm.jconfirm-black .jconfirm-box .jconfirm-buttons,.jconfirm.jconfirm-dark .jconfirm-box .jconfirm-buttons{float:right}.jconfirm.jconfirm-black .jconfirm-box .jconfirm-buttons button,.jconfirm.jconfirm-dark .jconfirm-box .jconfirm-buttons button{border:0;background-image:none;text-transform:uppercase;font-size:14px;font-weight:bold;text-shadow:none;-webkit-transition:background .1s;transition:background .1s;color:white}.jconfirm.jconfirm-black .jconfirm-box .jconfirm-buttons button.btn-default,.jconfirm.jconfirm-dark .jconfirm-box .jconfirm-buttons button.btn-default{-webkit-box-shadow:none;box-shadow:none;color:#fff;background:0}.jconfirm.jconfirm-black .jconfirm-box .jconfirm-buttons button.btn-default:hover,.jconfirm.jconfirm-dark .jconfirm-box .jconfirm-buttons button.btn-default:hover{background:#666}.jconfirm.jconfirm-black.jconfirm-rtl .jconfirm-title-c .jconfirm-icon-c,.jconfirm.jconfirm-dark.jconfirm-rtl .jconfirm-title-c .jconfirm-icon-c{margin-left:8px;margin-right:0}.jconfirm .jconfirm-box.hilight.jconfirm-hilight-shake{-webkit-animation:shake .82s cubic-bezier(0.36,0.07,0.19,0.97) both;animation:shake .82s cubic-bezier(0.36,0.07,0.19,0.97) both;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.jconfirm .jconfirm-box.hilight.jconfirm-hilight-glow{-webkit-animation:glow .82s cubic-bezier(0.36,0.07,0.19,0.97) both;animation:glow .82s cubic-bezier(0.36,0.07,0.19,0.97) both;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}@-webkit-keyframes shake{10%,90%{-webkit-transform:translate3d(-2px,0,0);transform:translate3d(-2px,0,0)}20%,80%{-webkit-transform:translate3d(4px,0,0);transform:translate3d(4px,0,0)}30%,50%,70%{-webkit-transform:translate3d(-8px,0,0);transform:translate3d(-8px,0,0)}40%,60%{-webkit-transform:translate3d(8px,0,0);transform:translate3d(8px,0,0)}}@keyframes shake{10%,90%{-webkit-transform:translate3d(-2px,0,0);transform:translate3d(-2px,0,0)}20%,80%{-webkit-transform:translate3d(4px,0,0);transform:translate3d(4px,0,0)}30%,50%,70%{-webkit-transform:translate3d(-8px,0,0);transform:translate3d(-8px,0,0)}40%,60%{-webkit-transform:translate3d(8px,0,0);transform:translate3d(8px,0,0)}}@-webkit-keyframes glow{0%,100%{-webkit-box-shadow:0 0 0 red;box-shadow:0 0 0 red}50%{-webkit-box-shadow:0 0 30px red;box-shadow:0 0 30px red}}@keyframes glow{0%,100%{-webkit-box-shadow:0 0 0 red;box-shadow:0 0 0 red}50%{-webkit-box-shadow:0 0 30px red;box-shadow:0 0 30px red}}.jconfirm{-webkit-perspective:400px;perspective:400px}.jconfirm .jconfirm-box{opacity:1;-webkit-transition-property:all;transition-property:all}.jconfirm .jconfirm-box.jconfirm-animation-top,.jconfirm .jconfirm-box.jconfirm-animation-left,.jconfirm .jconfirm-box.jconfirm-animation-right,.jconfirm .jconfirm-box.jconfirm-animation-bottom,.jconfirm .jconfirm-box.jconfirm-animation-opacity,.jconfirm .jconfirm-box.jconfirm-animation-zoom,.jconfirm .jconfirm-box.jconfirm-animation-scale,.jconfirm .jconfirm-box.jconfirm-animation-none,.jconfirm .jconfirm-box.jconfirm-animation-rotate,.jconfirm .jconfirm-box.jconfirm-animation-rotatex,.jconfirm .jconfirm-box.jconfirm-animation-rotatey,.jconfirm .jconfirm-box.jconfirm-animation-scaley,.jconfirm .jconfirm-box.jconfirm-animation-scalex{opacity:0}.jconfirm .jconfirm-box.jconfirm-animation-rotate{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.jconfirm .jconfirm-box.jconfirm-animation-rotatex{-webkit-transform:rotateX(90deg);transform:rotateX(90deg);-webkit-transform-origin:center;transform-origin:center}.jconfirm .jconfirm-box.jconfirm-animation-rotatexr{-webkit-transform:rotateX(-90deg);transform:rotateX(-90deg);-webkit-transform-origin:center;transform-origin:center}.jconfirm .jconfirm-box.jconfirm-animation-rotatey{-webkit-transform:rotatey(90deg);transform:rotatey(90deg);-webkit-transform-origin:center;transform-origin:center}.jconfirm .jconfirm-box.jconfirm-animation-rotateyr{-webkit-transform:rotatey(-90deg);transform:rotatey(-90deg);-webkit-transform-origin:center;transform-origin:center}.jconfirm .jconfirm-box.jconfirm-animation-scaley{-webkit-transform:scaley(1.5);transform:scaley(1.5);-webkit-transform-origin:center;transform-origin:center}.jconfirm .jconfirm-box.jconfirm-animation-scalex{-webkit-transform:scalex(1.5);transform:scalex(1.5);-webkit-transform-origin:center;transform-origin:center}.jconfirm .jconfirm-box.jconfirm-animation-top{-webkit-transform:translate(0px,-100px);transform:translate(0px,-100px)}.jconfirm .jconfirm-box.jconfirm-animation-left{-webkit-transform:translate(-100px,0px);transform:translate(-100px,0px)}.jconfirm .jconfirm-box.jconfirm-animation-right{-webkit-transform:translate(100px,0px);transform:translate(100px,0px)}.jconfirm .jconfirm-box.jconfirm-animation-bottom{-webkit-transform:translate(0px,100px);transform:translate(0px,100px)}.jconfirm .jconfirm-box.jconfirm-animation-zoom{-webkit-transform:scale(1.2);transform:scale(1.2)}.jconfirm .jconfirm-box.jconfirm-animation-scale{-webkit-transform:scale(0.5);transform:scale(0.5)}.jconfirm .jconfirm-box.jconfirm-animation-none{visibility:hidden}.jconfirm.jconfirm-supervan .jconfirm-bg{background-color:rgba(54,70,93,0.95)}.jconfirm.jconfirm-supervan .jconfirm-box{background-color:transparent}.jconfirm.jconfirm-supervan .jconfirm-box.jconfirm-type-blue{border:0}.jconfirm.jconfirm-supervan .jconfirm-box.jconfirm-type-green{border:0}.jconfirm.jconfirm-supervan .jconfirm-box.jconfirm-type-red{border:0}.jconfirm.jconfirm-supervan .jconfirm-box.jconfirm-type-orange{border:0}.jconfirm.jconfirm-supervan .jconfirm-box.jconfirm-type-purple{border:0}.jconfirm.jconfirm-supervan .jconfirm-box.jconfirm-type-dark{border:0}.jconfirm.jconfirm-supervan .jconfirm-box div.jconfirm-closeIcon{color:white}.jconfirm.jconfirm-supervan .jconfirm-box div.jconfirm-title-c{text-align:center;color:white;font-size:28px;font-weight:normal}.jconfirm.jconfirm-supervan .jconfirm-box div.jconfirm-title-c>*{padding-bottom:25px}.jconfirm.jconfirm-supervan .jconfirm-box div.jconfirm-title-c .jconfirm-icon-c{margin-right:8px;margin-left:0}.jconfirm.jconfirm-supervan .jconfirm-box div.jconfirm-content-pane{margin-bottom:25px}.jconfirm.jconfirm-supervan .jconfirm-box div.jconfirm-content{text-align:center;color:white}.jconfirm.jconfirm-supervan .jconfirm-box .jconfirm-buttons{text-align:center}.jconfirm.jconfirm-supervan .jconfirm-box .jconfirm-buttons button{font-size:16px;border-radius:2px;background:#303f53;text-shadow:none;border:0;color:white;padding:10px;min-width:100px}.jconfirm.jconfirm-supervan.jconfirm-rtl .jconfirm-box div.jconfirm-title-c .jconfirm-icon-c{margin-left:8px;margin-right:0}.jconfirm.jconfirm-material .jconfirm-bg{background-color:rgba(0,0,0,0.67)}.jconfirm.jconfirm-material .jconfirm-box{background-color:white;-webkit-box-shadow:0 7px 8px -4px rgba(0,0,0,0.2),0 13px 19px 2px rgba(0,0,0,0.14),0 5px 24px 4px rgba(0,0,0,0.12);box-shadow:0 7px 8px -4px rgba(0,0,0,0.2),0 13px 19px 2px rgba(0,0,0,0.14),0 5px 24px 4px rgba(0,0,0,0.12);padding:30px 25px 10px 25px}.jconfirm.jconfirm-material .jconfirm-box .jconfirm-title-c .jconfirm-icon-c{margin-right:8px;margin-left:0}.jconfirm.jconfirm-material .jconfirm-box div.jconfirm-closeIcon{color:rgba(0,0,0,0.87)}.jconfirm.jconfirm-material .jconfirm-box div.jconfirm-title-c{color:rgba(0,0,0,0.87);font-size:22px;font-weight:bold}.jconfirm.jconfirm-material .jconfirm-box div.jconfirm-content{color:rgba(0,0,0,0.87)}.jconfirm.jconfirm-material .jconfirm-box .jconfirm-buttons{text-align:right}.jconfirm.jconfirm-material .jconfirm-box .jconfirm-buttons button{text-transform:uppercase;font-weight:500}.jconfirm.jconfirm-material.jconfirm-rtl .jconfirm-title-c .jconfirm-icon-c{margin-left:8px;margin-right:0}.jconfirm.jconfirm-bootstrap .jconfirm-bg{background-color:rgba(0,0,0,0.21)}.jconfirm.jconfirm-bootstrap .jconfirm-box{background-color:white;-webkit-box-shadow:0 3px 8px 0 rgba(0,0,0,0.2);box-shadow:0 3px 8px 0 rgba(0,0,0,0.2);border:solid 1px rgba(0,0,0,0.4);padding:15px 0 0}.jconfirm.jconfirm-bootstrap .jconfirm-box .jconfirm-title-c .jconfirm-icon-c{margin-right:8px;margin-left:0}.jconfirm.jconfirm-bootstrap .jconfirm-box div.jconfirm-closeIcon{color:rgba(0,0,0,0.87)}.jconfirm.jconfirm-bootstrap .jconfirm-box div.jconfirm-title-c{color:rgba(0,0,0,0.87);font-size:22px;font-weight:bold;padding-left:15px;padding-right:15px}.jconfirm.jconfirm-bootstrap .jconfirm-box div.jconfirm-content{color:rgba(0,0,0,0.87);padding:0 15px}.jconfirm.jconfirm-bootstrap .jconfirm-box .jconfirm-buttons{text-align:right;padding:10px;margin:-5px 0 0;border-top:solid 1px #ddd;overflow:hidden;border-radius:0 0 4px 4px}.jconfirm.jconfirm-bootstrap .jconfirm-box .jconfirm-buttons button{font-weight:500}.jconfirm.jconfirm-bootstrap.jconfirm-rtl .jconfirm-title-c .jconfirm-icon-c{margin-left:8px;margin-right:0}.jconfirm.jconfirm-modern .jconfirm-bg{background-color:slategray;opacity:.6}.jconfirm.jconfirm-modern .jconfirm-box{background-color:white;-webkit-box-shadow:0 7px 8px -4px rgba(0,0,0,0.2),0 13px 19px 2px rgba(0,0,0,0.14),0 5px 24px 4px rgba(0,0,0,0.12);box-shadow:0 7px 8px -4px rgba(0,0,0,0.2),0 13px 19px 2px rgba(0,0,0,0.14),0 5px 24px 4px rgba(0,0,0,0.12);padding:30px 30px 15px}.jconfirm.jconfirm-modern .jconfirm-box div.jconfirm-closeIcon{color:rgba(0,0,0,0.87);top:15px;right:15px}.jconfirm.jconfirm-modern .jconfirm-box div.jconfirm-title-c{color:rgba(0,0,0,0.87);font-size:24px;font-weight:bold;text-align:center;margin-bottom:10px}.jconfirm.jconfirm-modern .jconfirm-box div.jconfirm-title-c .jconfirm-icon-c{-webkit-transition:-webkit-transform .5s;transition:-webkit-transform .5s;transition:transform .5s;transition:transform .5s,-webkit-transform .5s;-webkit-transform:scale(0);transform:scale(0);display:block;margin-right:0;margin-left:0;margin-bottom:10px;font-size:69px;color:#aaa}.jconfirm.jconfirm-modern .jconfirm-box div.jconfirm-content{text-align:center;font-size:15px;color:#777;margin-bottom:25px}.jconfirm.jconfirm-modern .jconfirm-box .jconfirm-buttons{text-align:center}.jconfirm.jconfirm-modern .jconfirm-box .jconfirm-buttons button{font-weight:bold;text-transform:uppercase;-webkit-transition:background .1s;transition:background .1s;padding:10px 20px}.jconfirm.jconfirm-modern .jconfirm-box .jconfirm-buttons button+button{margin-left:4px}.jconfirm.jconfirm-modern.jconfirm-open .jconfirm-box .jconfirm-title-c .jconfirm-icon-c{-webkit-transform:scale(1);transform:scale(1)} \ No newline at end of file diff --git a/web/public/css/simple-line-icons.css b/web/public/css/simple-line-icons.css new file mode 100644 index 0000000..343a4f5 --- /dev/null +++ b/web/public/css/simple-line-icons.css @@ -0,0 +1,777 @@ +@font-face { + font-family: 'simple-line-icons'; + src: url('../fonts/Simple-Line-Icons.woff2?v=2.4.0') format('woff2'); + font-weight: normal; + font-style: normal; + } + /* + Use the following CSS code if you want to have a class per icon. + Instead of a list of all class selectors, you can use the generic [class*="icon-"] selector, but it's slower: + */ + .icon-user, + .icon-people, + .icon-user-female, + .icon-user-follow, + .icon-user-following, + .icon-user-unfollow, + .icon-login, + .icon-logout, + .icon-emotsmile, + .icon-phone, + .icon-call-end, + .icon-call-in, + .icon-call-out, + .icon-map, + .icon-location-pin, + .icon-direction, + .icon-directions, + .icon-compass, + .icon-layers, + .icon-menu, + .icon-list, + .icon-options-vertical, + .icon-options, + .icon-arrow-down, + .icon-arrow-left, + .icon-arrow-right, + .icon-arrow-up, + .icon-arrow-up-circle, + .icon-arrow-left-circle, + .icon-arrow-right-circle, + .icon-arrow-down-circle, + .icon-check, + .icon-clock, + .icon-plus, + .icon-minus, + .icon-close, + .icon-event, + .icon-exclamation, + .icon-organization, + .icon-trophy, + .icon-screen-smartphone, + .icon-screen-desktop, + .icon-plane, + .icon-notebook, + .icon-mustache, + .icon-mouse, + .icon-magnet, + .icon-energy, + .icon-disc, + .icon-cursor, + .icon-cursor-move, + .icon-crop, + .icon-chemistry, + .icon-speedometer, + .icon-shield, + .icon-screen-tablet, + .icon-magic-wand, + .icon-hourglass, + .icon-graduation, + .icon-ghost, + .icon-game-controller, + .icon-fire, + .icon-eyeglass, + .icon-envelope-open, + .icon-envelope-letter, + .icon-bell, + .icon-badge, + .icon-anchor, + .icon-wallet, + .icon-vector, + .icon-speech, + .icon-puzzle, + .icon-printer, + .icon-present, + .icon-playlist, + .icon-pin, + .icon-picture, + .icon-handbag, + .icon-globe-alt, + .icon-globe, + .icon-folder-alt, + .icon-folder, + .icon-film, + .icon-feed, + .icon-drop, + .icon-drawer, + .icon-docs, + .icon-doc, + .icon-diamond, + .icon-cup, + .icon-calculator, + .icon-bubbles, + .icon-briefcase, + .icon-book-open, + .icon-basket-loaded, + .icon-basket, + .icon-bag, + .icon-action-undo, + .icon-action-redo, + .icon-wrench, + .icon-umbrella, + .icon-trash, + .icon-tag, + .icon-support, + .icon-frame, + .icon-size-fullscreen, + .icon-size-actual, + .icon-shuffle, + .icon-share-alt, + .icon-share, + .icon-rocket, + .icon-question, + .icon-pie-chart, + .icon-pencil, + .icon-note, + .icon-loop, + .icon-home, + .icon-grid, + .icon-graph, + .icon-microphone, + .icon-music-tone-alt, + .icon-music-tone, + .icon-earphones-alt, + .icon-earphones, + .icon-equalizer, + .icon-like, + .icon-dislike, + .icon-control-start, + .icon-control-rewind, + .icon-control-play, + .icon-control-pause, + .icon-control-forward, + .icon-control-end, + .icon-volume-1, + .icon-volume-2, + .icon-volume-off, + .icon-calendar, + .icon-bulb, + .icon-chart, + .icon-ban, + .icon-bubble, + .icon-camrecorder, + .icon-camera, + .icon-cloud-download, + .icon-cloud-upload, + .icon-envelope, + .icon-eye, + .icon-flag, + .icon-heart, + .icon-info, + .icon-key, + .icon-link, + .icon-lock, + .icon-lock-open, + .icon-magnifier, + .icon-magnifier-add, + .icon-magnifier-remove, + .icon-paper-clip, + .icon-paper-plane, + .icon-power, + .icon-refresh, + .icon-reload, + .icon-settings, + .icon-star, + .icon-symbol-female, + .icon-symbol-male, + .icon-target, + .icon-credit-card, + .icon-paypal, + .icon-social-tumblr, + .icon-social-twitter, + .icon-social-facebook, + .icon-social-instagram, + .icon-social-linkedin, + .icon-social-pinterest, + .icon-social-github, + .icon-social-google, + .icon-social-reddit, + .icon-social-skype, + .icon-social-dribbble, + .icon-social-behance, + .icon-social-foursqare, + .icon-social-soundcloud, + .icon-social-spotify, + .icon-social-stumbleupon, + .icon-social-youtube, + .icon-social-dropbox, + .icon-social-vkontakte, + .icon-social-steam { + font-family: 'simple-line-icons'; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + .icon-user:before { + content: "\e005"; + } + .icon-people:before { + content: "\e001"; + } + .icon-user-female:before { + content: "\e000"; + } + .icon-user-follow:before { + content: "\e002"; + } + .icon-user-following:before { + content: "\e003"; + } + .icon-user-unfollow:before { + content: "\e004"; + } + .icon-login:before { + content: "\e066"; + } + .icon-logout:before { + content: "\e065"; + } + .icon-emotsmile:before { + content: "\e021"; + } + .icon-phone:before { + content: "\e600"; + } + .icon-call-end:before { + content: "\e048"; + } + .icon-call-in:before { + content: "\e047"; + } + .icon-call-out:before { + content: "\e046"; + } + .icon-map:before { + content: "\e033"; + } + .icon-location-pin:before { + content: "\e096"; + } + .icon-direction:before { + content: "\e042"; + } + .icon-directions:before { + content: "\e041"; + } + .icon-compass:before { + content: "\e045"; + } + .icon-layers:before { + content: "\e034"; + } + .icon-menu:before { + content: "\e601"; + } + .icon-list:before { + content: "\e067"; + } + .icon-options-vertical:before { + content: "\e602"; + } + .icon-options:before { + content: "\e603"; + } + .icon-arrow-down:before { + content: "\e604"; + } + .icon-arrow-left:before { + content: "\e605"; + } + .icon-arrow-right:before { + content: "\e606"; + } + .icon-arrow-up:before { + content: "\e607"; + } + .icon-arrow-up-circle:before { + content: "\e078"; + } + .icon-arrow-left-circle:before { + content: "\e07a"; + } + .icon-arrow-right-circle:before { + content: "\e079"; + } + .icon-arrow-down-circle:before { + content: "\e07b"; + } + .icon-check:before { + content: "\e080"; + } + .icon-clock:before { + content: "\e081"; + } + .icon-plus:before { + content: "\e095"; + } + .icon-minus:before { + content: "\e615"; + } + .icon-close:before { + content: "\e082"; + } + .icon-event:before { + content: "\e619"; + } + .icon-exclamation:before { + content: "\e617"; + } + .icon-organization:before { + content: "\e616"; + } + .icon-trophy:before { + content: "\e006"; + } + .icon-screen-smartphone:before { + content: "\e010"; + } + .icon-screen-desktop:before { + content: "\e011"; + } + .icon-plane:before { + content: "\e012"; + } + .icon-notebook:before { + content: "\e013"; + } + .icon-mustache:before { + content: "\e014"; + } + .icon-mouse:before { + content: "\e015"; + } + .icon-magnet:before { + content: "\e016"; + } + .icon-energy:before { + content: "\e020"; + } + .icon-disc:before { + content: "\e022"; + } + .icon-cursor:before { + content: "\e06e"; + } + .icon-cursor-move:before { + content: "\e023"; + } + .icon-crop:before { + content: "\e024"; + } + .icon-chemistry:before { + content: "\e026"; + } + .icon-speedometer:before { + content: "\e007"; + } + .icon-shield:before { + content: "\e00e"; + } + .icon-screen-tablet:before { + content: "\e00f"; + } + .icon-magic-wand:before { + content: "\e017"; + } + .icon-hourglass:before { + content: "\e018"; + } + .icon-graduation:before { + content: "\e019"; + } + .icon-ghost:before { + content: "\e01a"; + } + .icon-game-controller:before { + content: "\e01b"; + } + .icon-fire:before { + content: "\e01c"; + } + .icon-eyeglass:before { + content: "\e01d"; + } + .icon-envelope-open:before { + content: "\e01e"; + } + .icon-envelope-letter:before { + content: "\e01f"; + } + .icon-bell:before { + content: "\e027"; + } + .icon-badge:before { + content: "\e028"; + } + .icon-anchor:before { + content: "\e029"; + } + .icon-wallet:before { + content: "\e02a"; + } + .icon-vector:before { + content: "\e02b"; + } + .icon-speech:before { + content: "\e02c"; + } + .icon-puzzle:before { + content: "\e02d"; + } + .icon-printer:before { + content: "\e02e"; + } + .icon-present:before { + content: "\e02f"; + } + .icon-playlist:before { + content: "\e030"; + } + .icon-pin:before { + content: "\e031"; + } + .icon-picture:before { + content: "\e032"; + } + .icon-handbag:before { + content: "\e035"; + } + .icon-globe-alt:before { + content: "\e036"; + } + .icon-globe:before { + content: "\e037"; + } + .icon-folder-alt:before { + content: "\e039"; + } + .icon-folder:before { + content: "\e089"; + } + .icon-film:before { + content: "\e03a"; + } + .icon-feed:before { + content: "\e03b"; + } + .icon-drop:before { + content: "\e03e"; + } + .icon-drawer:before { + content: "\e03f"; + } + .icon-docs:before { + content: "\e040"; + } + .icon-doc:before { + content: "\e085"; + } + .icon-diamond:before { + content: "\e043"; + } + .icon-cup:before { + content: "\e044"; + } + .icon-calculator:before { + content: "\e049"; + } + .icon-bubbles:before { + content: "\e04a"; + } + .icon-briefcase:before { + content: "\e04b"; + } + .icon-book-open:before { + content: "\e04c"; + } + .icon-basket-loaded:before { + content: "\e04d"; + } + .icon-basket:before { + content: "\e04e"; + } + .icon-bag:before { + content: "\e04f"; + } + .icon-action-undo:before { + content: "\e050"; + } + .icon-action-redo:before { + content: "\e051"; + } + .icon-wrench:before { + content: "\e052"; + } + .icon-umbrella:before { + content: "\e053"; + } + .icon-trash:before { + content: "\e054"; + } + .icon-tag:before { + content: "\e055"; + } + .icon-support:before { + content: "\e056"; + } + .icon-frame:before { + content: "\e038"; + } + .icon-size-fullscreen:before { + content: "\e057"; + } + .icon-size-actual:before { + content: "\e058"; + } + .icon-shuffle:before { + content: "\e059"; + } + .icon-share-alt:before { + content: "\e05a"; + } + .icon-share:before { + content: "\e05b"; + } + .icon-rocket:before { + content: "\e05c"; + } + .icon-question:before { + content: "\e05d"; + } + .icon-pie-chart:before { + content: "\e05e"; + } + .icon-pencil:before { + content: "\e05f"; + } + .icon-note:before { + content: "\e060"; + } + .icon-loop:before { + content: "\e064"; + } + .icon-home:before { + content: "\e069"; + } + .icon-grid:before { + content: "\e06a"; + } + .icon-graph:before { + content: "\e06b"; + } + .icon-microphone:before { + content: "\e063"; + } + .icon-music-tone-alt:before { + content: "\e061"; + } + .icon-music-tone:before { + content: "\e062"; + } + .icon-earphones-alt:before { + content: "\e03c"; + } + .icon-earphones:before { + content: "\e03d"; + } + .icon-equalizer:before { + content: "\e06c"; + } + .icon-like:before { + content: "\e068"; + } + .icon-dislike:before { + content: "\e06d"; + } + .icon-control-start:before { + content: "\e06f"; + } + .icon-control-rewind:before { + content: "\e070"; + } + .icon-control-play:before { + content: "\e071"; + } + .icon-control-pause:before { + content: "\e072"; + } + .icon-control-forward:before { + content: "\e073"; + } + .icon-control-end:before { + content: "\e074"; + } + .icon-volume-1:before { + content: "\e09f"; + } + .icon-volume-2:before { + content: "\e0a0"; + } + .icon-volume-off:before { + content: "\e0a1"; + } + .icon-calendar:before { + content: "\e075"; + } + .icon-bulb:before { + content: "\e076"; + } + .icon-chart:before { + content: "\e077"; + } + .icon-ban:before { + content: "\e07c"; + } + .icon-bubble:before { + content: "\e07d"; + } + .icon-camrecorder:before { + content: "\e07e"; + } + .icon-camera:before { + content: "\e07f"; + } + .icon-cloud-download:before { + content: "\e083"; + } + .icon-cloud-upload:before { + content: "\e084"; + } + .icon-envelope:before { + content: "\e086"; + } + .icon-eye:before { + content: "\e087"; + } + .icon-flag:before { + content: "\e088"; + } + .icon-heart:before { + content: "\e08a"; + } + .icon-info:before { + content: "\e08b"; + } + .icon-key:before { + content: "\e08c"; + } + .icon-link:before { + content: "\e08d"; + } + .icon-lock:before { + content: "\e08e"; + } + .icon-lock-open:before { + content: "\e08f"; + } + .icon-magnifier:before { + content: "\e090"; + } + .icon-magnifier-add:before { + content: "\e091"; + } + .icon-magnifier-remove:before { + content: "\e092"; + } + .icon-paper-clip:before { + content: "\e093"; + } + .icon-paper-plane:before { + content: "\e094"; + } + .icon-power:before { + content: "\e097"; + } + .icon-refresh:before { + content: "\e098"; + } + .icon-reload:before { + content: "\e099"; + } + .icon-settings:before { + content: "\e09a"; + } + .icon-star:before { + content: "\e09b"; + } + .icon-symbol-female:before { + content: "\e09c"; + } + .icon-symbol-male:before { + content: "\e09d"; + } + .icon-target:before { + content: "\e09e"; + } + .icon-credit-card:before { + content: "\e025"; + } + .icon-paypal:before { + content: "\e608"; + } + .icon-social-tumblr:before { + content: "\e00a"; + } + .icon-social-twitter:before { + content: "\e009"; + } + .icon-social-facebook:before { + content: "\e00b"; + } + .icon-social-instagram:before { + content: "\e609"; + } + .icon-social-linkedin:before { + content: "\e60a"; + } + .icon-social-pinterest:before { + content: "\e60b"; + } + .icon-social-github:before { + content: "\e60c"; + } + .icon-social-google:before { + content: "\e60d"; + } + .icon-social-reddit:before { + content: "\e60e"; + } + .icon-social-skype:before { + content: "\e60f"; + } + .icon-social-dribbble:before { + content: "\e00d"; + } + .icon-social-behance:before { + content: "\e610"; + } + .icon-social-foursqare:before { + content: "\e611"; + } + .icon-social-soundcloud:before { + content: "\e612"; + } + .icon-social-spotify:before { + content: "\e613"; + } + .icon-social-stumbleupon:before { + content: "\e614"; + } + .icon-social-youtube:before { + content: "\e008"; + } + .icon-social-dropbox:before { + content: "\e00c"; + } + .icon-social-vkontakte:before { + content: "\e618"; + } + .icon-social-steam:before { + content: "\e620"; + } diff --git a/web/public/css/txAdmin.css b/web/public/css/txAdmin.css new file mode 100644 index 0000000..ac51da4 --- /dev/null +++ b/web/public/css/txAdmin.css @@ -0,0 +1,508 @@ +/* Footer */ +.credit-links { + font-weight: 600; + color: #23282c; +} + + +/* Missing tooltip stuff */ +.tooltip[data-popper-placement^=top] .tooltip-arrow, .tooltip[data-popper-placement^=bottom] .tooltip-arrow { + width: .8rem; + height: .4rem; +} +.tooltip[data-popper-placement^=bottom] .tooltip-arrow { + top: 0; +} +.tooltip .tooltip-arrow { + position: absolute; + display: block; +} +.tooltip[data-popper-placement^=top], .tooltip[data-popper-placement^=bottom] { + padding: .4rem 0; +} +.tooltip[data-popper-placement^=bottom] .tooltip-arrow::before { + bottom: 0; + border-width: 0 .4rem .4rem; + border-bottom-color: #000015; +} +.tooltip .tooltip-arrow::before { + position: absolute; + content: ""; + border-color: transparent; + border-style: solid; +} + + +/* General responsivity */ +@media (min-width: 1200px){ + .dashboard-card{ + max-width: 460px !important; + } +} +@media (min-width: 768px){ + .mw-col8{ + min-width: 660px; + } +} +@media (min-width: 576px){ + .mw-col6{ + min-width: 460px; + } +} +@media (max-width: 560px){ + .main .container-fluid { + padding: 0 5px; + } + .main-top-spacer { + margin-top: 0.5em; + } +} +.tableActions{ + white-space: nowrap; + text-align: right !important; +} + + +/* General tweeks */ +.fix-pill-form { + margin-top: 0.3rem; + margin-bottom: 0; +} +option:disabled { + background-color: #f0f3f5; +} +.form-control{ + color: #5e6168; +} +.form-control:focus { + color: #373a40; +} +.form-control::placeholder { + color: #848d96; +} +.c-callout{ + background-color: #fff; +} +.c-sidebar .c-sidebar-nav-link.c-active { + border-left: aquamarine solid 3px; +} +[data-notify="message"] pre{ + white-space: normal; +} +.blur-input:not(:focus):not(:placeholder-shown) { + color: transparent !important; + text-shadow: 0 0 5px rgba(0,0,0,0.5) !important; +} +.attentionText { + /* background-color: #a7d4ff !important; */ +} +.permsCheckbox:checked+label { + font-weight: bold; +} +.btn-inline { + padding: 0.1rem 0.2rem; + font-size: .75rem; + line-height: 1.5; + border-radius: 0.2rem; + vertical-align: text-bottom; +} +.btn-inline-sm { + padding: 0.1rem 0.2rem; + font-size: .85rem; + font-weight: 600; + line-height: 1; + border-radius: 0.2rem; + vertical-align: text-bottom; +} + +.table td, .table th { + vertical-align: unset; /* it was top */ +} +.table-sm td, .table-sm th { + padding: 0.3rem 0.6rem; /* it was 0.3rem all sides */ +} + + +/* Theme dependant */ +.show-dark-mode { + display: none; +} +.theme--dark .show-dark-mode { + display: inline-block; +} +.show-light-mode { + display: inline-block; +} +.theme--dark .show-light-mode { + display: none; +} + +/* Console/Log marks */ +/* https://flatuicolors.com/palette/us */ +.consoleMark-cmd {background-color: #ffeaa7;} +.consoleMark-error {background-color: #ffbfa7;} +.consoleMark-info {background-color: #a7d4ff;} +.consoleMark-ok {background-color: #a7ffae;} +mark {background-color: #ffeaa7;} + + +/* Playerlist */ +@media (min-width: 1199.98px) { + .playerlist{ + /* 56+36 = 92 +4*/ + position: relative; + height: calc(100vh - 96px); + } +} +@media (max-width: 1199.98px) { + .playerlist-sidebar-max{ + margin-right: 0 !important; + } + .playerlist-sidebar{ + top: 56px !important; + } + .playerlist{ + /* 56+56+36 = 148*/ + position: relative; + height: calc(100vh - 152px); + } +} +.playerlist-sidebar{ + z-index: 1029; + right: 0; +} +.plheader{ + padding-top: 17px; + height: 56px; + background-color: #eeeeee !important; +} +.plheader-label{ + font-size: 1.15em; + color: #8f98a5 !important; +} +.plheader-bignum{ + margin-top: -26px; + font-size: 3.25em; + color: #a2a9b3 !important; + font-weight: 600; +} +.playerlist-search{ + padding: .25rem 1rem; +} +.playerlist>.player { + cursor: pointer; + padding: .25rem 1rem; +} +.playerlist>.player:hover { + background-color: #F2F2F2; +} +.playerlist>.player>.netid { + font-family: Consolas, monaco, monospace; +} +.playerlist>.player>.pname { + font-weight: 700; + padding-left: 5px; +} + + +/* Scrollable PRE */ +.thin-scroll { + overflow: scroll; + margin-right: 6px; +} +.thin-scroll::-webkit-scrollbar { + width: 8px; + height: 8px; + margin-left: -10px; + appearance: none; +} +.thin-scroll::-webkit-scrollbar-track { + background-color: white; + border-right: 1px solid #f2f2f2; + border-left: 1px solid #f2f2f2; +} +.thin-scroll::-webkit-scrollbar-thumb { + background-color: #f0f0f0; + background-clip: content-box; + border-color: transparent; + border-radius: 6px; +} +.thin-scroll:hover::-webkit-scrollbar-thumb{ + background-color: #cfcfcf; +} + + +/* Player info modal */ +#modPlayer > .modal-dialog { + max-width: 620px; +} + +#modPlayerTitle { + overflow: hidden; + text-overflow: ellipsis; +} + +#modPlayer .nav-link > i { + padding-right: 0.75rem; +} +.nav-pills .nav-link.nav-link-disabled{ + color: #cfcfcf; +} +.nav-pills .nav-link.nav-link-red{ + color: #e55353; +} +.nav-pills .nav-link.nav-link-red.active{ + color: #fff; + background-color: #e55353; +} +#modPlayerMain-notes{ + background-color: #fcf8e3; +} +#modPlayerMain-notes:disabled{ + background-color: #f4f3eb; +} +.player-history-entry{ + padding: .15rem .25rem; + margin-bottom: 2px !important; +} + +/* Player DB actions (players page + modal) */ +/* NOTE: this should not be here, but we will refactor the Web UI soon anyways */ +.logEntry{ + color: #768192; + padding: .15rem 0.55rem; + margin-bottom: 4px !important; +} +.logEntry:hover{ + background-color: #ebedef; +} +.logEntry:hover button{ + color: #fff; + background-color: #636f83; +} +.logEntry small{ + margin-top: .25em; +} +.logEntryClickable{ + cursor: pointer; +} +.txInlineBtn{ + border-radius: .25rem; + line-height: 1; + text-align: center; + white-space: nowrap; + letter-spacing: 0.1rem; +} +.txActionsBtn{ + font-size: 85%; + font-weight: 700; + padding: .25rem !important; + vertical-align: top; +} + + +/* HR Separator */ +.hrsep { + display: flex; + align-items: center; + text-align: center; + margin-top: 1rem; + margin-bottom: 1rem; + border: 0; + color: #b1afaf; +} +.hrsep-small { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} +.hrsep::before, .hrsep::after { + content: ''; + flex: 1; + border-top: 1px solid #e6e6e6; +} +.hrsep::before { + margin-right: .25em; +} +.hrsep::after { + margin-left: .25em; +} + + +/* Spinner */ +.txSpinner, +.txSpinner:before, +.txSpinner:after { + background: #00c88f; + -webkit-animation: txSpinnerLoad 1s infinite ease-in-out; + animation: txSpinnerLoad 1s infinite ease-in-out; + width: 1em; + height: 4em; +} + +.txSpinner { + color: #00c88f; + text-indent: -9999em; + margin: 88px auto; + position: relative; + font-size: 11px; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation-delay: -0.16s; + animation-delay: -0.16s; +} + +.txSpinner:before, +.txSpinner:after { + position: absolute; + top: 0; + content: ''; +} + +.txSpinner:before { + left: -1.5em; + -webkit-animation-delay: -0.32s; + animation-delay: -0.32s; +} + +.txSpinner:after { + left: 1.5em; +} + +@-webkit-keyframes txSpinnerLoad { + + 0%, + 80%, + 100% { + box-shadow: 0 0; + height: 4em; + } + + 40% { + box-shadow: 0 -2em; + height: 5em; + } +} + +@keyframes txSpinnerLoad { + + 0%, + 80%, + 100% { + box-shadow: 0 0; + height: 4em; + } + + 40% { + box-shadow: 0 -2em; + height: 5em; + } +} + +/* Necessary for the scrollbar */ +.c-body { + scrollbar-width: thin; + scrollbar-gutter: stable; +} + +.c-main { + padding-top: unset; +} + +.thin-scroll { + overflow-x: hidden; + overflow-y: auto; +} +pre.thin-scroll{ + white-space: pre-wrap; +} + + +/* AutoScroll Toggler */ +#autoScrollDiv { + position: absolute; + bottom: calc((24px * 3) + 12px); + right: calc(1rem + 24px + 18px); +} +#autoScrollDiv a { + padding-top: 80px; +} +#autoScrollDiv a span { + position: absolute; + top: 0; + left: 50%; + width: 24px; + height: 24px; + margin-left: -12px; + border-left: 1px solid #fff; + border-bottom: 1px solid #fff; + -webkit-transform: rotate(-45deg); + transform: rotate(-45deg); + -webkit-animation: autoScrollBtnAnimation 2s infinite; + animation: autoScrollBtnAnimation 2s infinite; + opacity: 0; + box-sizing: border-box; +} +#autoScrollDiv a span:nth-of-type(1) { + -webkit-animation-delay: 0s; + animation-delay: 0s; +} +#autoScrollDiv a span:nth-of-type(2) { + top: 16px; + -webkit-animation-delay: .15s; + animation-delay: .15s; +} +#autoScrollDiv a span:nth-of-type(3) { + top: 32px; + -webkit-animation-delay: .3s; + animation-delay: .3s; +} +@-webkit-keyframes autoScrollBtnAnimation { + 0% { + opacity: 0; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + } +} +@keyframes autoScrollBtnAnimation { + 0% { + opacity: 0; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.switch-lg { + width: 65px; +} + +.switch-lg .c-switch-input:checked~.c-switch-slider::before { + -webkit-transform:translateX(40px); + transform:translateX(40px) +} +.switch-lg .c-switch-slider::after { + width: 65%; +} + + +/* Shadcn patches for the light mode */ +body { + background-color: #0000 !important; +} + +.card { + --tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px -1px rgba(0, 0, 0, 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), + 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} diff --git a/web/public/fonts/Simple-Line-Icons.woff2 b/web/public/fonts/Simple-Line-Icons.woff2 new file mode 100644 index 0000000..c49fccf Binary files /dev/null and b/web/public/fonts/Simple-Line-Icons.woff2 differ diff --git a/web/public/img/default_avatar.png b/web/public/img/default_avatar.png new file mode 100644 index 0000000..46fb6f0 Binary files /dev/null and b/web/public/img/default_avatar.png differ diff --git a/web/public/img/fivem-server-icon.png b/web/public/img/fivem-server-icon.png new file mode 100644 index 0000000..191848d Binary files /dev/null and b/web/public/img/fivem-server-icon.png differ diff --git a/web/public/img/redm-server-icon.png b/web/public/img/redm-server-icon.png new file mode 100644 index 0000000..2fc2ddf Binary files /dev/null and b/web/public/img/redm-server-icon.png differ diff --git a/web/public/img/tx.png b/web/public/img/tx.png new file mode 100644 index 0000000..6dadf9a Binary files /dev/null and b/web/public/img/tx.png differ diff --git a/web/public/img/txSnaily2anim_320.png b/web/public/img/txSnaily2anim_320.png new file mode 100644 index 0000000..13ba458 Binary files /dev/null and b/web/public/img/txSnaily2anim_320.png differ diff --git a/web/public/img/txadmin.png b/web/public/img/txadmin.png new file mode 100644 index 0000000..7d696e0 Binary files /dev/null and b/web/public/img/txadmin.png differ diff --git a/web/public/img/txadmin_beta.png b/web/public/img/txadmin_beta.png new file mode 100644 index 0000000..4228f2c Binary files /dev/null and b/web/public/img/txadmin_beta.png differ diff --git a/web/public/img/unknown-server-icon.png b/web/public/img/unknown-server-icon.png new file mode 100644 index 0000000..8cf8d18 Binary files /dev/null and b/web/public/img/unknown-server-icon.png differ diff --git a/web/public/img/zap256_black.png b/web/public/img/zap256_black.png new file mode 100644 index 0000000..79ce4ed Binary files /dev/null and b/web/public/img/zap256_black.png differ diff --git a/web/public/img/zap256_white.png b/web/public/img/zap256_white.png new file mode 100644 index 0000000..f57aeb9 Binary files /dev/null and b/web/public/img/zap256_white.png differ diff --git a/web/public/js/bootstrap-notify.min.js b/web/public/js/bootstrap-notify.min.js new file mode 100644 index 0000000..f5ad385 --- /dev/null +++ b/web/public/js/bootstrap-notify.min.js @@ -0,0 +1,2 @@ +/* Project: Bootstrap Growl = v3.1.3 | Description: Turns standard Bootstrap alerts into "Growl-like" notifications. | Author: Mouse0270 aka Robert McIntosh | License: MIT License | Website: https://github.com/mouse0270/bootstrap-growl */ +!function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t("object"==typeof exports?require("jquery"):jQuery)}(function(t){function e(e,i,n){var i={content:{message:"object"==typeof i?i.message:i,title:i.title?i.title:"",icon:i.icon?i.icon:"",url:i.url?i.url:"#",target:i.target?i.target:"-"}};n=t.extend(!0,{},i,n),this.settings=t.extend(!0,{},s,n),this._defaults=s,"-"==this.settings.content.target&&(this.settings.content.target=this.settings.url_target),this.animations={start:"webkitAnimationStart oanimationstart MSAnimationStart animationstart",end:"webkitAnimationEnd oanimationend MSAnimationEnd animationend"},"number"==typeof this.settings.offset&&(this.settings.offset={x:this.settings.offset,y:this.settings.offset}),this.init()}var s={element:"body",position:null,type:"info",allow_dismiss:!0,newest_on_top:!1,showProgressbar:!1,placement:{from:"top",align:"right"},offset:20,spacing:10,z_index:1031,delay:5e3,timer:1e3,url_target:"_blank",mouse_over:null,animate:{enter:"animated fadeInDown",exit:"animated fadeOutUp"},onShow:null,onShown:null,onClose:null,onClosed:null,icon_type:"class",template:''};String.format=function(){for(var t=arguments[0],e=1;e .progress-bar').removeClass("progress-bar-"+t.settings.type),t.settings.type=i[e],this.$ele.addClass("alert-"+i[e]).find('[data-notify="progressbar"] > .progress-bar').addClass("progress-bar-"+i[e]);break;case"icon":var n=this.$ele.find('[data-notify="icon"]');"class"==t.settings.icon_type.toLowerCase()?n.removeClass(t.settings.content.icon).addClass(i[e]):(n.is("img")||n.find("img"),n.attr("src",i[e]));break;case"progress":var a=t.settings.delay-t.settings.delay*(i[e]/100);this.$ele.data("notify-delay",a),this.$ele.find('[data-notify="progressbar"] > div').attr("aria-valuenow",i[e]).css("width",i[e]+"%");break;case"url":this.$ele.find('[data-notify="url"]').attr("href",i[e]);break;case"target":this.$ele.find('[data-notify="url"]').attr("target",i[e]);break;default:this.$ele.find('[data-notify="'+e+'"]').html(i[e])}var o=this.$ele.outerHeight()+parseInt(t.settings.spacing)+parseInt(t.settings.offset.y);t.reposition(o)},close:function(){t.close()}}},buildNotify:function(){var e=this.settings.content;this.$ele=t(String.format(this.settings.template,this.settings.type,e.title,e.message,e.url,e.target)),this.$ele.attr("data-notify-position",this.settings.placement.from+"-"+this.settings.placement.align),this.settings.allow_dismiss||this.$ele.find('[data-notify="dismiss"]').css("display","none"),(this.settings.delay<=0&&!this.settings.showProgressbar||!this.settings.showProgressbar)&&this.$ele.find('[data-notify="progressbar"]').remove()},setIcon:function(){"class"==this.settings.icon_type.toLowerCase()?this.$ele.find('[data-notify="icon"]').addClass(this.settings.content.icon):this.$ele.find('[data-notify="icon"]').is("img")?this.$ele.find('[data-notify="icon"]').attr("src",this.settings.content.icon):this.$ele.find('[data-notify="icon"]').append('Notify Icon')},styleURL:function(){this.$ele.find('[data-notify="url"]').css({backgroundImage:"url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)",height:"100%",left:"0px",position:"absolute",top:"0px",width:"100%",zIndex:this.settings.z_index+1}),this.$ele.find('[data-notify="dismiss"]').css({position:"absolute",right:"10px",top:"5px",zIndex:this.settings.z_index+2})},placement:function(){var e=this,s=this.settings.offset.y,i={display:"inline-block",margin:"0px auto",position:this.settings.position?this.settings.position:"body"===this.settings.element?"fixed":"absolute",transition:"all .5s ease-in-out",zIndex:this.settings.z_index},n=!1,a=this.settings;switch(t('[data-notify-position="'+this.settings.placement.from+"-"+this.settings.placement.align+'"]:not([data-closing="true"])').each(function(){return s=Math.max(s,parseInt(t(this).css(a.placement.from))+parseInt(t(this).outerHeight())+parseInt(a.spacing))}),1==this.settings.newest_on_top&&(s=this.settings.offset.y),i[this.settings.placement.from]=s+"px",this.settings.placement.align){case"left":case"right":i[this.settings.placement.align]=this.settings.offset.x+"px";break;case"center":i.left=0,i.right=0}this.$ele.css(i).addClass(this.settings.animate.enter),t.each(Array("webkit","moz","o","ms",""),function(t,s){e.$ele[0].style[s+"AnimationIterationCount"]=1}),t(this.settings.element).append(this.$ele),1==this.settings.newest_on_top&&(s=parseInt(s)+parseInt(this.settings.spacing)+this.$ele.outerHeight(),this.reposition(s)),t.isFunction(e.settings.onShow)&&e.settings.onShow.call(this.$ele),this.$ele.one(this.animations.start,function(){n=!0}).one(this.animations.end,function(){t.isFunction(e.settings.onShown)&&e.settings.onShown.call(this)}),setTimeout(function(){n||t.isFunction(e.settings.onShown)&&e.settings.onShown.call(this)},600)},bind:function(){var e=this;if(this.$ele.find('[data-notify="dismiss"]').on("click",function(){e.close()}),this.$ele.mouseover(function(){t(this).data("data-hover","true")}).mouseout(function(){t(this).data("data-hover","false")}),this.$ele.data("data-hover","false"),this.settings.delay>0){e.$ele.data("notify-delay",e.settings.delay);var s=setInterval(function(){var t=parseInt(e.$ele.data("notify-delay"))-e.settings.timer;if("false"===e.$ele.data("data-hover")&&"pause"==e.settings.mouse_over||"pause"!=e.settings.mouse_over){var i=(e.settings.delay-t)/e.settings.delay*100;e.$ele.data("notify-delay",t),e.$ele.find('[data-notify="progressbar"] > div').attr("aria-valuenow",i).css("width",i+"%")}t<=-e.settings.timer&&(clearInterval(s),e.close())},e.settings.timer)}},close:function(){var e=this,s=parseInt(this.$ele.css(this.settings.placement.from)),i=!1;this.$ele.data("closing","true").addClass(this.settings.animate.exit),e.reposition(s),t.isFunction(e.settings.onClose)&&e.settings.onClose.call(this.$ele),this.$ele.one(this.animations.start,function(){i=!0}).one(this.animations.end,function(){t(this).remove(),t.isFunction(e.settings.onClosed)&&e.settings.onClosed.call(this)}),setTimeout(function(){i||(e.$ele.remove(),e.settings.onClosed&&e.settings.onClosed(e.$ele))},600)},reposition:function(e){var s=this,i='[data-notify-position="'+this.settings.placement.from+"-"+this.settings.placement.align+'"]:not([data-closing="true"])',n=this.$ele.nextAll(i);1==this.settings.newest_on_top&&(n=this.$ele.prevAll(i)),n.each(function(){t(this).css(s.settings.placement.from,e),e=parseInt(e)+parseInt(s.settings.spacing)+t(this).outerHeight()})}}),t.notify=function(t,s){var i=new e(this,t,s);return i.notify},t.notifyDefaults=function(e){return s=t.extend(!0,{},s,e)},t.notifyClose=function(e){"undefined"==typeof e||"all"==e?t("[data-notify]").find('[data-notify="dismiss"]').trigger("click"):t('[data-notify-position="'+e+'"]').find('[data-notify="dismiss"]').trigger("click")}}); \ No newline at end of file diff --git a/web/public/js/codeEditor/autorefresh.js b/web/public/js/codeEditor/autorefresh.js new file mode 100644 index 0000000..df4a3b4 --- /dev/null +++ b/web/public/js/codeEditor/autorefresh.js @@ -0,0 +1,47 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/5/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")) + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod) + else // Plain browser env + mod(CodeMirror) + })(function(CodeMirror) { + "use strict" + + CodeMirror.defineOption("autoRefresh", false, function(cm, val) { + if (cm.state.autoRefresh) { + stopListening(cm, cm.state.autoRefresh) + cm.state.autoRefresh = null + } + if (val && cm.display.wrapper.offsetHeight == 0) + startListening(cm, cm.state.autoRefresh = {delay: val.delay || 250}) + }) + + function startListening(cm, state) { + function check() { + if (cm.display.wrapper.offsetHeight) { + stopListening(cm, state) + if (cm.display.lastWrapHeight != cm.display.wrapper.clientHeight) + cm.refresh() + } else { + state.timeout = setTimeout(check, state.delay) + } + } + state.timeout = setTimeout(check, state.delay) + state.hurry = function() { + clearTimeout(state.timeout) + state.timeout = setTimeout(check, 50) + } + CodeMirror.on(window, "mouseup", state.hurry) + CodeMirror.on(window, "keyup", state.hurry) + } + + function stopListening(_cm, state) { + clearTimeout(state.timeout) + CodeMirror.off(window, "mouseup", state.hurry) + CodeMirror.off(window, "keyup", state.hurry) + } + }); diff --git a/web/public/js/codeEditor/codemirror.js b/web/public/js/codeEditor/codemirror.js new file mode 100644 index 0000000..f23842f --- /dev/null +++ b/web/public/js/codeEditor/codemirror.js @@ -0,0 +1,9765 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +// This is CodeMirror (https://codemirror.net), a code editor +// implemented in JavaScript on top of the browser's DOM. +// +// You can find some technical background for some of the code below +// at http://marijnhaverbeke.nl/blog/#cm-internals . + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.CodeMirror = factory()); +}(this, (function () { 'use strict'; + + // Kludges for bugs and behavior differences that can't be feature + // detected are enabled based on userAgent etc sniffing. + var userAgent = navigator.userAgent; + var platform = navigator.platform; + + var gecko = /gecko\/\d/i.test(userAgent); + var ie_upto10 = /MSIE \d/.test(userAgent); + var ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(userAgent); + var edge = /Edge\/(\d+)/.exec(userAgent); + var ie = ie_upto10 || ie_11up || edge; + var ie_version = ie && (ie_upto10 ? document.documentMode || 6 : +(edge || ie_11up)[1]); + var webkit = !edge && /WebKit\//.test(userAgent); + var qtwebkit = webkit && /Qt\/\d+\.\d+/.test(userAgent); + var chrome = !edge && /Chrome\//.test(userAgent); + var presto = /Opera\//.test(userAgent); + var safari = /Apple Computer/.test(navigator.vendor); + var mac_geMountainLion = /Mac OS X 1\d\D([8-9]|\d\d)\D/.test(userAgent); + var phantom = /PhantomJS/.test(userAgent); + + var ios = !edge && /AppleWebKit/.test(userAgent) && /Mobile\/\w+/.test(userAgent); + var android = /Android/.test(userAgent); + // This is woefully incomplete. Suggestions for alternative methods welcome. + var mobile = ios || android || /webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(userAgent); + var mac = ios || /Mac/.test(platform); + var chromeOS = /\bCrOS\b/.test(userAgent); + var windows = /win/i.test(platform); + + var presto_version = presto && userAgent.match(/Version\/(\d*\.\d*)/); + if (presto_version) { presto_version = Number(presto_version[1]); } + if (presto_version && presto_version >= 15) { presto = false; webkit = true; } + // Some browsers use the wrong event properties to signal cmd/ctrl on OS X + var flipCtrlCmd = mac && (qtwebkit || presto && (presto_version == null || presto_version < 12.11)); + var captureRightClick = gecko || (ie && ie_version >= 9); + + function classTest(cls) { return new RegExp("(^|\\s)" + cls + "(?:$|\\s)\\s*") } + + var rmClass = function(node, cls) { + var current = node.className; + var match = classTest(cls).exec(current); + if (match) { + var after = current.slice(match.index + match[0].length); + node.className = current.slice(0, match.index) + (after ? match[1] + after : ""); + } + }; + + function removeChildren(e) { + for (var count = e.childNodes.length; count > 0; --count) + { e.removeChild(e.firstChild); } + return e + } + + function removeChildrenAndAdd(parent, e) { + return removeChildren(parent).appendChild(e) + } + + function elt(tag, content, className, style) { + var e = document.createElement(tag); + if (className) { e.className = className; } + if (style) { e.style.cssText = style; } + if (typeof content == "string") { e.appendChild(document.createTextNode(content)); } + else if (content) { for (var i = 0; i < content.length; ++i) { e.appendChild(content[i]); } } + return e + } + // wrapper for elt, which removes the elt from the accessibility tree + function eltP(tag, content, className, style) { + var e = elt(tag, content, className, style); + e.setAttribute("role", "presentation"); + return e + } + + var range; + if (document.createRange) { range = function(node, start, end, endNode) { + var r = document.createRange(); + r.setEnd(endNode || node, end); + r.setStart(node, start); + return r + }; } + else { range = function(node, start, end) { + var r = document.body.createTextRange(); + try { r.moveToElementText(node.parentNode); } + catch(e) { return r } + r.collapse(true); + r.moveEnd("character", end); + r.moveStart("character", start); + return r + }; } + + function contains(parent, child) { + if (child.nodeType == 3) // Android browser always returns false when child is a textnode + { child = child.parentNode; } + if (parent.contains) + { return parent.contains(child) } + do { + if (child.nodeType == 11) { child = child.host; } + if (child == parent) { return true } + } while (child = child.parentNode) + } + + function activeElt() { + // IE and Edge may throw an "Unspecified Error" when accessing document.activeElement. + // IE < 10 will throw when accessed while the page is loading or in an iframe. + // IE > 9 and Edge will throw when accessed in an iframe if document.body is unavailable. + var activeElement; + try { + activeElement = document.activeElement; + } catch(e) { + activeElement = document.body || null; + } + while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) + { activeElement = activeElement.shadowRoot.activeElement; } + return activeElement + } + + function addClass(node, cls) { + var current = node.className; + if (!classTest(cls).test(current)) { node.className += (current ? " " : "") + cls; } + } + function joinClasses(a, b) { + var as = a.split(" "); + for (var i = 0; i < as.length; i++) + { if (as[i] && !classTest(as[i]).test(b)) { b += " " + as[i]; } } + return b + } + + var selectInput = function(node) { node.select(); }; + if (ios) // Mobile Safari apparently has a bug where select() is broken. + { selectInput = function(node) { node.selectionStart = 0; node.selectionEnd = node.value.length; }; } + else if (ie) // Suppress mysterious IE10 errors + { selectInput = function(node) { try { node.select(); } catch(_e) {} }; } + + function bind(f) { + var args = Array.prototype.slice.call(arguments, 1); + return function(){return f.apply(null, args)} + } + + function copyObj(obj, target, overwrite) { + if (!target) { target = {}; } + for (var prop in obj) + { if (obj.hasOwnProperty(prop) && (overwrite !== false || !target.hasOwnProperty(prop))) + { target[prop] = obj[prop]; } } + return target + } + + // Counts the column offset in a string, taking tabs into account. + // Used mostly to find indentation. + function countColumn(string, end, tabSize, startIndex, startValue) { + if (end == null) { + end = string.search(/[^\s\u00a0]/); + if (end == -1) { end = string.length; } + } + for (var i = startIndex || 0, n = startValue || 0;;) { + var nextTab = string.indexOf("\t", i); + if (nextTab < 0 || nextTab >= end) + { return n + (end - i) } + n += nextTab - i; + n += tabSize - (n % tabSize); + i = nextTab + 1; + } + } + + var Delayed = function() {this.id = null;}; + Delayed.prototype.set = function (ms, f) { + clearTimeout(this.id); + this.id = setTimeout(f, ms); + }; + + function indexOf(array, elt) { + for (var i = 0; i < array.length; ++i) + { if (array[i] == elt) { return i } } + return -1 + } + + // Number of pixels added to scroller and sizer to hide scrollbar + var scrollerGap = 30; + + // Returned or thrown by various protocols to signal 'I'm not + // handling this'. + var Pass = {toString: function(){return "CodeMirror.Pass"}}; + + // Reused option objects for setSelection & friends + var sel_dontScroll = {scroll: false}, sel_mouse = {origin: "*mouse"}, sel_move = {origin: "+move"}; + + // The inverse of countColumn -- find the offset that corresponds to + // a particular column. + function findColumn(string, goal, tabSize) { + for (var pos = 0, col = 0;;) { + var nextTab = string.indexOf("\t", pos); + if (nextTab == -1) { nextTab = string.length; } + var skipped = nextTab - pos; + if (nextTab == string.length || col + skipped >= goal) + { return pos + Math.min(skipped, goal - col) } + col += nextTab - pos; + col += tabSize - (col % tabSize); + pos = nextTab + 1; + if (col >= goal) { return pos } + } + } + + var spaceStrs = [""]; + function spaceStr(n) { + while (spaceStrs.length <= n) + { spaceStrs.push(lst(spaceStrs) + " "); } + return spaceStrs[n] + } + + function lst(arr) { return arr[arr.length-1] } + + function map(array, f) { + var out = []; + for (var i = 0; i < array.length; i++) { out[i] = f(array[i], i); } + return out + } + + function insertSorted(array, value, score) { + var pos = 0, priority = score(value); + while (pos < array.length && score(array[pos]) <= priority) { pos++; } + array.splice(pos, 0, value); + } + + function nothing() {} + + function createObj(base, props) { + var inst; + if (Object.create) { + inst = Object.create(base); + } else { + nothing.prototype = base; + inst = new nothing(); + } + if (props) { copyObj(props, inst); } + return inst + } + + var nonASCIISingleCaseWordChar = /[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/; + function isWordCharBasic(ch) { + return /\w/.test(ch) || ch > "\x80" && + (ch.toUpperCase() != ch.toLowerCase() || nonASCIISingleCaseWordChar.test(ch)) + } + function isWordChar(ch, helper) { + if (!helper) { return isWordCharBasic(ch) } + if (helper.source.indexOf("\\w") > -1 && isWordCharBasic(ch)) { return true } + return helper.test(ch) + } + + function isEmpty(obj) { + for (var n in obj) { if (obj.hasOwnProperty(n) && obj[n]) { return false } } + return true + } + + // Extending unicode characters. A series of a non-extending char + + // any number of extending chars is treated as a single unit as far + // as editing and measuring is concerned. This is not fully correct, + // since some scripts/fonts/browsers also treat other configurations + // of code points as a group. + var extendingChars = /[\u0300-\u036f\u0483-\u0489\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u065e\u0670\u06d6-\u06dc\u06de-\u06e4\u06e7\u06e8\u06ea-\u06ed\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0900-\u0902\u093c\u0941-\u0948\u094d\u0951-\u0955\u0962\u0963\u0981\u09bc\u09be\u09c1-\u09c4\u09cd\u09d7\u09e2\u09e3\u0a01\u0a02\u0a3c\u0a41\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a70\u0a71\u0a75\u0a81\u0a82\u0abc\u0ac1-\u0ac5\u0ac7\u0ac8\u0acd\u0ae2\u0ae3\u0b01\u0b3c\u0b3e\u0b3f\u0b41-\u0b44\u0b4d\u0b56\u0b57\u0b62\u0b63\u0b82\u0bbe\u0bc0\u0bcd\u0bd7\u0c3e-\u0c40\u0c46-\u0c48\u0c4a-\u0c4d\u0c55\u0c56\u0c62\u0c63\u0cbc\u0cbf\u0cc2\u0cc6\u0ccc\u0ccd\u0cd5\u0cd6\u0ce2\u0ce3\u0d3e\u0d41-\u0d44\u0d4d\u0d57\u0d62\u0d63\u0dca\u0dcf\u0dd2-\u0dd4\u0dd6\u0ddf\u0e31\u0e34-\u0e3a\u0e47-\u0e4e\u0eb1\u0eb4-\u0eb9\u0ebb\u0ebc\u0ec8-\u0ecd\u0f18\u0f19\u0f35\u0f37\u0f39\u0f71-\u0f7e\u0f80-\u0f84\u0f86\u0f87\u0f90-\u0f97\u0f99-\u0fbc\u0fc6\u102d-\u1030\u1032-\u1037\u1039\u103a\u103d\u103e\u1058\u1059\u105e-\u1060\u1071-\u1074\u1082\u1085\u1086\u108d\u109d\u135f\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17b7-\u17bd\u17c6\u17c9-\u17d3\u17dd\u180b-\u180d\u18a9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193b\u1a17\u1a18\u1a56\u1a58-\u1a5e\u1a60\u1a62\u1a65-\u1a6c\u1a73-\u1a7c\u1a7f\u1b00-\u1b03\u1b34\u1b36-\u1b3a\u1b3c\u1b42\u1b6b-\u1b73\u1b80\u1b81\u1ba2-\u1ba5\u1ba8\u1ba9\u1c2c-\u1c33\u1c36\u1c37\u1cd0-\u1cd2\u1cd4-\u1ce0\u1ce2-\u1ce8\u1ced\u1dc0-\u1de6\u1dfd-\u1dff\u200c\u200d\u20d0-\u20f0\u2cef-\u2cf1\u2de0-\u2dff\u302a-\u302f\u3099\u309a\ua66f-\ua672\ua67c\ua67d\ua6f0\ua6f1\ua802\ua806\ua80b\ua825\ua826\ua8c4\ua8e0-\ua8f1\ua926-\ua92d\ua947-\ua951\ua980-\ua982\ua9b3\ua9b6-\ua9b9\ua9bc\uaa29-\uaa2e\uaa31\uaa32\uaa35\uaa36\uaa43\uaa4c\uaab0\uaab2-\uaab4\uaab7\uaab8\uaabe\uaabf\uaac1\uabe5\uabe8\uabed\udc00-\udfff\ufb1e\ufe00-\ufe0f\ufe20-\ufe26\uff9e\uff9f]/; + function isExtendingChar(ch) { return ch.charCodeAt(0) >= 768 && extendingChars.test(ch) } + + // Returns a number from the range [`0`; `str.length`] unless `pos` is outside that range. + function skipExtendingChars(str, pos, dir) { + while ((dir < 0 ? pos > 0 : pos < str.length) && isExtendingChar(str.charAt(pos))) { pos += dir; } + return pos + } + + // Returns the value from the range [`from`; `to`] that satisfies + // `pred` and is closest to `from`. Assumes that at least `to` + // satisfies `pred`. Supports `from` being greater than `to`. + function findFirst(pred, from, to) { + // At any point we are certain `to` satisfies `pred`, don't know + // whether `from` does. + var dir = from > to ? -1 : 1; + for (;;) { + if (from == to) { return from } + var midF = (from + to) / 2, mid = dir < 0 ? Math.ceil(midF) : Math.floor(midF); + if (mid == from) { return pred(mid) ? from : to } + if (pred(mid)) { to = mid; } + else { from = mid + dir; } + } + } + + // BIDI HELPERS + + function iterateBidiSections(order, from, to, f) { + if (!order) { return f(from, to, "ltr", 0) } + var found = false; + for (var i = 0; i < order.length; ++i) { + var part = order[i]; + if (part.from < to && part.to > from || from == to && part.to == from) { + f(Math.max(part.from, from), Math.min(part.to, to), part.level == 1 ? "rtl" : "ltr", i); + found = true; + } + } + if (!found) { f(from, to, "ltr"); } + } + + var bidiOther = null; + function getBidiPartAt(order, ch, sticky) { + var found; + bidiOther = null; + for (var i = 0; i < order.length; ++i) { + var cur = order[i]; + if (cur.from < ch && cur.to > ch) { return i } + if (cur.to == ch) { + if (cur.from != cur.to && sticky == "before") { found = i; } + else { bidiOther = i; } + } + if (cur.from == ch) { + if (cur.from != cur.to && sticky != "before") { found = i; } + else { bidiOther = i; } + } + } + return found != null ? found : bidiOther + } + + // Bidirectional ordering algorithm + // See http://unicode.org/reports/tr9/tr9-13.html for the algorithm + // that this (partially) implements. + + // One-char codes used for character types: + // L (L): Left-to-Right + // R (R): Right-to-Left + // r (AL): Right-to-Left Arabic + // 1 (EN): European Number + // + (ES): European Number Separator + // % (ET): European Number Terminator + // n (AN): Arabic Number + // , (CS): Common Number Separator + // m (NSM): Non-Spacing Mark + // b (BN): Boundary Neutral + // s (B): Paragraph Separator + // t (S): Segment Separator + // w (WS): Whitespace + // N (ON): Other Neutrals + + // Returns null if characters are ordered as they appear + // (left-to-right), or an array of sections ({from, to, level} + // objects) in the order in which they occur visually. + var bidiOrdering = (function() { + // Character types for codepoints 0 to 0xff + var lowTypes = "bbbbbbbbbtstwsbbbbbbbbbbbbbbssstwNN%%%NNNNNN,N,N1111111111NNNNNNNLLLLLLLLLLLLLLLLLLLLLLLLLLNNNNNNLLLLLLLLLLLLLLLLLLLLLLLLLLNNNNbbbbbbsbbbbbbbbbbbbbbbbbbbbbbbbbb,N%%%%NNNNLNNNNN%%11NLNNN1LNNNNNLLLLLLLLLLLLLLLLLLLLLLLNLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLN"; + // Character types for codepoints 0x600 to 0x6f9 + var arabicTypes = "nnnnnnNNr%%r,rNNmmmmmmmmmmmrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrmmmmmmmmmmmmmmmmmmmmmnnnnnnnnnn%nnrrrmrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrmmmmmmmnNmmmmmmrrmmNmmmmrr1111111111"; + function charType(code) { + if (code <= 0xf7) { return lowTypes.charAt(code) } + else if (0x590 <= code && code <= 0x5f4) { return "R" } + else if (0x600 <= code && code <= 0x6f9) { return arabicTypes.charAt(code - 0x600) } + else if (0x6ee <= code && code <= 0x8ac) { return "r" } + else if (0x2000 <= code && code <= 0x200b) { return "w" } + else if (code == 0x200c) { return "b" } + else { return "L" } + } + + var bidiRE = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/; + var isNeutral = /[stwN]/, isStrong = /[LRr]/, countsAsLeft = /[Lb1n]/, countsAsNum = /[1n]/; + + function BidiSpan(level, from, to) { + this.level = level; + this.from = from; this.to = to; + } + + return function(str, direction) { + var outerType = direction == "ltr" ? "L" : "R"; + + if (str.length == 0 || direction == "ltr" && !bidiRE.test(str)) { return false } + var len = str.length, types = []; + for (var i = 0; i < len; ++i) + { types.push(charType(str.charCodeAt(i))); } + + // W1. Examine each non-spacing mark (NSM) in the level run, and + // change the type of the NSM to the type of the previous + // character. If the NSM is at the start of the level run, it will + // get the type of sor. + for (var i$1 = 0, prev = outerType; i$1 < len; ++i$1) { + var type = types[i$1]; + if (type == "m") { types[i$1] = prev; } + else { prev = type; } + } + + // W2. Search backwards from each instance of a European number + // until the first strong type (R, L, AL, or sor) is found. If an + // AL is found, change the type of the European number to Arabic + // number. + // W3. Change all ALs to R. + for (var i$2 = 0, cur = outerType; i$2 < len; ++i$2) { + var type$1 = types[i$2]; + if (type$1 == "1" && cur == "r") { types[i$2] = "n"; } + else if (isStrong.test(type$1)) { cur = type$1; if (type$1 == "r") { types[i$2] = "R"; } } + } + + // W4. A single European separator between two European numbers + // changes to a European number. A single common separator between + // two numbers of the same type changes to that type. + for (var i$3 = 1, prev$1 = types[0]; i$3 < len - 1; ++i$3) { + var type$2 = types[i$3]; + if (type$2 == "+" && prev$1 == "1" && types[i$3+1] == "1") { types[i$3] = "1"; } + else if (type$2 == "," && prev$1 == types[i$3+1] && + (prev$1 == "1" || prev$1 == "n")) { types[i$3] = prev$1; } + prev$1 = type$2; + } + + // W5. A sequence of European terminators adjacent to European + // numbers changes to all European numbers. + // W6. Otherwise, separators and terminators change to Other + // Neutral. + for (var i$4 = 0; i$4 < len; ++i$4) { + var type$3 = types[i$4]; + if (type$3 == ",") { types[i$4] = "N"; } + else if (type$3 == "%") { + var end = (void 0); + for (end = i$4 + 1; end < len && types[end] == "%"; ++end) {} + var replace = (i$4 && types[i$4-1] == "!") || (end < len && types[end] == "1") ? "1" : "N"; + for (var j = i$4; j < end; ++j) { types[j] = replace; } + i$4 = end - 1; + } + } + + // W7. Search backwards from each instance of a European number + // until the first strong type (R, L, or sor) is found. If an L is + // found, then change the type of the European number to L. + for (var i$5 = 0, cur$1 = outerType; i$5 < len; ++i$5) { + var type$4 = types[i$5]; + if (cur$1 == "L" && type$4 == "1") { types[i$5] = "L"; } + else if (isStrong.test(type$4)) { cur$1 = type$4; } + } + + // N1. A sequence of neutrals takes the direction of the + // surrounding strong text if the text on both sides has the same + // direction. European and Arabic numbers act as if they were R in + // terms of their influence on neutrals. Start-of-level-run (sor) + // and end-of-level-run (eor) are used at level run boundaries. + // N2. Any remaining neutrals take the embedding direction. + for (var i$6 = 0; i$6 < len; ++i$6) { + if (isNeutral.test(types[i$6])) { + var end$1 = (void 0); + for (end$1 = i$6 + 1; end$1 < len && isNeutral.test(types[end$1]); ++end$1) {} + var before = (i$6 ? types[i$6-1] : outerType) == "L"; + var after = (end$1 < len ? types[end$1] : outerType) == "L"; + var replace$1 = before == after ? (before ? "L" : "R") : outerType; + for (var j$1 = i$6; j$1 < end$1; ++j$1) { types[j$1] = replace$1; } + i$6 = end$1 - 1; + } + } + + // Here we depart from the documented algorithm, in order to avoid + // building up an actual levels array. Since there are only three + // levels (0, 1, 2) in an implementation that doesn't take + // explicit embedding into account, we can build up the order on + // the fly, without following the level-based algorithm. + var order = [], m; + for (var i$7 = 0; i$7 < len;) { + if (countsAsLeft.test(types[i$7])) { + var start = i$7; + for (++i$7; i$7 < len && countsAsLeft.test(types[i$7]); ++i$7) {} + order.push(new BidiSpan(0, start, i$7)); + } else { + var pos = i$7, at = order.length; + for (++i$7; i$7 < len && types[i$7] != "L"; ++i$7) {} + for (var j$2 = pos; j$2 < i$7;) { + if (countsAsNum.test(types[j$2])) { + if (pos < j$2) { order.splice(at, 0, new BidiSpan(1, pos, j$2)); } + var nstart = j$2; + for (++j$2; j$2 < i$7 && countsAsNum.test(types[j$2]); ++j$2) {} + order.splice(at, 0, new BidiSpan(2, nstart, j$2)); + pos = j$2; + } else { ++j$2; } + } + if (pos < i$7) { order.splice(at, 0, new BidiSpan(1, pos, i$7)); } + } + } + if (direction == "ltr") { + if (order[0].level == 1 && (m = str.match(/^\s+/))) { + order[0].from = m[0].length; + order.unshift(new BidiSpan(0, 0, m[0].length)); + } + if (lst(order).level == 1 && (m = str.match(/\s+$/))) { + lst(order).to -= m[0].length; + order.push(new BidiSpan(0, len - m[0].length, len)); + } + } + + return direction == "rtl" ? order.reverse() : order + } + })(); + + // Get the bidi ordering for the given line (and cache it). Returns + // false for lines that are fully left-to-right, and an array of + // BidiSpan objects otherwise. + function getOrder(line, direction) { + var order = line.order; + if (order == null) { order = line.order = bidiOrdering(line.text, direction); } + return order + } + + // EVENT HANDLING + + // Lightweight event framework. on/off also work on DOM nodes, + // registering native DOM handlers. + + var noHandlers = []; + + var on = function(emitter, type, f) { + if (emitter.addEventListener) { + emitter.addEventListener(type, f, false); + } else if (emitter.attachEvent) { + emitter.attachEvent("on" + type, f); + } else { + var map$$1 = emitter._handlers || (emitter._handlers = {}); + map$$1[type] = (map$$1[type] || noHandlers).concat(f); + } + }; + + function getHandlers(emitter, type) { + return emitter._handlers && emitter._handlers[type] || noHandlers + } + + function off(emitter, type, f) { + if (emitter.removeEventListener) { + emitter.removeEventListener(type, f, false); + } else if (emitter.detachEvent) { + emitter.detachEvent("on" + type, f); + } else { + var map$$1 = emitter._handlers, arr = map$$1 && map$$1[type]; + if (arr) { + var index = indexOf(arr, f); + if (index > -1) + { map$$1[type] = arr.slice(0, index).concat(arr.slice(index + 1)); } + } + } + } + + function signal(emitter, type /*, values...*/) { + var handlers = getHandlers(emitter, type); + if (!handlers.length) { return } + var args = Array.prototype.slice.call(arguments, 2); + for (var i = 0; i < handlers.length; ++i) { handlers[i].apply(null, args); } + } + + // The DOM events that CodeMirror handles can be overridden by + // registering a (non-DOM) handler on the editor for the event name, + // and preventDefault-ing the event in that handler. + function signalDOMEvent(cm, e, override) { + if (typeof e == "string") + { e = {type: e, preventDefault: function() { this.defaultPrevented = true; }}; } + signal(cm, override || e.type, cm, e); + return e_defaultPrevented(e) || e.codemirrorIgnore + } + + function signalCursorActivity(cm) { + var arr = cm._handlers && cm._handlers.cursorActivity; + if (!arr) { return } + var set = cm.curOp.cursorActivityHandlers || (cm.curOp.cursorActivityHandlers = []); + for (var i = 0; i < arr.length; ++i) { if (indexOf(set, arr[i]) == -1) + { set.push(arr[i]); } } + } + + function hasHandler(emitter, type) { + return getHandlers(emitter, type).length > 0 + } + + // Add on and off methods to a constructor's prototype, to make + // registering events on such objects more convenient. + function eventMixin(ctor) { + ctor.prototype.on = function(type, f) {on(this, type, f);}; + ctor.prototype.off = function(type, f) {off(this, type, f);}; + } + + // Due to the fact that we still support jurassic IE versions, some + // compatibility wrappers are needed. + + function e_preventDefault(e) { + if (e.preventDefault) { e.preventDefault(); } + else { e.returnValue = false; } + } + function e_stopPropagation(e) { + if (e.stopPropagation) { e.stopPropagation(); } + else { e.cancelBubble = true; } + } + function e_defaultPrevented(e) { + return e.defaultPrevented != null ? e.defaultPrevented : e.returnValue == false + } + function e_stop(e) {e_preventDefault(e); e_stopPropagation(e);} + + function e_target(e) {return e.target || e.srcElement} + function e_button(e) { + var b = e.which; + if (b == null) { + if (e.button & 1) { b = 1; } + else if (e.button & 2) { b = 3; } + else if (e.button & 4) { b = 2; } + } + if (mac && e.ctrlKey && b == 1) { b = 3; } + return b + } + + // Detect drag-and-drop + var dragAndDrop = function() { + // There is *some* kind of drag-and-drop support in IE6-8, but I + // couldn't get it to work yet. + if (ie && ie_version < 9) { return false } + var div = elt('div'); + return "draggable" in div || "dragDrop" in div + }(); + + var zwspSupported; + function zeroWidthElement(measure) { + if (zwspSupported == null) { + var test = elt("span", "\u200b"); + removeChildrenAndAdd(measure, elt("span", [test, document.createTextNode("x")])); + if (measure.firstChild.offsetHeight != 0) + { zwspSupported = test.offsetWidth <= 1 && test.offsetHeight > 2 && !(ie && ie_version < 8); } + } + var node = zwspSupported ? elt("span", "\u200b") : + elt("span", "\u00a0", null, "display: inline-block; width: 1px; margin-right: -1px"); + node.setAttribute("cm-text", ""); + return node + } + + // Feature-detect IE's crummy client rect reporting for bidi text + var badBidiRects; + function hasBadBidiRects(measure) { + if (badBidiRects != null) { return badBidiRects } + var txt = removeChildrenAndAdd(measure, document.createTextNode("A\u062eA")); + var r0 = range(txt, 0, 1).getBoundingClientRect(); + var r1 = range(txt, 1, 2).getBoundingClientRect(); + removeChildren(measure); + if (!r0 || r0.left == r0.right) { return false } // Safari returns null in some cases (#2780) + return badBidiRects = (r1.right - r0.right < 3) + } + + // See if "".split is the broken IE version, if so, provide an + // alternative way to split lines. + var splitLinesAuto = "\n\nb".split(/\n/).length != 3 ? function (string) { + var pos = 0, result = [], l = string.length; + while (pos <= l) { + var nl = string.indexOf("\n", pos); + if (nl == -1) { nl = string.length; } + var line = string.slice(pos, string.charAt(nl - 1) == "\r" ? nl - 1 : nl); + var rt = line.indexOf("\r"); + if (rt != -1) { + result.push(line.slice(0, rt)); + pos += rt + 1; + } else { + result.push(line); + pos = nl + 1; + } + } + return result + } : function (string) { return string.split(/\r\n?|\n/); }; + + var hasSelection = window.getSelection ? function (te) { + try { return te.selectionStart != te.selectionEnd } + catch(e) { return false } + } : function (te) { + var range$$1; + try {range$$1 = te.ownerDocument.selection.createRange();} + catch(e) {} + if (!range$$1 || range$$1.parentElement() != te) { return false } + return range$$1.compareEndPoints("StartToEnd", range$$1) != 0 + }; + + var hasCopyEvent = (function () { + var e = elt("div"); + if ("oncopy" in e) { return true } + e.setAttribute("oncopy", "return;"); + return typeof e.oncopy == "function" + })(); + + var badZoomedRects = null; + function hasBadZoomedRects(measure) { + if (badZoomedRects != null) { return badZoomedRects } + var node = removeChildrenAndAdd(measure, elt("span", "x")); + var normal = node.getBoundingClientRect(); + var fromRange = range(node, 0, 1).getBoundingClientRect(); + return badZoomedRects = Math.abs(normal.left - fromRange.left) > 1 + } + + // Known modes, by name and by MIME + var modes = {}, mimeModes = {}; + + // Extra arguments are stored as the mode's dependencies, which is + // used by (legacy) mechanisms like loadmode.js to automatically + // load a mode. (Preferred mechanism is the require/define calls.) + function defineMode(name, mode) { + if (arguments.length > 2) + { mode.dependencies = Array.prototype.slice.call(arguments, 2); } + modes[name] = mode; + } + + function defineMIME(mime, spec) { + mimeModes[mime] = spec; + } + + // Given a MIME type, a {name, ...options} config object, or a name + // string, return a mode config object. + function resolveMode(spec) { + if (typeof spec == "string" && mimeModes.hasOwnProperty(spec)) { + spec = mimeModes[spec]; + } else if (spec && typeof spec.name == "string" && mimeModes.hasOwnProperty(spec.name)) { + var found = mimeModes[spec.name]; + if (typeof found == "string") { found = {name: found}; } + spec = createObj(found, spec); + spec.name = found.name; + } else if (typeof spec == "string" && /^[\w\-]+\/[\w\-]+\+xml$/.test(spec)) { + return resolveMode("application/xml") + } else if (typeof spec == "string" && /^[\w\-]+\/[\w\-]+\+json$/.test(spec)) { + return resolveMode("application/json") + } + if (typeof spec == "string") { return {name: spec} } + else { return spec || {name: "null"} } + } + + // Given a mode spec (anything that resolveMode accepts), find and + // initialize an actual mode object. + function getMode(options, spec) { + spec = resolveMode(spec); + var mfactory = modes[spec.name]; + if (!mfactory) { return getMode(options, "text/plain") } + var modeObj = mfactory(options, spec); + if (modeExtensions.hasOwnProperty(spec.name)) { + var exts = modeExtensions[spec.name]; + for (var prop in exts) { + if (!exts.hasOwnProperty(prop)) { continue } + if (modeObj.hasOwnProperty(prop)) { modeObj["_" + prop] = modeObj[prop]; } + modeObj[prop] = exts[prop]; + } + } + modeObj.name = spec.name; + if (spec.helperType) { modeObj.helperType = spec.helperType; } + if (spec.modeProps) { for (var prop$1 in spec.modeProps) + { modeObj[prop$1] = spec.modeProps[prop$1]; } } + + return modeObj + } + + // This can be used to attach properties to mode objects from + // outside the actual mode definition. + var modeExtensions = {}; + function extendMode(mode, properties) { + var exts = modeExtensions.hasOwnProperty(mode) ? modeExtensions[mode] : (modeExtensions[mode] = {}); + copyObj(properties, exts); + } + + function copyState(mode, state) { + if (state === true) { return state } + if (mode.copyState) { return mode.copyState(state) } + var nstate = {}; + for (var n in state) { + var val = state[n]; + if (val instanceof Array) { val = val.concat([]); } + nstate[n] = val; + } + return nstate + } + + // Given a mode and a state (for that mode), find the inner mode and + // state at the position that the state refers to. + function innerMode(mode, state) { + var info; + while (mode.innerMode) { + info = mode.innerMode(state); + if (!info || info.mode == mode) { break } + state = info.state; + mode = info.mode; + } + return info || {mode: mode, state: state} + } + + function startState(mode, a1, a2) { + return mode.startState ? mode.startState(a1, a2) : true + } + + // STRING STREAM + + // Fed to the mode parsers, provides helper functions to make + // parsers more succinct. + + var StringStream = function(string, tabSize, lineOracle) { + this.pos = this.start = 0; + this.string = string; + this.tabSize = tabSize || 8; + this.lastColumnPos = this.lastColumnValue = 0; + this.lineStart = 0; + this.lineOracle = lineOracle; + }; + + StringStream.prototype.eol = function () {return this.pos >= this.string.length}; + StringStream.prototype.sol = function () {return this.pos == this.lineStart}; + StringStream.prototype.peek = function () {return this.string.charAt(this.pos) || undefined}; + StringStream.prototype.next = function () { + if (this.pos < this.string.length) + { return this.string.charAt(this.pos++) } + }; + StringStream.prototype.eat = function (match) { + var ch = this.string.charAt(this.pos); + var ok; + if (typeof match == "string") { ok = ch == match; } + else { ok = ch && (match.test ? match.test(ch) : match(ch)); } + if (ok) {++this.pos; return ch} + }; + StringStream.prototype.eatWhile = function (match) { + var start = this.pos; + while (this.eat(match)){} + return this.pos > start + }; + StringStream.prototype.eatSpace = function () { + var this$1 = this; + + var start = this.pos; + while (/[\s\u00a0]/.test(this.string.charAt(this.pos))) { ++this$1.pos; } + return this.pos > start + }; + StringStream.prototype.skipToEnd = function () {this.pos = this.string.length;}; + StringStream.prototype.skipTo = function (ch) { + var found = this.string.indexOf(ch, this.pos); + if (found > -1) {this.pos = found; return true} + }; + StringStream.prototype.backUp = function (n) {this.pos -= n;}; + StringStream.prototype.column = function () { + if (this.lastColumnPos < this.start) { + this.lastColumnValue = countColumn(this.string, this.start, this.tabSize, this.lastColumnPos, this.lastColumnValue); + this.lastColumnPos = this.start; + } + return this.lastColumnValue - (this.lineStart ? countColumn(this.string, this.lineStart, this.tabSize) : 0) + }; + StringStream.prototype.indentation = function () { + return countColumn(this.string, null, this.tabSize) - + (this.lineStart ? countColumn(this.string, this.lineStart, this.tabSize) : 0) + }; + StringStream.prototype.match = function (pattern, consume, caseInsensitive) { + if (typeof pattern == "string") { + var cased = function (str) { return caseInsensitive ? str.toLowerCase() : str; }; + var substr = this.string.substr(this.pos, pattern.length); + if (cased(substr) == cased(pattern)) { + if (consume !== false) { this.pos += pattern.length; } + return true + } + } else { + var match = this.string.slice(this.pos).match(pattern); + if (match && match.index > 0) { return null } + if (match && consume !== false) { this.pos += match[0].length; } + return match + } + }; + StringStream.prototype.current = function (){return this.string.slice(this.start, this.pos)}; + StringStream.prototype.hideFirstChars = function (n, inner) { + this.lineStart += n; + try { return inner() } + finally { this.lineStart -= n; } + }; + StringStream.prototype.lookAhead = function (n) { + var oracle = this.lineOracle; + return oracle && oracle.lookAhead(n) + }; + StringStream.prototype.baseToken = function () { + var oracle = this.lineOracle; + return oracle && oracle.baseToken(this.pos) + }; + + // Find the line object corresponding to the given line number. + function getLine(doc, n) { + n -= doc.first; + if (n < 0 || n >= doc.size) { throw new Error("There is no line " + (n + doc.first) + " in the document.") } + var chunk = doc; + while (!chunk.lines) { + for (var i = 0;; ++i) { + var child = chunk.children[i], sz = child.chunkSize(); + if (n < sz) { chunk = child; break } + n -= sz; + } + } + return chunk.lines[n] + } + + // Get the part of a document between two positions, as an array of + // strings. + function getBetween(doc, start, end) { + var out = [], n = start.line; + doc.iter(start.line, end.line + 1, function (line) { + var text = line.text; + if (n == end.line) { text = text.slice(0, end.ch); } + if (n == start.line) { text = text.slice(start.ch); } + out.push(text); + ++n; + }); + return out + } + // Get the lines between from and to, as array of strings. + function getLines(doc, from, to) { + var out = []; + doc.iter(from, to, function (line) { out.push(line.text); }); // iter aborts when callback returns truthy value + return out + } + + // Update the height of a line, propagating the height change + // upwards to parent nodes. + function updateLineHeight(line, height) { + var diff = height - line.height; + if (diff) { for (var n = line; n; n = n.parent) { n.height += diff; } } + } + + // Given a line object, find its line number by walking up through + // its parent links. + function lineNo(line) { + if (line.parent == null) { return null } + var cur = line.parent, no = indexOf(cur.lines, line); + for (var chunk = cur.parent; chunk; cur = chunk, chunk = chunk.parent) { + for (var i = 0;; ++i) { + if (chunk.children[i] == cur) { break } + no += chunk.children[i].chunkSize(); + } + } + return no + cur.first + } + + // Find the line at the given vertical position, using the height + // information in the document tree. + function lineAtHeight(chunk, h) { + var n = chunk.first; + outer: do { + for (var i$1 = 0; i$1 < chunk.children.length; ++i$1) { + var child = chunk.children[i$1], ch = child.height; + if (h < ch) { chunk = child; continue outer } + h -= ch; + n += child.chunkSize(); + } + return n + } while (!chunk.lines) + var i = 0; + for (; i < chunk.lines.length; ++i) { + var line = chunk.lines[i], lh = line.height; + if (h < lh) { break } + h -= lh; + } + return n + i + } + + function isLine(doc, l) {return l >= doc.first && l < doc.first + doc.size} + + function lineNumberFor(options, i) { + return String(options.lineNumberFormatter(i + options.firstLineNumber)) + } + + // A Pos instance represents a position within the text. + function Pos(line, ch, sticky) { + if ( sticky === void 0 ) sticky = null; + + if (!(this instanceof Pos)) { return new Pos(line, ch, sticky) } + this.line = line; + this.ch = ch; + this.sticky = sticky; + } + + // Compare two positions, return 0 if they are the same, a negative + // number when a is less, and a positive number otherwise. + function cmp(a, b) { return a.line - b.line || a.ch - b.ch } + + function equalCursorPos(a, b) { return a.sticky == b.sticky && cmp(a, b) == 0 } + + function copyPos(x) {return Pos(x.line, x.ch)} + function maxPos(a, b) { return cmp(a, b) < 0 ? b : a } + function minPos(a, b) { return cmp(a, b) < 0 ? a : b } + + // Most of the external API clips given positions to make sure they + // actually exist within the document. + function clipLine(doc, n) {return Math.max(doc.first, Math.min(n, doc.first + doc.size - 1))} + function clipPos(doc, pos) { + if (pos.line < doc.first) { return Pos(doc.first, 0) } + var last = doc.first + doc.size - 1; + if (pos.line > last) { return Pos(last, getLine(doc, last).text.length) } + return clipToLen(pos, getLine(doc, pos.line).text.length) + } + function clipToLen(pos, linelen) { + var ch = pos.ch; + if (ch == null || ch > linelen) { return Pos(pos.line, linelen) } + else if (ch < 0) { return Pos(pos.line, 0) } + else { return pos } + } + function clipPosArray(doc, array) { + var out = []; + for (var i = 0; i < array.length; i++) { out[i] = clipPos(doc, array[i]); } + return out + } + + var SavedContext = function(state, lookAhead) { + this.state = state; + this.lookAhead = lookAhead; + }; + + var Context = function(doc, state, line, lookAhead) { + this.state = state; + this.doc = doc; + this.line = line; + this.maxLookAhead = lookAhead || 0; + this.baseTokens = null; + this.baseTokenPos = 1; + }; + + Context.prototype.lookAhead = function (n) { + var line = this.doc.getLine(this.line + n); + if (line != null && n > this.maxLookAhead) { this.maxLookAhead = n; } + return line + }; + + Context.prototype.baseToken = function (n) { + var this$1 = this; + + if (!this.baseTokens) { return null } + while (this.baseTokens[this.baseTokenPos] <= n) + { this$1.baseTokenPos += 2; } + var type = this.baseTokens[this.baseTokenPos + 1]; + return {type: type && type.replace(/( |^)overlay .*/, ""), + size: this.baseTokens[this.baseTokenPos] - n} + }; + + Context.prototype.nextLine = function () { + this.line++; + if (this.maxLookAhead > 0) { this.maxLookAhead--; } + }; + + Context.fromSaved = function (doc, saved, line) { + if (saved instanceof SavedContext) + { return new Context(doc, copyState(doc.mode, saved.state), line, saved.lookAhead) } + else + { return new Context(doc, copyState(doc.mode, saved), line) } + }; + + Context.prototype.save = function (copy) { + var state = copy !== false ? copyState(this.doc.mode, this.state) : this.state; + return this.maxLookAhead > 0 ? new SavedContext(state, this.maxLookAhead) : state + }; + + + // Compute a style array (an array starting with a mode generation + // -- for invalidation -- followed by pairs of end positions and + // style strings), which is used to highlight the tokens on the + // line. + function highlightLine(cm, line, context, forceToEnd) { + // A styles array always starts with a number identifying the + // mode/overlays that it is based on (for easy invalidation). + var st = [cm.state.modeGen], lineClasses = {}; + // Compute the base array of styles + runMode(cm, line.text, cm.doc.mode, context, function (end, style) { return st.push(end, style); }, + lineClasses, forceToEnd); + var state = context.state; + + // Run overlays, adjust style array. + var loop = function ( o ) { + context.baseTokens = st; + var overlay = cm.state.overlays[o], i = 1, at = 0; + context.state = true; + runMode(cm, line.text, overlay.mode, context, function (end, style) { + var start = i; + // Ensure there's a token end at the current position, and that i points at it + while (at < end) { + var i_end = st[i]; + if (i_end > end) + { st.splice(i, 1, end, st[i+1], i_end); } + i += 2; + at = Math.min(end, i_end); + } + if (!style) { return } + if (overlay.opaque) { + st.splice(start, i - start, end, "overlay " + style); + i = start + 2; + } else { + for (; start < i; start += 2) { + var cur = st[start+1]; + st[start+1] = (cur ? cur + " " : "") + "overlay " + style; + } + } + }, lineClasses); + context.state = state; + context.baseTokens = null; + context.baseTokenPos = 1; + }; + + for (var o = 0; o < cm.state.overlays.length; ++o) loop( o ); + + return {styles: st, classes: lineClasses.bgClass || lineClasses.textClass ? lineClasses : null} + } + + function getLineStyles(cm, line, updateFrontier) { + if (!line.styles || line.styles[0] != cm.state.modeGen) { + var context = getContextBefore(cm, lineNo(line)); + var resetState = line.text.length > cm.options.maxHighlightLength && copyState(cm.doc.mode, context.state); + var result = highlightLine(cm, line, context); + if (resetState) { context.state = resetState; } + line.stateAfter = context.save(!resetState); + line.styles = result.styles; + if (result.classes) { line.styleClasses = result.classes; } + else if (line.styleClasses) { line.styleClasses = null; } + if (updateFrontier === cm.doc.highlightFrontier) + { cm.doc.modeFrontier = Math.max(cm.doc.modeFrontier, ++cm.doc.highlightFrontier); } + } + return line.styles + } + + function getContextBefore(cm, n, precise) { + var doc = cm.doc, display = cm.display; + if (!doc.mode.startState) { return new Context(doc, true, n) } + var start = findStartLine(cm, n, precise); + var saved = start > doc.first && getLine(doc, start - 1).stateAfter; + var context = saved ? Context.fromSaved(doc, saved, start) : new Context(doc, startState(doc.mode), start); + + doc.iter(start, n, function (line) { + processLine(cm, line.text, context); + var pos = context.line; + line.stateAfter = pos == n - 1 || pos % 5 == 0 || pos >= display.viewFrom && pos < display.viewTo ? context.save() : null; + context.nextLine(); + }); + if (precise) { doc.modeFrontier = context.line; } + return context + } + + // Lightweight form of highlight -- proceed over this line and + // update state, but don't save a style array. Used for lines that + // aren't currently visible. + function processLine(cm, text, context, startAt) { + var mode = cm.doc.mode; + var stream = new StringStream(text, cm.options.tabSize, context); + stream.start = stream.pos = startAt || 0; + if (text == "") { callBlankLine(mode, context.state); } + while (!stream.eol()) { + readToken(mode, stream, context.state); + stream.start = stream.pos; + } + } + + function callBlankLine(mode, state) { + if (mode.blankLine) { return mode.blankLine(state) } + if (!mode.innerMode) { return } + var inner = innerMode(mode, state); + if (inner.mode.blankLine) { return inner.mode.blankLine(inner.state) } + } + + function readToken(mode, stream, state, inner) { + for (var i = 0; i < 10; i++) { + if (inner) { inner[0] = innerMode(mode, state).mode; } + var style = mode.token(stream, state); + if (stream.pos > stream.start) { return style } + } + throw new Error("Mode " + mode.name + " failed to advance stream.") + } + + var Token = function(stream, type, state) { + this.start = stream.start; this.end = stream.pos; + this.string = stream.current(); + this.type = type || null; + this.state = state; + }; + + // Utility for getTokenAt and getLineTokens + function takeToken(cm, pos, precise, asArray) { + var doc = cm.doc, mode = doc.mode, style; + pos = clipPos(doc, pos); + var line = getLine(doc, pos.line), context = getContextBefore(cm, pos.line, precise); + var stream = new StringStream(line.text, cm.options.tabSize, context), tokens; + if (asArray) { tokens = []; } + while ((asArray || stream.pos < pos.ch) && !stream.eol()) { + stream.start = stream.pos; + style = readToken(mode, stream, context.state); + if (asArray) { tokens.push(new Token(stream, style, copyState(doc.mode, context.state))); } + } + return asArray ? tokens : new Token(stream, style, context.state) + } + + function extractLineClasses(type, output) { + if (type) { for (;;) { + var lineClass = type.match(/(?:^|\s+)line-(background-)?(\S+)/); + if (!lineClass) { break } + type = type.slice(0, lineClass.index) + type.slice(lineClass.index + lineClass[0].length); + var prop = lineClass[1] ? "bgClass" : "textClass"; + if (output[prop] == null) + { output[prop] = lineClass[2]; } + else if (!(new RegExp("(?:^|\s)" + lineClass[2] + "(?:$|\s)")).test(output[prop])) + { output[prop] += " " + lineClass[2]; } + } } + return type + } + + // Run the given mode's parser over a line, calling f for each token. + function runMode(cm, text, mode, context, f, lineClasses, forceToEnd) { + var flattenSpans = mode.flattenSpans; + if (flattenSpans == null) { flattenSpans = cm.options.flattenSpans; } + var curStart = 0, curStyle = null; + var stream = new StringStream(text, cm.options.tabSize, context), style; + var inner = cm.options.addModeClass && [null]; + if (text == "") { extractLineClasses(callBlankLine(mode, context.state), lineClasses); } + while (!stream.eol()) { + if (stream.pos > cm.options.maxHighlightLength) { + flattenSpans = false; + if (forceToEnd) { processLine(cm, text, context, stream.pos); } + stream.pos = text.length; + style = null; + } else { + style = extractLineClasses(readToken(mode, stream, context.state, inner), lineClasses); + } + if (inner) { + var mName = inner[0].name; + if (mName) { style = "m-" + (style ? mName + " " + style : mName); } + } + if (!flattenSpans || curStyle != style) { + while (curStart < stream.start) { + curStart = Math.min(stream.start, curStart + 5000); + f(curStart, curStyle); + } + curStyle = style; + } + stream.start = stream.pos; + } + while (curStart < stream.pos) { + // Webkit seems to refuse to render text nodes longer than 57444 + // characters, and returns inaccurate measurements in nodes + // starting around 5000 chars. + var pos = Math.min(stream.pos, curStart + 5000); + f(pos, curStyle); + curStart = pos; + } + } + + // Finds the line to start with when starting a parse. Tries to + // find a line with a stateAfter, so that it can start with a + // valid state. If that fails, it returns the line with the + // smallest indentation, which tends to need the least context to + // parse correctly. + function findStartLine(cm, n, precise) { + var minindent, minline, doc = cm.doc; + var lim = precise ? -1 : n - (cm.doc.mode.innerMode ? 1000 : 100); + for (var search = n; search > lim; --search) { + if (search <= doc.first) { return doc.first } + var line = getLine(doc, search - 1), after = line.stateAfter; + if (after && (!precise || search + (after instanceof SavedContext ? after.lookAhead : 0) <= doc.modeFrontier)) + { return search } + var indented = countColumn(line.text, null, cm.options.tabSize); + if (minline == null || minindent > indented) { + minline = search - 1; + minindent = indented; + } + } + return minline + } + + function retreatFrontier(doc, n) { + doc.modeFrontier = Math.min(doc.modeFrontier, n); + if (doc.highlightFrontier < n - 10) { return } + var start = doc.first; + for (var line = n - 1; line > start; line--) { + var saved = getLine(doc, line).stateAfter; + // change is on 3 + // state on line 1 looked ahead 2 -- so saw 3 + // test 1 + 2 < 3 should cover this + if (saved && (!(saved instanceof SavedContext) || line + saved.lookAhead < n)) { + start = line + 1; + break + } + } + doc.highlightFrontier = Math.min(doc.highlightFrontier, start); + } + + // Optimize some code when these features are not used. + var sawReadOnlySpans = false, sawCollapsedSpans = false; + + function seeReadOnlySpans() { + sawReadOnlySpans = true; + } + + function seeCollapsedSpans() { + sawCollapsedSpans = true; + } + + // TEXTMARKER SPANS + + function MarkedSpan(marker, from, to) { + this.marker = marker; + this.from = from; this.to = to; + } + + // Search an array of spans for a span matching the given marker. + function getMarkedSpanFor(spans, marker) { + if (spans) { for (var i = 0; i < spans.length; ++i) { + var span = spans[i]; + if (span.marker == marker) { return span } + } } + } + // Remove a span from an array, returning undefined if no spans are + // left (we don't store arrays for lines without spans). + function removeMarkedSpan(spans, span) { + var r; + for (var i = 0; i < spans.length; ++i) + { if (spans[i] != span) { (r || (r = [])).push(spans[i]); } } + return r + } + // Add a span to a line. + function addMarkedSpan(line, span) { + line.markedSpans = line.markedSpans ? line.markedSpans.concat([span]) : [span]; + span.marker.attachLine(line); + } + + // Used for the algorithm that adjusts markers for a change in the + // document. These functions cut an array of spans at a given + // character position, returning an array of remaining chunks (or + // undefined if nothing remains). + function markedSpansBefore(old, startCh, isInsert) { + var nw; + if (old) { for (var i = 0; i < old.length; ++i) { + var span = old[i], marker = span.marker; + var startsBefore = span.from == null || (marker.inclusiveLeft ? span.from <= startCh : span.from < startCh); + if (startsBefore || span.from == startCh && marker.type == "bookmark" && (!isInsert || !span.marker.insertLeft)) { + var endsAfter = span.to == null || (marker.inclusiveRight ? span.to >= startCh : span.to > startCh) + ;(nw || (nw = [])).push(new MarkedSpan(marker, span.from, endsAfter ? null : span.to)); + } + } } + return nw + } + function markedSpansAfter(old, endCh, isInsert) { + var nw; + if (old) { for (var i = 0; i < old.length; ++i) { + var span = old[i], marker = span.marker; + var endsAfter = span.to == null || (marker.inclusiveRight ? span.to >= endCh : span.to > endCh); + if (endsAfter || span.from == endCh && marker.type == "bookmark" && (!isInsert || span.marker.insertLeft)) { + var startsBefore = span.from == null || (marker.inclusiveLeft ? span.from <= endCh : span.from < endCh) + ;(nw || (nw = [])).push(new MarkedSpan(marker, startsBefore ? null : span.from - endCh, + span.to == null ? null : span.to - endCh)); + } + } } + return nw + } + + // Given a change object, compute the new set of marker spans that + // cover the line in which the change took place. Removes spans + // entirely within the change, reconnects spans belonging to the + // same marker that appear on both sides of the change, and cuts off + // spans partially within the change. Returns an array of span + // arrays with one element for each line in (after) the change. + function stretchSpansOverChange(doc, change) { + if (change.full) { return null } + var oldFirst = isLine(doc, change.from.line) && getLine(doc, change.from.line).markedSpans; + var oldLast = isLine(doc, change.to.line) && getLine(doc, change.to.line).markedSpans; + if (!oldFirst && !oldLast) { return null } + + var startCh = change.from.ch, endCh = change.to.ch, isInsert = cmp(change.from, change.to) == 0; + // Get the spans that 'stick out' on both sides + var first = markedSpansBefore(oldFirst, startCh, isInsert); + var last = markedSpansAfter(oldLast, endCh, isInsert); + + // Next, merge those two ends + var sameLine = change.text.length == 1, offset = lst(change.text).length + (sameLine ? startCh : 0); + if (first) { + // Fix up .to properties of first + for (var i = 0; i < first.length; ++i) { + var span = first[i]; + if (span.to == null) { + var found = getMarkedSpanFor(last, span.marker); + if (!found) { span.to = startCh; } + else if (sameLine) { span.to = found.to == null ? null : found.to + offset; } + } + } + } + if (last) { + // Fix up .from in last (or move them into first in case of sameLine) + for (var i$1 = 0; i$1 < last.length; ++i$1) { + var span$1 = last[i$1]; + if (span$1.to != null) { span$1.to += offset; } + if (span$1.from == null) { + var found$1 = getMarkedSpanFor(first, span$1.marker); + if (!found$1) { + span$1.from = offset; + if (sameLine) { (first || (first = [])).push(span$1); } + } + } else { + span$1.from += offset; + if (sameLine) { (first || (first = [])).push(span$1); } + } + } + } + // Make sure we didn't create any zero-length spans + if (first) { first = clearEmptySpans(first); } + if (last && last != first) { last = clearEmptySpans(last); } + + var newMarkers = [first]; + if (!sameLine) { + // Fill gap with whole-line-spans + var gap = change.text.length - 2, gapMarkers; + if (gap > 0 && first) + { for (var i$2 = 0; i$2 < first.length; ++i$2) + { if (first[i$2].to == null) + { (gapMarkers || (gapMarkers = [])).push(new MarkedSpan(first[i$2].marker, null, null)); } } } + for (var i$3 = 0; i$3 < gap; ++i$3) + { newMarkers.push(gapMarkers); } + newMarkers.push(last); + } + return newMarkers + } + + // Remove spans that are empty and don't have a clearWhenEmpty + // option of false. + function clearEmptySpans(spans) { + for (var i = 0; i < spans.length; ++i) { + var span = spans[i]; + if (span.from != null && span.from == span.to && span.marker.clearWhenEmpty !== false) + { spans.splice(i--, 1); } + } + if (!spans.length) { return null } + return spans + } + + // Used to 'clip' out readOnly ranges when making a change. + function removeReadOnlyRanges(doc, from, to) { + var markers = null; + doc.iter(from.line, to.line + 1, function (line) { + if (line.markedSpans) { for (var i = 0; i < line.markedSpans.length; ++i) { + var mark = line.markedSpans[i].marker; + if (mark.readOnly && (!markers || indexOf(markers, mark) == -1)) + { (markers || (markers = [])).push(mark); } + } } + }); + if (!markers) { return null } + var parts = [{from: from, to: to}]; + for (var i = 0; i < markers.length; ++i) { + var mk = markers[i], m = mk.find(0); + for (var j = 0; j < parts.length; ++j) { + var p = parts[j]; + if (cmp(p.to, m.from) < 0 || cmp(p.from, m.to) > 0) { continue } + var newParts = [j, 1], dfrom = cmp(p.from, m.from), dto = cmp(p.to, m.to); + if (dfrom < 0 || !mk.inclusiveLeft && !dfrom) + { newParts.push({from: p.from, to: m.from}); } + if (dto > 0 || !mk.inclusiveRight && !dto) + { newParts.push({from: m.to, to: p.to}); } + parts.splice.apply(parts, newParts); + j += newParts.length - 3; + } + } + return parts + } + + // Connect or disconnect spans from a line. + function detachMarkedSpans(line) { + var spans = line.markedSpans; + if (!spans) { return } + for (var i = 0; i < spans.length; ++i) + { spans[i].marker.detachLine(line); } + line.markedSpans = null; + } + function attachMarkedSpans(line, spans) { + if (!spans) { return } + for (var i = 0; i < spans.length; ++i) + { spans[i].marker.attachLine(line); } + line.markedSpans = spans; + } + + // Helpers used when computing which overlapping collapsed span + // counts as the larger one. + function extraLeft(marker) { return marker.inclusiveLeft ? -1 : 0 } + function extraRight(marker) { return marker.inclusiveRight ? 1 : 0 } + + // Returns a number indicating which of two overlapping collapsed + // spans is larger (and thus includes the other). Falls back to + // comparing ids when the spans cover exactly the same range. + function compareCollapsedMarkers(a, b) { + var lenDiff = a.lines.length - b.lines.length; + if (lenDiff != 0) { return lenDiff } + var aPos = a.find(), bPos = b.find(); + var fromCmp = cmp(aPos.from, bPos.from) || extraLeft(a) - extraLeft(b); + if (fromCmp) { return -fromCmp } + var toCmp = cmp(aPos.to, bPos.to) || extraRight(a) - extraRight(b); + if (toCmp) { return toCmp } + return b.id - a.id + } + + // Find out whether a line ends or starts in a collapsed span. If + // so, return the marker for that span. + function collapsedSpanAtSide(line, start) { + var sps = sawCollapsedSpans && line.markedSpans, found; + if (sps) { for (var sp = (void 0), i = 0; i < sps.length; ++i) { + sp = sps[i]; + if (sp.marker.collapsed && (start ? sp.from : sp.to) == null && + (!found || compareCollapsedMarkers(found, sp.marker) < 0)) + { found = sp.marker; } + } } + return found + } + function collapsedSpanAtStart(line) { return collapsedSpanAtSide(line, true) } + function collapsedSpanAtEnd(line) { return collapsedSpanAtSide(line, false) } + + function collapsedSpanAround(line, ch) { + var sps = sawCollapsedSpans && line.markedSpans, found; + if (sps) { for (var i = 0; i < sps.length; ++i) { + var sp = sps[i]; + if (sp.marker.collapsed && (sp.from == null || sp.from < ch) && (sp.to == null || sp.to > ch) && + (!found || compareCollapsedMarkers(found, sp.marker) < 0)) { found = sp.marker; } + } } + return found + } + + // Test whether there exists a collapsed span that partially + // overlaps (covers the start or end, but not both) of a new span. + // Such overlap is not allowed. + function conflictingCollapsedRange(doc, lineNo$$1, from, to, marker) { + var line = getLine(doc, lineNo$$1); + var sps = sawCollapsedSpans && line.markedSpans; + if (sps) { for (var i = 0; i < sps.length; ++i) { + var sp = sps[i]; + if (!sp.marker.collapsed) { continue } + var found = sp.marker.find(0); + var fromCmp = cmp(found.from, from) || extraLeft(sp.marker) - extraLeft(marker); + var toCmp = cmp(found.to, to) || extraRight(sp.marker) - extraRight(marker); + if (fromCmp >= 0 && toCmp <= 0 || fromCmp <= 0 && toCmp >= 0) { continue } + if (fromCmp <= 0 && (sp.marker.inclusiveRight && marker.inclusiveLeft ? cmp(found.to, from) >= 0 : cmp(found.to, from) > 0) || + fromCmp >= 0 && (sp.marker.inclusiveRight && marker.inclusiveLeft ? cmp(found.from, to) <= 0 : cmp(found.from, to) < 0)) + { return true } + } } + } + + // A visual line is a line as drawn on the screen. Folding, for + // example, can cause multiple logical lines to appear on the same + // visual line. This finds the start of the visual line that the + // given line is part of (usually that is the line itself). + function visualLine(line) { + var merged; + while (merged = collapsedSpanAtStart(line)) + { line = merged.find(-1, true).line; } + return line + } + + function visualLineEnd(line) { + var merged; + while (merged = collapsedSpanAtEnd(line)) + { line = merged.find(1, true).line; } + return line + } + + // Returns an array of logical lines that continue the visual line + // started by the argument, or undefined if there are no such lines. + function visualLineContinued(line) { + var merged, lines; + while (merged = collapsedSpanAtEnd(line)) { + line = merged.find(1, true).line + ;(lines || (lines = [])).push(line); + } + return lines + } + + // Get the line number of the start of the visual line that the + // given line number is part of. + function visualLineNo(doc, lineN) { + var line = getLine(doc, lineN), vis = visualLine(line); + if (line == vis) { return lineN } + return lineNo(vis) + } + + // Get the line number of the start of the next visual line after + // the given line. + function visualLineEndNo(doc, lineN) { + if (lineN > doc.lastLine()) { return lineN } + var line = getLine(doc, lineN), merged; + if (!lineIsHidden(doc, line)) { return lineN } + while (merged = collapsedSpanAtEnd(line)) + { line = merged.find(1, true).line; } + return lineNo(line) + 1 + } + + // Compute whether a line is hidden. Lines count as hidden when they + // are part of a visual line that starts with another line, or when + // they are entirely covered by collapsed, non-widget span. + function lineIsHidden(doc, line) { + var sps = sawCollapsedSpans && line.markedSpans; + if (sps) { for (var sp = (void 0), i = 0; i < sps.length; ++i) { + sp = sps[i]; + if (!sp.marker.collapsed) { continue } + if (sp.from == null) { return true } + if (sp.marker.widgetNode) { continue } + if (sp.from == 0 && sp.marker.inclusiveLeft && lineIsHiddenInner(doc, line, sp)) + { return true } + } } + } + function lineIsHiddenInner(doc, line, span) { + if (span.to == null) { + var end = span.marker.find(1, true); + return lineIsHiddenInner(doc, end.line, getMarkedSpanFor(end.line.markedSpans, span.marker)) + } + if (span.marker.inclusiveRight && span.to == line.text.length) + { return true } + for (var sp = (void 0), i = 0; i < line.markedSpans.length; ++i) { + sp = line.markedSpans[i]; + if (sp.marker.collapsed && !sp.marker.widgetNode && sp.from == span.to && + (sp.to == null || sp.to != span.from) && + (sp.marker.inclusiveLeft || span.marker.inclusiveRight) && + lineIsHiddenInner(doc, line, sp)) { return true } + } + } + + // Find the height above the given line. + function heightAtLine(lineObj) { + lineObj = visualLine(lineObj); + + var h = 0, chunk = lineObj.parent; + for (var i = 0; i < chunk.lines.length; ++i) { + var line = chunk.lines[i]; + if (line == lineObj) { break } + else { h += line.height; } + } + for (var p = chunk.parent; p; chunk = p, p = chunk.parent) { + for (var i$1 = 0; i$1 < p.children.length; ++i$1) { + var cur = p.children[i$1]; + if (cur == chunk) { break } + else { h += cur.height; } + } + } + return h + } + + // Compute the character length of a line, taking into account + // collapsed ranges (see markText) that might hide parts, and join + // other lines onto it. + function lineLength(line) { + if (line.height == 0) { return 0 } + var len = line.text.length, merged, cur = line; + while (merged = collapsedSpanAtStart(cur)) { + var found = merged.find(0, true); + cur = found.from.line; + len += found.from.ch - found.to.ch; + } + cur = line; + while (merged = collapsedSpanAtEnd(cur)) { + var found$1 = merged.find(0, true); + len -= cur.text.length - found$1.from.ch; + cur = found$1.to.line; + len += cur.text.length - found$1.to.ch; + } + return len + } + + // Find the longest line in the document. + function findMaxLine(cm) { + var d = cm.display, doc = cm.doc; + d.maxLine = getLine(doc, doc.first); + d.maxLineLength = lineLength(d.maxLine); + d.maxLineChanged = true; + doc.iter(function (line) { + var len = lineLength(line); + if (len > d.maxLineLength) { + d.maxLineLength = len; + d.maxLine = line; + } + }); + } + + // LINE DATA STRUCTURE + + // Line objects. These hold state related to a line, including + // highlighting info (the styles array). + var Line = function(text, markedSpans, estimateHeight) { + this.text = text; + attachMarkedSpans(this, markedSpans); + this.height = estimateHeight ? estimateHeight(this) : 1; + }; + + Line.prototype.lineNo = function () { return lineNo(this) }; + eventMixin(Line); + + // Change the content (text, markers) of a line. Automatically + // invalidates cached information and tries to re-estimate the + // line's height. + function updateLine(line, text, markedSpans, estimateHeight) { + line.text = text; + if (line.stateAfter) { line.stateAfter = null; } + if (line.styles) { line.styles = null; } + if (line.order != null) { line.order = null; } + detachMarkedSpans(line); + attachMarkedSpans(line, markedSpans); + var estHeight = estimateHeight ? estimateHeight(line) : 1; + if (estHeight != line.height) { updateLineHeight(line, estHeight); } + } + + // Detach a line from the document tree and its markers. + function cleanUpLine(line) { + line.parent = null; + detachMarkedSpans(line); + } + + // Convert a style as returned by a mode (either null, or a string + // containing one or more styles) to a CSS style. This is cached, + // and also looks for line-wide styles. + var styleToClassCache = {}, styleToClassCacheWithMode = {}; + function interpretTokenStyle(style, options) { + if (!style || /^\s*$/.test(style)) { return null } + var cache = options.addModeClass ? styleToClassCacheWithMode : styleToClassCache; + return cache[style] || + (cache[style] = style.replace(/\S+/g, "cm-$&")) + } + + // Render the DOM representation of the text of a line. Also builds + // up a 'line map', which points at the DOM nodes that represent + // specific stretches of text, and is used by the measuring code. + // The returned object contains the DOM node, this map, and + // information about line-wide styles that were set by the mode. + function buildLineContent(cm, lineView) { + // The padding-right forces the element to have a 'border', which + // is needed on Webkit to be able to get line-level bounding + // rectangles for it (in measureChar). + var content = eltP("span", null, null, webkit ? "padding-right: .1px" : null); + var builder = {pre: eltP("pre", [content], "CodeMirror-line"), content: content, + col: 0, pos: 0, cm: cm, + trailingSpace: false, + splitSpaces: cm.getOption("lineWrapping")}; + lineView.measure = {}; + + // Iterate over the logical lines that make up this visual line. + for (var i = 0; i <= (lineView.rest ? lineView.rest.length : 0); i++) { + var line = i ? lineView.rest[i - 1] : lineView.line, order = (void 0); + builder.pos = 0; + builder.addToken = buildToken; + // Optionally wire in some hacks into the token-rendering + // algorithm, to deal with browser quirks. + if (hasBadBidiRects(cm.display.measure) && (order = getOrder(line, cm.doc.direction))) + { builder.addToken = buildTokenBadBidi(builder.addToken, order); } + builder.map = []; + var allowFrontierUpdate = lineView != cm.display.externalMeasured && lineNo(line); + insertLineContent(line, builder, getLineStyles(cm, line, allowFrontierUpdate)); + if (line.styleClasses) { + if (line.styleClasses.bgClass) + { builder.bgClass = joinClasses(line.styleClasses.bgClass, builder.bgClass || ""); } + if (line.styleClasses.textClass) + { builder.textClass = joinClasses(line.styleClasses.textClass, builder.textClass || ""); } + } + + // Ensure at least a single node is present, for measuring. + if (builder.map.length == 0) + { builder.map.push(0, 0, builder.content.appendChild(zeroWidthElement(cm.display.measure))); } + + // Store the map and a cache object for the current logical line + if (i == 0) { + lineView.measure.map = builder.map; + lineView.measure.cache = {}; + } else { + (lineView.measure.maps || (lineView.measure.maps = [])).push(builder.map) + ;(lineView.measure.caches || (lineView.measure.caches = [])).push({}); + } + } + + // See issue #2901 + if (webkit) { + var last = builder.content.lastChild; + if (/\bcm-tab\b/.test(last.className) || (last.querySelector && last.querySelector(".cm-tab"))) + { builder.content.className = "cm-tab-wrap-hack"; } + } + + signal(cm, "renderLine", cm, lineView.line, builder.pre); + if (builder.pre.className) + { builder.textClass = joinClasses(builder.pre.className, builder.textClass || ""); } + + return builder + } + + function defaultSpecialCharPlaceholder(ch) { + var token = elt("span", "\u2022", "cm-invalidchar"); + token.title = "\\u" + ch.charCodeAt(0).toString(16); + token.setAttribute("aria-label", token.title); + return token + } + + // Build up the DOM representation for a single token, and add it to + // the line map. Takes care to render special characters separately. + function buildToken(builder, text, style, startStyle, endStyle, css, attributes) { + if (!text) { return } + var displayText = builder.splitSpaces ? splitSpaces(text, builder.trailingSpace) : text; + var special = builder.cm.state.specialChars, mustWrap = false; + var content; + if (!special.test(text)) { + builder.col += text.length; + content = document.createTextNode(displayText); + builder.map.push(builder.pos, builder.pos + text.length, content); + if (ie && ie_version < 9) { mustWrap = true; } + builder.pos += text.length; + } else { + content = document.createDocumentFragment(); + var pos = 0; + while (true) { + special.lastIndex = pos; + var m = special.exec(text); + var skipped = m ? m.index - pos : text.length - pos; + if (skipped) { + var txt = document.createTextNode(displayText.slice(pos, pos + skipped)); + if (ie && ie_version < 9) { content.appendChild(elt("span", [txt])); } + else { content.appendChild(txt); } + builder.map.push(builder.pos, builder.pos + skipped, txt); + builder.col += skipped; + builder.pos += skipped; + } + if (!m) { break } + pos += skipped + 1; + var txt$1 = (void 0); + if (m[0] == "\t") { + var tabSize = builder.cm.options.tabSize, tabWidth = tabSize - builder.col % tabSize; + txt$1 = content.appendChild(elt("span", spaceStr(tabWidth), "cm-tab")); + txt$1.setAttribute("role", "presentation"); + txt$1.setAttribute("cm-text", "\t"); + builder.col += tabWidth; + } else if (m[0] == "\r" || m[0] == "\n") { + txt$1 = content.appendChild(elt("span", m[0] == "\r" ? "\u240d" : "\u2424", "cm-invalidchar")); + txt$1.setAttribute("cm-text", m[0]); + builder.col += 1; + } else { + txt$1 = builder.cm.options.specialCharPlaceholder(m[0]); + txt$1.setAttribute("cm-text", m[0]); + if (ie && ie_version < 9) { content.appendChild(elt("span", [txt$1])); } + else { content.appendChild(txt$1); } + builder.col += 1; + } + builder.map.push(builder.pos, builder.pos + 1, txt$1); + builder.pos++; + } + } + builder.trailingSpace = displayText.charCodeAt(text.length - 1) == 32; + if (style || startStyle || endStyle || mustWrap || css) { + var fullStyle = style || ""; + if (startStyle) { fullStyle += startStyle; } + if (endStyle) { fullStyle += endStyle; } + var token = elt("span", [content], fullStyle, css); + if (attributes) { + for (var attr in attributes) { if (attributes.hasOwnProperty(attr) && attr != "style" && attr != "class") + { token.setAttribute(attr, attributes[attr]); } } + } + return builder.content.appendChild(token) + } + builder.content.appendChild(content); + } + + // Change some spaces to NBSP to prevent the browser from collapsing + // trailing spaces at the end of a line when rendering text (issue #1362). + function splitSpaces(text, trailingBefore) { + if (text.length > 1 && !/ /.test(text)) { return text } + var spaceBefore = trailingBefore, result = ""; + for (var i = 0; i < text.length; i++) { + var ch = text.charAt(i); + if (ch == " " && spaceBefore && (i == text.length - 1 || text.charCodeAt(i + 1) == 32)) + { ch = "\u00a0"; } + result += ch; + spaceBefore = ch == " "; + } + return result + } + + // Work around nonsense dimensions being reported for stretches of + // right-to-left text. + function buildTokenBadBidi(inner, order) { + return function (builder, text, style, startStyle, endStyle, css, attributes) { + style = style ? style + " cm-force-border" : "cm-force-border"; + var start = builder.pos, end = start + text.length; + for (;;) { + // Find the part that overlaps with the start of this text + var part = (void 0); + for (var i = 0; i < order.length; i++) { + part = order[i]; + if (part.to > start && part.from <= start) { break } + } + if (part.to >= end) { return inner(builder, text, style, startStyle, endStyle, css, attributes) } + inner(builder, text.slice(0, part.to - start), style, startStyle, null, css, attributes); + startStyle = null; + text = text.slice(part.to - start); + start = part.to; + } + } + } + + function buildCollapsedSpan(builder, size, marker, ignoreWidget) { + var widget = !ignoreWidget && marker.widgetNode; + if (widget) { builder.map.push(builder.pos, builder.pos + size, widget); } + if (!ignoreWidget && builder.cm.display.input.needsContentAttribute) { + if (!widget) + { widget = builder.content.appendChild(document.createElement("span")); } + widget.setAttribute("cm-marker", marker.id); + } + if (widget) { + builder.cm.display.input.setUneditable(widget); + builder.content.appendChild(widget); + } + builder.pos += size; + builder.trailingSpace = false; + } + + // Outputs a number of spans to make up a line, taking highlighting + // and marked text into account. + function insertLineContent(line, builder, styles) { + var spans = line.markedSpans, allText = line.text, at = 0; + if (!spans) { + for (var i$1 = 1; i$1 < styles.length; i$1+=2) + { builder.addToken(builder, allText.slice(at, at = styles[i$1]), interpretTokenStyle(styles[i$1+1], builder.cm.options)); } + return + } + + var len = allText.length, pos = 0, i = 1, text = "", style, css; + var nextChange = 0, spanStyle, spanEndStyle, spanStartStyle, collapsed, attributes; + for (;;) { + if (nextChange == pos) { // Update current marker set + spanStyle = spanEndStyle = spanStartStyle = css = ""; + attributes = null; + collapsed = null; nextChange = Infinity; + var foundBookmarks = [], endStyles = (void 0); + for (var j = 0; j < spans.length; ++j) { + var sp = spans[j], m = sp.marker; + if (m.type == "bookmark" && sp.from == pos && m.widgetNode) { + foundBookmarks.push(m); + } else if (sp.from <= pos && (sp.to == null || sp.to > pos || m.collapsed && sp.to == pos && sp.from == pos)) { + if (sp.to != null && sp.to != pos && nextChange > sp.to) { + nextChange = sp.to; + spanEndStyle = ""; + } + if (m.className) { spanStyle += " " + m.className; } + if (m.css) { css = (css ? css + ";" : "") + m.css; } + if (m.startStyle && sp.from == pos) { spanStartStyle += " " + m.startStyle; } + if (m.endStyle && sp.to == nextChange) { (endStyles || (endStyles = [])).push(m.endStyle, sp.to); } + // support for the old title property + // https://github.com/codemirror/CodeMirror/pull/5673 + if (m.title) { (attributes || (attributes = {})).title = m.title; } + if (m.attributes) { + for (var attr in m.attributes) + { (attributes || (attributes = {}))[attr] = m.attributes[attr]; } + } + if (m.collapsed && (!collapsed || compareCollapsedMarkers(collapsed.marker, m) < 0)) + { collapsed = sp; } + } else if (sp.from > pos && nextChange > sp.from) { + nextChange = sp.from; + } + } + if (endStyles) { for (var j$1 = 0; j$1 < endStyles.length; j$1 += 2) + { if (endStyles[j$1 + 1] == nextChange) { spanEndStyle += " " + endStyles[j$1]; } } } + + if (!collapsed || collapsed.from == pos) { for (var j$2 = 0; j$2 < foundBookmarks.length; ++j$2) + { buildCollapsedSpan(builder, 0, foundBookmarks[j$2]); } } + if (collapsed && (collapsed.from || 0) == pos) { + buildCollapsedSpan(builder, (collapsed.to == null ? len + 1 : collapsed.to) - pos, + collapsed.marker, collapsed.from == null); + if (collapsed.to == null) { return } + if (collapsed.to == pos) { collapsed = false; } + } + } + if (pos >= len) { break } + + var upto = Math.min(len, nextChange); + while (true) { + if (text) { + var end = pos + text.length; + if (!collapsed) { + var tokenText = end > upto ? text.slice(0, upto - pos) : text; + builder.addToken(builder, tokenText, style ? style + spanStyle : spanStyle, + spanStartStyle, pos + tokenText.length == nextChange ? spanEndStyle : "", css, attributes); + } + if (end >= upto) {text = text.slice(upto - pos); pos = upto; break} + pos = end; + spanStartStyle = ""; + } + text = allText.slice(at, at = styles[i++]); + style = interpretTokenStyle(styles[i++], builder.cm.options); + } + } + } + + + // These objects are used to represent the visible (currently drawn) + // part of the document. A LineView may correspond to multiple + // logical lines, if those are connected by collapsed ranges. + function LineView(doc, line, lineN) { + // The starting line + this.line = line; + // Continuing lines, if any + this.rest = visualLineContinued(line); + // Number of logical lines in this visual line + this.size = this.rest ? lineNo(lst(this.rest)) - lineN + 1 : 1; + this.node = this.text = null; + this.hidden = lineIsHidden(doc, line); + } + + // Create a range of LineView objects for the given lines. + function buildViewArray(cm, from, to) { + var array = [], nextPos; + for (var pos = from; pos < to; pos = nextPos) { + var view = new LineView(cm.doc, getLine(cm.doc, pos), pos); + nextPos = pos + view.size; + array.push(view); + } + return array + } + + var operationGroup = null; + + function pushOperation(op) { + if (operationGroup) { + operationGroup.ops.push(op); + } else { + op.ownsGroup = operationGroup = { + ops: [op], + delayedCallbacks: [] + }; + } + } + + function fireCallbacksForOps(group) { + // Calls delayed callbacks and cursorActivity handlers until no + // new ones appear + var callbacks = group.delayedCallbacks, i = 0; + do { + for (; i < callbacks.length; i++) + { callbacks[i].call(null); } + for (var j = 0; j < group.ops.length; j++) { + var op = group.ops[j]; + if (op.cursorActivityHandlers) + { while (op.cursorActivityCalled < op.cursorActivityHandlers.length) + { op.cursorActivityHandlers[op.cursorActivityCalled++].call(null, op.cm); } } + } + } while (i < callbacks.length) + } + + function finishOperation(op, endCb) { + var group = op.ownsGroup; + if (!group) { return } + + try { fireCallbacksForOps(group); } + finally { + operationGroup = null; + endCb(group); + } + } + + var orphanDelayedCallbacks = null; + + // Often, we want to signal events at a point where we are in the + // middle of some work, but don't want the handler to start calling + // other methods on the editor, which might be in an inconsistent + // state or simply not expect any other events to happen. + // signalLater looks whether there are any handlers, and schedules + // them to be executed when the last operation ends, or, if no + // operation is active, when a timeout fires. + function signalLater(emitter, type /*, values...*/) { + var arr = getHandlers(emitter, type); + if (!arr.length) { return } + var args = Array.prototype.slice.call(arguments, 2), list; + if (operationGroup) { + list = operationGroup.delayedCallbacks; + } else if (orphanDelayedCallbacks) { + list = orphanDelayedCallbacks; + } else { + list = orphanDelayedCallbacks = []; + setTimeout(fireOrphanDelayed, 0); + } + var loop = function ( i ) { + list.push(function () { return arr[i].apply(null, args); }); + }; + + for (var i = 0; i < arr.length; ++i) + loop( i ); + } + + function fireOrphanDelayed() { + var delayed = orphanDelayedCallbacks; + orphanDelayedCallbacks = null; + for (var i = 0; i < delayed.length; ++i) { delayed[i](); } + } + + // When an aspect of a line changes, a string is added to + // lineView.changes. This updates the relevant part of the line's + // DOM structure. + function updateLineForChanges(cm, lineView, lineN, dims) { + for (var j = 0; j < lineView.changes.length; j++) { + var type = lineView.changes[j]; + if (type == "text") { updateLineText(cm, lineView); } + else if (type == "gutter") { updateLineGutter(cm, lineView, lineN, dims); } + else if (type == "class") { updateLineClasses(cm, lineView); } + else if (type == "widget") { updateLineWidgets(cm, lineView, dims); } + } + lineView.changes = null; + } + + // Lines with gutter elements, widgets or a background class need to + // be wrapped, and have the extra elements added to the wrapper div + function ensureLineWrapped(lineView) { + if (lineView.node == lineView.text) { + lineView.node = elt("div", null, null, "position: relative"); + if (lineView.text.parentNode) + { lineView.text.parentNode.replaceChild(lineView.node, lineView.text); } + lineView.node.appendChild(lineView.text); + if (ie && ie_version < 8) { lineView.node.style.zIndex = 2; } + } + return lineView.node + } + + function updateLineBackground(cm, lineView) { + var cls = lineView.bgClass ? lineView.bgClass + " " + (lineView.line.bgClass || "") : lineView.line.bgClass; + if (cls) { cls += " CodeMirror-linebackground"; } + if (lineView.background) { + if (cls) { lineView.background.className = cls; } + else { lineView.background.parentNode.removeChild(lineView.background); lineView.background = null; } + } else if (cls) { + var wrap = ensureLineWrapped(lineView); + lineView.background = wrap.insertBefore(elt("div", null, cls), wrap.firstChild); + cm.display.input.setUneditable(lineView.background); + } + } + + // Wrapper around buildLineContent which will reuse the structure + // in display.externalMeasured when possible. + function getLineContent(cm, lineView) { + var ext = cm.display.externalMeasured; + if (ext && ext.line == lineView.line) { + cm.display.externalMeasured = null; + lineView.measure = ext.measure; + return ext.built + } + return buildLineContent(cm, lineView) + } + + // Redraw the line's text. Interacts with the background and text + // classes because the mode may output tokens that influence these + // classes. + function updateLineText(cm, lineView) { + var cls = lineView.text.className; + var built = getLineContent(cm, lineView); + if (lineView.text == lineView.node) { lineView.node = built.pre; } + lineView.text.parentNode.replaceChild(built.pre, lineView.text); + lineView.text = built.pre; + if (built.bgClass != lineView.bgClass || built.textClass != lineView.textClass) { + lineView.bgClass = built.bgClass; + lineView.textClass = built.textClass; + updateLineClasses(cm, lineView); + } else if (cls) { + lineView.text.className = cls; + } + } + + function updateLineClasses(cm, lineView) { + updateLineBackground(cm, lineView); + if (lineView.line.wrapClass) + { ensureLineWrapped(lineView).className = lineView.line.wrapClass; } + else if (lineView.node != lineView.text) + { lineView.node.className = ""; } + var textClass = lineView.textClass ? lineView.textClass + " " + (lineView.line.textClass || "") : lineView.line.textClass; + lineView.text.className = textClass || ""; + } + + function updateLineGutter(cm, lineView, lineN, dims) { + if (lineView.gutter) { + lineView.node.removeChild(lineView.gutter); + lineView.gutter = null; + } + if (lineView.gutterBackground) { + lineView.node.removeChild(lineView.gutterBackground); + lineView.gutterBackground = null; + } + if (lineView.line.gutterClass) { + var wrap = ensureLineWrapped(lineView); + lineView.gutterBackground = elt("div", null, "CodeMirror-gutter-background " + lineView.line.gutterClass, + ("left: " + (cm.options.fixedGutter ? dims.fixedPos : -dims.gutterTotalWidth) + "px; width: " + (dims.gutterTotalWidth) + "px")); + cm.display.input.setUneditable(lineView.gutterBackground); + wrap.insertBefore(lineView.gutterBackground, lineView.text); + } + var markers = lineView.line.gutterMarkers; + if (cm.options.lineNumbers || markers) { + var wrap$1 = ensureLineWrapped(lineView); + var gutterWrap = lineView.gutter = elt("div", null, "CodeMirror-gutter-wrapper", ("left: " + (cm.options.fixedGutter ? dims.fixedPos : -dims.gutterTotalWidth) + "px")); + cm.display.input.setUneditable(gutterWrap); + wrap$1.insertBefore(gutterWrap, lineView.text); + if (lineView.line.gutterClass) + { gutterWrap.className += " " + lineView.line.gutterClass; } + if (cm.options.lineNumbers && (!markers || !markers["CodeMirror-linenumbers"])) + { lineView.lineNumber = gutterWrap.appendChild( + elt("div", lineNumberFor(cm.options, lineN), + "CodeMirror-linenumber CodeMirror-gutter-elt", + ("left: " + (dims.gutterLeft["CodeMirror-linenumbers"]) + "px; width: " + (cm.display.lineNumInnerWidth) + "px"))); } + if (markers) { for (var k = 0; k < cm.display.gutterSpecs.length; ++k) { + var id = cm.display.gutterSpecs[k].className, found = markers.hasOwnProperty(id) && markers[id]; + if (found) + { gutterWrap.appendChild(elt("div", [found], "CodeMirror-gutter-elt", + ("left: " + (dims.gutterLeft[id]) + "px; width: " + (dims.gutterWidth[id]) + "px"))); } + } } + } + } + + function updateLineWidgets(cm, lineView, dims) { + if (lineView.alignable) { lineView.alignable = null; } + for (var node = lineView.node.firstChild, next = (void 0); node; node = next) { + next = node.nextSibling; + if (node.className == "CodeMirror-linewidget") + { lineView.node.removeChild(node); } + } + insertLineWidgets(cm, lineView, dims); + } + + // Build a line's DOM representation from scratch + function buildLineElement(cm, lineView, lineN, dims) { + var built = getLineContent(cm, lineView); + lineView.text = lineView.node = built.pre; + if (built.bgClass) { lineView.bgClass = built.bgClass; } + if (built.textClass) { lineView.textClass = built.textClass; } + + updateLineClasses(cm, lineView); + updateLineGutter(cm, lineView, lineN, dims); + insertLineWidgets(cm, lineView, dims); + return lineView.node + } + + // A lineView may contain multiple logical lines (when merged by + // collapsed spans). The widgets for all of them need to be drawn. + function insertLineWidgets(cm, lineView, dims) { + insertLineWidgetsFor(cm, lineView.line, lineView, dims, true); + if (lineView.rest) { for (var i = 0; i < lineView.rest.length; i++) + { insertLineWidgetsFor(cm, lineView.rest[i], lineView, dims, false); } } + } + + function insertLineWidgetsFor(cm, line, lineView, dims, allowAbove) { + if (!line.widgets) { return } + var wrap = ensureLineWrapped(lineView); + for (var i = 0, ws = line.widgets; i < ws.length; ++i) { + var widget = ws[i], node = elt("div", [widget.node], "CodeMirror-linewidget"); + if (!widget.handleMouseEvents) { node.setAttribute("cm-ignore-events", "true"); } + positionLineWidget(widget, node, lineView, dims); + cm.display.input.setUneditable(node); + if (allowAbove && widget.above) + { wrap.insertBefore(node, lineView.gutter || lineView.text); } + else + { wrap.appendChild(node); } + signalLater(widget, "redraw"); + } + } + + function positionLineWidget(widget, node, lineView, dims) { + if (widget.noHScroll) { + (lineView.alignable || (lineView.alignable = [])).push(node); + var width = dims.wrapperWidth; + node.style.left = dims.fixedPos + "px"; + if (!widget.coverGutter) { + width -= dims.gutterTotalWidth; + node.style.paddingLeft = dims.gutterTotalWidth + "px"; + } + node.style.width = width + "px"; + } + if (widget.coverGutter) { + node.style.zIndex = 5; + node.style.position = "relative"; + if (!widget.noHScroll) { node.style.marginLeft = -dims.gutterTotalWidth + "px"; } + } + } + + function widgetHeight(widget) { + if (widget.height != null) { return widget.height } + var cm = widget.doc.cm; + if (!cm) { return 0 } + if (!contains(document.body, widget.node)) { + var parentStyle = "position: relative;"; + if (widget.coverGutter) + { parentStyle += "margin-left: -" + cm.display.gutters.offsetWidth + "px;"; } + if (widget.noHScroll) + { parentStyle += "width: " + cm.display.wrapper.clientWidth + "px;"; } + removeChildrenAndAdd(cm.display.measure, elt("div", [widget.node], null, parentStyle)); + } + return widget.height = widget.node.parentNode.offsetHeight + } + + // Return true when the given mouse event happened in a widget + function eventInWidget(display, e) { + for (var n = e_target(e); n != display.wrapper; n = n.parentNode) { + if (!n || (n.nodeType == 1 && n.getAttribute("cm-ignore-events") == "true") || + (n.parentNode == display.sizer && n != display.mover)) + { return true } + } + } + + // POSITION MEASUREMENT + + function paddingTop(display) {return display.lineSpace.offsetTop} + function paddingVert(display) {return display.mover.offsetHeight - display.lineSpace.offsetHeight} + function paddingH(display) { + if (display.cachedPaddingH) { return display.cachedPaddingH } + var e = removeChildrenAndAdd(display.measure, elt("pre", "x", "CodeMirror-line-like")); + var style = window.getComputedStyle ? window.getComputedStyle(e) : e.currentStyle; + var data = {left: parseInt(style.paddingLeft), right: parseInt(style.paddingRight)}; + if (!isNaN(data.left) && !isNaN(data.right)) { display.cachedPaddingH = data; } + return data + } + + function scrollGap(cm) { return scrollerGap - cm.display.nativeBarWidth } + function displayWidth(cm) { + return cm.display.scroller.clientWidth - scrollGap(cm) - cm.display.barWidth + } + function displayHeight(cm) { + return cm.display.scroller.clientHeight - scrollGap(cm) - cm.display.barHeight + } + + // Ensure the lineView.wrapping.heights array is populated. This is + // an array of bottom offsets for the lines that make up a drawn + // line. When lineWrapping is on, there might be more than one + // height. + function ensureLineHeights(cm, lineView, rect) { + var wrapping = cm.options.lineWrapping; + var curWidth = wrapping && displayWidth(cm); + if (!lineView.measure.heights || wrapping && lineView.measure.width != curWidth) { + var heights = lineView.measure.heights = []; + if (wrapping) { + lineView.measure.width = curWidth; + var rects = lineView.text.firstChild.getClientRects(); + for (var i = 0; i < rects.length - 1; i++) { + var cur = rects[i], next = rects[i + 1]; + if (Math.abs(cur.bottom - next.bottom) > 2) + { heights.push((cur.bottom + next.top) / 2 - rect.top); } + } + } + heights.push(rect.bottom - rect.top); + } + } + + // Find a line map (mapping character offsets to text nodes) and a + // measurement cache for the given line number. (A line view might + // contain multiple lines when collapsed ranges are present.) + function mapFromLineView(lineView, line, lineN) { + if (lineView.line == line) + { return {map: lineView.measure.map, cache: lineView.measure.cache} } + for (var i = 0; i < lineView.rest.length; i++) + { if (lineView.rest[i] == line) + { return {map: lineView.measure.maps[i], cache: lineView.measure.caches[i]} } } + for (var i$1 = 0; i$1 < lineView.rest.length; i$1++) + { if (lineNo(lineView.rest[i$1]) > lineN) + { return {map: lineView.measure.maps[i$1], cache: lineView.measure.caches[i$1], before: true} } } + } + + // Render a line into the hidden node display.externalMeasured. Used + // when measurement is needed for a line that's not in the viewport. + function updateExternalMeasurement(cm, line) { + line = visualLine(line); + var lineN = lineNo(line); + var view = cm.display.externalMeasured = new LineView(cm.doc, line, lineN); + view.lineN = lineN; + var built = view.built = buildLineContent(cm, view); + view.text = built.pre; + removeChildrenAndAdd(cm.display.lineMeasure, built.pre); + return view + } + + // Get a {top, bottom, left, right} box (in line-local coordinates) + // for a given character. + function measureChar(cm, line, ch, bias) { + return measureCharPrepared(cm, prepareMeasureForLine(cm, line), ch, bias) + } + + // Find a line view that corresponds to the given line number. + function findViewForLine(cm, lineN) { + if (lineN >= cm.display.viewFrom && lineN < cm.display.viewTo) + { return cm.display.view[findViewIndex(cm, lineN)] } + var ext = cm.display.externalMeasured; + if (ext && lineN >= ext.lineN && lineN < ext.lineN + ext.size) + { return ext } + } + + // Measurement can be split in two steps, the set-up work that + // applies to the whole line, and the measurement of the actual + // character. Functions like coordsChar, that need to do a lot of + // measurements in a row, can thus ensure that the set-up work is + // only done once. + function prepareMeasureForLine(cm, line) { + var lineN = lineNo(line); + var view = findViewForLine(cm, lineN); + if (view && !view.text) { + view = null; + } else if (view && view.changes) { + updateLineForChanges(cm, view, lineN, getDimensions(cm)); + cm.curOp.forceUpdate = true; + } + if (!view) + { view = updateExternalMeasurement(cm, line); } + + var info = mapFromLineView(view, line, lineN); + return { + line: line, view: view, rect: null, + map: info.map, cache: info.cache, before: info.before, + hasHeights: false + } + } + + // Given a prepared measurement object, measures the position of an + // actual character (or fetches it from the cache). + function measureCharPrepared(cm, prepared, ch, bias, varHeight) { + if (prepared.before) { ch = -1; } + var key = ch + (bias || ""), found; + if (prepared.cache.hasOwnProperty(key)) { + found = prepared.cache[key]; + } else { + if (!prepared.rect) + { prepared.rect = prepared.view.text.getBoundingClientRect(); } + if (!prepared.hasHeights) { + ensureLineHeights(cm, prepared.view, prepared.rect); + prepared.hasHeights = true; + } + found = measureCharInner(cm, prepared, ch, bias); + if (!found.bogus) { prepared.cache[key] = found; } + } + return {left: found.left, right: found.right, + top: varHeight ? found.rtop : found.top, + bottom: varHeight ? found.rbottom : found.bottom} + } + + var nullRect = {left: 0, right: 0, top: 0, bottom: 0}; + + function nodeAndOffsetInLineMap(map$$1, ch, bias) { + var node, start, end, collapse, mStart, mEnd; + // First, search the line map for the text node corresponding to, + // or closest to, the target character. + for (var i = 0; i < map$$1.length; i += 3) { + mStart = map$$1[i]; + mEnd = map$$1[i + 1]; + if (ch < mStart) { + start = 0; end = 1; + collapse = "left"; + } else if (ch < mEnd) { + start = ch - mStart; + end = start + 1; + } else if (i == map$$1.length - 3 || ch == mEnd && map$$1[i + 3] > ch) { + end = mEnd - mStart; + start = end - 1; + if (ch >= mEnd) { collapse = "right"; } + } + if (start != null) { + node = map$$1[i + 2]; + if (mStart == mEnd && bias == (node.insertLeft ? "left" : "right")) + { collapse = bias; } + if (bias == "left" && start == 0) + { while (i && map$$1[i - 2] == map$$1[i - 3] && map$$1[i - 1].insertLeft) { + node = map$$1[(i -= 3) + 2]; + collapse = "left"; + } } + if (bias == "right" && start == mEnd - mStart) + { while (i < map$$1.length - 3 && map$$1[i + 3] == map$$1[i + 4] && !map$$1[i + 5].insertLeft) { + node = map$$1[(i += 3) + 2]; + collapse = "right"; + } } + break + } + } + return {node: node, start: start, end: end, collapse: collapse, coverStart: mStart, coverEnd: mEnd} + } + + function getUsefulRect(rects, bias) { + var rect = nullRect; + if (bias == "left") { for (var i = 0; i < rects.length; i++) { + if ((rect = rects[i]).left != rect.right) { break } + } } else { for (var i$1 = rects.length - 1; i$1 >= 0; i$1--) { + if ((rect = rects[i$1]).left != rect.right) { break } + } } + return rect + } + + function measureCharInner(cm, prepared, ch, bias) { + var place = nodeAndOffsetInLineMap(prepared.map, ch, bias); + var node = place.node, start = place.start, end = place.end, collapse = place.collapse; + + var rect; + if (node.nodeType == 3) { // If it is a text node, use a range to retrieve the coordinates. + for (var i$1 = 0; i$1 < 4; i$1++) { // Retry a maximum of 4 times when nonsense rectangles are returned + while (start && isExtendingChar(prepared.line.text.charAt(place.coverStart + start))) { --start; } + while (place.coverStart + end < place.coverEnd && isExtendingChar(prepared.line.text.charAt(place.coverStart + end))) { ++end; } + if (ie && ie_version < 9 && start == 0 && end == place.coverEnd - place.coverStart) + { rect = node.parentNode.getBoundingClientRect(); } + else + { rect = getUsefulRect(range(node, start, end).getClientRects(), bias); } + if (rect.left || rect.right || start == 0) { break } + end = start; + start = start - 1; + collapse = "right"; + } + if (ie && ie_version < 11) { rect = maybeUpdateRectForZooming(cm.display.measure, rect); } + } else { // If it is a widget, simply get the box for the whole widget. + if (start > 0) { collapse = bias = "right"; } + var rects; + if (cm.options.lineWrapping && (rects = node.getClientRects()).length > 1) + { rect = rects[bias == "right" ? rects.length - 1 : 0]; } + else + { rect = node.getBoundingClientRect(); } + } + if (ie && ie_version < 9 && !start && (!rect || !rect.left && !rect.right)) { + var rSpan = node.parentNode.getClientRects()[0]; + if (rSpan) + { rect = {left: rSpan.left, right: rSpan.left + charWidth(cm.display), top: rSpan.top, bottom: rSpan.bottom}; } + else + { rect = nullRect; } + } + + var rtop = rect.top - prepared.rect.top, rbot = rect.bottom - prepared.rect.top; + var mid = (rtop + rbot) / 2; + var heights = prepared.view.measure.heights; + var i = 0; + for (; i < heights.length - 1; i++) + { if (mid < heights[i]) { break } } + var top = i ? heights[i - 1] : 0, bot = heights[i]; + var result = {left: (collapse == "right" ? rect.right : rect.left) - prepared.rect.left, + right: (collapse == "left" ? rect.left : rect.right) - prepared.rect.left, + top: top, bottom: bot}; + if (!rect.left && !rect.right) { result.bogus = true; } + if (!cm.options.singleCursorHeightPerLine) { result.rtop = rtop; result.rbottom = rbot; } + + return result + } + + // Work around problem with bounding client rects on ranges being + // returned incorrectly when zoomed on IE10 and below. + function maybeUpdateRectForZooming(measure, rect) { + if (!window.screen || screen.logicalXDPI == null || + screen.logicalXDPI == screen.deviceXDPI || !hasBadZoomedRects(measure)) + { return rect } + var scaleX = screen.logicalXDPI / screen.deviceXDPI; + var scaleY = screen.logicalYDPI / screen.deviceYDPI; + return {left: rect.left * scaleX, right: rect.right * scaleX, + top: rect.top * scaleY, bottom: rect.bottom * scaleY} + } + + function clearLineMeasurementCacheFor(lineView) { + if (lineView.measure) { + lineView.measure.cache = {}; + lineView.measure.heights = null; + if (lineView.rest) { for (var i = 0; i < lineView.rest.length; i++) + { lineView.measure.caches[i] = {}; } } + } + } + + function clearLineMeasurementCache(cm) { + cm.display.externalMeasure = null; + removeChildren(cm.display.lineMeasure); + for (var i = 0; i < cm.display.view.length; i++) + { clearLineMeasurementCacheFor(cm.display.view[i]); } + } + + function clearCaches(cm) { + clearLineMeasurementCache(cm); + cm.display.cachedCharWidth = cm.display.cachedTextHeight = cm.display.cachedPaddingH = null; + if (!cm.options.lineWrapping) { cm.display.maxLineChanged = true; } + cm.display.lineNumChars = null; + } + + function pageScrollX() { + // Work around https://bugs.chromium.org/p/chromium/issues/detail?id=489206 + // which causes page_Offset and bounding client rects to use + // different reference viewports and invalidate our calculations. + if (chrome && android) { return -(document.body.getBoundingClientRect().left - parseInt(getComputedStyle(document.body).marginLeft)) } + return window.pageXOffset || (document.documentElement || document.body).scrollLeft + } + function pageScrollY() { + if (chrome && android) { return -(document.body.getBoundingClientRect().top - parseInt(getComputedStyle(document.body).marginTop)) } + return window.pageYOffset || (document.documentElement || document.body).scrollTop + } + + function widgetTopHeight(lineObj) { + var height = 0; + if (lineObj.widgets) { for (var i = 0; i < lineObj.widgets.length; ++i) { if (lineObj.widgets[i].above) + { height += widgetHeight(lineObj.widgets[i]); } } } + return height + } + + // Converts a {top, bottom, left, right} box from line-local + // coordinates into another coordinate system. Context may be one of + // "line", "div" (display.lineDiv), "local"./null (editor), "window", + // or "page". + function intoCoordSystem(cm, lineObj, rect, context, includeWidgets) { + if (!includeWidgets) { + var height = widgetTopHeight(lineObj); + rect.top += height; rect.bottom += height; + } + if (context == "line") { return rect } + if (!context) { context = "local"; } + var yOff = heightAtLine(lineObj); + if (context == "local") { yOff += paddingTop(cm.display); } + else { yOff -= cm.display.viewOffset; } + if (context == "page" || context == "window") { + var lOff = cm.display.lineSpace.getBoundingClientRect(); + yOff += lOff.top + (context == "window" ? 0 : pageScrollY()); + var xOff = lOff.left + (context == "window" ? 0 : pageScrollX()); + rect.left += xOff; rect.right += xOff; + } + rect.top += yOff; rect.bottom += yOff; + return rect + } + + // Coverts a box from "div" coords to another coordinate system. + // Context may be "window", "page", "div", or "local"./null. + function fromCoordSystem(cm, coords, context) { + if (context == "div") { return coords } + var left = coords.left, top = coords.top; + // First move into "page" coordinate system + if (context == "page") { + left -= pageScrollX(); + top -= pageScrollY(); + } else if (context == "local" || !context) { + var localBox = cm.display.sizer.getBoundingClientRect(); + left += localBox.left; + top += localBox.top; + } + + var lineSpaceBox = cm.display.lineSpace.getBoundingClientRect(); + return {left: left - lineSpaceBox.left, top: top - lineSpaceBox.top} + } + + function charCoords(cm, pos, context, lineObj, bias) { + if (!lineObj) { lineObj = getLine(cm.doc, pos.line); } + return intoCoordSystem(cm, lineObj, measureChar(cm, lineObj, pos.ch, bias), context) + } + + // Returns a box for a given cursor position, which may have an + // 'other' property containing the position of the secondary cursor + // on a bidi boundary. + // A cursor Pos(line, char, "before") is on the same visual line as `char - 1` + // and after `char - 1` in writing order of `char - 1` + // A cursor Pos(line, char, "after") is on the same visual line as `char` + // and before `char` in writing order of `char` + // Examples (upper-case letters are RTL, lower-case are LTR): + // Pos(0, 1, ...) + // before after + // ab a|b a|b + // aB a|B aB| + // Ab |Ab A|b + // AB B|A B|A + // Every position after the last character on a line is considered to stick + // to the last character on the line. + function cursorCoords(cm, pos, context, lineObj, preparedMeasure, varHeight) { + lineObj = lineObj || getLine(cm.doc, pos.line); + if (!preparedMeasure) { preparedMeasure = prepareMeasureForLine(cm, lineObj); } + function get(ch, right) { + var m = measureCharPrepared(cm, preparedMeasure, ch, right ? "right" : "left", varHeight); + if (right) { m.left = m.right; } else { m.right = m.left; } + return intoCoordSystem(cm, lineObj, m, context) + } + var order = getOrder(lineObj, cm.doc.direction), ch = pos.ch, sticky = pos.sticky; + if (ch >= lineObj.text.length) { + ch = lineObj.text.length; + sticky = "before"; + } else if (ch <= 0) { + ch = 0; + sticky = "after"; + } + if (!order) { return get(sticky == "before" ? ch - 1 : ch, sticky == "before") } + + function getBidi(ch, partPos, invert) { + var part = order[partPos], right = part.level == 1; + return get(invert ? ch - 1 : ch, right != invert) + } + var partPos = getBidiPartAt(order, ch, sticky); + var other = bidiOther; + var val = getBidi(ch, partPos, sticky == "before"); + if (other != null) { val.other = getBidi(ch, other, sticky != "before"); } + return val + } + + // Used to cheaply estimate the coordinates for a position. Used for + // intermediate scroll updates. + function estimateCoords(cm, pos) { + var left = 0; + pos = clipPos(cm.doc, pos); + if (!cm.options.lineWrapping) { left = charWidth(cm.display) * pos.ch; } + var lineObj = getLine(cm.doc, pos.line); + var top = heightAtLine(lineObj) + paddingTop(cm.display); + return {left: left, right: left, top: top, bottom: top + lineObj.height} + } + + // Positions returned by coordsChar contain some extra information. + // xRel is the relative x position of the input coordinates compared + // to the found position (so xRel > 0 means the coordinates are to + // the right of the character position, for example). When outside + // is true, that means the coordinates lie outside the line's + // vertical range. + function PosWithInfo(line, ch, sticky, outside, xRel) { + var pos = Pos(line, ch, sticky); + pos.xRel = xRel; + if (outside) { pos.outside = outside; } + return pos + } + + // Compute the character position closest to the given coordinates. + // Input must be lineSpace-local ("div" coordinate system). + function coordsChar(cm, x, y) { + var doc = cm.doc; + y += cm.display.viewOffset; + if (y < 0) { return PosWithInfo(doc.first, 0, null, -1, -1) } + var lineN = lineAtHeight(doc, y), last = doc.first + doc.size - 1; + if (lineN > last) + { return PosWithInfo(doc.first + doc.size - 1, getLine(doc, last).text.length, null, 1, 1) } + if (x < 0) { x = 0; } + + var lineObj = getLine(doc, lineN); + for (;;) { + var found = coordsCharInner(cm, lineObj, lineN, x, y); + var collapsed = collapsedSpanAround(lineObj, found.ch + (found.xRel > 0 || found.outside > 0 ? 1 : 0)); + if (!collapsed) { return found } + var rangeEnd = collapsed.find(1); + if (rangeEnd.line == lineN) { return rangeEnd } + lineObj = getLine(doc, lineN = rangeEnd.line); + } + } + + function wrappedLineExtent(cm, lineObj, preparedMeasure, y) { + y -= widgetTopHeight(lineObj); + var end = lineObj.text.length; + var begin = findFirst(function (ch) { return measureCharPrepared(cm, preparedMeasure, ch - 1).bottom <= y; }, end, 0); + end = findFirst(function (ch) { return measureCharPrepared(cm, preparedMeasure, ch).top > y; }, begin, end); + return {begin: begin, end: end} + } + + function wrappedLineExtentChar(cm, lineObj, preparedMeasure, target) { + if (!preparedMeasure) { preparedMeasure = prepareMeasureForLine(cm, lineObj); } + var targetTop = intoCoordSystem(cm, lineObj, measureCharPrepared(cm, preparedMeasure, target), "line").top; + return wrappedLineExtent(cm, lineObj, preparedMeasure, targetTop) + } + + // Returns true if the given side of a box is after the given + // coordinates, in top-to-bottom, left-to-right order. + function boxIsAfter(box, x, y, left) { + return box.bottom <= y ? false : box.top > y ? true : (left ? box.left : box.right) > x + } + + function coordsCharInner(cm, lineObj, lineNo$$1, x, y) { + // Move y into line-local coordinate space + y -= heightAtLine(lineObj); + var preparedMeasure = prepareMeasureForLine(cm, lineObj); + // When directly calling `measureCharPrepared`, we have to adjust + // for the widgets at this line. + var widgetHeight$$1 = widgetTopHeight(lineObj); + var begin = 0, end = lineObj.text.length, ltr = true; + + var order = getOrder(lineObj, cm.doc.direction); + // If the line isn't plain left-to-right text, first figure out + // which bidi section the coordinates fall into. + if (order) { + var part = (cm.options.lineWrapping ? coordsBidiPartWrapped : coordsBidiPart) + (cm, lineObj, lineNo$$1, preparedMeasure, order, x, y); + ltr = part.level != 1; + // The awkward -1 offsets are needed because findFirst (called + // on these below) will treat its first bound as inclusive, + // second as exclusive, but we want to actually address the + // characters in the part's range + begin = ltr ? part.from : part.to - 1; + end = ltr ? part.to : part.from - 1; + } + + // A binary search to find the first character whose bounding box + // starts after the coordinates. If we run across any whose box wrap + // the coordinates, store that. + var chAround = null, boxAround = null; + var ch = findFirst(function (ch) { + var box = measureCharPrepared(cm, preparedMeasure, ch); + box.top += widgetHeight$$1; box.bottom += widgetHeight$$1; + if (!boxIsAfter(box, x, y, false)) { return false } + if (box.top <= y && box.left <= x) { + chAround = ch; + boxAround = box; + } + return true + }, begin, end); + + var baseX, sticky, outside = false; + // If a box around the coordinates was found, use that + if (boxAround) { + // Distinguish coordinates nearer to the left or right side of the box + var atLeft = x - boxAround.left < boxAround.right - x, atStart = atLeft == ltr; + ch = chAround + (atStart ? 0 : 1); + sticky = atStart ? "after" : "before"; + baseX = atLeft ? boxAround.left : boxAround.right; + } else { + // (Adjust for extended bound, if necessary.) + if (!ltr && (ch == end || ch == begin)) { ch++; } + // To determine which side to associate with, get the box to the + // left of the character and compare it's vertical position to the + // coordinates + sticky = ch == 0 ? "after" : ch == lineObj.text.length ? "before" : + (measureCharPrepared(cm, preparedMeasure, ch - (ltr ? 1 : 0)).bottom + widgetHeight$$1 <= y) == ltr ? + "after" : "before"; + // Now get accurate coordinates for this place, in order to get a + // base X position + var coords = cursorCoords(cm, Pos(lineNo$$1, ch, sticky), "line", lineObj, preparedMeasure); + baseX = coords.left; + outside = y < coords.top ? -1 : y >= coords.bottom ? 1 : 0; + } + + ch = skipExtendingChars(lineObj.text, ch, 1); + return PosWithInfo(lineNo$$1, ch, sticky, outside, x - baseX) + } + + function coordsBidiPart(cm, lineObj, lineNo$$1, preparedMeasure, order, x, y) { + // Bidi parts are sorted left-to-right, and in a non-line-wrapping + // situation, we can take this ordering to correspond to the visual + // ordering. This finds the first part whose end is after the given + // coordinates. + var index = findFirst(function (i) { + var part = order[i], ltr = part.level != 1; + return boxIsAfter(cursorCoords(cm, Pos(lineNo$$1, ltr ? part.to : part.from, ltr ? "before" : "after"), + "line", lineObj, preparedMeasure), x, y, true) + }, 0, order.length - 1); + var part = order[index]; + // If this isn't the first part, the part's start is also after + // the coordinates, and the coordinates aren't on the same line as + // that start, move one part back. + if (index > 0) { + var ltr = part.level != 1; + var start = cursorCoords(cm, Pos(lineNo$$1, ltr ? part.from : part.to, ltr ? "after" : "before"), + "line", lineObj, preparedMeasure); + if (boxIsAfter(start, x, y, true) && start.top > y) + { part = order[index - 1]; } + } + return part + } + + function coordsBidiPartWrapped(cm, lineObj, _lineNo, preparedMeasure, order, x, y) { + // In a wrapped line, rtl text on wrapping boundaries can do things + // that don't correspond to the ordering in our `order` array at + // all, so a binary search doesn't work, and we want to return a + // part that only spans one line so that the binary search in + // coordsCharInner is safe. As such, we first find the extent of the + // wrapped line, and then do a flat search in which we discard any + // spans that aren't on the line. + var ref = wrappedLineExtent(cm, lineObj, preparedMeasure, y); + var begin = ref.begin; + var end = ref.end; + if (/\s/.test(lineObj.text.charAt(end - 1))) { end--; } + var part = null, closestDist = null; + for (var i = 0; i < order.length; i++) { + var p = order[i]; + if (p.from >= end || p.to <= begin) { continue } + var ltr = p.level != 1; + var endX = measureCharPrepared(cm, preparedMeasure, ltr ? Math.min(end, p.to) - 1 : Math.max(begin, p.from)).right; + // Weigh against spans ending before this, so that they are only + // picked if nothing ends after + var dist = endX < x ? x - endX + 1e9 : endX - x; + if (!part || closestDist > dist) { + part = p; + closestDist = dist; + } + } + if (!part) { part = order[order.length - 1]; } + // Clip the part to the wrapped line. + if (part.from < begin) { part = {from: begin, to: part.to, level: part.level}; } + if (part.to > end) { part = {from: part.from, to: end, level: part.level}; } + return part + } + + var measureText; + // Compute the default text height. + function textHeight(display) { + if (display.cachedTextHeight != null) { return display.cachedTextHeight } + if (measureText == null) { + measureText = elt("pre", null, "CodeMirror-line-like"); + // Measure a bunch of lines, for browsers that compute + // fractional heights. + for (var i = 0; i < 49; ++i) { + measureText.appendChild(document.createTextNode("x")); + measureText.appendChild(elt("br")); + } + measureText.appendChild(document.createTextNode("x")); + } + removeChildrenAndAdd(display.measure, measureText); + var height = measureText.offsetHeight / 50; + if (height > 3) { display.cachedTextHeight = height; } + removeChildren(display.measure); + return height || 1 + } + + // Compute the default character width. + function charWidth(display) { + if (display.cachedCharWidth != null) { return display.cachedCharWidth } + var anchor = elt("span", "xxxxxxxxxx"); + var pre = elt("pre", [anchor], "CodeMirror-line-like"); + removeChildrenAndAdd(display.measure, pre); + var rect = anchor.getBoundingClientRect(), width = (rect.right - rect.left) / 10; + if (width > 2) { display.cachedCharWidth = width; } + return width || 10 + } + + // Do a bulk-read of the DOM positions and sizes needed to draw the + // view, so that we don't interleave reading and writing to the DOM. + function getDimensions(cm) { + var d = cm.display, left = {}, width = {}; + var gutterLeft = d.gutters.clientLeft; + for (var n = d.gutters.firstChild, i = 0; n; n = n.nextSibling, ++i) { + var id = cm.display.gutterSpecs[i].className; + left[id] = n.offsetLeft + n.clientLeft + gutterLeft; + width[id] = n.clientWidth; + } + return {fixedPos: compensateForHScroll(d), + gutterTotalWidth: d.gutters.offsetWidth, + gutterLeft: left, + gutterWidth: width, + wrapperWidth: d.wrapper.clientWidth} + } + + // Computes display.scroller.scrollLeft + display.gutters.offsetWidth, + // but using getBoundingClientRect to get a sub-pixel-accurate + // result. + function compensateForHScroll(display) { + return display.scroller.getBoundingClientRect().left - display.sizer.getBoundingClientRect().left + } + + // Returns a function that estimates the height of a line, to use as + // first approximation until the line becomes visible (and is thus + // properly measurable). + function estimateHeight(cm) { + var th = textHeight(cm.display), wrapping = cm.options.lineWrapping; + var perLine = wrapping && Math.max(5, cm.display.scroller.clientWidth / charWidth(cm.display) - 3); + return function (line) { + if (lineIsHidden(cm.doc, line)) { return 0 } + + var widgetsHeight = 0; + if (line.widgets) { for (var i = 0; i < line.widgets.length; i++) { + if (line.widgets[i].height) { widgetsHeight += line.widgets[i].height; } + } } + + if (wrapping) + { return widgetsHeight + (Math.ceil(line.text.length / perLine) || 1) * th } + else + { return widgetsHeight + th } + } + } + + function estimateLineHeights(cm) { + var doc = cm.doc, est = estimateHeight(cm); + doc.iter(function (line) { + var estHeight = est(line); + if (estHeight != line.height) { updateLineHeight(line, estHeight); } + }); + } + + // Given a mouse event, find the corresponding position. If liberal + // is false, it checks whether a gutter or scrollbar was clicked, + // and returns null if it was. forRect is used by rectangular + // selections, and tries to estimate a character position even for + // coordinates beyond the right of the text. + function posFromMouse(cm, e, liberal, forRect) { + var display = cm.display; + if (!liberal && e_target(e).getAttribute("cm-not-content") == "true") { return null } + + var x, y, space = display.lineSpace.getBoundingClientRect(); + // Fails unpredictably on IE[67] when mouse is dragged around quickly. + try { x = e.clientX - space.left; y = e.clientY - space.top; } + catch (e) { return null } + var coords = coordsChar(cm, x, y), line; + if (forRect && coords.xRel == 1 && (line = getLine(cm.doc, coords.line).text).length == coords.ch) { + var colDiff = countColumn(line, line.length, cm.options.tabSize) - line.length; + coords = Pos(coords.line, Math.max(0, Math.round((x - paddingH(cm.display).left) / charWidth(cm.display)) - colDiff)); + } + return coords + } + + // Find the view element corresponding to a given line. Return null + // when the line isn't visible. + function findViewIndex(cm, n) { + if (n >= cm.display.viewTo) { return null } + n -= cm.display.viewFrom; + if (n < 0) { return null } + var view = cm.display.view; + for (var i = 0; i < view.length; i++) { + n -= view[i].size; + if (n < 0) { return i } + } + } + + // Updates the display.view data structure for a given change to the + // document. From and to are in pre-change coordinates. Lendiff is + // the amount of lines added or subtracted by the change. This is + // used for changes that span multiple lines, or change the way + // lines are divided into visual lines. regLineChange (below) + // registers single-line changes. + function regChange(cm, from, to, lendiff) { + if (from == null) { from = cm.doc.first; } + if (to == null) { to = cm.doc.first + cm.doc.size; } + if (!lendiff) { lendiff = 0; } + + var display = cm.display; + if (lendiff && to < display.viewTo && + (display.updateLineNumbers == null || display.updateLineNumbers > from)) + { display.updateLineNumbers = from; } + + cm.curOp.viewChanged = true; + + if (from >= display.viewTo) { // Change after + if (sawCollapsedSpans && visualLineNo(cm.doc, from) < display.viewTo) + { resetView(cm); } + } else if (to <= display.viewFrom) { // Change before + if (sawCollapsedSpans && visualLineEndNo(cm.doc, to + lendiff) > display.viewFrom) { + resetView(cm); + } else { + display.viewFrom += lendiff; + display.viewTo += lendiff; + } + } else if (from <= display.viewFrom && to >= display.viewTo) { // Full overlap + resetView(cm); + } else if (from <= display.viewFrom) { // Top overlap + var cut = viewCuttingPoint(cm, to, to + lendiff, 1); + if (cut) { + display.view = display.view.slice(cut.index); + display.viewFrom = cut.lineN; + display.viewTo += lendiff; + } else { + resetView(cm); + } + } else if (to >= display.viewTo) { // Bottom overlap + var cut$1 = viewCuttingPoint(cm, from, from, -1); + if (cut$1) { + display.view = display.view.slice(0, cut$1.index); + display.viewTo = cut$1.lineN; + } else { + resetView(cm); + } + } else { // Gap in the middle + var cutTop = viewCuttingPoint(cm, from, from, -1); + var cutBot = viewCuttingPoint(cm, to, to + lendiff, 1); + if (cutTop && cutBot) { + display.view = display.view.slice(0, cutTop.index) + .concat(buildViewArray(cm, cutTop.lineN, cutBot.lineN)) + .concat(display.view.slice(cutBot.index)); + display.viewTo += lendiff; + } else { + resetView(cm); + } + } + + var ext = display.externalMeasured; + if (ext) { + if (to < ext.lineN) + { ext.lineN += lendiff; } + else if (from < ext.lineN + ext.size) + { display.externalMeasured = null; } + } + } + + // Register a change to a single line. Type must be one of "text", + // "gutter", "class", "widget" + function regLineChange(cm, line, type) { + cm.curOp.viewChanged = true; + var display = cm.display, ext = cm.display.externalMeasured; + if (ext && line >= ext.lineN && line < ext.lineN + ext.size) + { display.externalMeasured = null; } + + if (line < display.viewFrom || line >= display.viewTo) { return } + var lineView = display.view[findViewIndex(cm, line)]; + if (lineView.node == null) { return } + var arr = lineView.changes || (lineView.changes = []); + if (indexOf(arr, type) == -1) { arr.push(type); } + } + + // Clear the view. + function resetView(cm) { + cm.display.viewFrom = cm.display.viewTo = cm.doc.first; + cm.display.view = []; + cm.display.viewOffset = 0; + } + + function viewCuttingPoint(cm, oldN, newN, dir) { + var index = findViewIndex(cm, oldN), diff, view = cm.display.view; + if (!sawCollapsedSpans || newN == cm.doc.first + cm.doc.size) + { return {index: index, lineN: newN} } + var n = cm.display.viewFrom; + for (var i = 0; i < index; i++) + { n += view[i].size; } + if (n != oldN) { + if (dir > 0) { + if (index == view.length - 1) { return null } + diff = (n + view[index].size) - oldN; + index++; + } else { + diff = n - oldN; + } + oldN += diff; newN += diff; + } + while (visualLineNo(cm.doc, newN) != newN) { + if (index == (dir < 0 ? 0 : view.length - 1)) { return null } + newN += dir * view[index - (dir < 0 ? 1 : 0)].size; + index += dir; + } + return {index: index, lineN: newN} + } + + // Force the view to cover a given range, adding empty view element + // or clipping off existing ones as needed. + function adjustView(cm, from, to) { + var display = cm.display, view = display.view; + if (view.length == 0 || from >= display.viewTo || to <= display.viewFrom) { + display.view = buildViewArray(cm, from, to); + display.viewFrom = from; + } else { + if (display.viewFrom > from) + { display.view = buildViewArray(cm, from, display.viewFrom).concat(display.view); } + else if (display.viewFrom < from) + { display.view = display.view.slice(findViewIndex(cm, from)); } + display.viewFrom = from; + if (display.viewTo < to) + { display.view = display.view.concat(buildViewArray(cm, display.viewTo, to)); } + else if (display.viewTo > to) + { display.view = display.view.slice(0, findViewIndex(cm, to)); } + } + display.viewTo = to; + } + + // Count the number of lines in the view whose DOM representation is + // out of date (or nonexistent). + function countDirtyView(cm) { + var view = cm.display.view, dirty = 0; + for (var i = 0; i < view.length; i++) { + var lineView = view[i]; + if (!lineView.hidden && (!lineView.node || lineView.changes)) { ++dirty; } + } + return dirty + } + + function updateSelection(cm) { + cm.display.input.showSelection(cm.display.input.prepareSelection()); + } + + function prepareSelection(cm, primary) { + if ( primary === void 0 ) primary = true; + + var doc = cm.doc, result = {}; + var curFragment = result.cursors = document.createDocumentFragment(); + var selFragment = result.selection = document.createDocumentFragment(); + + for (var i = 0; i < doc.sel.ranges.length; i++) { + if (!primary && i == doc.sel.primIndex) { continue } + var range$$1 = doc.sel.ranges[i]; + if (range$$1.from().line >= cm.display.viewTo || range$$1.to().line < cm.display.viewFrom) { continue } + var collapsed = range$$1.empty(); + if (collapsed || cm.options.showCursorWhenSelecting) + { drawSelectionCursor(cm, range$$1.head, curFragment); } + if (!collapsed) + { drawSelectionRange(cm, range$$1, selFragment); } + } + return result + } + + // Draws a cursor for the given range + function drawSelectionCursor(cm, head, output) { + var pos = cursorCoords(cm, head, "div", null, null, !cm.options.singleCursorHeightPerLine); + + var cursor = output.appendChild(elt("div", "\u00a0", "CodeMirror-cursor")); + cursor.style.left = pos.left + "px"; + cursor.style.top = pos.top + "px"; + cursor.style.height = Math.max(0, pos.bottom - pos.top) * cm.options.cursorHeight + "px"; + + if (pos.other) { + // Secondary cursor, shown when on a 'jump' in bi-directional text + var otherCursor = output.appendChild(elt("div", "\u00a0", "CodeMirror-cursor CodeMirror-secondarycursor")); + otherCursor.style.display = ""; + otherCursor.style.left = pos.other.left + "px"; + otherCursor.style.top = pos.other.top + "px"; + otherCursor.style.height = (pos.other.bottom - pos.other.top) * .85 + "px"; + } + } + + function cmpCoords(a, b) { return a.top - b.top || a.left - b.left } + + // Draws the given range as a highlighted selection + function drawSelectionRange(cm, range$$1, output) { + var display = cm.display, doc = cm.doc; + var fragment = document.createDocumentFragment(); + var padding = paddingH(cm.display), leftSide = padding.left; + var rightSide = Math.max(display.sizerWidth, displayWidth(cm) - display.sizer.offsetLeft) - padding.right; + var docLTR = doc.direction == "ltr"; + + function add(left, top, width, bottom) { + if (top < 0) { top = 0; } + top = Math.round(top); + bottom = Math.round(bottom); + fragment.appendChild(elt("div", null, "CodeMirror-selected", ("position: absolute; left: " + left + "px;\n top: " + top + "px; width: " + (width == null ? rightSide - left : width) + "px;\n height: " + (bottom - top) + "px"))); + } + + function drawForLine(line, fromArg, toArg) { + var lineObj = getLine(doc, line); + var lineLen = lineObj.text.length; + var start, end; + function coords(ch, bias) { + return charCoords(cm, Pos(line, ch), "div", lineObj, bias) + } + + function wrapX(pos, dir, side) { + var extent = wrappedLineExtentChar(cm, lineObj, null, pos); + var prop = (dir == "ltr") == (side == "after") ? "left" : "right"; + var ch = side == "after" ? extent.begin : extent.end - (/\s/.test(lineObj.text.charAt(extent.end - 1)) ? 2 : 1); + return coords(ch, prop)[prop] + } + + var order = getOrder(lineObj, doc.direction); + iterateBidiSections(order, fromArg || 0, toArg == null ? lineLen : toArg, function (from, to, dir, i) { + var ltr = dir == "ltr"; + var fromPos = coords(from, ltr ? "left" : "right"); + var toPos = coords(to - 1, ltr ? "right" : "left"); + + var openStart = fromArg == null && from == 0, openEnd = toArg == null && to == lineLen; + var first = i == 0, last = !order || i == order.length - 1; + if (toPos.top - fromPos.top <= 3) { // Single line + var openLeft = (docLTR ? openStart : openEnd) && first; + var openRight = (docLTR ? openEnd : openStart) && last; + var left = openLeft ? leftSide : (ltr ? fromPos : toPos).left; + var right = openRight ? rightSide : (ltr ? toPos : fromPos).right; + add(left, fromPos.top, right - left, fromPos.bottom); + } else { // Multiple lines + var topLeft, topRight, botLeft, botRight; + if (ltr) { + topLeft = docLTR && openStart && first ? leftSide : fromPos.left; + topRight = docLTR ? rightSide : wrapX(from, dir, "before"); + botLeft = docLTR ? leftSide : wrapX(to, dir, "after"); + botRight = docLTR && openEnd && last ? rightSide : toPos.right; + } else { + topLeft = !docLTR ? leftSide : wrapX(from, dir, "before"); + topRight = !docLTR && openStart && first ? rightSide : fromPos.right; + botLeft = !docLTR && openEnd && last ? leftSide : toPos.left; + botRight = !docLTR ? rightSide : wrapX(to, dir, "after"); + } + add(topLeft, fromPos.top, topRight - topLeft, fromPos.bottom); + if (fromPos.bottom < toPos.top) { add(leftSide, fromPos.bottom, null, toPos.top); } + add(botLeft, toPos.top, botRight - botLeft, toPos.bottom); + } + + if (!start || cmpCoords(fromPos, start) < 0) { start = fromPos; } + if (cmpCoords(toPos, start) < 0) { start = toPos; } + if (!end || cmpCoords(fromPos, end) < 0) { end = fromPos; } + if (cmpCoords(toPos, end) < 0) { end = toPos; } + }); + return {start: start, end: end} + } + + var sFrom = range$$1.from(), sTo = range$$1.to(); + if (sFrom.line == sTo.line) { + drawForLine(sFrom.line, sFrom.ch, sTo.ch); + } else { + var fromLine = getLine(doc, sFrom.line), toLine = getLine(doc, sTo.line); + var singleVLine = visualLine(fromLine) == visualLine(toLine); + var leftEnd = drawForLine(sFrom.line, sFrom.ch, singleVLine ? fromLine.text.length + 1 : null).end; + var rightStart = drawForLine(sTo.line, singleVLine ? 0 : null, sTo.ch).start; + if (singleVLine) { + if (leftEnd.top < rightStart.top - 2) { + add(leftEnd.right, leftEnd.top, null, leftEnd.bottom); + add(leftSide, rightStart.top, rightStart.left, rightStart.bottom); + } else { + add(leftEnd.right, leftEnd.top, rightStart.left - leftEnd.right, leftEnd.bottom); + } + } + if (leftEnd.bottom < rightStart.top) + { add(leftSide, leftEnd.bottom, null, rightStart.top); } + } + + output.appendChild(fragment); + } + + // Cursor-blinking + function restartBlink(cm) { + if (!cm.state.focused) { return } + var display = cm.display; + clearInterval(display.blinker); + var on = true; + display.cursorDiv.style.visibility = ""; + if (cm.options.cursorBlinkRate > 0) + { display.blinker = setInterval(function () { return display.cursorDiv.style.visibility = (on = !on) ? "" : "hidden"; }, + cm.options.cursorBlinkRate); } + else if (cm.options.cursorBlinkRate < 0) + { display.cursorDiv.style.visibility = "hidden"; } + } + + function ensureFocus(cm) { + if (!cm.state.focused) { cm.display.input.focus(); onFocus(cm); } + } + + function delayBlurEvent(cm) { + cm.state.delayingBlurEvent = true; + setTimeout(function () { if (cm.state.delayingBlurEvent) { + cm.state.delayingBlurEvent = false; + onBlur(cm); + } }, 100); + } + + function onFocus(cm, e) { + if (cm.state.delayingBlurEvent) { cm.state.delayingBlurEvent = false; } + + if (cm.options.readOnly == "nocursor") { return } + if (!cm.state.focused) { + signal(cm, "focus", cm, e); + cm.state.focused = true; + addClass(cm.display.wrapper, "CodeMirror-focused"); + // This test prevents this from firing when a context + // menu is closed (since the input reset would kill the + // select-all detection hack) + if (!cm.curOp && cm.display.selForContextMenu != cm.doc.sel) { + cm.display.input.reset(); + if (webkit) { setTimeout(function () { return cm.display.input.reset(true); }, 20); } // Issue #1730 + } + cm.display.input.receivedFocus(); + } + restartBlink(cm); + } + function onBlur(cm, e) { + if (cm.state.delayingBlurEvent) { return } + + if (cm.state.focused) { + signal(cm, "blur", cm, e); + cm.state.focused = false; + rmClass(cm.display.wrapper, "CodeMirror-focused"); + } + clearInterval(cm.display.blinker); + setTimeout(function () { if (!cm.state.focused) { cm.display.shift = false; } }, 150); + } + + // Read the actual heights of the rendered lines, and update their + // stored heights to match. + function updateHeightsInViewport(cm) { + var display = cm.display; + var prevBottom = display.lineDiv.offsetTop; + for (var i = 0; i < display.view.length; i++) { + var cur = display.view[i], wrapping = cm.options.lineWrapping; + var height = (void 0), width = 0; + if (cur.hidden) { continue } + if (ie && ie_version < 8) { + var bot = cur.node.offsetTop + cur.node.offsetHeight; + height = bot - prevBottom; + prevBottom = bot; + } else { + var box = cur.node.getBoundingClientRect(); + height = box.bottom - box.top; + // Check that lines don't extend past the right of the current + // editor width + if (!wrapping && cur.text.firstChild) + { width = cur.text.firstChild.getBoundingClientRect().right - box.left - 1; } + } + var diff = cur.line.height - height; + if (diff > .005 || diff < -.005) { + updateLineHeight(cur.line, height); + updateWidgetHeight(cur.line); + if (cur.rest) { for (var j = 0; j < cur.rest.length; j++) + { updateWidgetHeight(cur.rest[j]); } } + } + if (width > cm.display.sizerWidth) { + var chWidth = Math.ceil(width / charWidth(cm.display)); + if (chWidth > cm.display.maxLineLength) { + cm.display.maxLineLength = chWidth; + cm.display.maxLine = cur.line; + cm.display.maxLineChanged = true; + } + } + } + } + + // Read and store the height of line widgets associated with the + // given line. + function updateWidgetHeight(line) { + if (line.widgets) { for (var i = 0; i < line.widgets.length; ++i) { + var w = line.widgets[i], parent = w.node.parentNode; + if (parent) { w.height = parent.offsetHeight; } + } } + } + + // Compute the lines that are visible in a given viewport (defaults + // the the current scroll position). viewport may contain top, + // height, and ensure (see op.scrollToPos) properties. + function visibleLines(display, doc, viewport) { + var top = viewport && viewport.top != null ? Math.max(0, viewport.top) : display.scroller.scrollTop; + top = Math.floor(top - paddingTop(display)); + var bottom = viewport && viewport.bottom != null ? viewport.bottom : top + display.wrapper.clientHeight; + + var from = lineAtHeight(doc, top), to = lineAtHeight(doc, bottom); + // Ensure is a {from: {line, ch}, to: {line, ch}} object, and + // forces those lines into the viewport (if possible). + if (viewport && viewport.ensure) { + var ensureFrom = viewport.ensure.from.line, ensureTo = viewport.ensure.to.line; + if (ensureFrom < from) { + from = ensureFrom; + to = lineAtHeight(doc, heightAtLine(getLine(doc, ensureFrom)) + display.wrapper.clientHeight); + } else if (Math.min(ensureTo, doc.lastLine()) >= to) { + from = lineAtHeight(doc, heightAtLine(getLine(doc, ensureTo)) - display.wrapper.clientHeight); + to = ensureTo; + } + } + return {from: from, to: Math.max(to, from + 1)} + } + + // SCROLLING THINGS INTO VIEW + + // If an editor sits on the top or bottom of the window, partially + // scrolled out of view, this ensures that the cursor is visible. + function maybeScrollWindow(cm, rect) { + if (signalDOMEvent(cm, "scrollCursorIntoView")) { return } + + var display = cm.display, box = display.sizer.getBoundingClientRect(), doScroll = null; + if (rect.top + box.top < 0) { doScroll = true; } + else if (rect.bottom + box.top > (window.innerHeight || document.documentElement.clientHeight)) { doScroll = false; } + if (doScroll != null && !phantom) { + var scrollNode = elt("div", "\u200b", null, ("position: absolute;\n top: " + (rect.top - display.viewOffset - paddingTop(cm.display)) + "px;\n height: " + (rect.bottom - rect.top + scrollGap(cm) + display.barHeight) + "px;\n left: " + (rect.left) + "px; width: " + (Math.max(2, rect.right - rect.left)) + "px;")); + cm.display.lineSpace.appendChild(scrollNode); + scrollNode.scrollIntoView(doScroll); + cm.display.lineSpace.removeChild(scrollNode); + } + } + + // Scroll a given position into view (immediately), verifying that + // it actually became visible (as line heights are accurately + // measured, the position of something may 'drift' during drawing). + function scrollPosIntoView(cm, pos, end, margin) { + if (margin == null) { margin = 0; } + var rect; + if (!cm.options.lineWrapping && pos == end) { + // Set pos and end to the cursor positions around the character pos sticks to + // If pos.sticky == "before", that is around pos.ch - 1, otherwise around pos.ch + // If pos == Pos(_, 0, "before"), pos and end are unchanged + pos = pos.ch ? Pos(pos.line, pos.sticky == "before" ? pos.ch - 1 : pos.ch, "after") : pos; + end = pos.sticky == "before" ? Pos(pos.line, pos.ch + 1, "before") : pos; + } + for (var limit = 0; limit < 5; limit++) { + var changed = false; + var coords = cursorCoords(cm, pos); + var endCoords = !end || end == pos ? coords : cursorCoords(cm, end); + rect = {left: Math.min(coords.left, endCoords.left), + top: Math.min(coords.top, endCoords.top) - margin, + right: Math.max(coords.left, endCoords.left), + bottom: Math.max(coords.bottom, endCoords.bottom) + margin}; + var scrollPos = calculateScrollPos(cm, rect); + var startTop = cm.doc.scrollTop, startLeft = cm.doc.scrollLeft; + if (scrollPos.scrollTop != null) { + updateScrollTop(cm, scrollPos.scrollTop); + if (Math.abs(cm.doc.scrollTop - startTop) > 1) { changed = true; } + } + if (scrollPos.scrollLeft != null) { + setScrollLeft(cm, scrollPos.scrollLeft); + if (Math.abs(cm.doc.scrollLeft - startLeft) > 1) { changed = true; } + } + if (!changed) { break } + } + return rect + } + + // Scroll a given set of coordinates into view (immediately). + function scrollIntoView(cm, rect) { + var scrollPos = calculateScrollPos(cm, rect); + if (scrollPos.scrollTop != null) { updateScrollTop(cm, scrollPos.scrollTop); } + if (scrollPos.scrollLeft != null) { setScrollLeft(cm, scrollPos.scrollLeft); } + } + + // Calculate a new scroll position needed to scroll the given + // rectangle into view. Returns an object with scrollTop and + // scrollLeft properties. When these are undefined, the + // vertical/horizontal position does not need to be adjusted. + function calculateScrollPos(cm, rect) { + var display = cm.display, snapMargin = textHeight(cm.display); + if (rect.top < 0) { rect.top = 0; } + var screentop = cm.curOp && cm.curOp.scrollTop != null ? cm.curOp.scrollTop : display.scroller.scrollTop; + var screen = displayHeight(cm), result = {}; + if (rect.bottom - rect.top > screen) { rect.bottom = rect.top + screen; } + var docBottom = cm.doc.height + paddingVert(display); + var atTop = rect.top < snapMargin, atBottom = rect.bottom > docBottom - snapMargin; + if (rect.top < screentop) { + result.scrollTop = atTop ? 0 : rect.top; + } else if (rect.bottom > screentop + screen) { + var newTop = Math.min(rect.top, (atBottom ? docBottom : rect.bottom) - screen); + if (newTop != screentop) { result.scrollTop = newTop; } + } + + var screenleft = cm.curOp && cm.curOp.scrollLeft != null ? cm.curOp.scrollLeft : display.scroller.scrollLeft; + var screenw = displayWidth(cm) - (cm.options.fixedGutter ? display.gutters.offsetWidth : 0); + var tooWide = rect.right - rect.left > screenw; + if (tooWide) { rect.right = rect.left + screenw; } + if (rect.left < 10) + { result.scrollLeft = 0; } + else if (rect.left < screenleft) + { result.scrollLeft = Math.max(0, rect.left - (tooWide ? 0 : 10)); } + else if (rect.right > screenw + screenleft - 3) + { result.scrollLeft = rect.right + (tooWide ? 0 : 10) - screenw; } + return result + } + + // Store a relative adjustment to the scroll position in the current + // operation (to be applied when the operation finishes). + function addToScrollTop(cm, top) { + if (top == null) { return } + resolveScrollToPos(cm); + cm.curOp.scrollTop = (cm.curOp.scrollTop == null ? cm.doc.scrollTop : cm.curOp.scrollTop) + top; + } + + // Make sure that at the end of the operation the current cursor is + // shown. + function ensureCursorVisible(cm) { + resolveScrollToPos(cm); + var cur = cm.getCursor(); + cm.curOp.scrollToPos = {from: cur, to: cur, margin: cm.options.cursorScrollMargin}; + } + + function scrollToCoords(cm, x, y) { + if (x != null || y != null) { resolveScrollToPos(cm); } + if (x != null) { cm.curOp.scrollLeft = x; } + if (y != null) { cm.curOp.scrollTop = y; } + } + + function scrollToRange(cm, range$$1) { + resolveScrollToPos(cm); + cm.curOp.scrollToPos = range$$1; + } + + // When an operation has its scrollToPos property set, and another + // scroll action is applied before the end of the operation, this + // 'simulates' scrolling that position into view in a cheap way, so + // that the effect of intermediate scroll commands is not ignored. + function resolveScrollToPos(cm) { + var range$$1 = cm.curOp.scrollToPos; + if (range$$1) { + cm.curOp.scrollToPos = null; + var from = estimateCoords(cm, range$$1.from), to = estimateCoords(cm, range$$1.to); + scrollToCoordsRange(cm, from, to, range$$1.margin); + } + } + + function scrollToCoordsRange(cm, from, to, margin) { + var sPos = calculateScrollPos(cm, { + left: Math.min(from.left, to.left), + top: Math.min(from.top, to.top) - margin, + right: Math.max(from.right, to.right), + bottom: Math.max(from.bottom, to.bottom) + margin + }); + scrollToCoords(cm, sPos.scrollLeft, sPos.scrollTop); + } + + // Sync the scrollable area and scrollbars, ensure the viewport + // covers the visible area. + function updateScrollTop(cm, val) { + if (Math.abs(cm.doc.scrollTop - val) < 2) { return } + if (!gecko) { updateDisplaySimple(cm, {top: val}); } + setScrollTop(cm, val, true); + if (gecko) { updateDisplaySimple(cm); } + startWorker(cm, 100); + } + + function setScrollTop(cm, val, forceScroll) { + val = Math.min(cm.display.scroller.scrollHeight - cm.display.scroller.clientHeight, val); + if (cm.display.scroller.scrollTop == val && !forceScroll) { return } + cm.doc.scrollTop = val; + cm.display.scrollbars.setScrollTop(val); + if (cm.display.scroller.scrollTop != val) { cm.display.scroller.scrollTop = val; } + } + + // Sync scroller and scrollbar, ensure the gutter elements are + // aligned. + function setScrollLeft(cm, val, isScroller, forceScroll) { + val = Math.min(val, cm.display.scroller.scrollWidth - cm.display.scroller.clientWidth); + if ((isScroller ? val == cm.doc.scrollLeft : Math.abs(cm.doc.scrollLeft - val) < 2) && !forceScroll) { return } + cm.doc.scrollLeft = val; + alignHorizontally(cm); + if (cm.display.scroller.scrollLeft != val) { cm.display.scroller.scrollLeft = val; } + cm.display.scrollbars.setScrollLeft(val); + } + + // SCROLLBARS + + // Prepare DOM reads needed to update the scrollbars. Done in one + // shot to minimize update/measure roundtrips. + function measureForScrollbars(cm) { + var d = cm.display, gutterW = d.gutters.offsetWidth; + var docH = Math.round(cm.doc.height + paddingVert(cm.display)); + return { + clientHeight: d.scroller.clientHeight, + viewHeight: d.wrapper.clientHeight, + scrollWidth: d.scroller.scrollWidth, clientWidth: d.scroller.clientWidth, + viewWidth: d.wrapper.clientWidth, + barLeft: cm.options.fixedGutter ? gutterW : 0, + docHeight: docH, + scrollHeight: docH + scrollGap(cm) + d.barHeight, + nativeBarWidth: d.nativeBarWidth, + gutterWidth: gutterW + } + } + + var NativeScrollbars = function(place, scroll, cm) { + this.cm = cm; + var vert = this.vert = elt("div", [elt("div", null, null, "min-width: 1px")], "CodeMirror-vscrollbar"); + var horiz = this.horiz = elt("div", [elt("div", null, null, "height: 100%; min-height: 1px")], "CodeMirror-hscrollbar"); + vert.tabIndex = horiz.tabIndex = -1; + place(vert); place(horiz); + + on(vert, "scroll", function () { + if (vert.clientHeight) { scroll(vert.scrollTop, "vertical"); } + }); + on(horiz, "scroll", function () { + if (horiz.clientWidth) { scroll(horiz.scrollLeft, "horizontal"); } + }); + + this.checkedZeroWidth = false; + // Need to set a minimum width to see the scrollbar on IE7 (but must not set it on IE8). + if (ie && ie_version < 8) { this.horiz.style.minHeight = this.vert.style.minWidth = "18px"; } + }; + + NativeScrollbars.prototype.update = function (measure) { + var needsH = measure.scrollWidth > measure.clientWidth + 1; + var needsV = measure.scrollHeight > measure.clientHeight + 1; + var sWidth = measure.nativeBarWidth; + + if (needsV) { + this.vert.style.display = "block"; + this.vert.style.bottom = needsH ? sWidth + "px" : "0"; + var totalHeight = measure.viewHeight - (needsH ? sWidth : 0); + // A bug in IE8 can cause this value to be negative, so guard it. + this.vert.firstChild.style.height = + Math.max(0, measure.scrollHeight - measure.clientHeight + totalHeight) + "px"; + } else { + this.vert.style.display = ""; + this.vert.firstChild.style.height = "0"; + } + + if (needsH) { + this.horiz.style.display = "block"; + this.horiz.style.right = needsV ? sWidth + "px" : "0"; + this.horiz.style.left = measure.barLeft + "px"; + var totalWidth = measure.viewWidth - measure.barLeft - (needsV ? sWidth : 0); + this.horiz.firstChild.style.width = + Math.max(0, measure.scrollWidth - measure.clientWidth + totalWidth) + "px"; + } else { + this.horiz.style.display = ""; + this.horiz.firstChild.style.width = "0"; + } + + if (!this.checkedZeroWidth && measure.clientHeight > 0) { + if (sWidth == 0) { this.zeroWidthHack(); } + this.checkedZeroWidth = true; + } + + return {right: needsV ? sWidth : 0, bottom: needsH ? sWidth : 0} + }; + + NativeScrollbars.prototype.setScrollLeft = function (pos) { + if (this.horiz.scrollLeft != pos) { this.horiz.scrollLeft = pos; } + if (this.disableHoriz) { this.enableZeroWidthBar(this.horiz, this.disableHoriz, "horiz"); } + }; + + NativeScrollbars.prototype.setScrollTop = function (pos) { + if (this.vert.scrollTop != pos) { this.vert.scrollTop = pos; } + if (this.disableVert) { this.enableZeroWidthBar(this.vert, this.disableVert, "vert"); } + }; + + NativeScrollbars.prototype.zeroWidthHack = function () { + var w = mac && !mac_geMountainLion ? "12px" : "18px"; + this.horiz.style.height = this.vert.style.width = w; + this.horiz.style.pointerEvents = this.vert.style.pointerEvents = "none"; + this.disableHoriz = new Delayed; + this.disableVert = new Delayed; + }; + + NativeScrollbars.prototype.enableZeroWidthBar = function (bar, delay, type) { + bar.style.pointerEvents = "auto"; + function maybeDisable() { + // To find out whether the scrollbar is still visible, we + // check whether the element under the pixel in the bottom + // right corner of the scrollbar box is the scrollbar box + // itself (when the bar is still visible) or its filler child + // (when the bar is hidden). If it is still visible, we keep + // it enabled, if it's hidden, we disable pointer events. + var box = bar.getBoundingClientRect(); + var elt$$1 = type == "vert" ? document.elementFromPoint(box.right - 1, (box.top + box.bottom) / 2) + : document.elementFromPoint((box.right + box.left) / 2, box.bottom - 1); + if (elt$$1 != bar) { bar.style.pointerEvents = "none"; } + else { delay.set(1000, maybeDisable); } + } + delay.set(1000, maybeDisable); + }; + + NativeScrollbars.prototype.clear = function () { + var parent = this.horiz.parentNode; + parent.removeChild(this.horiz); + parent.removeChild(this.vert); + }; + + var NullScrollbars = function () {}; + + NullScrollbars.prototype.update = function () { return {bottom: 0, right: 0} }; + NullScrollbars.prototype.setScrollLeft = function () {}; + NullScrollbars.prototype.setScrollTop = function () {}; + NullScrollbars.prototype.clear = function () {}; + + function updateScrollbars(cm, measure) { + if (!measure) { measure = measureForScrollbars(cm); } + var startWidth = cm.display.barWidth, startHeight = cm.display.barHeight; + updateScrollbarsInner(cm, measure); + for (var i = 0; i < 4 && startWidth != cm.display.barWidth || startHeight != cm.display.barHeight; i++) { + if (startWidth != cm.display.barWidth && cm.options.lineWrapping) + { updateHeightsInViewport(cm); } + updateScrollbarsInner(cm, measureForScrollbars(cm)); + startWidth = cm.display.barWidth; startHeight = cm.display.barHeight; + } + } + + // Re-synchronize the fake scrollbars with the actual size of the + // content. + function updateScrollbarsInner(cm, measure) { + var d = cm.display; + var sizes = d.scrollbars.update(measure); + + d.sizer.style.paddingRight = (d.barWidth = sizes.right) + "px"; + d.sizer.style.paddingBottom = (d.barHeight = sizes.bottom) + "px"; + d.heightForcer.style.borderBottom = sizes.bottom + "px solid transparent"; + + if (sizes.right && sizes.bottom) { + d.scrollbarFiller.style.display = "block"; + d.scrollbarFiller.style.height = sizes.bottom + "px"; + d.scrollbarFiller.style.width = sizes.right + "px"; + } else { d.scrollbarFiller.style.display = ""; } + if (sizes.bottom && cm.options.coverGutterNextToScrollbar && cm.options.fixedGutter) { + d.gutterFiller.style.display = "block"; + d.gutterFiller.style.height = sizes.bottom + "px"; + d.gutterFiller.style.width = measure.gutterWidth + "px"; + } else { d.gutterFiller.style.display = ""; } + } + + var scrollbarModel = {"native": NativeScrollbars, "null": NullScrollbars}; + + function initScrollbars(cm) { + if (cm.display.scrollbars) { + cm.display.scrollbars.clear(); + if (cm.display.scrollbars.addClass) + { rmClass(cm.display.wrapper, cm.display.scrollbars.addClass); } + } + + cm.display.scrollbars = new scrollbarModel[cm.options.scrollbarStyle](function (node) { + cm.display.wrapper.insertBefore(node, cm.display.scrollbarFiller); + // Prevent clicks in the scrollbars from killing focus + on(node, "mousedown", function () { + if (cm.state.focused) { setTimeout(function () { return cm.display.input.focus(); }, 0); } + }); + node.setAttribute("cm-not-content", "true"); + }, function (pos, axis) { + if (axis == "horizontal") { setScrollLeft(cm, pos); } + else { updateScrollTop(cm, pos); } + }, cm); + if (cm.display.scrollbars.addClass) + { addClass(cm.display.wrapper, cm.display.scrollbars.addClass); } + } + + // Operations are used to wrap a series of changes to the editor + // state in such a way that each change won't have to update the + // cursor and display (which would be awkward, slow, and + // error-prone). Instead, display updates are batched and then all + // combined and executed at once. + + var nextOpId = 0; + // Start a new operation. + function startOperation(cm) { + cm.curOp = { + cm: cm, + viewChanged: false, // Flag that indicates that lines might need to be redrawn + startHeight: cm.doc.height, // Used to detect need to update scrollbar + forceUpdate: false, // Used to force a redraw + updateInput: 0, // Whether to reset the input textarea + typing: false, // Whether this reset should be careful to leave existing text (for compositing) + changeObjs: null, // Accumulated changes, for firing change events + cursorActivityHandlers: null, // Set of handlers to fire cursorActivity on + cursorActivityCalled: 0, // Tracks which cursorActivity handlers have been called already + selectionChanged: false, // Whether the selection needs to be redrawn + updateMaxLine: false, // Set when the widest line needs to be determined anew + scrollLeft: null, scrollTop: null, // Intermediate scroll position, not pushed to DOM yet + scrollToPos: null, // Used to scroll to a specific position + focus: false, + id: ++nextOpId // Unique ID + }; + pushOperation(cm.curOp); + } + + // Finish an operation, updating the display and signalling delayed events + function endOperation(cm) { + var op = cm.curOp; + if (op) { finishOperation(op, function (group) { + for (var i = 0; i < group.ops.length; i++) + { group.ops[i].cm.curOp = null; } + endOperations(group); + }); } + } + + // The DOM updates done when an operation finishes are batched so + // that the minimum number of relayouts are required. + function endOperations(group) { + var ops = group.ops; + for (var i = 0; i < ops.length; i++) // Read DOM + { endOperation_R1(ops[i]); } + for (var i$1 = 0; i$1 < ops.length; i$1++) // Write DOM (maybe) + { endOperation_W1(ops[i$1]); } + for (var i$2 = 0; i$2 < ops.length; i$2++) // Read DOM + { endOperation_R2(ops[i$2]); } + for (var i$3 = 0; i$3 < ops.length; i$3++) // Write DOM (maybe) + { endOperation_W2(ops[i$3]); } + for (var i$4 = 0; i$4 < ops.length; i$4++) // Read DOM + { endOperation_finish(ops[i$4]); } + } + + function endOperation_R1(op) { + var cm = op.cm, display = cm.display; + maybeClipScrollbars(cm); + if (op.updateMaxLine) { findMaxLine(cm); } + + op.mustUpdate = op.viewChanged || op.forceUpdate || op.scrollTop != null || + op.scrollToPos && (op.scrollToPos.from.line < display.viewFrom || + op.scrollToPos.to.line >= display.viewTo) || + display.maxLineChanged && cm.options.lineWrapping; + op.update = op.mustUpdate && + new DisplayUpdate(cm, op.mustUpdate && {top: op.scrollTop, ensure: op.scrollToPos}, op.forceUpdate); + } + + function endOperation_W1(op) { + op.updatedDisplay = op.mustUpdate && updateDisplayIfNeeded(op.cm, op.update); + } + + function endOperation_R2(op) { + var cm = op.cm, display = cm.display; + if (op.updatedDisplay) { updateHeightsInViewport(cm); } + + op.barMeasure = measureForScrollbars(cm); + + // If the max line changed since it was last measured, measure it, + // and ensure the document's width matches it. + // updateDisplay_W2 will use these properties to do the actual resizing + if (display.maxLineChanged && !cm.options.lineWrapping) { + op.adjustWidthTo = measureChar(cm, display.maxLine, display.maxLine.text.length).left + 3; + cm.display.sizerWidth = op.adjustWidthTo; + op.barMeasure.scrollWidth = + Math.max(display.scroller.clientWidth, display.sizer.offsetLeft + op.adjustWidthTo + scrollGap(cm) + cm.display.barWidth); + op.maxScrollLeft = Math.max(0, display.sizer.offsetLeft + op.adjustWidthTo - displayWidth(cm)); + } + + if (op.updatedDisplay || op.selectionChanged) + { op.preparedSelection = display.input.prepareSelection(); } + } + + function endOperation_W2(op) { + var cm = op.cm; + + if (op.adjustWidthTo != null) { + cm.display.sizer.style.minWidth = op.adjustWidthTo + "px"; + if (op.maxScrollLeft < cm.doc.scrollLeft) + { setScrollLeft(cm, Math.min(cm.display.scroller.scrollLeft, op.maxScrollLeft), true); } + cm.display.maxLineChanged = false; + } + + var takeFocus = op.focus && op.focus == activeElt(); + if (op.preparedSelection) + { cm.display.input.showSelection(op.preparedSelection, takeFocus); } + if (op.updatedDisplay || op.startHeight != cm.doc.height) + { updateScrollbars(cm, op.barMeasure); } + if (op.updatedDisplay) + { setDocumentHeight(cm, op.barMeasure); } + + if (op.selectionChanged) { restartBlink(cm); } + + if (cm.state.focused && op.updateInput) + { cm.display.input.reset(op.typing); } + if (takeFocus) { ensureFocus(op.cm); } + } + + function endOperation_finish(op) { + var cm = op.cm, display = cm.display, doc = cm.doc; + + if (op.updatedDisplay) { postUpdateDisplay(cm, op.update); } + + // Abort mouse wheel delta measurement, when scrolling explicitly + if (display.wheelStartX != null && (op.scrollTop != null || op.scrollLeft != null || op.scrollToPos)) + { display.wheelStartX = display.wheelStartY = null; } + + // Propagate the scroll position to the actual DOM scroller + if (op.scrollTop != null) { setScrollTop(cm, op.scrollTop, op.forceScroll); } + + if (op.scrollLeft != null) { setScrollLeft(cm, op.scrollLeft, true, true); } + // If we need to scroll a specific position into view, do so. + if (op.scrollToPos) { + var rect = scrollPosIntoView(cm, clipPos(doc, op.scrollToPos.from), + clipPos(doc, op.scrollToPos.to), op.scrollToPos.margin); + maybeScrollWindow(cm, rect); + } + + // Fire events for markers that are hidden/unidden by editing or + // undoing + var hidden = op.maybeHiddenMarkers, unhidden = op.maybeUnhiddenMarkers; + if (hidden) { for (var i = 0; i < hidden.length; ++i) + { if (!hidden[i].lines.length) { signal(hidden[i], "hide"); } } } + if (unhidden) { for (var i$1 = 0; i$1 < unhidden.length; ++i$1) + { if (unhidden[i$1].lines.length) { signal(unhidden[i$1], "unhide"); } } } + + if (display.wrapper.offsetHeight) + { doc.scrollTop = cm.display.scroller.scrollTop; } + + // Fire change events, and delayed event handlers + if (op.changeObjs) + { signal(cm, "changes", cm, op.changeObjs); } + if (op.update) + { op.update.finish(); } + } + + // Run the given function in an operation + function runInOp(cm, f) { + if (cm.curOp) { return f() } + startOperation(cm); + try { return f() } + finally { endOperation(cm); } + } + // Wraps a function in an operation. Returns the wrapped function. + function operation(cm, f) { + return function() { + if (cm.curOp) { return f.apply(cm, arguments) } + startOperation(cm); + try { return f.apply(cm, arguments) } + finally { endOperation(cm); } + } + } + // Used to add methods to editor and doc instances, wrapping them in + // operations. + function methodOp(f) { + return function() { + if (this.curOp) { return f.apply(this, arguments) } + startOperation(this); + try { return f.apply(this, arguments) } + finally { endOperation(this); } + } + } + function docMethodOp(f) { + return function() { + var cm = this.cm; + if (!cm || cm.curOp) { return f.apply(this, arguments) } + startOperation(cm); + try { return f.apply(this, arguments) } + finally { endOperation(cm); } + } + } + + // HIGHLIGHT WORKER + + function startWorker(cm, time) { + if (cm.doc.highlightFrontier < cm.display.viewTo) + { cm.state.highlight.set(time, bind(highlightWorker, cm)); } + } + + function highlightWorker(cm) { + var doc = cm.doc; + if (doc.highlightFrontier >= cm.display.viewTo) { return } + var end = +new Date + cm.options.workTime; + var context = getContextBefore(cm, doc.highlightFrontier); + var changedLines = []; + + doc.iter(context.line, Math.min(doc.first + doc.size, cm.display.viewTo + 500), function (line) { + if (context.line >= cm.display.viewFrom) { // Visible + var oldStyles = line.styles; + var resetState = line.text.length > cm.options.maxHighlightLength ? copyState(doc.mode, context.state) : null; + var highlighted = highlightLine(cm, line, context, true); + if (resetState) { context.state = resetState; } + line.styles = highlighted.styles; + var oldCls = line.styleClasses, newCls = highlighted.classes; + if (newCls) { line.styleClasses = newCls; } + else if (oldCls) { line.styleClasses = null; } + var ischange = !oldStyles || oldStyles.length != line.styles.length || + oldCls != newCls && (!oldCls || !newCls || oldCls.bgClass != newCls.bgClass || oldCls.textClass != newCls.textClass); + for (var i = 0; !ischange && i < oldStyles.length; ++i) { ischange = oldStyles[i] != line.styles[i]; } + if (ischange) { changedLines.push(context.line); } + line.stateAfter = context.save(); + context.nextLine(); + } else { + if (line.text.length <= cm.options.maxHighlightLength) + { processLine(cm, line.text, context); } + line.stateAfter = context.line % 5 == 0 ? context.save() : null; + context.nextLine(); + } + if (+new Date > end) { + startWorker(cm, cm.options.workDelay); + return true + } + }); + doc.highlightFrontier = context.line; + doc.modeFrontier = Math.max(doc.modeFrontier, context.line); + if (changedLines.length) { runInOp(cm, function () { + for (var i = 0; i < changedLines.length; i++) + { regLineChange(cm, changedLines[i], "text"); } + }); } + } + + // DISPLAY DRAWING + + var DisplayUpdate = function(cm, viewport, force) { + var display = cm.display; + + this.viewport = viewport; + // Store some values that we'll need later (but don't want to force a relayout for) + this.visible = visibleLines(display, cm.doc, viewport); + this.editorIsHidden = !display.wrapper.offsetWidth; + this.wrapperHeight = display.wrapper.clientHeight; + this.wrapperWidth = display.wrapper.clientWidth; + this.oldDisplayWidth = displayWidth(cm); + this.force = force; + this.dims = getDimensions(cm); + this.events = []; + }; + + DisplayUpdate.prototype.signal = function (emitter, type) { + if (hasHandler(emitter, type)) + { this.events.push(arguments); } + }; + DisplayUpdate.prototype.finish = function () { + var this$1 = this; + + for (var i = 0; i < this.events.length; i++) + { signal.apply(null, this$1.events[i]); } + }; + + function maybeClipScrollbars(cm) { + var display = cm.display; + if (!display.scrollbarsClipped && display.scroller.offsetWidth) { + display.nativeBarWidth = display.scroller.offsetWidth - display.scroller.clientWidth; + display.heightForcer.style.height = scrollGap(cm) + "px"; + display.sizer.style.marginBottom = -display.nativeBarWidth + "px"; + display.sizer.style.borderRightWidth = scrollGap(cm) + "px"; + display.scrollbarsClipped = true; + } + } + + function selectionSnapshot(cm) { + if (cm.hasFocus()) { return null } + var active = activeElt(); + if (!active || !contains(cm.display.lineDiv, active)) { return null } + var result = {activeElt: active}; + if (window.getSelection) { + var sel = window.getSelection(); + if (sel.anchorNode && sel.extend && contains(cm.display.lineDiv, sel.anchorNode)) { + result.anchorNode = sel.anchorNode; + result.anchorOffset = sel.anchorOffset; + result.focusNode = sel.focusNode; + result.focusOffset = sel.focusOffset; + } + } + return result + } + + function restoreSelection(snapshot) { + if (!snapshot || !snapshot.activeElt || snapshot.activeElt == activeElt()) { return } + snapshot.activeElt.focus(); + if (snapshot.anchorNode && contains(document.body, snapshot.anchorNode) && contains(document.body, snapshot.focusNode)) { + var sel = window.getSelection(), range$$1 = document.createRange(); + range$$1.setEnd(snapshot.anchorNode, snapshot.anchorOffset); + range$$1.collapse(false); + sel.removeAllRanges(); + sel.addRange(range$$1); + sel.extend(snapshot.focusNode, snapshot.focusOffset); + } + } + + // Does the actual updating of the line display. Bails out + // (returning false) when there is nothing to be done and forced is + // false. + function updateDisplayIfNeeded(cm, update) { + var display = cm.display, doc = cm.doc; + + if (update.editorIsHidden) { + resetView(cm); + return false + } + + // Bail out if the visible area is already rendered and nothing changed. + if (!update.force && + update.visible.from >= display.viewFrom && update.visible.to <= display.viewTo && + (display.updateLineNumbers == null || display.updateLineNumbers >= display.viewTo) && + display.renderedView == display.view && countDirtyView(cm) == 0) + { return false } + + if (maybeUpdateLineNumberWidth(cm)) { + resetView(cm); + update.dims = getDimensions(cm); + } + + // Compute a suitable new viewport (from & to) + var end = doc.first + doc.size; + var from = Math.max(update.visible.from - cm.options.viewportMargin, doc.first); + var to = Math.min(end, update.visible.to + cm.options.viewportMargin); + if (display.viewFrom < from && from - display.viewFrom < 20) { from = Math.max(doc.first, display.viewFrom); } + if (display.viewTo > to && display.viewTo - to < 20) { to = Math.min(end, display.viewTo); } + if (sawCollapsedSpans) { + from = visualLineNo(cm.doc, from); + to = visualLineEndNo(cm.doc, to); + } + + var different = from != display.viewFrom || to != display.viewTo || + display.lastWrapHeight != update.wrapperHeight || display.lastWrapWidth != update.wrapperWidth; + adjustView(cm, from, to); + + display.viewOffset = heightAtLine(getLine(cm.doc, display.viewFrom)); + // Position the mover div to align with the current scroll position + cm.display.mover.style.top = display.viewOffset + "px"; + + var toUpdate = countDirtyView(cm); + if (!different && toUpdate == 0 && !update.force && display.renderedView == display.view && + (display.updateLineNumbers == null || display.updateLineNumbers >= display.viewTo)) + { return false } + + // For big changes, we hide the enclosing element during the + // update, since that speeds up the operations on most browsers. + var selSnapshot = selectionSnapshot(cm); + if (toUpdate > 4) { display.lineDiv.style.display = "none"; } + patchDisplay(cm, display.updateLineNumbers, update.dims); + if (toUpdate > 4) { display.lineDiv.style.display = ""; } + display.renderedView = display.view; + // There might have been a widget with a focused element that got + // hidden or updated, if so re-focus it. + restoreSelection(selSnapshot); + + // Prevent selection and cursors from interfering with the scroll + // width and height. + removeChildren(display.cursorDiv); + removeChildren(display.selectionDiv); + display.gutters.style.height = display.sizer.style.minHeight = 0; + + if (different) { + display.lastWrapHeight = update.wrapperHeight; + display.lastWrapWidth = update.wrapperWidth; + startWorker(cm, 400); + } + + display.updateLineNumbers = null; + + return true + } + + function postUpdateDisplay(cm, update) { + var viewport = update.viewport; + + for (var first = true;; first = false) { + if (!first || !cm.options.lineWrapping || update.oldDisplayWidth == displayWidth(cm)) { + // Clip forced viewport to actual scrollable area. + if (viewport && viewport.top != null) + { viewport = {top: Math.min(cm.doc.height + paddingVert(cm.display) - displayHeight(cm), viewport.top)}; } + // Updated line heights might result in the drawn area not + // actually covering the viewport. Keep looping until it does. + update.visible = visibleLines(cm.display, cm.doc, viewport); + if (update.visible.from >= cm.display.viewFrom && update.visible.to <= cm.display.viewTo) + { break } + } + if (!updateDisplayIfNeeded(cm, update)) { break } + updateHeightsInViewport(cm); + var barMeasure = measureForScrollbars(cm); + updateSelection(cm); + updateScrollbars(cm, barMeasure); + setDocumentHeight(cm, barMeasure); + update.force = false; + } + + update.signal(cm, "update", cm); + if (cm.display.viewFrom != cm.display.reportedViewFrom || cm.display.viewTo != cm.display.reportedViewTo) { + update.signal(cm, "viewportChange", cm, cm.display.viewFrom, cm.display.viewTo); + cm.display.reportedViewFrom = cm.display.viewFrom; cm.display.reportedViewTo = cm.display.viewTo; + } + } + + function updateDisplaySimple(cm, viewport) { + var update = new DisplayUpdate(cm, viewport); + if (updateDisplayIfNeeded(cm, update)) { + updateHeightsInViewport(cm); + postUpdateDisplay(cm, update); + var barMeasure = measureForScrollbars(cm); + updateSelection(cm); + updateScrollbars(cm, barMeasure); + setDocumentHeight(cm, barMeasure); + update.finish(); + } + } + + // Sync the actual display DOM structure with display.view, removing + // nodes for lines that are no longer in view, and creating the ones + // that are not there yet, and updating the ones that are out of + // date. + function patchDisplay(cm, updateNumbersFrom, dims) { + var display = cm.display, lineNumbers = cm.options.lineNumbers; + var container = display.lineDiv, cur = container.firstChild; + + function rm(node) { + var next = node.nextSibling; + // Works around a throw-scroll bug in OS X Webkit + if (webkit && mac && cm.display.currentWheelTarget == node) + { node.style.display = "none"; } + else + { node.parentNode.removeChild(node); } + return next + } + + var view = display.view, lineN = display.viewFrom; + // Loop over the elements in the view, syncing cur (the DOM nodes + // in display.lineDiv) with the view as we go. + for (var i = 0; i < view.length; i++) { + var lineView = view[i]; + if (lineView.hidden) ; else if (!lineView.node || lineView.node.parentNode != container) { // Not drawn yet + var node = buildLineElement(cm, lineView, lineN, dims); + container.insertBefore(node, cur); + } else { // Already drawn + while (cur != lineView.node) { cur = rm(cur); } + var updateNumber = lineNumbers && updateNumbersFrom != null && + updateNumbersFrom <= lineN && lineView.lineNumber; + if (lineView.changes) { + if (indexOf(lineView.changes, "gutter") > -1) { updateNumber = false; } + updateLineForChanges(cm, lineView, lineN, dims); + } + if (updateNumber) { + removeChildren(lineView.lineNumber); + lineView.lineNumber.appendChild(document.createTextNode(lineNumberFor(cm.options, lineN))); + } + cur = lineView.node.nextSibling; + } + lineN += lineView.size; + } + while (cur) { cur = rm(cur); } + } + + function updateGutterSpace(display) { + var width = display.gutters.offsetWidth; + display.sizer.style.marginLeft = width + "px"; + } + + function setDocumentHeight(cm, measure) { + cm.display.sizer.style.minHeight = measure.docHeight + "px"; + cm.display.heightForcer.style.top = measure.docHeight + "px"; + cm.display.gutters.style.height = (measure.docHeight + cm.display.barHeight + scrollGap(cm)) + "px"; + } + + // Re-align line numbers and gutter marks to compensate for + // horizontal scrolling. + function alignHorizontally(cm) { + var display = cm.display, view = display.view; + if (!display.alignWidgets && (!display.gutters.firstChild || !cm.options.fixedGutter)) { return } + var comp = compensateForHScroll(display) - display.scroller.scrollLeft + cm.doc.scrollLeft; + var gutterW = display.gutters.offsetWidth, left = comp + "px"; + for (var i = 0; i < view.length; i++) { if (!view[i].hidden) { + if (cm.options.fixedGutter) { + if (view[i].gutter) + { view[i].gutter.style.left = left; } + if (view[i].gutterBackground) + { view[i].gutterBackground.style.left = left; } + } + var align = view[i].alignable; + if (align) { for (var j = 0; j < align.length; j++) + { align[j].style.left = left; } } + } } + if (cm.options.fixedGutter) + { display.gutters.style.left = (comp + gutterW) + "px"; } + } + + // Used to ensure that the line number gutter is still the right + // size for the current document size. Returns true when an update + // is needed. + function maybeUpdateLineNumberWidth(cm) { + if (!cm.options.lineNumbers) { return false } + var doc = cm.doc, last = lineNumberFor(cm.options, doc.first + doc.size - 1), display = cm.display; + if (last.length != display.lineNumChars) { + var test = display.measure.appendChild(elt("div", [elt("div", last)], + "CodeMirror-linenumber CodeMirror-gutter-elt")); + var innerW = test.firstChild.offsetWidth, padding = test.offsetWidth - innerW; + display.lineGutter.style.width = ""; + display.lineNumInnerWidth = Math.max(innerW, display.lineGutter.offsetWidth - padding) + 1; + display.lineNumWidth = display.lineNumInnerWidth + padding; + display.lineNumChars = display.lineNumInnerWidth ? last.length : -1; + display.lineGutter.style.width = display.lineNumWidth + "px"; + updateGutterSpace(cm.display); + return true + } + return false + } + + function getGutters(gutters, lineNumbers) { + var result = [], sawLineNumbers = false; + for (var i = 0; i < gutters.length; i++) { + var name = gutters[i], style = null; + if (typeof name != "string") { style = name.style; name = name.className; } + if (name == "CodeMirror-linenumbers") { + if (!lineNumbers) { continue } + else { sawLineNumbers = true; } + } + result.push({className: name, style: style}); + } + if (lineNumbers && !sawLineNumbers) { result.push({className: "CodeMirror-linenumbers", style: null}); } + return result + } + + // Rebuild the gutter elements, ensure the margin to the left of the + // code matches their width. + function renderGutters(display) { + var gutters = display.gutters, specs = display.gutterSpecs; + removeChildren(gutters); + display.lineGutter = null; + for (var i = 0; i < specs.length; ++i) { + var ref = specs[i]; + var className = ref.className; + var style = ref.style; + var gElt = gutters.appendChild(elt("div", null, "CodeMirror-gutter " + className)); + if (style) { gElt.style.cssText = style; } + if (className == "CodeMirror-linenumbers") { + display.lineGutter = gElt; + gElt.style.width = (display.lineNumWidth || 1) + "px"; + } + } + gutters.style.display = specs.length ? "" : "none"; + updateGutterSpace(display); + } + + function updateGutters(cm) { + renderGutters(cm.display); + regChange(cm); + alignHorizontally(cm); + } + + // The display handles the DOM integration, both for input reading + // and content drawing. It holds references to DOM nodes and + // display-related state. + + function Display(place, doc, input, options) { + var d = this; + this.input = input; + + // Covers bottom-right square when both scrollbars are present. + d.scrollbarFiller = elt("div", null, "CodeMirror-scrollbar-filler"); + d.scrollbarFiller.setAttribute("cm-not-content", "true"); + // Covers bottom of gutter when coverGutterNextToScrollbar is on + // and h scrollbar is present. + d.gutterFiller = elt("div", null, "CodeMirror-gutter-filler"); + d.gutterFiller.setAttribute("cm-not-content", "true"); + // Will contain the actual code, positioned to cover the viewport. + d.lineDiv = eltP("div", null, "CodeMirror-code"); + // Elements are added to these to represent selection and cursors. + d.selectionDiv = elt("div", null, null, "position: relative; z-index: 1"); + d.cursorDiv = elt("div", null, "CodeMirror-cursors"); + // A visibility: hidden element used to find the size of things. + d.measure = elt("div", null, "CodeMirror-measure"); + // When lines outside of the viewport are measured, they are drawn in this. + d.lineMeasure = elt("div", null, "CodeMirror-measure"); + // Wraps everything that needs to exist inside the vertically-padded coordinate system + d.lineSpace = eltP("div", [d.measure, d.lineMeasure, d.selectionDiv, d.cursorDiv, d.lineDiv], + null, "position: relative; outline: none"); + var lines = eltP("div", [d.lineSpace], "CodeMirror-lines"); + // Moved around its parent to cover visible view. + d.mover = elt("div", [lines], null, "position: relative"); + // Set to the height of the document, allowing scrolling. + d.sizer = elt("div", [d.mover], "CodeMirror-sizer"); + d.sizerWidth = null; + // Behavior of elts with overflow: auto and padding is + // inconsistent across browsers. This is used to ensure the + // scrollable area is big enough. + d.heightForcer = elt("div", null, null, "position: absolute; height: " + scrollerGap + "px; width: 1px;"); + // Will contain the gutters, if any. + d.gutters = elt("div", null, "CodeMirror-gutters"); + d.lineGutter = null; + // Actual scrollable element. + d.scroller = elt("div", [d.sizer, d.heightForcer, d.gutters], "CodeMirror-scroll"); + d.scroller.setAttribute("tabIndex", "-1"); + // The element in which the editor lives. + d.wrapper = elt("div", [d.scrollbarFiller, d.gutterFiller, d.scroller], "CodeMirror"); + + // Work around IE7 z-index bug (not perfect, hence IE7 not really being supported) + if (ie && ie_version < 8) { d.gutters.style.zIndex = -1; d.scroller.style.paddingRight = 0; } + if (!webkit && !(gecko && mobile)) { d.scroller.draggable = true; } + + if (place) { + if (place.appendChild) { place.appendChild(d.wrapper); } + else { place(d.wrapper); } + } + + // Current rendered range (may be bigger than the view window). + d.viewFrom = d.viewTo = doc.first; + d.reportedViewFrom = d.reportedViewTo = doc.first; + // Information about the rendered lines. + d.view = []; + d.renderedView = null; + // Holds info about a single rendered line when it was rendered + // for measurement, while not in view. + d.externalMeasured = null; + // Empty space (in pixels) above the view + d.viewOffset = 0; + d.lastWrapHeight = d.lastWrapWidth = 0; + d.updateLineNumbers = null; + + d.nativeBarWidth = d.barHeight = d.barWidth = 0; + d.scrollbarsClipped = false; + + // Used to only resize the line number gutter when necessary (when + // the amount of lines crosses a boundary that makes its width change) + d.lineNumWidth = d.lineNumInnerWidth = d.lineNumChars = null; + // Set to true when a non-horizontal-scrolling line widget is + // added. As an optimization, line widget aligning is skipped when + // this is false. + d.alignWidgets = false; + + d.cachedCharWidth = d.cachedTextHeight = d.cachedPaddingH = null; + + // Tracks the maximum line length so that the horizontal scrollbar + // can be kept static when scrolling. + d.maxLine = null; + d.maxLineLength = 0; + d.maxLineChanged = false; + + // Used for measuring wheel scrolling granularity + d.wheelDX = d.wheelDY = d.wheelStartX = d.wheelStartY = null; + + // True when shift is held down. + d.shift = false; + + // Used to track whether anything happened since the context menu + // was opened. + d.selForContextMenu = null; + + d.activeTouch = null; + + d.gutterSpecs = getGutters(options.gutters, options.lineNumbers); + renderGutters(d); + + input.init(d); + } + + // Since the delta values reported on mouse wheel events are + // unstandardized between browsers and even browser versions, and + // generally horribly unpredictable, this code starts by measuring + // the scroll effect that the first few mouse wheel events have, + // and, from that, detects the way it can convert deltas to pixel + // offsets afterwards. + // + // The reason we want to know the amount a wheel event will scroll + // is that it gives us a chance to update the display before the + // actual scrolling happens, reducing flickering. + + var wheelSamples = 0, wheelPixelsPerUnit = null; + // Fill in a browser-detected starting value on browsers where we + // know one. These don't have to be accurate -- the result of them + // being wrong would just be a slight flicker on the first wheel + // scroll (if it is large enough). + if (ie) { wheelPixelsPerUnit = -.53; } + else if (gecko) { wheelPixelsPerUnit = 15; } + else if (chrome) { wheelPixelsPerUnit = -.7; } + else if (safari) { wheelPixelsPerUnit = -1/3; } + + function wheelEventDelta(e) { + var dx = e.wheelDeltaX, dy = e.wheelDeltaY; + if (dx == null && e.detail && e.axis == e.HORIZONTAL_AXIS) { dx = e.detail; } + if (dy == null && e.detail && e.axis == e.VERTICAL_AXIS) { dy = e.detail; } + else if (dy == null) { dy = e.wheelDelta; } + return {x: dx, y: dy} + } + function wheelEventPixels(e) { + var delta = wheelEventDelta(e); + delta.x *= wheelPixelsPerUnit; + delta.y *= wheelPixelsPerUnit; + return delta + } + + function onScrollWheel(cm, e) { + var delta = wheelEventDelta(e), dx = delta.x, dy = delta.y; + + var display = cm.display, scroll = display.scroller; + // Quit if there's nothing to scroll here + var canScrollX = scroll.scrollWidth > scroll.clientWidth; + var canScrollY = scroll.scrollHeight > scroll.clientHeight; + if (!(dx && canScrollX || dy && canScrollY)) { return } + + // Webkit browsers on OS X abort momentum scrolls when the target + // of the scroll event is removed from the scrollable element. + // This hack (see related code in patchDisplay) makes sure the + // element is kept around. + if (dy && mac && webkit) { + outer: for (var cur = e.target, view = display.view; cur != scroll; cur = cur.parentNode) { + for (var i = 0; i < view.length; i++) { + if (view[i].node == cur) { + cm.display.currentWheelTarget = cur; + break outer + } + } + } + } + + // On some browsers, horizontal scrolling will cause redraws to + // happen before the gutter has been realigned, causing it to + // wriggle around in a most unseemly way. When we have an + // estimated pixels/delta value, we just handle horizontal + // scrolling entirely here. It'll be slightly off from native, but + // better than glitching out. + if (dx && !gecko && !presto && wheelPixelsPerUnit != null) { + if (dy && canScrollY) + { updateScrollTop(cm, Math.max(0, scroll.scrollTop + dy * wheelPixelsPerUnit)); } + setScrollLeft(cm, Math.max(0, scroll.scrollLeft + dx * wheelPixelsPerUnit)); + // Only prevent default scrolling if vertical scrolling is + // actually possible. Otherwise, it causes vertical scroll + // jitter on OSX trackpads when deltaX is small and deltaY + // is large (issue #3579) + if (!dy || (dy && canScrollY)) + { e_preventDefault(e); } + display.wheelStartX = null; // Abort measurement, if in progress + return + } + + // 'Project' the visible viewport to cover the area that is being + // scrolled into view (if we know enough to estimate it). + if (dy && wheelPixelsPerUnit != null) { + var pixels = dy * wheelPixelsPerUnit; + var top = cm.doc.scrollTop, bot = top + display.wrapper.clientHeight; + if (pixels < 0) { top = Math.max(0, top + pixels - 50); } + else { bot = Math.min(cm.doc.height, bot + pixels + 50); } + updateDisplaySimple(cm, {top: top, bottom: bot}); + } + + if (wheelSamples < 20) { + if (display.wheelStartX == null) { + display.wheelStartX = scroll.scrollLeft; display.wheelStartY = scroll.scrollTop; + display.wheelDX = dx; display.wheelDY = dy; + setTimeout(function () { + if (display.wheelStartX == null) { return } + var movedX = scroll.scrollLeft - display.wheelStartX; + var movedY = scroll.scrollTop - display.wheelStartY; + var sample = (movedY && display.wheelDY && movedY / display.wheelDY) || + (movedX && display.wheelDX && movedX / display.wheelDX); + display.wheelStartX = display.wheelStartY = null; + if (!sample) { return } + wheelPixelsPerUnit = (wheelPixelsPerUnit * wheelSamples + sample) / (wheelSamples + 1); + ++wheelSamples; + }, 200); + } else { + display.wheelDX += dx; display.wheelDY += dy; + } + } + } + + // Selection objects are immutable. A new one is created every time + // the selection changes. A selection is one or more non-overlapping + // (and non-touching) ranges, sorted, and an integer that indicates + // which one is the primary selection (the one that's scrolled into + // view, that getCursor returns, etc). + var Selection = function(ranges, primIndex) { + this.ranges = ranges; + this.primIndex = primIndex; + }; + + Selection.prototype.primary = function () { return this.ranges[this.primIndex] }; + + Selection.prototype.equals = function (other) { + var this$1 = this; + + if (other == this) { return true } + if (other.primIndex != this.primIndex || other.ranges.length != this.ranges.length) { return false } + for (var i = 0; i < this.ranges.length; i++) { + var here = this$1.ranges[i], there = other.ranges[i]; + if (!equalCursorPos(here.anchor, there.anchor) || !equalCursorPos(here.head, there.head)) { return false } + } + return true + }; + + Selection.prototype.deepCopy = function () { + var this$1 = this; + + var out = []; + for (var i = 0; i < this.ranges.length; i++) + { out[i] = new Range(copyPos(this$1.ranges[i].anchor), copyPos(this$1.ranges[i].head)); } + return new Selection(out, this.primIndex) + }; + + Selection.prototype.somethingSelected = function () { + var this$1 = this; + + for (var i = 0; i < this.ranges.length; i++) + { if (!this$1.ranges[i].empty()) { return true } } + return false + }; + + Selection.prototype.contains = function (pos, end) { + var this$1 = this; + + if (!end) { end = pos; } + for (var i = 0; i < this.ranges.length; i++) { + var range = this$1.ranges[i]; + if (cmp(end, range.from()) >= 0 && cmp(pos, range.to()) <= 0) + { return i } + } + return -1 + }; + + var Range = function(anchor, head) { + this.anchor = anchor; this.head = head; + }; + + Range.prototype.from = function () { return minPos(this.anchor, this.head) }; + Range.prototype.to = function () { return maxPos(this.anchor, this.head) }; + Range.prototype.empty = function () { return this.head.line == this.anchor.line && this.head.ch == this.anchor.ch }; + + // Take an unsorted, potentially overlapping set of ranges, and + // build a selection out of it. 'Consumes' ranges array (modifying + // it). + function normalizeSelection(cm, ranges, primIndex) { + var mayTouch = cm && cm.options.selectionsMayTouch; + var prim = ranges[primIndex]; + ranges.sort(function (a, b) { return cmp(a.from(), b.from()); }); + primIndex = indexOf(ranges, prim); + for (var i = 1; i < ranges.length; i++) { + var cur = ranges[i], prev = ranges[i - 1]; + var diff = cmp(prev.to(), cur.from()); + if (mayTouch && !cur.empty() ? diff > 0 : diff >= 0) { + var from = minPos(prev.from(), cur.from()), to = maxPos(prev.to(), cur.to()); + var inv = prev.empty() ? cur.from() == cur.head : prev.from() == prev.head; + if (i <= primIndex) { --primIndex; } + ranges.splice(--i, 2, new Range(inv ? to : from, inv ? from : to)); + } + } + return new Selection(ranges, primIndex) + } + + function simpleSelection(anchor, head) { + return new Selection([new Range(anchor, head || anchor)], 0) + } + + // Compute the position of the end of a change (its 'to' property + // refers to the pre-change end). + function changeEnd(change) { + if (!change.text) { return change.to } + return Pos(change.from.line + change.text.length - 1, + lst(change.text).length + (change.text.length == 1 ? change.from.ch : 0)) + } + + // Adjust a position to refer to the post-change position of the + // same text, or the end of the change if the change covers it. + function adjustForChange(pos, change) { + if (cmp(pos, change.from) < 0) { return pos } + if (cmp(pos, change.to) <= 0) { return changeEnd(change) } + + var line = pos.line + change.text.length - (change.to.line - change.from.line) - 1, ch = pos.ch; + if (pos.line == change.to.line) { ch += changeEnd(change).ch - change.to.ch; } + return Pos(line, ch) + } + + function computeSelAfterChange(doc, change) { + var out = []; + for (var i = 0; i < doc.sel.ranges.length; i++) { + var range = doc.sel.ranges[i]; + out.push(new Range(adjustForChange(range.anchor, change), + adjustForChange(range.head, change))); + } + return normalizeSelection(doc.cm, out, doc.sel.primIndex) + } + + function offsetPos(pos, old, nw) { + if (pos.line == old.line) + { return Pos(nw.line, pos.ch - old.ch + nw.ch) } + else + { return Pos(nw.line + (pos.line - old.line), pos.ch) } + } + + // Used by replaceSelections to allow moving the selection to the + // start or around the replaced test. Hint may be "start" or "around". + function computeReplacedSel(doc, changes, hint) { + var out = []; + var oldPrev = Pos(doc.first, 0), newPrev = oldPrev; + for (var i = 0; i < changes.length; i++) { + var change = changes[i]; + var from = offsetPos(change.from, oldPrev, newPrev); + var to = offsetPos(changeEnd(change), oldPrev, newPrev); + oldPrev = change.to; + newPrev = to; + if (hint == "around") { + var range = doc.sel.ranges[i], inv = cmp(range.head, range.anchor) < 0; + out[i] = new Range(inv ? to : from, inv ? from : to); + } else { + out[i] = new Range(from, from); + } + } + return new Selection(out, doc.sel.primIndex) + } + + // Used to get the editor into a consistent state again when options change. + + function loadMode(cm) { + cm.doc.mode = getMode(cm.options, cm.doc.modeOption); + resetModeState(cm); + } + + function resetModeState(cm) { + cm.doc.iter(function (line) { + if (line.stateAfter) { line.stateAfter = null; } + if (line.styles) { line.styles = null; } + }); + cm.doc.modeFrontier = cm.doc.highlightFrontier = cm.doc.first; + startWorker(cm, 100); + cm.state.modeGen++; + if (cm.curOp) { regChange(cm); } + } + + // DOCUMENT DATA STRUCTURE + + // By default, updates that start and end at the beginning of a line + // are treated specially, in order to make the association of line + // widgets and marker elements with the text behave more intuitive. + function isWholeLineUpdate(doc, change) { + return change.from.ch == 0 && change.to.ch == 0 && lst(change.text) == "" && + (!doc.cm || doc.cm.options.wholeLineUpdateBefore) + } + + // Perform a change on the document data structure. + function updateDoc(doc, change, markedSpans, estimateHeight$$1) { + function spansFor(n) {return markedSpans ? markedSpans[n] : null} + function update(line, text, spans) { + updateLine(line, text, spans, estimateHeight$$1); + signalLater(line, "change", line, change); + } + function linesFor(start, end) { + var result = []; + for (var i = start; i < end; ++i) + { result.push(new Line(text[i], spansFor(i), estimateHeight$$1)); } + return result + } + + var from = change.from, to = change.to, text = change.text; + var firstLine = getLine(doc, from.line), lastLine = getLine(doc, to.line); + var lastText = lst(text), lastSpans = spansFor(text.length - 1), nlines = to.line - from.line; + + // Adjust the line structure + if (change.full) { + doc.insert(0, linesFor(0, text.length)); + doc.remove(text.length, doc.size - text.length); + } else if (isWholeLineUpdate(doc, change)) { + // This is a whole-line replace. Treated specially to make + // sure line objects move the way they are supposed to. + var added = linesFor(0, text.length - 1); + update(lastLine, lastLine.text, lastSpans); + if (nlines) { doc.remove(from.line, nlines); } + if (added.length) { doc.insert(from.line, added); } + } else if (firstLine == lastLine) { + if (text.length == 1) { + update(firstLine, firstLine.text.slice(0, from.ch) + lastText + firstLine.text.slice(to.ch), lastSpans); + } else { + var added$1 = linesFor(1, text.length - 1); + added$1.push(new Line(lastText + firstLine.text.slice(to.ch), lastSpans, estimateHeight$$1)); + update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0)); + doc.insert(from.line + 1, added$1); + } + } else if (text.length == 1) { + update(firstLine, firstLine.text.slice(0, from.ch) + text[0] + lastLine.text.slice(to.ch), spansFor(0)); + doc.remove(from.line + 1, nlines); + } else { + update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0)); + update(lastLine, lastText + lastLine.text.slice(to.ch), lastSpans); + var added$2 = linesFor(1, text.length - 1); + if (nlines > 1) { doc.remove(from.line + 1, nlines - 1); } + doc.insert(from.line + 1, added$2); + } + + signalLater(doc, "change", doc, change); + } + + // Call f for all linked documents. + function linkedDocs(doc, f, sharedHistOnly) { + function propagate(doc, skip, sharedHist) { + if (doc.linked) { for (var i = 0; i < doc.linked.length; ++i) { + var rel = doc.linked[i]; + if (rel.doc == skip) { continue } + var shared = sharedHist && rel.sharedHist; + if (sharedHistOnly && !shared) { continue } + f(rel.doc, shared); + propagate(rel.doc, doc, shared); + } } + } + propagate(doc, null, true); + } + + // Attach a document to an editor. + function attachDoc(cm, doc) { + if (doc.cm) { throw new Error("This document is already in use.") } + cm.doc = doc; + doc.cm = cm; + estimateLineHeights(cm); + loadMode(cm); + setDirectionClass(cm); + if (!cm.options.lineWrapping) { findMaxLine(cm); } + cm.options.mode = doc.modeOption; + regChange(cm); + } + + function setDirectionClass(cm) { + (cm.doc.direction == "rtl" ? addClass : rmClass)(cm.display.lineDiv, "CodeMirror-rtl"); + } + + function directionChanged(cm) { + runInOp(cm, function () { + setDirectionClass(cm); + regChange(cm); + }); + } + + function History(startGen) { + // Arrays of change events and selections. Doing something adds an + // event to done and clears undo. Undoing moves events from done + // to undone, redoing moves them in the other direction. + this.done = []; this.undone = []; + this.undoDepth = Infinity; + // Used to track when changes can be merged into a single undo + // event + this.lastModTime = this.lastSelTime = 0; + this.lastOp = this.lastSelOp = null; + this.lastOrigin = this.lastSelOrigin = null; + // Used by the isClean() method + this.generation = this.maxGeneration = startGen || 1; + } + + // Create a history change event from an updateDoc-style change + // object. + function historyChangeFromChange(doc, change) { + var histChange = {from: copyPos(change.from), to: changeEnd(change), text: getBetween(doc, change.from, change.to)}; + attachLocalSpans(doc, histChange, change.from.line, change.to.line + 1); + linkedDocs(doc, function (doc) { return attachLocalSpans(doc, histChange, change.from.line, change.to.line + 1); }, true); + return histChange + } + + // Pop all selection events off the end of a history array. Stop at + // a change event. + function clearSelectionEvents(array) { + while (array.length) { + var last = lst(array); + if (last.ranges) { array.pop(); } + else { break } + } + } + + // Find the top change event in the history. Pop off selection + // events that are in the way. + function lastChangeEvent(hist, force) { + if (force) { + clearSelectionEvents(hist.done); + return lst(hist.done) + } else if (hist.done.length && !lst(hist.done).ranges) { + return lst(hist.done) + } else if (hist.done.length > 1 && !hist.done[hist.done.length - 2].ranges) { + hist.done.pop(); + return lst(hist.done) + } + } + + // Register a change in the history. Merges changes that are within + // a single operation, or are close together with an origin that + // allows merging (starting with "+") into a single event. + function addChangeToHistory(doc, change, selAfter, opId) { + var hist = doc.history; + hist.undone.length = 0; + var time = +new Date, cur; + var last; + + if ((hist.lastOp == opId || + hist.lastOrigin == change.origin && change.origin && + ((change.origin.charAt(0) == "+" && hist.lastModTime > time - (doc.cm ? doc.cm.options.historyEventDelay : 500)) || + change.origin.charAt(0) == "*")) && + (cur = lastChangeEvent(hist, hist.lastOp == opId))) { + // Merge this change into the last event + last = lst(cur.changes); + if (cmp(change.from, change.to) == 0 && cmp(change.from, last.to) == 0) { + // Optimized case for simple insertion -- don't want to add + // new changesets for every character typed + last.to = changeEnd(change); + } else { + // Add new sub-event + cur.changes.push(historyChangeFromChange(doc, change)); + } + } else { + // Can not be merged, start a new event. + var before = lst(hist.done); + if (!before || !before.ranges) + { pushSelectionToHistory(doc.sel, hist.done); } + cur = {changes: [historyChangeFromChange(doc, change)], + generation: hist.generation}; + hist.done.push(cur); + while (hist.done.length > hist.undoDepth) { + hist.done.shift(); + if (!hist.done[0].ranges) { hist.done.shift(); } + } + } + hist.done.push(selAfter); + hist.generation = ++hist.maxGeneration; + hist.lastModTime = hist.lastSelTime = time; + hist.lastOp = hist.lastSelOp = opId; + hist.lastOrigin = hist.lastSelOrigin = change.origin; + + if (!last) { signal(doc, "historyAdded"); } + } + + function selectionEventCanBeMerged(doc, origin, prev, sel) { + var ch = origin.charAt(0); + return ch == "*" || + ch == "+" && + prev.ranges.length == sel.ranges.length && + prev.somethingSelected() == sel.somethingSelected() && + new Date - doc.history.lastSelTime <= (doc.cm ? doc.cm.options.historyEventDelay : 500) + } + + // Called whenever the selection changes, sets the new selection as + // the pending selection in the history, and pushes the old pending + // selection into the 'done' array when it was significantly + // different (in number of selected ranges, emptiness, or time). + function addSelectionToHistory(doc, sel, opId, options) { + var hist = doc.history, origin = options && options.origin; + + // A new event is started when the previous origin does not match + // the current, or the origins don't allow matching. Origins + // starting with * are always merged, those starting with + are + // merged when similar and close together in time. + if (opId == hist.lastSelOp || + (origin && hist.lastSelOrigin == origin && + (hist.lastModTime == hist.lastSelTime && hist.lastOrigin == origin || + selectionEventCanBeMerged(doc, origin, lst(hist.done), sel)))) + { hist.done[hist.done.length - 1] = sel; } + else + { pushSelectionToHistory(sel, hist.done); } + + hist.lastSelTime = +new Date; + hist.lastSelOrigin = origin; + hist.lastSelOp = opId; + if (options && options.clearRedo !== false) + { clearSelectionEvents(hist.undone); } + } + + function pushSelectionToHistory(sel, dest) { + var top = lst(dest); + if (!(top && top.ranges && top.equals(sel))) + { dest.push(sel); } + } + + // Used to store marked span information in the history. + function attachLocalSpans(doc, change, from, to) { + var existing = change["spans_" + doc.id], n = 0; + doc.iter(Math.max(doc.first, from), Math.min(doc.first + doc.size, to), function (line) { + if (line.markedSpans) + { (existing || (existing = change["spans_" + doc.id] = {}))[n] = line.markedSpans; } + ++n; + }); + } + + // When un/re-doing restores text containing marked spans, those + // that have been explicitly cleared should not be restored. + function removeClearedSpans(spans) { + if (!spans) { return null } + var out; + for (var i = 0; i < spans.length; ++i) { + if (spans[i].marker.explicitlyCleared) { if (!out) { out = spans.slice(0, i); } } + else if (out) { out.push(spans[i]); } + } + return !out ? spans : out.length ? out : null + } + + // Retrieve and filter the old marked spans stored in a change event. + function getOldSpans(doc, change) { + var found = change["spans_" + doc.id]; + if (!found) { return null } + var nw = []; + for (var i = 0; i < change.text.length; ++i) + { nw.push(removeClearedSpans(found[i])); } + return nw + } + + // Used for un/re-doing changes from the history. Combines the + // result of computing the existing spans with the set of spans that + // existed in the history (so that deleting around a span and then + // undoing brings back the span). + function mergeOldSpans(doc, change) { + var old = getOldSpans(doc, change); + var stretched = stretchSpansOverChange(doc, change); + if (!old) { return stretched } + if (!stretched) { return old } + + for (var i = 0; i < old.length; ++i) { + var oldCur = old[i], stretchCur = stretched[i]; + if (oldCur && stretchCur) { + spans: for (var j = 0; j < stretchCur.length; ++j) { + var span = stretchCur[j]; + for (var k = 0; k < oldCur.length; ++k) + { if (oldCur[k].marker == span.marker) { continue spans } } + oldCur.push(span); + } + } else if (stretchCur) { + old[i] = stretchCur; + } + } + return old + } + + // Used both to provide a JSON-safe object in .getHistory, and, when + // detaching a document, to split the history in two + function copyHistoryArray(events, newGroup, instantiateSel) { + var copy = []; + for (var i = 0; i < events.length; ++i) { + var event = events[i]; + if (event.ranges) { + copy.push(instantiateSel ? Selection.prototype.deepCopy.call(event) : event); + continue + } + var changes = event.changes, newChanges = []; + copy.push({changes: newChanges}); + for (var j = 0; j < changes.length; ++j) { + var change = changes[j], m = (void 0); + newChanges.push({from: change.from, to: change.to, text: change.text}); + if (newGroup) { for (var prop in change) { if (m = prop.match(/^spans_(\d+)$/)) { + if (indexOf(newGroup, Number(m[1])) > -1) { + lst(newChanges)[prop] = change[prop]; + delete change[prop]; + } + } } } + } + } + return copy + } + + // The 'scroll' parameter given to many of these indicated whether + // the new cursor position should be scrolled into view after + // modifying the selection. + + // If shift is held or the extend flag is set, extends a range to + // include a given position (and optionally a second position). + // Otherwise, simply returns the range between the given positions. + // Used for cursor motion and such. + function extendRange(range, head, other, extend) { + if (extend) { + var anchor = range.anchor; + if (other) { + var posBefore = cmp(head, anchor) < 0; + if (posBefore != (cmp(other, anchor) < 0)) { + anchor = head; + head = other; + } else if (posBefore != (cmp(head, other) < 0)) { + head = other; + } + } + return new Range(anchor, head) + } else { + return new Range(other || head, head) + } + } + + // Extend the primary selection range, discard the rest. + function extendSelection(doc, head, other, options, extend) { + if (extend == null) { extend = doc.cm && (doc.cm.display.shift || doc.extend); } + setSelection(doc, new Selection([extendRange(doc.sel.primary(), head, other, extend)], 0), options); + } + + // Extend all selections (pos is an array of selections with length + // equal the number of selections) + function extendSelections(doc, heads, options) { + var out = []; + var extend = doc.cm && (doc.cm.display.shift || doc.extend); + for (var i = 0; i < doc.sel.ranges.length; i++) + { out[i] = extendRange(doc.sel.ranges[i], heads[i], null, extend); } + var newSel = normalizeSelection(doc.cm, out, doc.sel.primIndex); + setSelection(doc, newSel, options); + } + + // Updates a single range in the selection. + function replaceOneSelection(doc, i, range, options) { + var ranges = doc.sel.ranges.slice(0); + ranges[i] = range; + setSelection(doc, normalizeSelection(doc.cm, ranges, doc.sel.primIndex), options); + } + + // Reset the selection to a single range. + function setSimpleSelection(doc, anchor, head, options) { + setSelection(doc, simpleSelection(anchor, head), options); + } + + // Give beforeSelectionChange handlers a change to influence a + // selection update. + function filterSelectionChange(doc, sel, options) { + var obj = { + ranges: sel.ranges, + update: function(ranges) { + var this$1 = this; + + this.ranges = []; + for (var i = 0; i < ranges.length; i++) + { this$1.ranges[i] = new Range(clipPos(doc, ranges[i].anchor), + clipPos(doc, ranges[i].head)); } + }, + origin: options && options.origin + }; + signal(doc, "beforeSelectionChange", doc, obj); + if (doc.cm) { signal(doc.cm, "beforeSelectionChange", doc.cm, obj); } + if (obj.ranges != sel.ranges) { return normalizeSelection(doc.cm, obj.ranges, obj.ranges.length - 1) } + else { return sel } + } + + function setSelectionReplaceHistory(doc, sel, options) { + var done = doc.history.done, last = lst(done); + if (last && last.ranges) { + done[done.length - 1] = sel; + setSelectionNoUndo(doc, sel, options); + } else { + setSelection(doc, sel, options); + } + } + + // Set a new selection. + function setSelection(doc, sel, options) { + setSelectionNoUndo(doc, sel, options); + addSelectionToHistory(doc, doc.sel, doc.cm ? doc.cm.curOp.id : NaN, options); + } + + function setSelectionNoUndo(doc, sel, options) { + if (hasHandler(doc, "beforeSelectionChange") || doc.cm && hasHandler(doc.cm, "beforeSelectionChange")) + { sel = filterSelectionChange(doc, sel, options); } + + var bias = options && options.bias || + (cmp(sel.primary().head, doc.sel.primary().head) < 0 ? -1 : 1); + setSelectionInner(doc, skipAtomicInSelection(doc, sel, bias, true)); + + if (!(options && options.scroll === false) && doc.cm) + { ensureCursorVisible(doc.cm); } + } + + function setSelectionInner(doc, sel) { + if (sel.equals(doc.sel)) { return } + + doc.sel = sel; + + if (doc.cm) { + doc.cm.curOp.updateInput = 1; + doc.cm.curOp.selectionChanged = true; + signalCursorActivity(doc.cm); + } + signalLater(doc, "cursorActivity", doc); + } + + // Verify that the selection does not partially select any atomic + // marked ranges. + function reCheckSelection(doc) { + setSelectionInner(doc, skipAtomicInSelection(doc, doc.sel, null, false)); + } + + // Return a selection that does not partially select any atomic + // ranges. + function skipAtomicInSelection(doc, sel, bias, mayClear) { + var out; + for (var i = 0; i < sel.ranges.length; i++) { + var range = sel.ranges[i]; + var old = sel.ranges.length == doc.sel.ranges.length && doc.sel.ranges[i]; + var newAnchor = skipAtomic(doc, range.anchor, old && old.anchor, bias, mayClear); + var newHead = skipAtomic(doc, range.head, old && old.head, bias, mayClear); + if (out || newAnchor != range.anchor || newHead != range.head) { + if (!out) { out = sel.ranges.slice(0, i); } + out[i] = new Range(newAnchor, newHead); + } + } + return out ? normalizeSelection(doc.cm, out, sel.primIndex) : sel + } + + function skipAtomicInner(doc, pos, oldPos, dir, mayClear) { + var line = getLine(doc, pos.line); + if (line.markedSpans) { for (var i = 0; i < line.markedSpans.length; ++i) { + var sp = line.markedSpans[i], m = sp.marker; + + // Determine if we should prevent the cursor being placed to the left/right of an atomic marker + // Historically this was determined using the inclusiveLeft/Right option, but the new way to control it + // is with selectLeft/Right + var preventCursorLeft = ("selectLeft" in m) ? !m.selectLeft : m.inclusiveLeft; + var preventCursorRight = ("selectRight" in m) ? !m.selectRight : m.inclusiveRight; + + if ((sp.from == null || (preventCursorLeft ? sp.from <= pos.ch : sp.from < pos.ch)) && + (sp.to == null || (preventCursorRight ? sp.to >= pos.ch : sp.to > pos.ch))) { + if (mayClear) { + signal(m, "beforeCursorEnter"); + if (m.explicitlyCleared) { + if (!line.markedSpans) { break } + else {--i; continue} + } + } + if (!m.atomic) { continue } + + if (oldPos) { + var near = m.find(dir < 0 ? 1 : -1), diff = (void 0); + if (dir < 0 ? preventCursorRight : preventCursorLeft) + { near = movePos(doc, near, -dir, near && near.line == pos.line ? line : null); } + if (near && near.line == pos.line && (diff = cmp(near, oldPos)) && (dir < 0 ? diff < 0 : diff > 0)) + { return skipAtomicInner(doc, near, pos, dir, mayClear) } + } + + var far = m.find(dir < 0 ? -1 : 1); + if (dir < 0 ? preventCursorLeft : preventCursorRight) + { far = movePos(doc, far, dir, far.line == pos.line ? line : null); } + return far ? skipAtomicInner(doc, far, pos, dir, mayClear) : null + } + } } + return pos + } + + // Ensure a given position is not inside an atomic range. + function skipAtomic(doc, pos, oldPos, bias, mayClear) { + var dir = bias || 1; + var found = skipAtomicInner(doc, pos, oldPos, dir, mayClear) || + (!mayClear && skipAtomicInner(doc, pos, oldPos, dir, true)) || + skipAtomicInner(doc, pos, oldPos, -dir, mayClear) || + (!mayClear && skipAtomicInner(doc, pos, oldPos, -dir, true)); + if (!found) { + doc.cantEdit = true; + return Pos(doc.first, 0) + } + return found + } + + function movePos(doc, pos, dir, line) { + if (dir < 0 && pos.ch == 0) { + if (pos.line > doc.first) { return clipPos(doc, Pos(pos.line - 1)) } + else { return null } + } else if (dir > 0 && pos.ch == (line || getLine(doc, pos.line)).text.length) { + if (pos.line < doc.first + doc.size - 1) { return Pos(pos.line + 1, 0) } + else { return null } + } else { + return new Pos(pos.line, pos.ch + dir) + } + } + + function selectAll(cm) { + cm.setSelection(Pos(cm.firstLine(), 0), Pos(cm.lastLine()), sel_dontScroll); + } + + // UPDATING + + // Allow "beforeChange" event handlers to influence a change + function filterChange(doc, change, update) { + var obj = { + canceled: false, + from: change.from, + to: change.to, + text: change.text, + origin: change.origin, + cancel: function () { return obj.canceled = true; } + }; + if (update) { obj.update = function (from, to, text, origin) { + if (from) { obj.from = clipPos(doc, from); } + if (to) { obj.to = clipPos(doc, to); } + if (text) { obj.text = text; } + if (origin !== undefined) { obj.origin = origin; } + }; } + signal(doc, "beforeChange", doc, obj); + if (doc.cm) { signal(doc.cm, "beforeChange", doc.cm, obj); } + + if (obj.canceled) { + if (doc.cm) { doc.cm.curOp.updateInput = 2; } + return null + } + return {from: obj.from, to: obj.to, text: obj.text, origin: obj.origin} + } + + // Apply a change to a document, and add it to the document's + // history, and propagating it to all linked documents. + function makeChange(doc, change, ignoreReadOnly) { + if (doc.cm) { + if (!doc.cm.curOp) { return operation(doc.cm, makeChange)(doc, change, ignoreReadOnly) } + if (doc.cm.state.suppressEdits) { return } + } + + if (hasHandler(doc, "beforeChange") || doc.cm && hasHandler(doc.cm, "beforeChange")) { + change = filterChange(doc, change, true); + if (!change) { return } + } + + // Possibly split or suppress the update based on the presence + // of read-only spans in its range. + var split = sawReadOnlySpans && !ignoreReadOnly && removeReadOnlyRanges(doc, change.from, change.to); + if (split) { + for (var i = split.length - 1; i >= 0; --i) + { makeChangeInner(doc, {from: split[i].from, to: split[i].to, text: i ? [""] : change.text, origin: change.origin}); } + } else { + makeChangeInner(doc, change); + } + } + + function makeChangeInner(doc, change) { + if (change.text.length == 1 && change.text[0] == "" && cmp(change.from, change.to) == 0) { return } + var selAfter = computeSelAfterChange(doc, change); + addChangeToHistory(doc, change, selAfter, doc.cm ? doc.cm.curOp.id : NaN); + + makeChangeSingleDoc(doc, change, selAfter, stretchSpansOverChange(doc, change)); + var rebased = []; + + linkedDocs(doc, function (doc, sharedHist) { + if (!sharedHist && indexOf(rebased, doc.history) == -1) { + rebaseHist(doc.history, change); + rebased.push(doc.history); + } + makeChangeSingleDoc(doc, change, null, stretchSpansOverChange(doc, change)); + }); + } + + // Revert a change stored in a document's history. + function makeChangeFromHistory(doc, type, allowSelectionOnly) { + var suppress = doc.cm && doc.cm.state.suppressEdits; + if (suppress && !allowSelectionOnly) { return } + + var hist = doc.history, event, selAfter = doc.sel; + var source = type == "undo" ? hist.done : hist.undone, dest = type == "undo" ? hist.undone : hist.done; + + // Verify that there is a useable event (so that ctrl-z won't + // needlessly clear selection events) + var i = 0; + for (; i < source.length; i++) { + event = source[i]; + if (allowSelectionOnly ? event.ranges && !event.equals(doc.sel) : !event.ranges) + { break } + } + if (i == source.length) { return } + hist.lastOrigin = hist.lastSelOrigin = null; + + for (;;) { + event = source.pop(); + if (event.ranges) { + pushSelectionToHistory(event, dest); + if (allowSelectionOnly && !event.equals(doc.sel)) { + setSelection(doc, event, {clearRedo: false}); + return + } + selAfter = event; + } else if (suppress) { + source.push(event); + return + } else { break } + } + + // Build up a reverse change object to add to the opposite history + // stack (redo when undoing, and vice versa). + var antiChanges = []; + pushSelectionToHistory(selAfter, dest); + dest.push({changes: antiChanges, generation: hist.generation}); + hist.generation = event.generation || ++hist.maxGeneration; + + var filter = hasHandler(doc, "beforeChange") || doc.cm && hasHandler(doc.cm, "beforeChange"); + + var loop = function ( i ) { + var change = event.changes[i]; + change.origin = type; + if (filter && !filterChange(doc, change, false)) { + source.length = 0; + return {} + } + + antiChanges.push(historyChangeFromChange(doc, change)); + + var after = i ? computeSelAfterChange(doc, change) : lst(source); + makeChangeSingleDoc(doc, change, after, mergeOldSpans(doc, change)); + if (!i && doc.cm) { doc.cm.scrollIntoView({from: change.from, to: changeEnd(change)}); } + var rebased = []; + + // Propagate to the linked documents + linkedDocs(doc, function (doc, sharedHist) { + if (!sharedHist && indexOf(rebased, doc.history) == -1) { + rebaseHist(doc.history, change); + rebased.push(doc.history); + } + makeChangeSingleDoc(doc, change, null, mergeOldSpans(doc, change)); + }); + }; + + for (var i$1 = event.changes.length - 1; i$1 >= 0; --i$1) { + var returned = loop( i$1 ); + + if ( returned ) return returned.v; + } + } + + // Sub-views need their line numbers shifted when text is added + // above or below them in the parent document. + function shiftDoc(doc, distance) { + if (distance == 0) { return } + doc.first += distance; + doc.sel = new Selection(map(doc.sel.ranges, function (range) { return new Range( + Pos(range.anchor.line + distance, range.anchor.ch), + Pos(range.head.line + distance, range.head.ch) + ); }), doc.sel.primIndex); + if (doc.cm) { + regChange(doc.cm, doc.first, doc.first - distance, distance); + for (var d = doc.cm.display, l = d.viewFrom; l < d.viewTo; l++) + { regLineChange(doc.cm, l, "gutter"); } + } + } + + // More lower-level change function, handling only a single document + // (not linked ones). + function makeChangeSingleDoc(doc, change, selAfter, spans) { + if (doc.cm && !doc.cm.curOp) + { return operation(doc.cm, makeChangeSingleDoc)(doc, change, selAfter, spans) } + + if (change.to.line < doc.first) { + shiftDoc(doc, change.text.length - 1 - (change.to.line - change.from.line)); + return + } + if (change.from.line > doc.lastLine()) { return } + + // Clip the change to the size of this doc + if (change.from.line < doc.first) { + var shift = change.text.length - 1 - (doc.first - change.from.line); + shiftDoc(doc, shift); + change = {from: Pos(doc.first, 0), to: Pos(change.to.line + shift, change.to.ch), + text: [lst(change.text)], origin: change.origin}; + } + var last = doc.lastLine(); + if (change.to.line > last) { + change = {from: change.from, to: Pos(last, getLine(doc, last).text.length), + text: [change.text[0]], origin: change.origin}; + } + + change.removed = getBetween(doc, change.from, change.to); + + if (!selAfter) { selAfter = computeSelAfterChange(doc, change); } + if (doc.cm) { makeChangeSingleDocInEditor(doc.cm, change, spans); } + else { updateDoc(doc, change, spans); } + setSelectionNoUndo(doc, selAfter, sel_dontScroll); + + if (doc.cantEdit && skipAtomic(doc, Pos(doc.firstLine(), 0))) + { doc.cantEdit = false; } + } + + // Handle the interaction of a change to a document with the editor + // that this document is part of. + function makeChangeSingleDocInEditor(cm, change, spans) { + var doc = cm.doc, display = cm.display, from = change.from, to = change.to; + + var recomputeMaxLength = false, checkWidthStart = from.line; + if (!cm.options.lineWrapping) { + checkWidthStart = lineNo(visualLine(getLine(doc, from.line))); + doc.iter(checkWidthStart, to.line + 1, function (line) { + if (line == display.maxLine) { + recomputeMaxLength = true; + return true + } + }); + } + + if (doc.sel.contains(change.from, change.to) > -1) + { signalCursorActivity(cm); } + + updateDoc(doc, change, spans, estimateHeight(cm)); + + if (!cm.options.lineWrapping) { + doc.iter(checkWidthStart, from.line + change.text.length, function (line) { + var len = lineLength(line); + if (len > display.maxLineLength) { + display.maxLine = line; + display.maxLineLength = len; + display.maxLineChanged = true; + recomputeMaxLength = false; + } + }); + if (recomputeMaxLength) { cm.curOp.updateMaxLine = true; } + } + + retreatFrontier(doc, from.line); + startWorker(cm, 400); + + var lendiff = change.text.length - (to.line - from.line) - 1; + // Remember that these lines changed, for updating the display + if (change.full) + { regChange(cm); } + else if (from.line == to.line && change.text.length == 1 && !isWholeLineUpdate(cm.doc, change)) + { regLineChange(cm, from.line, "text"); } + else + { regChange(cm, from.line, to.line + 1, lendiff); } + + var changesHandler = hasHandler(cm, "changes"), changeHandler = hasHandler(cm, "change"); + if (changeHandler || changesHandler) { + var obj = { + from: from, to: to, + text: change.text, + removed: change.removed, + origin: change.origin + }; + if (changeHandler) { signalLater(cm, "change", cm, obj); } + if (changesHandler) { (cm.curOp.changeObjs || (cm.curOp.changeObjs = [])).push(obj); } + } + cm.display.selForContextMenu = null; + } + + function replaceRange(doc, code, from, to, origin) { + var assign; + + if (!to) { to = from; } + if (cmp(to, from) < 0) { (assign = [to, from], from = assign[0], to = assign[1]); } + if (typeof code == "string") { code = doc.splitLines(code); } + makeChange(doc, {from: from, to: to, text: code, origin: origin}); + } + + // Rebasing/resetting history to deal with externally-sourced changes + + function rebaseHistSelSingle(pos, from, to, diff) { + if (to < pos.line) { + pos.line += diff; + } else if (from < pos.line) { + pos.line = from; + pos.ch = 0; + } + } + + // Tries to rebase an array of history events given a change in the + // document. If the change touches the same lines as the event, the + // event, and everything 'behind' it, is discarded. If the change is + // before the event, the event's positions are updated. Uses a + // copy-on-write scheme for the positions, to avoid having to + // reallocate them all on every rebase, but also avoid problems with + // shared position objects being unsafely updated. + function rebaseHistArray(array, from, to, diff) { + for (var i = 0; i < array.length; ++i) { + var sub = array[i], ok = true; + if (sub.ranges) { + if (!sub.copied) { sub = array[i] = sub.deepCopy(); sub.copied = true; } + for (var j = 0; j < sub.ranges.length; j++) { + rebaseHistSelSingle(sub.ranges[j].anchor, from, to, diff); + rebaseHistSelSingle(sub.ranges[j].head, from, to, diff); + } + continue + } + for (var j$1 = 0; j$1 < sub.changes.length; ++j$1) { + var cur = sub.changes[j$1]; + if (to < cur.from.line) { + cur.from = Pos(cur.from.line + diff, cur.from.ch); + cur.to = Pos(cur.to.line + diff, cur.to.ch); + } else if (from <= cur.to.line) { + ok = false; + break + } + } + if (!ok) { + array.splice(0, i + 1); + i = 0; + } + } + } + + function rebaseHist(hist, change) { + var from = change.from.line, to = change.to.line, diff = change.text.length - (to - from) - 1; + rebaseHistArray(hist.done, from, to, diff); + rebaseHistArray(hist.undone, from, to, diff); + } + + // Utility for applying a change to a line by handle or number, + // returning the number and optionally registering the line as + // changed. + function changeLine(doc, handle, changeType, op) { + var no = handle, line = handle; + if (typeof handle == "number") { line = getLine(doc, clipLine(doc, handle)); } + else { no = lineNo(handle); } + if (no == null) { return null } + if (op(line, no) && doc.cm) { regLineChange(doc.cm, no, changeType); } + return line + } + + // The document is represented as a BTree consisting of leaves, with + // chunk of lines in them, and branches, with up to ten leaves or + // other branch nodes below them. The top node is always a branch + // node, and is the document object itself (meaning it has + // additional methods and properties). + // + // All nodes have parent links. The tree is used both to go from + // line numbers to line objects, and to go from objects to numbers. + // It also indexes by height, and is used to convert between height + // and line object, and to find the total height of the document. + // + // See also http://marijnhaverbeke.nl/blog/codemirror-line-tree.html + + function LeafChunk(lines) { + var this$1 = this; + + this.lines = lines; + this.parent = null; + var height = 0; + for (var i = 0; i < lines.length; ++i) { + lines[i].parent = this$1; + height += lines[i].height; + } + this.height = height; + } + + LeafChunk.prototype = { + chunkSize: function() { return this.lines.length }, + + // Remove the n lines at offset 'at'. + removeInner: function(at, n) { + var this$1 = this; + + for (var i = at, e = at + n; i < e; ++i) { + var line = this$1.lines[i]; + this$1.height -= line.height; + cleanUpLine(line); + signalLater(line, "delete"); + } + this.lines.splice(at, n); + }, + + // Helper used to collapse a small branch into a single leaf. + collapse: function(lines) { + lines.push.apply(lines, this.lines); + }, + + // Insert the given array of lines at offset 'at', count them as + // having the given height. + insertInner: function(at, lines, height) { + var this$1 = this; + + this.height += height; + this.lines = this.lines.slice(0, at).concat(lines).concat(this.lines.slice(at)); + for (var i = 0; i < lines.length; ++i) { lines[i].parent = this$1; } + }, + + // Used to iterate over a part of the tree. + iterN: function(at, n, op) { + var this$1 = this; + + for (var e = at + n; at < e; ++at) + { if (op(this$1.lines[at])) { return true } } + } + }; + + function BranchChunk(children) { + var this$1 = this; + + this.children = children; + var size = 0, height = 0; + for (var i = 0; i < children.length; ++i) { + var ch = children[i]; + size += ch.chunkSize(); height += ch.height; + ch.parent = this$1; + } + this.size = size; + this.height = height; + this.parent = null; + } + + BranchChunk.prototype = { + chunkSize: function() { return this.size }, + + removeInner: function(at, n) { + var this$1 = this; + + this.size -= n; + for (var i = 0; i < this.children.length; ++i) { + var child = this$1.children[i], sz = child.chunkSize(); + if (at < sz) { + var rm = Math.min(n, sz - at), oldHeight = child.height; + child.removeInner(at, rm); + this$1.height -= oldHeight - child.height; + if (sz == rm) { this$1.children.splice(i--, 1); child.parent = null; } + if ((n -= rm) == 0) { break } + at = 0; + } else { at -= sz; } + } + // If the result is smaller than 25 lines, ensure that it is a + // single leaf node. + if (this.size - n < 25 && + (this.children.length > 1 || !(this.children[0] instanceof LeafChunk))) { + var lines = []; + this.collapse(lines); + this.children = [new LeafChunk(lines)]; + this.children[0].parent = this; + } + }, + + collapse: function(lines) { + var this$1 = this; + + for (var i = 0; i < this.children.length; ++i) { this$1.children[i].collapse(lines); } + }, + + insertInner: function(at, lines, height) { + var this$1 = this; + + this.size += lines.length; + this.height += height; + for (var i = 0; i < this.children.length; ++i) { + var child = this$1.children[i], sz = child.chunkSize(); + if (at <= sz) { + child.insertInner(at, lines, height); + if (child.lines && child.lines.length > 50) { + // To avoid memory thrashing when child.lines is huge (e.g. first view of a large file), it's never spliced. + // Instead, small slices are taken. They're taken in order because sequential memory accesses are fastest. + var remaining = child.lines.length % 25 + 25; + for (var pos = remaining; pos < child.lines.length;) { + var leaf = new LeafChunk(child.lines.slice(pos, pos += 25)); + child.height -= leaf.height; + this$1.children.splice(++i, 0, leaf); + leaf.parent = this$1; + } + child.lines = child.lines.slice(0, remaining); + this$1.maybeSpill(); + } + break + } + at -= sz; + } + }, + + // When a node has grown, check whether it should be split. + maybeSpill: function() { + if (this.children.length <= 10) { return } + var me = this; + do { + var spilled = me.children.splice(me.children.length - 5, 5); + var sibling = new BranchChunk(spilled); + if (!me.parent) { // Become the parent node + var copy = new BranchChunk(me.children); + copy.parent = me; + me.children = [copy, sibling]; + me = copy; + } else { + me.size -= sibling.size; + me.height -= sibling.height; + var myIndex = indexOf(me.parent.children, me); + me.parent.children.splice(myIndex + 1, 0, sibling); + } + sibling.parent = me.parent; + } while (me.children.length > 10) + me.parent.maybeSpill(); + }, + + iterN: function(at, n, op) { + var this$1 = this; + + for (var i = 0; i < this.children.length; ++i) { + var child = this$1.children[i], sz = child.chunkSize(); + if (at < sz) { + var used = Math.min(n, sz - at); + if (child.iterN(at, used, op)) { return true } + if ((n -= used) == 0) { break } + at = 0; + } else { at -= sz; } + } + } + }; + + // Line widgets are block elements displayed above or below a line. + + var LineWidget = function(doc, node, options) { + var this$1 = this; + + if (options) { for (var opt in options) { if (options.hasOwnProperty(opt)) + { this$1[opt] = options[opt]; } } } + this.doc = doc; + this.node = node; + }; + + LineWidget.prototype.clear = function () { + var this$1 = this; + + var cm = this.doc.cm, ws = this.line.widgets, line = this.line, no = lineNo(line); + if (no == null || !ws) { return } + for (var i = 0; i < ws.length; ++i) { if (ws[i] == this$1) { ws.splice(i--, 1); } } + if (!ws.length) { line.widgets = null; } + var height = widgetHeight(this); + updateLineHeight(line, Math.max(0, line.height - height)); + if (cm) { + runInOp(cm, function () { + adjustScrollWhenAboveVisible(cm, line, -height); + regLineChange(cm, no, "widget"); + }); + signalLater(cm, "lineWidgetCleared", cm, this, no); + } + }; + + LineWidget.prototype.changed = function () { + var this$1 = this; + + var oldH = this.height, cm = this.doc.cm, line = this.line; + this.height = null; + var diff = widgetHeight(this) - oldH; + if (!diff) { return } + if (!lineIsHidden(this.doc, line)) { updateLineHeight(line, line.height + diff); } + if (cm) { + runInOp(cm, function () { + cm.curOp.forceUpdate = true; + adjustScrollWhenAboveVisible(cm, line, diff); + signalLater(cm, "lineWidgetChanged", cm, this$1, lineNo(line)); + }); + } + }; + eventMixin(LineWidget); + + function adjustScrollWhenAboveVisible(cm, line, diff) { + if (heightAtLine(line) < ((cm.curOp && cm.curOp.scrollTop) || cm.doc.scrollTop)) + { addToScrollTop(cm, diff); } + } + + function addLineWidget(doc, handle, node, options) { + var widget = new LineWidget(doc, node, options); + var cm = doc.cm; + if (cm && widget.noHScroll) { cm.display.alignWidgets = true; } + changeLine(doc, handle, "widget", function (line) { + var widgets = line.widgets || (line.widgets = []); + if (widget.insertAt == null) { widgets.push(widget); } + else { widgets.splice(Math.min(widgets.length - 1, Math.max(0, widget.insertAt)), 0, widget); } + widget.line = line; + if (cm && !lineIsHidden(doc, line)) { + var aboveVisible = heightAtLine(line) < doc.scrollTop; + updateLineHeight(line, line.height + widgetHeight(widget)); + if (aboveVisible) { addToScrollTop(cm, widget.height); } + cm.curOp.forceUpdate = true; + } + return true + }); + if (cm) { signalLater(cm, "lineWidgetAdded", cm, widget, typeof handle == "number" ? handle : lineNo(handle)); } + return widget + } + + // TEXTMARKERS + + // Created with markText and setBookmark methods. A TextMarker is a + // handle that can be used to clear or find a marked position in the + // document. Line objects hold arrays (markedSpans) containing + // {from, to, marker} object pointing to such marker objects, and + // indicating that such a marker is present on that line. Multiple + // lines may point to the same marker when it spans across lines. + // The spans will have null for their from/to properties when the + // marker continues beyond the start/end of the line. Markers have + // links back to the lines they currently touch. + + // Collapsed markers have unique ids, in order to be able to order + // them, which is needed for uniquely determining an outer marker + // when they overlap (they may nest, but not partially overlap). + var nextMarkerId = 0; + + var TextMarker = function(doc, type) { + this.lines = []; + this.type = type; + this.doc = doc; + this.id = ++nextMarkerId; + }; + + // Clear the marker. + TextMarker.prototype.clear = function () { + var this$1 = this; + + if (this.explicitlyCleared) { return } + var cm = this.doc.cm, withOp = cm && !cm.curOp; + if (withOp) { startOperation(cm); } + if (hasHandler(this, "clear")) { + var found = this.find(); + if (found) { signalLater(this, "clear", found.from, found.to); } + } + var min = null, max = null; + for (var i = 0; i < this.lines.length; ++i) { + var line = this$1.lines[i]; + var span = getMarkedSpanFor(line.markedSpans, this$1); + if (cm && !this$1.collapsed) { regLineChange(cm, lineNo(line), "text"); } + else if (cm) { + if (span.to != null) { max = lineNo(line); } + if (span.from != null) { min = lineNo(line); } + } + line.markedSpans = removeMarkedSpan(line.markedSpans, span); + if (span.from == null && this$1.collapsed && !lineIsHidden(this$1.doc, line) && cm) + { updateLineHeight(line, textHeight(cm.display)); } + } + if (cm && this.collapsed && !cm.options.lineWrapping) { for (var i$1 = 0; i$1 < this.lines.length; ++i$1) { + var visual = visualLine(this$1.lines[i$1]), len = lineLength(visual); + if (len > cm.display.maxLineLength) { + cm.display.maxLine = visual; + cm.display.maxLineLength = len; + cm.display.maxLineChanged = true; + } + } } + + if (min != null && cm && this.collapsed) { regChange(cm, min, max + 1); } + this.lines.length = 0; + this.explicitlyCleared = true; + if (this.atomic && this.doc.cantEdit) { + this.doc.cantEdit = false; + if (cm) { reCheckSelection(cm.doc); } + } + if (cm) { signalLater(cm, "markerCleared", cm, this, min, max); } + if (withOp) { endOperation(cm); } + if (this.parent) { this.parent.clear(); } + }; + + // Find the position of the marker in the document. Returns a {from, + // to} object by default. Side can be passed to get a specific side + // -- 0 (both), -1 (left), or 1 (right). When lineObj is true, the + // Pos objects returned contain a line object, rather than a line + // number (used to prevent looking up the same line twice). + TextMarker.prototype.find = function (side, lineObj) { + var this$1 = this; + + if (side == null && this.type == "bookmark") { side = 1; } + var from, to; + for (var i = 0; i < this.lines.length; ++i) { + var line = this$1.lines[i]; + var span = getMarkedSpanFor(line.markedSpans, this$1); + if (span.from != null) { + from = Pos(lineObj ? line : lineNo(line), span.from); + if (side == -1) { return from } + } + if (span.to != null) { + to = Pos(lineObj ? line : lineNo(line), span.to); + if (side == 1) { return to } + } + } + return from && {from: from, to: to} + }; + + // Signals that the marker's widget changed, and surrounding layout + // should be recomputed. + TextMarker.prototype.changed = function () { + var this$1 = this; + + var pos = this.find(-1, true), widget = this, cm = this.doc.cm; + if (!pos || !cm) { return } + runInOp(cm, function () { + var line = pos.line, lineN = lineNo(pos.line); + var view = findViewForLine(cm, lineN); + if (view) { + clearLineMeasurementCacheFor(view); + cm.curOp.selectionChanged = cm.curOp.forceUpdate = true; + } + cm.curOp.updateMaxLine = true; + if (!lineIsHidden(widget.doc, line) && widget.height != null) { + var oldHeight = widget.height; + widget.height = null; + var dHeight = widgetHeight(widget) - oldHeight; + if (dHeight) + { updateLineHeight(line, line.height + dHeight); } + } + signalLater(cm, "markerChanged", cm, this$1); + }); + }; + + TextMarker.prototype.attachLine = function (line) { + if (!this.lines.length && this.doc.cm) { + var op = this.doc.cm.curOp; + if (!op.maybeHiddenMarkers || indexOf(op.maybeHiddenMarkers, this) == -1) + { (op.maybeUnhiddenMarkers || (op.maybeUnhiddenMarkers = [])).push(this); } + } + this.lines.push(line); + }; + + TextMarker.prototype.detachLine = function (line) { + this.lines.splice(indexOf(this.lines, line), 1); + if (!this.lines.length && this.doc.cm) { + var op = this.doc.cm.curOp + ;(op.maybeHiddenMarkers || (op.maybeHiddenMarkers = [])).push(this); + } + }; + eventMixin(TextMarker); + + // Create a marker, wire it up to the right lines, and + function markText(doc, from, to, options, type) { + // Shared markers (across linked documents) are handled separately + // (markTextShared will call out to this again, once per + // document). + if (options && options.shared) { return markTextShared(doc, from, to, options, type) } + // Ensure we are in an operation. + if (doc.cm && !doc.cm.curOp) { return operation(doc.cm, markText)(doc, from, to, options, type) } + + var marker = new TextMarker(doc, type), diff = cmp(from, to); + if (options) { copyObj(options, marker, false); } + // Don't connect empty markers unless clearWhenEmpty is false + if (diff > 0 || diff == 0 && marker.clearWhenEmpty !== false) + { return marker } + if (marker.replacedWith) { + // Showing up as a widget implies collapsed (widget replaces text) + marker.collapsed = true; + marker.widgetNode = eltP("span", [marker.replacedWith], "CodeMirror-widget"); + if (!options.handleMouseEvents) { marker.widgetNode.setAttribute("cm-ignore-events", "true"); } + if (options.insertLeft) { marker.widgetNode.insertLeft = true; } + } + if (marker.collapsed) { + if (conflictingCollapsedRange(doc, from.line, from, to, marker) || + from.line != to.line && conflictingCollapsedRange(doc, to.line, from, to, marker)) + { throw new Error("Inserting collapsed marker partially overlapping an existing one") } + seeCollapsedSpans(); + } + + if (marker.addToHistory) + { addChangeToHistory(doc, {from: from, to: to, origin: "markText"}, doc.sel, NaN); } + + var curLine = from.line, cm = doc.cm, updateMaxLine; + doc.iter(curLine, to.line + 1, function (line) { + if (cm && marker.collapsed && !cm.options.lineWrapping && visualLine(line) == cm.display.maxLine) + { updateMaxLine = true; } + if (marker.collapsed && curLine != from.line) { updateLineHeight(line, 0); } + addMarkedSpan(line, new MarkedSpan(marker, + curLine == from.line ? from.ch : null, + curLine == to.line ? to.ch : null)); + ++curLine; + }); + // lineIsHidden depends on the presence of the spans, so needs a second pass + if (marker.collapsed) { doc.iter(from.line, to.line + 1, function (line) { + if (lineIsHidden(doc, line)) { updateLineHeight(line, 0); } + }); } + + if (marker.clearOnEnter) { on(marker, "beforeCursorEnter", function () { return marker.clear(); }); } + + if (marker.readOnly) { + seeReadOnlySpans(); + if (doc.history.done.length || doc.history.undone.length) + { doc.clearHistory(); } + } + if (marker.collapsed) { + marker.id = ++nextMarkerId; + marker.atomic = true; + } + if (cm) { + // Sync editor state + if (updateMaxLine) { cm.curOp.updateMaxLine = true; } + if (marker.collapsed) + { regChange(cm, from.line, to.line + 1); } + else if (marker.className || marker.startStyle || marker.endStyle || marker.css || + marker.attributes || marker.title) + { for (var i = from.line; i <= to.line; i++) { regLineChange(cm, i, "text"); } } + if (marker.atomic) { reCheckSelection(cm.doc); } + signalLater(cm, "markerAdded", cm, marker); + } + return marker + } + + // SHARED TEXTMARKERS + + // A shared marker spans multiple linked documents. It is + // implemented as a meta-marker-object controlling multiple normal + // markers. + var SharedTextMarker = function(markers, primary) { + var this$1 = this; + + this.markers = markers; + this.primary = primary; + for (var i = 0; i < markers.length; ++i) + { markers[i].parent = this$1; } + }; + + SharedTextMarker.prototype.clear = function () { + var this$1 = this; + + if (this.explicitlyCleared) { return } + this.explicitlyCleared = true; + for (var i = 0; i < this.markers.length; ++i) + { this$1.markers[i].clear(); } + signalLater(this, "clear"); + }; + + SharedTextMarker.prototype.find = function (side, lineObj) { + return this.primary.find(side, lineObj) + }; + eventMixin(SharedTextMarker); + + function markTextShared(doc, from, to, options, type) { + options = copyObj(options); + options.shared = false; + var markers = [markText(doc, from, to, options, type)], primary = markers[0]; + var widget = options.widgetNode; + linkedDocs(doc, function (doc) { + if (widget) { options.widgetNode = widget.cloneNode(true); } + markers.push(markText(doc, clipPos(doc, from), clipPos(doc, to), options, type)); + for (var i = 0; i < doc.linked.length; ++i) + { if (doc.linked[i].isParent) { return } } + primary = lst(markers); + }); + return new SharedTextMarker(markers, primary) + } + + function findSharedMarkers(doc) { + return doc.findMarks(Pos(doc.first, 0), doc.clipPos(Pos(doc.lastLine())), function (m) { return m.parent; }) + } + + function copySharedMarkers(doc, markers) { + for (var i = 0; i < markers.length; i++) { + var marker = markers[i], pos = marker.find(); + var mFrom = doc.clipPos(pos.from), mTo = doc.clipPos(pos.to); + if (cmp(mFrom, mTo)) { + var subMark = markText(doc, mFrom, mTo, marker.primary, marker.primary.type); + marker.markers.push(subMark); + subMark.parent = marker; + } + } + } + + function detachSharedMarkers(markers) { + var loop = function ( i ) { + var marker = markers[i], linked = [marker.primary.doc]; + linkedDocs(marker.primary.doc, function (d) { return linked.push(d); }); + for (var j = 0; j < marker.markers.length; j++) { + var subMarker = marker.markers[j]; + if (indexOf(linked, subMarker.doc) == -1) { + subMarker.parent = null; + marker.markers.splice(j--, 1); + } + } + }; + + for (var i = 0; i < markers.length; i++) loop( i ); + } + + var nextDocId = 0; + var Doc = function(text, mode, firstLine, lineSep, direction) { + if (!(this instanceof Doc)) { return new Doc(text, mode, firstLine, lineSep, direction) } + if (firstLine == null) { firstLine = 0; } + + BranchChunk.call(this, [new LeafChunk([new Line("", null)])]); + this.first = firstLine; + this.scrollTop = this.scrollLeft = 0; + this.cantEdit = false; + this.cleanGeneration = 1; + this.modeFrontier = this.highlightFrontier = firstLine; + var start = Pos(firstLine, 0); + this.sel = simpleSelection(start); + this.history = new History(null); + this.id = ++nextDocId; + this.modeOption = mode; + this.lineSep = lineSep; + this.direction = (direction == "rtl") ? "rtl" : "ltr"; + this.extend = false; + + if (typeof text == "string") { text = this.splitLines(text); } + updateDoc(this, {from: start, to: start, text: text}); + setSelection(this, simpleSelection(start), sel_dontScroll); + }; + + Doc.prototype = createObj(BranchChunk.prototype, { + constructor: Doc, + // Iterate over the document. Supports two forms -- with only one + // argument, it calls that for each line in the document. With + // three, it iterates over the range given by the first two (with + // the second being non-inclusive). + iter: function(from, to, op) { + if (op) { this.iterN(from - this.first, to - from, op); } + else { this.iterN(this.first, this.first + this.size, from); } + }, + + // Non-public interface for adding and removing lines. + insert: function(at, lines) { + var height = 0; + for (var i = 0; i < lines.length; ++i) { height += lines[i].height; } + this.insertInner(at - this.first, lines, height); + }, + remove: function(at, n) { this.removeInner(at - this.first, n); }, + + // From here, the methods are part of the public interface. Most + // are also available from CodeMirror (editor) instances. + + getValue: function(lineSep) { + var lines = getLines(this, this.first, this.first + this.size); + if (lineSep === false) { return lines } + return lines.join(lineSep || this.lineSeparator()) + }, + setValue: docMethodOp(function(code) { + var top = Pos(this.first, 0), last = this.first + this.size - 1; + makeChange(this, {from: top, to: Pos(last, getLine(this, last).text.length), + text: this.splitLines(code), origin: "setValue", full: true}, true); + if (this.cm) { scrollToCoords(this.cm, 0, 0); } + setSelection(this, simpleSelection(top), sel_dontScroll); + }), + replaceRange: function(code, from, to, origin) { + from = clipPos(this, from); + to = to ? clipPos(this, to) : from; + replaceRange(this, code, from, to, origin); + }, + getRange: function(from, to, lineSep) { + var lines = getBetween(this, clipPos(this, from), clipPos(this, to)); + if (lineSep === false) { return lines } + return lines.join(lineSep || this.lineSeparator()) + }, + + getLine: function(line) {var l = this.getLineHandle(line); return l && l.text}, + + getLineHandle: function(line) {if (isLine(this, line)) { return getLine(this, line) }}, + getLineNumber: function(line) {return lineNo(line)}, + + getLineHandleVisualStart: function(line) { + if (typeof line == "number") { line = getLine(this, line); } + return visualLine(line) + }, + + lineCount: function() {return this.size}, + firstLine: function() {return this.first}, + lastLine: function() {return this.first + this.size - 1}, + + clipPos: function(pos) {return clipPos(this, pos)}, + + getCursor: function(start) { + var range$$1 = this.sel.primary(), pos; + if (start == null || start == "head") { pos = range$$1.head; } + else if (start == "anchor") { pos = range$$1.anchor; } + else if (start == "end" || start == "to" || start === false) { pos = range$$1.to(); } + else { pos = range$$1.from(); } + return pos + }, + listSelections: function() { return this.sel.ranges }, + somethingSelected: function() {return this.sel.somethingSelected()}, + + setCursor: docMethodOp(function(line, ch, options) { + setSimpleSelection(this, clipPos(this, typeof line == "number" ? Pos(line, ch || 0) : line), null, options); + }), + setSelection: docMethodOp(function(anchor, head, options) { + setSimpleSelection(this, clipPos(this, anchor), clipPos(this, head || anchor), options); + }), + extendSelection: docMethodOp(function(head, other, options) { + extendSelection(this, clipPos(this, head), other && clipPos(this, other), options); + }), + extendSelections: docMethodOp(function(heads, options) { + extendSelections(this, clipPosArray(this, heads), options); + }), + extendSelectionsBy: docMethodOp(function(f, options) { + var heads = map(this.sel.ranges, f); + extendSelections(this, clipPosArray(this, heads), options); + }), + setSelections: docMethodOp(function(ranges, primary, options) { + var this$1 = this; + + if (!ranges.length) { return } + var out = []; + for (var i = 0; i < ranges.length; i++) + { out[i] = new Range(clipPos(this$1, ranges[i].anchor), + clipPos(this$1, ranges[i].head)); } + if (primary == null) { primary = Math.min(ranges.length - 1, this.sel.primIndex); } + setSelection(this, normalizeSelection(this.cm, out, primary), options); + }), + addSelection: docMethodOp(function(anchor, head, options) { + var ranges = this.sel.ranges.slice(0); + ranges.push(new Range(clipPos(this, anchor), clipPos(this, head || anchor))); + setSelection(this, normalizeSelection(this.cm, ranges, ranges.length - 1), options); + }), + + getSelection: function(lineSep) { + var this$1 = this; + + var ranges = this.sel.ranges, lines; + for (var i = 0; i < ranges.length; i++) { + var sel = getBetween(this$1, ranges[i].from(), ranges[i].to()); + lines = lines ? lines.concat(sel) : sel; + } + if (lineSep === false) { return lines } + else { return lines.join(lineSep || this.lineSeparator()) } + }, + getSelections: function(lineSep) { + var this$1 = this; + + var parts = [], ranges = this.sel.ranges; + for (var i = 0; i < ranges.length; i++) { + var sel = getBetween(this$1, ranges[i].from(), ranges[i].to()); + if (lineSep !== false) { sel = sel.join(lineSep || this$1.lineSeparator()); } + parts[i] = sel; + } + return parts + }, + replaceSelection: function(code, collapse, origin) { + var dup = []; + for (var i = 0; i < this.sel.ranges.length; i++) + { dup[i] = code; } + this.replaceSelections(dup, collapse, origin || "+input"); + }, + replaceSelections: docMethodOp(function(code, collapse, origin) { + var this$1 = this; + + var changes = [], sel = this.sel; + for (var i = 0; i < sel.ranges.length; i++) { + var range$$1 = sel.ranges[i]; + changes[i] = {from: range$$1.from(), to: range$$1.to(), text: this$1.splitLines(code[i]), origin: origin}; + } + var newSel = collapse && collapse != "end" && computeReplacedSel(this, changes, collapse); + for (var i$1 = changes.length - 1; i$1 >= 0; i$1--) + { makeChange(this$1, changes[i$1]); } + if (newSel) { setSelectionReplaceHistory(this, newSel); } + else if (this.cm) { ensureCursorVisible(this.cm); } + }), + undo: docMethodOp(function() {makeChangeFromHistory(this, "undo");}), + redo: docMethodOp(function() {makeChangeFromHistory(this, "redo");}), + undoSelection: docMethodOp(function() {makeChangeFromHistory(this, "undo", true);}), + redoSelection: docMethodOp(function() {makeChangeFromHistory(this, "redo", true);}), + + setExtending: function(val) {this.extend = val;}, + getExtending: function() {return this.extend}, + + historySize: function() { + var hist = this.history, done = 0, undone = 0; + for (var i = 0; i < hist.done.length; i++) { if (!hist.done[i].ranges) { ++done; } } + for (var i$1 = 0; i$1 < hist.undone.length; i$1++) { if (!hist.undone[i$1].ranges) { ++undone; } } + return {undo: done, redo: undone} + }, + clearHistory: function() {this.history = new History(this.history.maxGeneration);}, + + markClean: function() { + this.cleanGeneration = this.changeGeneration(true); + }, + changeGeneration: function(forceSplit) { + if (forceSplit) + { this.history.lastOp = this.history.lastSelOp = this.history.lastOrigin = null; } + return this.history.generation + }, + isClean: function (gen) { + return this.history.generation == (gen || this.cleanGeneration) + }, + + getHistory: function() { + return {done: copyHistoryArray(this.history.done), + undone: copyHistoryArray(this.history.undone)} + }, + setHistory: function(histData) { + var hist = this.history = new History(this.history.maxGeneration); + hist.done = copyHistoryArray(histData.done.slice(0), null, true); + hist.undone = copyHistoryArray(histData.undone.slice(0), null, true); + }, + + setGutterMarker: docMethodOp(function(line, gutterID, value) { + return changeLine(this, line, "gutter", function (line) { + var markers = line.gutterMarkers || (line.gutterMarkers = {}); + markers[gutterID] = value; + if (!value && isEmpty(markers)) { line.gutterMarkers = null; } + return true + }) + }), + + clearGutter: docMethodOp(function(gutterID) { + var this$1 = this; + + this.iter(function (line) { + if (line.gutterMarkers && line.gutterMarkers[gutterID]) { + changeLine(this$1, line, "gutter", function () { + line.gutterMarkers[gutterID] = null; + if (isEmpty(line.gutterMarkers)) { line.gutterMarkers = null; } + return true + }); + } + }); + }), + + lineInfo: function(line) { + var n; + if (typeof line == "number") { + if (!isLine(this, line)) { return null } + n = line; + line = getLine(this, line); + if (!line) { return null } + } else { + n = lineNo(line); + if (n == null) { return null } + } + return {line: n, handle: line, text: line.text, gutterMarkers: line.gutterMarkers, + textClass: line.textClass, bgClass: line.bgClass, wrapClass: line.wrapClass, + widgets: line.widgets} + }, + + addLineClass: docMethodOp(function(handle, where, cls) { + return changeLine(this, handle, where == "gutter" ? "gutter" : "class", function (line) { + var prop = where == "text" ? "textClass" + : where == "background" ? "bgClass" + : where == "gutter" ? "gutterClass" : "wrapClass"; + if (!line[prop]) { line[prop] = cls; } + else if (classTest(cls).test(line[prop])) { return false } + else { line[prop] += " " + cls; } + return true + }) + }), + removeLineClass: docMethodOp(function(handle, where, cls) { + return changeLine(this, handle, where == "gutter" ? "gutter" : "class", function (line) { + var prop = where == "text" ? "textClass" + : where == "background" ? "bgClass" + : where == "gutter" ? "gutterClass" : "wrapClass"; + var cur = line[prop]; + if (!cur) { return false } + else if (cls == null) { line[prop] = null; } + else { + var found = cur.match(classTest(cls)); + if (!found) { return false } + var end = found.index + found[0].length; + line[prop] = cur.slice(0, found.index) + (!found.index || end == cur.length ? "" : " ") + cur.slice(end) || null; + } + return true + }) + }), + + addLineWidget: docMethodOp(function(handle, node, options) { + return addLineWidget(this, handle, node, options) + }), + removeLineWidget: function(widget) { widget.clear(); }, + + markText: function(from, to, options) { + return markText(this, clipPos(this, from), clipPos(this, to), options, options && options.type || "range") + }, + setBookmark: function(pos, options) { + var realOpts = {replacedWith: options && (options.nodeType == null ? options.widget : options), + insertLeft: options && options.insertLeft, + clearWhenEmpty: false, shared: options && options.shared, + handleMouseEvents: options && options.handleMouseEvents}; + pos = clipPos(this, pos); + return markText(this, pos, pos, realOpts, "bookmark") + }, + findMarksAt: function(pos) { + pos = clipPos(this, pos); + var markers = [], spans = getLine(this, pos.line).markedSpans; + if (spans) { for (var i = 0; i < spans.length; ++i) { + var span = spans[i]; + if ((span.from == null || span.from <= pos.ch) && + (span.to == null || span.to >= pos.ch)) + { markers.push(span.marker.parent || span.marker); } + } } + return markers + }, + findMarks: function(from, to, filter) { + from = clipPos(this, from); to = clipPos(this, to); + var found = [], lineNo$$1 = from.line; + this.iter(from.line, to.line + 1, function (line) { + var spans = line.markedSpans; + if (spans) { for (var i = 0; i < spans.length; i++) { + var span = spans[i]; + if (!(span.to != null && lineNo$$1 == from.line && from.ch >= span.to || + span.from == null && lineNo$$1 != from.line || + span.from != null && lineNo$$1 == to.line && span.from >= to.ch) && + (!filter || filter(span.marker))) + { found.push(span.marker.parent || span.marker); } + } } + ++lineNo$$1; + }); + return found + }, + getAllMarks: function() { + var markers = []; + this.iter(function (line) { + var sps = line.markedSpans; + if (sps) { for (var i = 0; i < sps.length; ++i) + { if (sps[i].from != null) { markers.push(sps[i].marker); } } } + }); + return markers + }, + + posFromIndex: function(off) { + var ch, lineNo$$1 = this.first, sepSize = this.lineSeparator().length; + this.iter(function (line) { + var sz = line.text.length + sepSize; + if (sz > off) { ch = off; return true } + off -= sz; + ++lineNo$$1; + }); + return clipPos(this, Pos(lineNo$$1, ch)) + }, + indexFromPos: function (coords) { + coords = clipPos(this, coords); + var index = coords.ch; + if (coords.line < this.first || coords.ch < 0) { return 0 } + var sepSize = this.lineSeparator().length; + this.iter(this.first, coords.line, function (line) { // iter aborts when callback returns a truthy value + index += line.text.length + sepSize; + }); + return index + }, + + copy: function(copyHistory) { + var doc = new Doc(getLines(this, this.first, this.first + this.size), + this.modeOption, this.first, this.lineSep, this.direction); + doc.scrollTop = this.scrollTop; doc.scrollLeft = this.scrollLeft; + doc.sel = this.sel; + doc.extend = false; + if (copyHistory) { + doc.history.undoDepth = this.history.undoDepth; + doc.setHistory(this.getHistory()); + } + return doc + }, + + linkedDoc: function(options) { + if (!options) { options = {}; } + var from = this.first, to = this.first + this.size; + if (options.from != null && options.from > from) { from = options.from; } + if (options.to != null && options.to < to) { to = options.to; } + var copy = new Doc(getLines(this, from, to), options.mode || this.modeOption, from, this.lineSep, this.direction); + if (options.sharedHist) { copy.history = this.history + ; }(this.linked || (this.linked = [])).push({doc: copy, sharedHist: options.sharedHist}); + copy.linked = [{doc: this, isParent: true, sharedHist: options.sharedHist}]; + copySharedMarkers(copy, findSharedMarkers(this)); + return copy + }, + unlinkDoc: function(other) { + var this$1 = this; + + if (other instanceof CodeMirror) { other = other.doc; } + if (this.linked) { for (var i = 0; i < this.linked.length; ++i) { + var link = this$1.linked[i]; + if (link.doc != other) { continue } + this$1.linked.splice(i, 1); + other.unlinkDoc(this$1); + detachSharedMarkers(findSharedMarkers(this$1)); + break + } } + // If the histories were shared, split them again + if (other.history == this.history) { + var splitIds = [other.id]; + linkedDocs(other, function (doc) { return splitIds.push(doc.id); }, true); + other.history = new History(null); + other.history.done = copyHistoryArray(this.history.done, splitIds); + other.history.undone = copyHistoryArray(this.history.undone, splitIds); + } + }, + iterLinkedDocs: function(f) {linkedDocs(this, f);}, + + getMode: function() {return this.mode}, + getEditor: function() {return this.cm}, + + splitLines: function(str) { + if (this.lineSep) { return str.split(this.lineSep) } + return splitLinesAuto(str) + }, + lineSeparator: function() { return this.lineSep || "\n" }, + + setDirection: docMethodOp(function (dir) { + if (dir != "rtl") { dir = "ltr"; } + if (dir == this.direction) { return } + this.direction = dir; + this.iter(function (line) { return line.order = null; }); + if (this.cm) { directionChanged(this.cm); } + }) + }); + + // Public alias. + Doc.prototype.eachLine = Doc.prototype.iter; + + // Kludge to work around strange IE behavior where it'll sometimes + // re-fire a series of drag-related events right after the drop (#1551) + var lastDrop = 0; + + function onDrop(e) { + var cm = this; + clearDragCursor(cm); + if (signalDOMEvent(cm, e) || eventInWidget(cm.display, e)) + { return } + e_preventDefault(e); + if (ie) { lastDrop = +new Date; } + var pos = posFromMouse(cm, e, true), files = e.dataTransfer.files; + if (!pos || cm.isReadOnly()) { return } + // Might be a file drop, in which case we simply extract the text + // and insert it. + if (files && files.length && window.FileReader && window.File) { + var n = files.length, text = Array(n), read = 0; + var loadFile = function (file, i) { + if (cm.options.allowDropFileTypes && + indexOf(cm.options.allowDropFileTypes, file.type) == -1) + { return } + + var reader = new FileReader; + reader.onload = operation(cm, function () { + var content = reader.result; + if (/[\x00-\x08\x0e-\x1f]{2}/.test(content)) { content = ""; } + text[i] = content; + if (++read == n) { + pos = clipPos(cm.doc, pos); + var change = {from: pos, to: pos, + text: cm.doc.splitLines(text.join(cm.doc.lineSeparator())), + origin: "paste"}; + makeChange(cm.doc, change); + setSelectionReplaceHistory(cm.doc, simpleSelection(pos, changeEnd(change))); + } + }); + reader.readAsText(file); + }; + for (var i = 0; i < n; ++i) { loadFile(files[i], i); } + } else { // Normal drop + // Don't do a replace if the drop happened inside of the selected text. + if (cm.state.draggingText && cm.doc.sel.contains(pos) > -1) { + cm.state.draggingText(e); + // Ensure the editor is re-focused + setTimeout(function () { return cm.display.input.focus(); }, 20); + return + } + try { + var text$1 = e.dataTransfer.getData("Text"); + if (text$1) { + var selected; + if (cm.state.draggingText && !cm.state.draggingText.copy) + { selected = cm.listSelections(); } + setSelectionNoUndo(cm.doc, simpleSelection(pos, pos)); + if (selected) { for (var i$1 = 0; i$1 < selected.length; ++i$1) + { replaceRange(cm.doc, "", selected[i$1].anchor, selected[i$1].head, "drag"); } } + cm.replaceSelection(text$1, "around", "paste"); + cm.display.input.focus(); + } + } + catch(e){} + } + } + + function onDragStart(cm, e) { + if (ie && (!cm.state.draggingText || +new Date - lastDrop < 100)) { e_stop(e); return } + if (signalDOMEvent(cm, e) || eventInWidget(cm.display, e)) { return } + + e.dataTransfer.setData("Text", cm.getSelection()); + e.dataTransfer.effectAllowed = "copyMove"; + + // Use dummy image instead of default browsers image. + // Recent Safari (~6.0.2) have a tendency to segfault when this happens, so we don't do it there. + if (e.dataTransfer.setDragImage && !safari) { + var img = elt("img", null, null, "position: fixed; left: 0; top: 0;"); + img.src = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + if (presto) { + img.width = img.height = 1; + cm.display.wrapper.appendChild(img); + // Force a relayout, or Opera won't use our image for some obscure reason + img._top = img.offsetTop; + } + e.dataTransfer.setDragImage(img, 0, 0); + if (presto) { img.parentNode.removeChild(img); } + } + } + + function onDragOver(cm, e) { + var pos = posFromMouse(cm, e); + if (!pos) { return } + var frag = document.createDocumentFragment(); + drawSelectionCursor(cm, pos, frag); + if (!cm.display.dragCursor) { + cm.display.dragCursor = elt("div", null, "CodeMirror-cursors CodeMirror-dragcursors"); + cm.display.lineSpace.insertBefore(cm.display.dragCursor, cm.display.cursorDiv); + } + removeChildrenAndAdd(cm.display.dragCursor, frag); + } + + function clearDragCursor(cm) { + if (cm.display.dragCursor) { + cm.display.lineSpace.removeChild(cm.display.dragCursor); + cm.display.dragCursor = null; + } + } + + // These must be handled carefully, because naively registering a + // handler for each editor will cause the editors to never be + // garbage collected. + + function forEachCodeMirror(f) { + if (!document.getElementsByClassName) { return } + var byClass = document.getElementsByClassName("CodeMirror"), editors = []; + for (var i = 0; i < byClass.length; i++) { + var cm = byClass[i].CodeMirror; + if (cm) { editors.push(cm); } + } + if (editors.length) { editors[0].operation(function () { + for (var i = 0; i < editors.length; i++) { f(editors[i]); } + }); } + } + + var globalsRegistered = false; + function ensureGlobalHandlers() { + if (globalsRegistered) { return } + registerGlobalHandlers(); + globalsRegistered = true; + } + function registerGlobalHandlers() { + // When the window resizes, we need to refresh active editors. + var resizeTimer; + on(window, "resize", function () { + if (resizeTimer == null) { resizeTimer = setTimeout(function () { + resizeTimer = null; + forEachCodeMirror(onResize); + }, 100); } + }); + // When the window loses focus, we want to show the editor as blurred + on(window, "blur", function () { return forEachCodeMirror(onBlur); }); + } + // Called when the window resizes + function onResize(cm) { + var d = cm.display; + // Might be a text scaling operation, clear size caches. + d.cachedCharWidth = d.cachedTextHeight = d.cachedPaddingH = null; + d.scrollbarsClipped = false; + cm.setSize(); + } + + var keyNames = { + 3: "Pause", 8: "Backspace", 9: "Tab", 13: "Enter", 16: "Shift", 17: "Ctrl", 18: "Alt", + 19: "Pause", 20: "CapsLock", 27: "Esc", 32: "Space", 33: "PageUp", 34: "PageDown", 35: "End", + 36: "Home", 37: "Left", 38: "Up", 39: "Right", 40: "Down", 44: "PrintScrn", 45: "Insert", + 46: "Delete", 59: ";", 61: "=", 91: "Mod", 92: "Mod", 93: "Mod", + 106: "*", 107: "=", 109: "-", 110: ".", 111: "/", 145: "ScrollLock", + 173: "-", 186: ";", 187: "=", 188: ",", 189: "-", 190: ".", 191: "/", 192: "`", 219: "[", 220: "\\", + 221: "]", 222: "'", 63232: "Up", 63233: "Down", 63234: "Left", 63235: "Right", 63272: "Delete", + 63273: "Home", 63275: "End", 63276: "PageUp", 63277: "PageDown", 63302: "Insert" + }; + + // Number keys + for (var i = 0; i < 10; i++) { keyNames[i + 48] = keyNames[i + 96] = String(i); } + // Alphabetic keys + for (var i$1 = 65; i$1 <= 90; i$1++) { keyNames[i$1] = String.fromCharCode(i$1); } + // Function keys + for (var i$2 = 1; i$2 <= 12; i$2++) { keyNames[i$2 + 111] = keyNames[i$2 + 63235] = "F" + i$2; } + + var keyMap = {}; + + keyMap.basic = { + "Left": "goCharLeft", "Right": "goCharRight", "Up": "goLineUp", "Down": "goLineDown", + "End": "goLineEnd", "Home": "goLineStartSmart", "PageUp": "goPageUp", "PageDown": "goPageDown", + "Delete": "delCharAfter", "Backspace": "delCharBefore", "Shift-Backspace": "delCharBefore", + "Tab": "defaultTab", "Shift-Tab": "indentAuto", + "Enter": "newlineAndIndent", "Insert": "toggleOverwrite", + "Esc": "singleSelection" + }; + // Note that the save and find-related commands aren't defined by + // default. User code or addons can define them. Unknown commands + // are simply ignored. + keyMap.pcDefault = { + "Ctrl-A": "selectAll", "Ctrl-D": "deleteLine", "Ctrl-Z": "undo", "Shift-Ctrl-Z": "redo", "Ctrl-Y": "redo", + "Ctrl-Home": "goDocStart", "Ctrl-End": "goDocEnd", "Ctrl-Up": "goLineUp", "Ctrl-Down": "goLineDown", + "Ctrl-Left": "goGroupLeft", "Ctrl-Right": "goGroupRight", "Alt-Left": "goLineStart", "Alt-Right": "goLineEnd", + "Ctrl-Backspace": "delGroupBefore", "Ctrl-Delete": "delGroupAfter", "Ctrl-S": "save", "Ctrl-F": "find", + "Ctrl-G": "findNext", "Shift-Ctrl-G": "findPrev", "Shift-Ctrl-F": "replace", "Shift-Ctrl-R": "replaceAll", + "Ctrl-[": "indentLess", "Ctrl-]": "indentMore", + "Ctrl-U": "undoSelection", "Shift-Ctrl-U": "redoSelection", "Alt-U": "redoSelection", + "fallthrough": "basic" + }; + // Very basic readline/emacs-style bindings, which are standard on Mac. + keyMap.emacsy = { + "Ctrl-F": "goCharRight", "Ctrl-B": "goCharLeft", "Ctrl-P": "goLineUp", "Ctrl-N": "goLineDown", + "Alt-F": "goWordRight", "Alt-B": "goWordLeft", "Ctrl-A": "goLineStart", "Ctrl-E": "goLineEnd", + "Ctrl-V": "goPageDown", "Shift-Ctrl-V": "goPageUp", "Ctrl-D": "delCharAfter", "Ctrl-H": "delCharBefore", + "Alt-D": "delWordAfter", "Alt-Backspace": "delWordBefore", "Ctrl-K": "killLine", "Ctrl-T": "transposeChars", + "Ctrl-O": "openLine" + }; + keyMap.macDefault = { + "Cmd-A": "selectAll", "Cmd-D": "deleteLine", "Cmd-Z": "undo", "Shift-Cmd-Z": "redo", "Cmd-Y": "redo", + "Cmd-Home": "goDocStart", "Cmd-Up": "goDocStart", "Cmd-End": "goDocEnd", "Cmd-Down": "goDocEnd", "Alt-Left": "goGroupLeft", + "Alt-Right": "goGroupRight", "Cmd-Left": "goLineLeft", "Cmd-Right": "goLineRight", "Alt-Backspace": "delGroupBefore", + "Ctrl-Alt-Backspace": "delGroupAfter", "Alt-Delete": "delGroupAfter", "Cmd-S": "save", "Cmd-F": "find", + "Cmd-G": "findNext", "Shift-Cmd-G": "findPrev", "Cmd-Alt-F": "replace", "Shift-Cmd-Alt-F": "replaceAll", + "Cmd-[": "indentLess", "Cmd-]": "indentMore", "Cmd-Backspace": "delWrappedLineLeft", "Cmd-Delete": "delWrappedLineRight", + "Cmd-U": "undoSelection", "Shift-Cmd-U": "redoSelection", "Ctrl-Up": "goDocStart", "Ctrl-Down": "goDocEnd", + "fallthrough": ["basic", "emacsy"] + }; + keyMap["default"] = mac ? keyMap.macDefault : keyMap.pcDefault; + + // KEYMAP DISPATCH + + function normalizeKeyName(name) { + var parts = name.split(/-(?!$)/); + name = parts[parts.length - 1]; + var alt, ctrl, shift, cmd; + for (var i = 0; i < parts.length - 1; i++) { + var mod = parts[i]; + if (/^(cmd|meta|m)$/i.test(mod)) { cmd = true; } + else if (/^a(lt)?$/i.test(mod)) { alt = true; } + else if (/^(c|ctrl|control)$/i.test(mod)) { ctrl = true; } + else if (/^s(hift)?$/i.test(mod)) { shift = true; } + else { throw new Error("Unrecognized modifier name: " + mod) } + } + if (alt) { name = "Alt-" + name; } + if (ctrl) { name = "Ctrl-" + name; } + if (cmd) { name = "Cmd-" + name; } + if (shift) { name = "Shift-" + name; } + return name + } + + // This is a kludge to keep keymaps mostly working as raw objects + // (backwards compatibility) while at the same time support features + // like normalization and multi-stroke key bindings. It compiles a + // new normalized keymap, and then updates the old object to reflect + // this. + function normalizeKeyMap(keymap) { + var copy = {}; + for (var keyname in keymap) { if (keymap.hasOwnProperty(keyname)) { + var value = keymap[keyname]; + if (/^(name|fallthrough|(de|at)tach)$/.test(keyname)) { continue } + if (value == "...") { delete keymap[keyname]; continue } + + var keys = map(keyname.split(" "), normalizeKeyName); + for (var i = 0; i < keys.length; i++) { + var val = (void 0), name = (void 0); + if (i == keys.length - 1) { + name = keys.join(" "); + val = value; + } else { + name = keys.slice(0, i + 1).join(" "); + val = "..."; + } + var prev = copy[name]; + if (!prev) { copy[name] = val; } + else if (prev != val) { throw new Error("Inconsistent bindings for " + name) } + } + delete keymap[keyname]; + } } + for (var prop in copy) { keymap[prop] = copy[prop]; } + return keymap + } + + function lookupKey(key, map$$1, handle, context) { + map$$1 = getKeyMap(map$$1); + var found = map$$1.call ? map$$1.call(key, context) : map$$1[key]; + if (found === false) { return "nothing" } + if (found === "...") { return "multi" } + if (found != null && handle(found)) { return "handled" } + + if (map$$1.fallthrough) { + if (Object.prototype.toString.call(map$$1.fallthrough) != "[object Array]") + { return lookupKey(key, map$$1.fallthrough, handle, context) } + for (var i = 0; i < map$$1.fallthrough.length; i++) { + var result = lookupKey(key, map$$1.fallthrough[i], handle, context); + if (result) { return result } + } + } + } + + // Modifier key presses don't count as 'real' key presses for the + // purpose of keymap fallthrough. + function isModifierKey(value) { + var name = typeof value == "string" ? value : keyNames[value.keyCode]; + return name == "Ctrl" || name == "Alt" || name == "Shift" || name == "Mod" + } + + function addModifierNames(name, event, noShift) { + var base = name; + if (event.altKey && base != "Alt") { name = "Alt-" + name; } + if ((flipCtrlCmd ? event.metaKey : event.ctrlKey) && base != "Ctrl") { name = "Ctrl-" + name; } + if ((flipCtrlCmd ? event.ctrlKey : event.metaKey) && base != "Cmd") { name = "Cmd-" + name; } + if (!noShift && event.shiftKey && base != "Shift") { name = "Shift-" + name; } + return name + } + + // Look up the name of a key as indicated by an event object. + function keyName(event, noShift) { + if (presto && event.keyCode == 34 && event["char"]) { return false } + var name = keyNames[event.keyCode]; + if (name == null || event.altGraphKey) { return false } + // Ctrl-ScrollLock has keyCode 3, same as Ctrl-Pause, + // so we'll use event.code when available (Chrome 48+, FF 38+, Safari 10.1+) + if (event.keyCode == 3 && event.code) { name = event.code; } + return addModifierNames(name, event, noShift) + } + + function getKeyMap(val) { + return typeof val == "string" ? keyMap[val] : val + } + + // Helper for deleting text near the selection(s), used to implement + // backspace, delete, and similar functionality. + function deleteNearSelection(cm, compute) { + var ranges = cm.doc.sel.ranges, kill = []; + // Build up a set of ranges to kill first, merging overlapping + // ranges. + for (var i = 0; i < ranges.length; i++) { + var toKill = compute(ranges[i]); + while (kill.length && cmp(toKill.from, lst(kill).to) <= 0) { + var replaced = kill.pop(); + if (cmp(replaced.from, toKill.from) < 0) { + toKill.from = replaced.from; + break + } + } + kill.push(toKill); + } + // Next, remove those actual ranges. + runInOp(cm, function () { + for (var i = kill.length - 1; i >= 0; i--) + { replaceRange(cm.doc, "", kill[i].from, kill[i].to, "+delete"); } + ensureCursorVisible(cm); + }); + } + + function moveCharLogically(line, ch, dir) { + var target = skipExtendingChars(line.text, ch + dir, dir); + return target < 0 || target > line.text.length ? null : target + } + + function moveLogically(line, start, dir) { + var ch = moveCharLogically(line, start.ch, dir); + return ch == null ? null : new Pos(start.line, ch, dir < 0 ? "after" : "before") + } + + function endOfLine(visually, cm, lineObj, lineNo, dir) { + if (visually) { + var order = getOrder(lineObj, cm.doc.direction); + if (order) { + var part = dir < 0 ? lst(order) : order[0]; + var moveInStorageOrder = (dir < 0) == (part.level == 1); + var sticky = moveInStorageOrder ? "after" : "before"; + var ch; + // With a wrapped rtl chunk (possibly spanning multiple bidi parts), + // it could be that the last bidi part is not on the last visual line, + // since visual lines contain content order-consecutive chunks. + // Thus, in rtl, we are looking for the first (content-order) character + // in the rtl chunk that is on the last line (that is, the same line + // as the last (content-order) character). + if (part.level > 0 || cm.doc.direction == "rtl") { + var prep = prepareMeasureForLine(cm, lineObj); + ch = dir < 0 ? lineObj.text.length - 1 : 0; + var targetTop = measureCharPrepared(cm, prep, ch).top; + ch = findFirst(function (ch) { return measureCharPrepared(cm, prep, ch).top == targetTop; }, (dir < 0) == (part.level == 1) ? part.from : part.to - 1, ch); + if (sticky == "before") { ch = moveCharLogically(lineObj, ch, 1); } + } else { ch = dir < 0 ? part.to : part.from; } + return new Pos(lineNo, ch, sticky) + } + } + return new Pos(lineNo, dir < 0 ? lineObj.text.length : 0, dir < 0 ? "before" : "after") + } + + function moveVisually(cm, line, start, dir) { + var bidi = getOrder(line, cm.doc.direction); + if (!bidi) { return moveLogically(line, start, dir) } + if (start.ch >= line.text.length) { + start.ch = line.text.length; + start.sticky = "before"; + } else if (start.ch <= 0) { + start.ch = 0; + start.sticky = "after"; + } + var partPos = getBidiPartAt(bidi, start.ch, start.sticky), part = bidi[partPos]; + if (cm.doc.direction == "ltr" && part.level % 2 == 0 && (dir > 0 ? part.to > start.ch : part.from < start.ch)) { + // Case 1: We move within an ltr part in an ltr editor. Even with wrapped lines, + // nothing interesting happens. + return moveLogically(line, start, dir) + } + + var mv = function (pos, dir) { return moveCharLogically(line, pos instanceof Pos ? pos.ch : pos, dir); }; + var prep; + var getWrappedLineExtent = function (ch) { + if (!cm.options.lineWrapping) { return {begin: 0, end: line.text.length} } + prep = prep || prepareMeasureForLine(cm, line); + return wrappedLineExtentChar(cm, line, prep, ch) + }; + var wrappedLineExtent = getWrappedLineExtent(start.sticky == "before" ? mv(start, -1) : start.ch); + + if (cm.doc.direction == "rtl" || part.level == 1) { + var moveInStorageOrder = (part.level == 1) == (dir < 0); + var ch = mv(start, moveInStorageOrder ? 1 : -1); + if (ch != null && (!moveInStorageOrder ? ch >= part.from && ch >= wrappedLineExtent.begin : ch <= part.to && ch <= wrappedLineExtent.end)) { + // Case 2: We move within an rtl part or in an rtl editor on the same visual line + var sticky = moveInStorageOrder ? "before" : "after"; + return new Pos(start.line, ch, sticky) + } + } + + // Case 3: Could not move within this bidi part in this visual line, so leave + // the current bidi part + + var searchInVisualLine = function (partPos, dir, wrappedLineExtent) { + var getRes = function (ch, moveInStorageOrder) { return moveInStorageOrder + ? new Pos(start.line, mv(ch, 1), "before") + : new Pos(start.line, ch, "after"); }; + + for (; partPos >= 0 && partPos < bidi.length; partPos += dir) { + var part = bidi[partPos]; + var moveInStorageOrder = (dir > 0) == (part.level != 1); + var ch = moveInStorageOrder ? wrappedLineExtent.begin : mv(wrappedLineExtent.end, -1); + if (part.from <= ch && ch < part.to) { return getRes(ch, moveInStorageOrder) } + ch = moveInStorageOrder ? part.from : mv(part.to, -1); + if (wrappedLineExtent.begin <= ch && ch < wrappedLineExtent.end) { return getRes(ch, moveInStorageOrder) } + } + }; + + // Case 3a: Look for other bidi parts on the same visual line + var res = searchInVisualLine(partPos + dir, dir, wrappedLineExtent); + if (res) { return res } + + // Case 3b: Look for other bidi parts on the next visual line + var nextCh = dir > 0 ? wrappedLineExtent.end : mv(wrappedLineExtent.begin, -1); + if (nextCh != null && !(dir > 0 && nextCh == line.text.length)) { + res = searchInVisualLine(dir > 0 ? 0 : bidi.length - 1, dir, getWrappedLineExtent(nextCh)); + if (res) { return res } + } + + // Case 4: Nowhere to move + return null + } + + // Commands are parameter-less actions that can be performed on an + // editor, mostly used for keybindings. + var commands = { + selectAll: selectAll, + singleSelection: function (cm) { return cm.setSelection(cm.getCursor("anchor"), cm.getCursor("head"), sel_dontScroll); }, + killLine: function (cm) { return deleteNearSelection(cm, function (range) { + if (range.empty()) { + var len = getLine(cm.doc, range.head.line).text.length; + if (range.head.ch == len && range.head.line < cm.lastLine()) + { return {from: range.head, to: Pos(range.head.line + 1, 0)} } + else + { return {from: range.head, to: Pos(range.head.line, len)} } + } else { + return {from: range.from(), to: range.to()} + } + }); }, + deleteLine: function (cm) { return deleteNearSelection(cm, function (range) { return ({ + from: Pos(range.from().line, 0), + to: clipPos(cm.doc, Pos(range.to().line + 1, 0)) + }); }); }, + delLineLeft: function (cm) { return deleteNearSelection(cm, function (range) { return ({ + from: Pos(range.from().line, 0), to: range.from() + }); }); }, + delWrappedLineLeft: function (cm) { return deleteNearSelection(cm, function (range) { + var top = cm.charCoords(range.head, "div").top + 5; + var leftPos = cm.coordsChar({left: 0, top: top}, "div"); + return {from: leftPos, to: range.from()} + }); }, + delWrappedLineRight: function (cm) { return deleteNearSelection(cm, function (range) { + var top = cm.charCoords(range.head, "div").top + 5; + var rightPos = cm.coordsChar({left: cm.display.lineDiv.offsetWidth + 100, top: top}, "div"); + return {from: range.from(), to: rightPos } + }); }, + undo: function (cm) { return cm.undo(); }, + redo: function (cm) { return cm.redo(); }, + undoSelection: function (cm) { return cm.undoSelection(); }, + redoSelection: function (cm) { return cm.redoSelection(); }, + goDocStart: function (cm) { return cm.extendSelection(Pos(cm.firstLine(), 0)); }, + goDocEnd: function (cm) { return cm.extendSelection(Pos(cm.lastLine())); }, + goLineStart: function (cm) { return cm.extendSelectionsBy(function (range) { return lineStart(cm, range.head.line); }, + {origin: "+move", bias: 1} + ); }, + goLineStartSmart: function (cm) { return cm.extendSelectionsBy(function (range) { return lineStartSmart(cm, range.head); }, + {origin: "+move", bias: 1} + ); }, + goLineEnd: function (cm) { return cm.extendSelectionsBy(function (range) { return lineEnd(cm, range.head.line); }, + {origin: "+move", bias: -1} + ); }, + goLineRight: function (cm) { return cm.extendSelectionsBy(function (range) { + var top = cm.cursorCoords(range.head, "div").top + 5; + return cm.coordsChar({left: cm.display.lineDiv.offsetWidth + 100, top: top}, "div") + }, sel_move); }, + goLineLeft: function (cm) { return cm.extendSelectionsBy(function (range) { + var top = cm.cursorCoords(range.head, "div").top + 5; + return cm.coordsChar({left: 0, top: top}, "div") + }, sel_move); }, + goLineLeftSmart: function (cm) { return cm.extendSelectionsBy(function (range) { + var top = cm.cursorCoords(range.head, "div").top + 5; + var pos = cm.coordsChar({left: 0, top: top}, "div"); + if (pos.ch < cm.getLine(pos.line).search(/\S/)) { return lineStartSmart(cm, range.head) } + return pos + }, sel_move); }, + goLineUp: function (cm) { return cm.moveV(-1, "line"); }, + goLineDown: function (cm) { return cm.moveV(1, "line"); }, + goPageUp: function (cm) { return cm.moveV(-1, "page"); }, + goPageDown: function (cm) { return cm.moveV(1, "page"); }, + goCharLeft: function (cm) { return cm.moveH(-1, "char"); }, + goCharRight: function (cm) { return cm.moveH(1, "char"); }, + goColumnLeft: function (cm) { return cm.moveH(-1, "column"); }, + goColumnRight: function (cm) { return cm.moveH(1, "column"); }, + goWordLeft: function (cm) { return cm.moveH(-1, "word"); }, + goGroupRight: function (cm) { return cm.moveH(1, "group"); }, + goGroupLeft: function (cm) { return cm.moveH(-1, "group"); }, + goWordRight: function (cm) { return cm.moveH(1, "word"); }, + delCharBefore: function (cm) { return cm.deleteH(-1, "char"); }, + delCharAfter: function (cm) { return cm.deleteH(1, "char"); }, + delWordBefore: function (cm) { return cm.deleteH(-1, "word"); }, + delWordAfter: function (cm) { return cm.deleteH(1, "word"); }, + delGroupBefore: function (cm) { return cm.deleteH(-1, "group"); }, + delGroupAfter: function (cm) { return cm.deleteH(1, "group"); }, + indentAuto: function (cm) { return cm.indentSelection("smart"); }, + indentMore: function (cm) { return cm.indentSelection("add"); }, + indentLess: function (cm) { return cm.indentSelection("subtract"); }, + insertTab: function (cm) { return cm.replaceSelection("\t"); }, + insertSoftTab: function (cm) { + var spaces = [], ranges = cm.listSelections(), tabSize = cm.options.tabSize; + for (var i = 0; i < ranges.length; i++) { + var pos = ranges[i].from(); + var col = countColumn(cm.getLine(pos.line), pos.ch, tabSize); + spaces.push(spaceStr(tabSize - col % tabSize)); + } + cm.replaceSelections(spaces); + }, + defaultTab: function (cm) { + if (cm.somethingSelected()) { cm.indentSelection("add"); } + else { cm.execCommand("insertTab"); } + }, + // Swap the two chars left and right of each selection's head. + // Move cursor behind the two swapped characters afterwards. + // + // Doesn't consider line feeds a character. + // Doesn't scan more than one line above to find a character. + // Doesn't do anything on an empty line. + // Doesn't do anything with non-empty selections. + transposeChars: function (cm) { return runInOp(cm, function () { + var ranges = cm.listSelections(), newSel = []; + for (var i = 0; i < ranges.length; i++) { + if (!ranges[i].empty()) { continue } + var cur = ranges[i].head, line = getLine(cm.doc, cur.line).text; + if (line) { + if (cur.ch == line.length) { cur = new Pos(cur.line, cur.ch - 1); } + if (cur.ch > 0) { + cur = new Pos(cur.line, cur.ch + 1); + cm.replaceRange(line.charAt(cur.ch - 1) + line.charAt(cur.ch - 2), + Pos(cur.line, cur.ch - 2), cur, "+transpose"); + } else if (cur.line > cm.doc.first) { + var prev = getLine(cm.doc, cur.line - 1).text; + if (prev) { + cur = new Pos(cur.line, 1); + cm.replaceRange(line.charAt(0) + cm.doc.lineSeparator() + + prev.charAt(prev.length - 1), + Pos(cur.line - 1, prev.length - 1), cur, "+transpose"); + } + } + } + newSel.push(new Range(cur, cur)); + } + cm.setSelections(newSel); + }); }, + newlineAndIndent: function (cm) { return runInOp(cm, function () { + var sels = cm.listSelections(); + for (var i = sels.length - 1; i >= 0; i--) + { cm.replaceRange(cm.doc.lineSeparator(), sels[i].anchor, sels[i].head, "+input"); } + sels = cm.listSelections(); + for (var i$1 = 0; i$1 < sels.length; i$1++) + { cm.indentLine(sels[i$1].from().line, null, true); } + ensureCursorVisible(cm); + }); }, + openLine: function (cm) { return cm.replaceSelection("\n", "start"); }, + toggleOverwrite: function (cm) { return cm.toggleOverwrite(); } + }; + + + function lineStart(cm, lineN) { + var line = getLine(cm.doc, lineN); + var visual = visualLine(line); + if (visual != line) { lineN = lineNo(visual); } + return endOfLine(true, cm, visual, lineN, 1) + } + function lineEnd(cm, lineN) { + var line = getLine(cm.doc, lineN); + var visual = visualLineEnd(line); + if (visual != line) { lineN = lineNo(visual); } + return endOfLine(true, cm, line, lineN, -1) + } + function lineStartSmart(cm, pos) { + var start = lineStart(cm, pos.line); + var line = getLine(cm.doc, start.line); + var order = getOrder(line, cm.doc.direction); + if (!order || order[0].level == 0) { + var firstNonWS = Math.max(0, line.text.search(/\S/)); + var inWS = pos.line == start.line && pos.ch <= firstNonWS && pos.ch; + return Pos(start.line, inWS ? 0 : firstNonWS, start.sticky) + } + return start + } + + // Run a handler that was bound to a key. + function doHandleBinding(cm, bound, dropShift) { + if (typeof bound == "string") { + bound = commands[bound]; + if (!bound) { return false } + } + // Ensure previous input has been read, so that the handler sees a + // consistent view of the document + cm.display.input.ensurePolled(); + var prevShift = cm.display.shift, done = false; + try { + if (cm.isReadOnly()) { cm.state.suppressEdits = true; } + if (dropShift) { cm.display.shift = false; } + done = bound(cm) != Pass; + } finally { + cm.display.shift = prevShift; + cm.state.suppressEdits = false; + } + return done + } + + function lookupKeyForEditor(cm, name, handle) { + for (var i = 0; i < cm.state.keyMaps.length; i++) { + var result = lookupKey(name, cm.state.keyMaps[i], handle, cm); + if (result) { return result } + } + return (cm.options.extraKeys && lookupKey(name, cm.options.extraKeys, handle, cm)) + || lookupKey(name, cm.options.keyMap, handle, cm) + } + + // Note that, despite the name, this function is also used to check + // for bound mouse clicks. + + var stopSeq = new Delayed; + + function dispatchKey(cm, name, e, handle) { + var seq = cm.state.keySeq; + if (seq) { + if (isModifierKey(name)) { return "handled" } + if (/\'$/.test(name)) + { cm.state.keySeq = null; } + else + { stopSeq.set(50, function () { + if (cm.state.keySeq == seq) { + cm.state.keySeq = null; + cm.display.input.reset(); + } + }); } + if (dispatchKeyInner(cm, seq + " " + name, e, handle)) { return true } + } + return dispatchKeyInner(cm, name, e, handle) + } + + function dispatchKeyInner(cm, name, e, handle) { + var result = lookupKeyForEditor(cm, name, handle); + + if (result == "multi") + { cm.state.keySeq = name; } + if (result == "handled") + { signalLater(cm, "keyHandled", cm, name, e); } + + if (result == "handled" || result == "multi") { + e_preventDefault(e); + restartBlink(cm); + } + + return !!result + } + + // Handle a key from the keydown event. + function handleKeyBinding(cm, e) { + var name = keyName(e, true); + if (!name) { return false } + + if (e.shiftKey && !cm.state.keySeq) { + // First try to resolve full name (including 'Shift-'). Failing + // that, see if there is a cursor-motion command (starting with + // 'go') bound to the keyname without 'Shift-'. + return dispatchKey(cm, "Shift-" + name, e, function (b) { return doHandleBinding(cm, b, true); }) + || dispatchKey(cm, name, e, function (b) { + if (typeof b == "string" ? /^go[A-Z]/.test(b) : b.motion) + { return doHandleBinding(cm, b) } + }) + } else { + return dispatchKey(cm, name, e, function (b) { return doHandleBinding(cm, b); }) + } + } + + // Handle a key from the keypress event + function handleCharBinding(cm, e, ch) { + return dispatchKey(cm, "'" + ch + "'", e, function (b) { return doHandleBinding(cm, b, true); }) + } + + var lastStoppedKey = null; + function onKeyDown(e) { + var cm = this; + cm.curOp.focus = activeElt(); + if (signalDOMEvent(cm, e)) { return } + // IE does strange things with escape. + if (ie && ie_version < 11 && e.keyCode == 27) { e.returnValue = false; } + var code = e.keyCode; + cm.display.shift = code == 16 || e.shiftKey; + var handled = handleKeyBinding(cm, e); + if (presto) { + lastStoppedKey = handled ? code : null; + // Opera has no cut event... we try to at least catch the key combo + if (!handled && code == 88 && !hasCopyEvent && (mac ? e.metaKey : e.ctrlKey)) + { cm.replaceSelection("", null, "cut"); } + } + + // Turn mouse into crosshair when Alt is held on Mac. + if (code == 18 && !/\bCodeMirror-crosshair\b/.test(cm.display.lineDiv.className)) + { showCrossHair(cm); } + } + + function showCrossHair(cm) { + var lineDiv = cm.display.lineDiv; + addClass(lineDiv, "CodeMirror-crosshair"); + + function up(e) { + if (e.keyCode == 18 || !e.altKey) { + rmClass(lineDiv, "CodeMirror-crosshair"); + off(document, "keyup", up); + off(document, "mouseover", up); + } + } + on(document, "keyup", up); + on(document, "mouseover", up); + } + + function onKeyUp(e) { + if (e.keyCode == 16) { this.doc.sel.shift = false; } + signalDOMEvent(this, e); + } + + function onKeyPress(e) { + var cm = this; + if (eventInWidget(cm.display, e) || signalDOMEvent(cm, e) || e.ctrlKey && !e.altKey || mac && e.metaKey) { return } + var keyCode = e.keyCode, charCode = e.charCode; + if (presto && keyCode == lastStoppedKey) {lastStoppedKey = null; e_preventDefault(e); return} + if ((presto && (!e.which || e.which < 10)) && handleKeyBinding(cm, e)) { return } + var ch = String.fromCharCode(charCode == null ? keyCode : charCode); + // Some browsers fire keypress events for backspace + if (ch == "\x08") { return } + if (handleCharBinding(cm, e, ch)) { return } + cm.display.input.onKeyPress(e); + } + + var DOUBLECLICK_DELAY = 400; + + var PastClick = function(time, pos, button) { + this.time = time; + this.pos = pos; + this.button = button; + }; + + PastClick.prototype.compare = function (time, pos, button) { + return this.time + DOUBLECLICK_DELAY > time && + cmp(pos, this.pos) == 0 && button == this.button + }; + + var lastClick, lastDoubleClick; + function clickRepeat(pos, button) { + var now = +new Date; + if (lastDoubleClick && lastDoubleClick.compare(now, pos, button)) { + lastClick = lastDoubleClick = null; + return "triple" + } else if (lastClick && lastClick.compare(now, pos, button)) { + lastDoubleClick = new PastClick(now, pos, button); + lastClick = null; + return "double" + } else { + lastClick = new PastClick(now, pos, button); + lastDoubleClick = null; + return "single" + } + } + + // A mouse down can be a single click, double click, triple click, + // start of selection drag, start of text drag, new cursor + // (ctrl-click), rectangle drag (alt-drag), or xwin + // middle-click-paste. Or it might be a click on something we should + // not interfere with, such as a scrollbar or widget. + function onMouseDown(e) { + var cm = this, display = cm.display; + if (signalDOMEvent(cm, e) || display.activeTouch && display.input.supportsTouch()) { return } + display.input.ensurePolled(); + display.shift = e.shiftKey; + + if (eventInWidget(display, e)) { + if (!webkit) { + // Briefly turn off draggability, to allow widgets to do + // normal dragging things. + display.scroller.draggable = false; + setTimeout(function () { return display.scroller.draggable = true; }, 100); + } + return + } + if (clickInGutter(cm, e)) { return } + var pos = posFromMouse(cm, e), button = e_button(e), repeat = pos ? clickRepeat(pos, button) : "single"; + window.focus(); + + // #3261: make sure, that we're not starting a second selection + if (button == 1 && cm.state.selectingText) + { cm.state.selectingText(e); } + + if (pos && handleMappedButton(cm, button, pos, repeat, e)) { return } + + if (button == 1) { + if (pos) { leftButtonDown(cm, pos, repeat, e); } + else if (e_target(e) == display.scroller) { e_preventDefault(e); } + } else if (button == 2) { + if (pos) { extendSelection(cm.doc, pos); } + setTimeout(function () { return display.input.focus(); }, 20); + } else if (button == 3) { + if (captureRightClick) { cm.display.input.onContextMenu(e); } + else { delayBlurEvent(cm); } + } + } + + function handleMappedButton(cm, button, pos, repeat, event) { + var name = "Click"; + if (repeat == "double") { name = "Double" + name; } + else if (repeat == "triple") { name = "Triple" + name; } + name = (button == 1 ? "Left" : button == 2 ? "Middle" : "Right") + name; + + return dispatchKey(cm, addModifierNames(name, event), event, function (bound) { + if (typeof bound == "string") { bound = commands[bound]; } + if (!bound) { return false } + var done = false; + try { + if (cm.isReadOnly()) { cm.state.suppressEdits = true; } + done = bound(cm, pos) != Pass; + } finally { + cm.state.suppressEdits = false; + } + return done + }) + } + + function configureMouse(cm, repeat, event) { + var option = cm.getOption("configureMouse"); + var value = option ? option(cm, repeat, event) : {}; + if (value.unit == null) { + var rect = chromeOS ? event.shiftKey && event.metaKey : event.altKey; + value.unit = rect ? "rectangle" : repeat == "single" ? "char" : repeat == "double" ? "word" : "line"; + } + if (value.extend == null || cm.doc.extend) { value.extend = cm.doc.extend || event.shiftKey; } + if (value.addNew == null) { value.addNew = mac ? event.metaKey : event.ctrlKey; } + if (value.moveOnDrag == null) { value.moveOnDrag = !(mac ? event.altKey : event.ctrlKey); } + return value + } + + function leftButtonDown(cm, pos, repeat, event) { + if (ie) { setTimeout(bind(ensureFocus, cm), 0); } + else { cm.curOp.focus = activeElt(); } + + var behavior = configureMouse(cm, repeat, event); + + var sel = cm.doc.sel, contained; + if (cm.options.dragDrop && dragAndDrop && !cm.isReadOnly() && + repeat == "single" && (contained = sel.contains(pos)) > -1 && + (cmp((contained = sel.ranges[contained]).from(), pos) < 0 || pos.xRel > 0) && + (cmp(contained.to(), pos) > 0 || pos.xRel < 0)) + { leftButtonStartDrag(cm, event, pos, behavior); } + else + { leftButtonSelect(cm, event, pos, behavior); } + } + + // Start a text drag. When it ends, see if any dragging actually + // happen, and treat as a click if it didn't. + function leftButtonStartDrag(cm, event, pos, behavior) { + var display = cm.display, moved = false; + var dragEnd = operation(cm, function (e) { + if (webkit) { display.scroller.draggable = false; } + cm.state.draggingText = false; + off(display.wrapper.ownerDocument, "mouseup", dragEnd); + off(display.wrapper.ownerDocument, "mousemove", mouseMove); + off(display.scroller, "dragstart", dragStart); + off(display.scroller, "drop", dragEnd); + if (!moved) { + e_preventDefault(e); + if (!behavior.addNew) + { extendSelection(cm.doc, pos, null, null, behavior.extend); } + // Work around unexplainable focus problem in IE9 (#2127) and Chrome (#3081) + if (webkit || ie && ie_version == 9) + { setTimeout(function () {display.wrapper.ownerDocument.body.focus(); display.input.focus();}, 20); } + else + { display.input.focus(); } + } + }); + var mouseMove = function(e2) { + moved = moved || Math.abs(event.clientX - e2.clientX) + Math.abs(event.clientY - e2.clientY) >= 10; + }; + var dragStart = function () { return moved = true; }; + // Let the drag handler handle this. + if (webkit) { display.scroller.draggable = true; } + cm.state.draggingText = dragEnd; + dragEnd.copy = !behavior.moveOnDrag; + // IE's approach to draggable + if (display.scroller.dragDrop) { display.scroller.dragDrop(); } + on(display.wrapper.ownerDocument, "mouseup", dragEnd); + on(display.wrapper.ownerDocument, "mousemove", mouseMove); + on(display.scroller, "dragstart", dragStart); + on(display.scroller, "drop", dragEnd); + + delayBlurEvent(cm); + setTimeout(function () { return display.input.focus(); }, 20); + } + + function rangeForUnit(cm, pos, unit) { + if (unit == "char") { return new Range(pos, pos) } + if (unit == "word") { return cm.findWordAt(pos) } + if (unit == "line") { return new Range(Pos(pos.line, 0), clipPos(cm.doc, Pos(pos.line + 1, 0))) } + var result = unit(cm, pos); + return new Range(result.from, result.to) + } + + // Normal selection, as opposed to text dragging. + function leftButtonSelect(cm, event, start, behavior) { + var display = cm.display, doc = cm.doc; + e_preventDefault(event); + + var ourRange, ourIndex, startSel = doc.sel, ranges = startSel.ranges; + if (behavior.addNew && !behavior.extend) { + ourIndex = doc.sel.contains(start); + if (ourIndex > -1) + { ourRange = ranges[ourIndex]; } + else + { ourRange = new Range(start, start); } + } else { + ourRange = doc.sel.primary(); + ourIndex = doc.sel.primIndex; + } + + if (behavior.unit == "rectangle") { + if (!behavior.addNew) { ourRange = new Range(start, start); } + start = posFromMouse(cm, event, true, true); + ourIndex = -1; + } else { + var range$$1 = rangeForUnit(cm, start, behavior.unit); + if (behavior.extend) + { ourRange = extendRange(ourRange, range$$1.anchor, range$$1.head, behavior.extend); } + else + { ourRange = range$$1; } + } + + if (!behavior.addNew) { + ourIndex = 0; + setSelection(doc, new Selection([ourRange], 0), sel_mouse); + startSel = doc.sel; + } else if (ourIndex == -1) { + ourIndex = ranges.length; + setSelection(doc, normalizeSelection(cm, ranges.concat([ourRange]), ourIndex), + {scroll: false, origin: "*mouse"}); + } else if (ranges.length > 1 && ranges[ourIndex].empty() && behavior.unit == "char" && !behavior.extend) { + setSelection(doc, normalizeSelection(cm, ranges.slice(0, ourIndex).concat(ranges.slice(ourIndex + 1)), 0), + {scroll: false, origin: "*mouse"}); + startSel = doc.sel; + } else { + replaceOneSelection(doc, ourIndex, ourRange, sel_mouse); + } + + var lastPos = start; + function extendTo(pos) { + if (cmp(lastPos, pos) == 0) { return } + lastPos = pos; + + if (behavior.unit == "rectangle") { + var ranges = [], tabSize = cm.options.tabSize; + var startCol = countColumn(getLine(doc, start.line).text, start.ch, tabSize); + var posCol = countColumn(getLine(doc, pos.line).text, pos.ch, tabSize); + var left = Math.min(startCol, posCol), right = Math.max(startCol, posCol); + for (var line = Math.min(start.line, pos.line), end = Math.min(cm.lastLine(), Math.max(start.line, pos.line)); + line <= end; line++) { + var text = getLine(doc, line).text, leftPos = findColumn(text, left, tabSize); + if (left == right) + { ranges.push(new Range(Pos(line, leftPos), Pos(line, leftPos))); } + else if (text.length > leftPos) + { ranges.push(new Range(Pos(line, leftPos), Pos(line, findColumn(text, right, tabSize)))); } + } + if (!ranges.length) { ranges.push(new Range(start, start)); } + setSelection(doc, normalizeSelection(cm, startSel.ranges.slice(0, ourIndex).concat(ranges), ourIndex), + {origin: "*mouse", scroll: false}); + cm.scrollIntoView(pos); + } else { + var oldRange = ourRange; + var range$$1 = rangeForUnit(cm, pos, behavior.unit); + var anchor = oldRange.anchor, head; + if (cmp(range$$1.anchor, anchor) > 0) { + head = range$$1.head; + anchor = minPos(oldRange.from(), range$$1.anchor); + } else { + head = range$$1.anchor; + anchor = maxPos(oldRange.to(), range$$1.head); + } + var ranges$1 = startSel.ranges.slice(0); + ranges$1[ourIndex] = bidiSimplify(cm, new Range(clipPos(doc, anchor), head)); + setSelection(doc, normalizeSelection(cm, ranges$1, ourIndex), sel_mouse); + } + } + + var editorSize = display.wrapper.getBoundingClientRect(); + // Used to ensure timeout re-tries don't fire when another extend + // happened in the meantime (clearTimeout isn't reliable -- at + // least on Chrome, the timeouts still happen even when cleared, + // if the clear happens after their scheduled firing time). + var counter = 0; + + function extend(e) { + var curCount = ++counter; + var cur = posFromMouse(cm, e, true, behavior.unit == "rectangle"); + if (!cur) { return } + if (cmp(cur, lastPos) != 0) { + cm.curOp.focus = activeElt(); + extendTo(cur); + var visible = visibleLines(display, doc); + if (cur.line >= visible.to || cur.line < visible.from) + { setTimeout(operation(cm, function () {if (counter == curCount) { extend(e); }}), 150); } + } else { + var outside = e.clientY < editorSize.top ? -20 : e.clientY > editorSize.bottom ? 20 : 0; + if (outside) { setTimeout(operation(cm, function () { + if (counter != curCount) { return } + display.scroller.scrollTop += outside; + extend(e); + }), 50); } + } + } + + function done(e) { + cm.state.selectingText = false; + counter = Infinity; + // If e is null or undefined we interpret this as someone trying + // to explicitly cancel the selection rather than the user + // letting go of the mouse button. + if (e) { + e_preventDefault(e); + display.input.focus(); + } + off(display.wrapper.ownerDocument, "mousemove", move); + off(display.wrapper.ownerDocument, "mouseup", up); + doc.history.lastSelOrigin = null; + } + + var move = operation(cm, function (e) { + if (e.buttons === 0 || !e_button(e)) { done(e); } + else { extend(e); } + }); + var up = operation(cm, done); + cm.state.selectingText = up; + on(display.wrapper.ownerDocument, "mousemove", move); + on(display.wrapper.ownerDocument, "mouseup", up); + } + + // Used when mouse-selecting to adjust the anchor to the proper side + // of a bidi jump depending on the visual position of the head. + function bidiSimplify(cm, range$$1) { + var anchor = range$$1.anchor; + var head = range$$1.head; + var anchorLine = getLine(cm.doc, anchor.line); + if (cmp(anchor, head) == 0 && anchor.sticky == head.sticky) { return range$$1 } + var order = getOrder(anchorLine); + if (!order) { return range$$1 } + var index = getBidiPartAt(order, anchor.ch, anchor.sticky), part = order[index]; + if (part.from != anchor.ch && part.to != anchor.ch) { return range$$1 } + var boundary = index + ((part.from == anchor.ch) == (part.level != 1) ? 0 : 1); + if (boundary == 0 || boundary == order.length) { return range$$1 } + + // Compute the relative visual position of the head compared to the + // anchor (<0 is to the left, >0 to the right) + var leftSide; + if (head.line != anchor.line) { + leftSide = (head.line - anchor.line) * (cm.doc.direction == "ltr" ? 1 : -1) > 0; + } else { + var headIndex = getBidiPartAt(order, head.ch, head.sticky); + var dir = headIndex - index || (head.ch - anchor.ch) * (part.level == 1 ? -1 : 1); + if (headIndex == boundary - 1 || headIndex == boundary) + { leftSide = dir < 0; } + else + { leftSide = dir > 0; } + } + + var usePart = order[boundary + (leftSide ? -1 : 0)]; + var from = leftSide == (usePart.level == 1); + var ch = from ? usePart.from : usePart.to, sticky = from ? "after" : "before"; + return anchor.ch == ch && anchor.sticky == sticky ? range$$1 : new Range(new Pos(anchor.line, ch, sticky), head) + } + + + // Determines whether an event happened in the gutter, and fires the + // handlers for the corresponding event. + function gutterEvent(cm, e, type, prevent) { + var mX, mY; + if (e.touches) { + mX = e.touches[0].clientX; + mY = e.touches[0].clientY; + } else { + try { mX = e.clientX; mY = e.clientY; } + catch(e) { return false } + } + if (mX >= Math.floor(cm.display.gutters.getBoundingClientRect().right)) { return false } + if (prevent) { e_preventDefault(e); } + + var display = cm.display; + var lineBox = display.lineDiv.getBoundingClientRect(); + + if (mY > lineBox.bottom || !hasHandler(cm, type)) { return e_defaultPrevented(e) } + mY -= lineBox.top - display.viewOffset; + + for (var i = 0; i < cm.display.gutterSpecs.length; ++i) { + var g = display.gutters.childNodes[i]; + if (g && g.getBoundingClientRect().right >= mX) { + var line = lineAtHeight(cm.doc, mY); + var gutter = cm.display.gutterSpecs[i]; + signal(cm, type, cm, line, gutter.className, e); + return e_defaultPrevented(e) + } + } + } + + function clickInGutter(cm, e) { + return gutterEvent(cm, e, "gutterClick", true) + } + + // CONTEXT MENU HANDLING + + // To make the context menu work, we need to briefly unhide the + // textarea (making it as unobtrusive as possible) to let the + // right-click take effect on it. + function onContextMenu(cm, e) { + if (eventInWidget(cm.display, e) || contextMenuInGutter(cm, e)) { return } + if (signalDOMEvent(cm, e, "contextmenu")) { return } + if (!captureRightClick) { cm.display.input.onContextMenu(e); } + } + + function contextMenuInGutter(cm, e) { + if (!hasHandler(cm, "gutterContextMenu")) { return false } + return gutterEvent(cm, e, "gutterContextMenu", false) + } + + function themeChanged(cm) { + cm.display.wrapper.className = cm.display.wrapper.className.replace(/\s*cm-s-\S+/g, "") + + cm.options.theme.replace(/(^|\s)\s*/g, " cm-s-"); + clearCaches(cm); + } + + var Init = {toString: function(){return "CodeMirror.Init"}}; + + var defaults = {}; + var optionHandlers = {}; + + function defineOptions(CodeMirror) { + var optionHandlers = CodeMirror.optionHandlers; + + function option(name, deflt, handle, notOnInit) { + CodeMirror.defaults[name] = deflt; + if (handle) { optionHandlers[name] = + notOnInit ? function (cm, val, old) {if (old != Init) { handle(cm, val, old); }} : handle; } + } + + CodeMirror.defineOption = option; + + // Passed to option handlers when there is no old value. + CodeMirror.Init = Init; + + // These two are, on init, called from the constructor because they + // have to be initialized before the editor can start at all. + option("value", "", function (cm, val) { return cm.setValue(val); }, true); + option("mode", null, function (cm, val) { + cm.doc.modeOption = val; + loadMode(cm); + }, true); + + option("indentUnit", 2, loadMode, true); + option("indentWithTabs", false); + option("smartIndent", true); + option("tabSize", 4, function (cm) { + resetModeState(cm); + clearCaches(cm); + regChange(cm); + }, true); + + option("lineSeparator", null, function (cm, val) { + cm.doc.lineSep = val; + if (!val) { return } + var newBreaks = [], lineNo = cm.doc.first; + cm.doc.iter(function (line) { + for (var pos = 0;;) { + var found = line.text.indexOf(val, pos); + if (found == -1) { break } + pos = found + val.length; + newBreaks.push(Pos(lineNo, found)); + } + lineNo++; + }); + for (var i = newBreaks.length - 1; i >= 0; i--) + { replaceRange(cm.doc, val, newBreaks[i], Pos(newBreaks[i].line, newBreaks[i].ch + val.length)); } + }); + option("specialChars", /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200f\u2028\u2029\ufeff\ufff9-\ufffc]/g, function (cm, val, old) { + cm.state.specialChars = new RegExp(val.source + (val.test("\t") ? "" : "|\t"), "g"); + if (old != Init) { cm.refresh(); } + }); + option("specialCharPlaceholder", defaultSpecialCharPlaceholder, function (cm) { return cm.refresh(); }, true); + option("electricChars", true); + option("inputStyle", mobile ? "contenteditable" : "textarea", function () { + throw new Error("inputStyle can not (yet) be changed in a running editor") // FIXME + }, true); + option("spellcheck", false, function (cm, val) { return cm.getInputField().spellcheck = val; }, true); + option("autocorrect", false, function (cm, val) { return cm.getInputField().autocorrect = val; }, true); + option("autocapitalize", false, function (cm, val) { return cm.getInputField().autocapitalize = val; }, true); + option("rtlMoveVisually", !windows); + option("wholeLineUpdateBefore", true); + + option("theme", "default", function (cm) { + themeChanged(cm); + updateGutters(cm); + }, true); + option("keyMap", "default", function (cm, val, old) { + var next = getKeyMap(val); + var prev = old != Init && getKeyMap(old); + if (prev && prev.detach) { prev.detach(cm, next); } + if (next.attach) { next.attach(cm, prev || null); } + }); + option("extraKeys", null); + option("configureMouse", null); + + option("lineWrapping", false, wrappingChanged, true); + option("gutters", [], function (cm, val) { + cm.display.gutterSpecs = getGutters(val, cm.options.lineNumbers); + updateGutters(cm); + }, true); + option("fixedGutter", true, function (cm, val) { + cm.display.gutters.style.left = val ? compensateForHScroll(cm.display) + "px" : "0"; + cm.refresh(); + }, true); + option("coverGutterNextToScrollbar", false, function (cm) { return updateScrollbars(cm); }, true); + option("scrollbarStyle", "native", function (cm) { + initScrollbars(cm); + updateScrollbars(cm); + cm.display.scrollbars.setScrollTop(cm.doc.scrollTop); + cm.display.scrollbars.setScrollLeft(cm.doc.scrollLeft); + }, true); + option("lineNumbers", false, function (cm, val) { + cm.display.gutterSpecs = getGutters(cm.options.gutters, val); + updateGutters(cm); + }, true); + option("firstLineNumber", 1, updateGutters, true); + option("lineNumberFormatter", function (integer) { return integer; }, updateGutters, true); + option("showCursorWhenSelecting", false, updateSelection, true); + + option("resetSelectionOnContextMenu", true); + option("lineWiseCopyCut", true); + option("pasteLinesPerSelection", true); + option("selectionsMayTouch", false); + + option("readOnly", false, function (cm, val) { + if (val == "nocursor") { + onBlur(cm); + cm.display.input.blur(); + } + cm.display.input.readOnlyChanged(val); + }); + option("disableInput", false, function (cm, val) {if (!val) { cm.display.input.reset(); }}, true); + option("dragDrop", true, dragDropChanged); + option("allowDropFileTypes", null); + + option("cursorBlinkRate", 530); + option("cursorScrollMargin", 0); + option("cursorHeight", 1, updateSelection, true); + option("singleCursorHeightPerLine", true, updateSelection, true); + option("workTime", 100); + option("workDelay", 100); + option("flattenSpans", true, resetModeState, true); + option("addModeClass", false, resetModeState, true); + option("pollInterval", 100); + option("undoDepth", 200, function (cm, val) { return cm.doc.history.undoDepth = val; }); + option("historyEventDelay", 1250); + option("viewportMargin", 10, function (cm) { return cm.refresh(); }, true); + option("maxHighlightLength", 10000, resetModeState, true); + option("moveInputWithCursor", true, function (cm, val) { + if (!val) { cm.display.input.resetPosition(); } + }); + + option("tabindex", null, function (cm, val) { return cm.display.input.getField().tabIndex = val || ""; }); + option("autofocus", null); + option("direction", "ltr", function (cm, val) { return cm.doc.setDirection(val); }, true); + option("phrases", null); + } + + function dragDropChanged(cm, value, old) { + var wasOn = old && old != Init; + if (!value != !wasOn) { + var funcs = cm.display.dragFunctions; + var toggle = value ? on : off; + toggle(cm.display.scroller, "dragstart", funcs.start); + toggle(cm.display.scroller, "dragenter", funcs.enter); + toggle(cm.display.scroller, "dragover", funcs.over); + toggle(cm.display.scroller, "dragleave", funcs.leave); + toggle(cm.display.scroller, "drop", funcs.drop); + } + } + + function wrappingChanged(cm) { + if (cm.options.lineWrapping) { + addClass(cm.display.wrapper, "CodeMirror-wrap"); + cm.display.sizer.style.minWidth = ""; + cm.display.sizerWidth = null; + } else { + rmClass(cm.display.wrapper, "CodeMirror-wrap"); + findMaxLine(cm); + } + estimateLineHeights(cm); + regChange(cm); + clearCaches(cm); + setTimeout(function () { return updateScrollbars(cm); }, 100); + } + + // A CodeMirror instance represents an editor. This is the object + // that user code is usually dealing with. + + function CodeMirror(place, options) { + var this$1 = this; + + if (!(this instanceof CodeMirror)) { return new CodeMirror(place, options) } + + this.options = options = options ? copyObj(options) : {}; + // Determine effective options based on given values and defaults. + copyObj(defaults, options, false); + + var doc = options.value; + if (typeof doc == "string") { doc = new Doc(doc, options.mode, null, options.lineSeparator, options.direction); } + else if (options.mode) { doc.modeOption = options.mode; } + this.doc = doc; + + var input = new CodeMirror.inputStyles[options.inputStyle](this); + var display = this.display = new Display(place, doc, input, options); + display.wrapper.CodeMirror = this; + themeChanged(this); + if (options.lineWrapping) + { this.display.wrapper.className += " CodeMirror-wrap"; } + initScrollbars(this); + + this.state = { + keyMaps: [], // stores maps added by addKeyMap + overlays: [], // highlighting overlays, as added by addOverlay + modeGen: 0, // bumped when mode/overlay changes, used to invalidate highlighting info + overwrite: false, + delayingBlurEvent: false, + focused: false, + suppressEdits: false, // used to disable editing during key handlers when in readOnly mode + pasteIncoming: -1, cutIncoming: -1, // help recognize paste/cut edits in input.poll + selectingText: false, + draggingText: false, + highlight: new Delayed(), // stores highlight worker timeout + keySeq: null, // Unfinished key sequence + specialChars: null + }; + + if (options.autofocus && !mobile) { display.input.focus(); } + + // Override magic textarea content restore that IE sometimes does + // on our hidden textarea on reload + if (ie && ie_version < 11) { setTimeout(function () { return this$1.display.input.reset(true); }, 20); } + + registerEventHandlers(this); + ensureGlobalHandlers(); + + startOperation(this); + this.curOp.forceUpdate = true; + attachDoc(this, doc); + + if ((options.autofocus && !mobile) || this.hasFocus()) + { setTimeout(bind(onFocus, this), 20); } + else + { onBlur(this); } + + for (var opt in optionHandlers) { if (optionHandlers.hasOwnProperty(opt)) + { optionHandlers[opt](this$1, options[opt], Init); } } + maybeUpdateLineNumberWidth(this); + if (options.finishInit) { options.finishInit(this); } + for (var i = 0; i < initHooks.length; ++i) { initHooks[i](this$1); } + endOperation(this); + // Suppress optimizelegibility in Webkit, since it breaks text + // measuring on line wrapping boundaries. + if (webkit && options.lineWrapping && + getComputedStyle(display.lineDiv).textRendering == "optimizelegibility") + { display.lineDiv.style.textRendering = "auto"; } + } + + // The default configuration options. + CodeMirror.defaults = defaults; + // Functions to run when options are changed. + CodeMirror.optionHandlers = optionHandlers; + + // Attach the necessary event handlers when initializing the editor + function registerEventHandlers(cm) { + var d = cm.display; + on(d.scroller, "mousedown", operation(cm, onMouseDown)); + // Older IE's will not fire a second mousedown for a double click + if (ie && ie_version < 11) + { on(d.scroller, "dblclick", operation(cm, function (e) { + if (signalDOMEvent(cm, e)) { return } + var pos = posFromMouse(cm, e); + if (!pos || clickInGutter(cm, e) || eventInWidget(cm.display, e)) { return } + e_preventDefault(e); + var word = cm.findWordAt(pos); + extendSelection(cm.doc, word.anchor, word.head); + })); } + else + { on(d.scroller, "dblclick", function (e) { return signalDOMEvent(cm, e) || e_preventDefault(e); }); } + // Some browsers fire contextmenu *after* opening the menu, at + // which point we can't mess with it anymore. Context menu is + // handled in onMouseDown for these browsers. + on(d.scroller, "contextmenu", function (e) { return onContextMenu(cm, e); }); + + // Used to suppress mouse event handling when a touch happens + var touchFinished, prevTouch = {end: 0}; + function finishTouch() { + if (d.activeTouch) { + touchFinished = setTimeout(function () { return d.activeTouch = null; }, 1000); + prevTouch = d.activeTouch; + prevTouch.end = +new Date; + } + } + function isMouseLikeTouchEvent(e) { + if (e.touches.length != 1) { return false } + var touch = e.touches[0]; + return touch.radiusX <= 1 && touch.radiusY <= 1 + } + function farAway(touch, other) { + if (other.left == null) { return true } + var dx = other.left - touch.left, dy = other.top - touch.top; + return dx * dx + dy * dy > 20 * 20 + } + on(d.scroller, "touchstart", function (e) { + if (!signalDOMEvent(cm, e) && !isMouseLikeTouchEvent(e) && !clickInGutter(cm, e)) { + d.input.ensurePolled(); + clearTimeout(touchFinished); + var now = +new Date; + d.activeTouch = {start: now, moved: false, + prev: now - prevTouch.end <= 300 ? prevTouch : null}; + if (e.touches.length == 1) { + d.activeTouch.left = e.touches[0].pageX; + d.activeTouch.top = e.touches[0].pageY; + } + } + }); + on(d.scroller, "touchmove", function () { + if (d.activeTouch) { d.activeTouch.moved = true; } + }); + on(d.scroller, "touchend", function (e) { + var touch = d.activeTouch; + if (touch && !eventInWidget(d, e) && touch.left != null && + !touch.moved && new Date - touch.start < 300) { + var pos = cm.coordsChar(d.activeTouch, "page"), range; + if (!touch.prev || farAway(touch, touch.prev)) // Single tap + { range = new Range(pos, pos); } + else if (!touch.prev.prev || farAway(touch, touch.prev.prev)) // Double tap + { range = cm.findWordAt(pos); } + else // Triple tap + { range = new Range(Pos(pos.line, 0), clipPos(cm.doc, Pos(pos.line + 1, 0))); } + cm.setSelection(range.anchor, range.head); + cm.focus(); + e_preventDefault(e); + } + finishTouch(); + }); + on(d.scroller, "touchcancel", finishTouch); + + // Sync scrolling between fake scrollbars and real scrollable + // area, ensure viewport is updated when scrolling. + on(d.scroller, "scroll", function () { + if (d.scroller.clientHeight) { + updateScrollTop(cm, d.scroller.scrollTop); + setScrollLeft(cm, d.scroller.scrollLeft, true); + signal(cm, "scroll", cm); + } + }); + + // Listen to wheel events in order to try and update the viewport on time. + on(d.scroller, "mousewheel", function (e) { return onScrollWheel(cm, e); }); + on(d.scroller, "DOMMouseScroll", function (e) { return onScrollWheel(cm, e); }); + + // Prevent wrapper from ever scrolling + on(d.wrapper, "scroll", function () { return d.wrapper.scrollTop = d.wrapper.scrollLeft = 0; }); + + d.dragFunctions = { + enter: function (e) {if (!signalDOMEvent(cm, e)) { e_stop(e); }}, + over: function (e) {if (!signalDOMEvent(cm, e)) { onDragOver(cm, e); e_stop(e); }}, + start: function (e) { return onDragStart(cm, e); }, + drop: operation(cm, onDrop), + leave: function (e) {if (!signalDOMEvent(cm, e)) { clearDragCursor(cm); }} + }; + + var inp = d.input.getField(); + on(inp, "keyup", function (e) { return onKeyUp.call(cm, e); }); + on(inp, "keydown", operation(cm, onKeyDown)); + on(inp, "keypress", operation(cm, onKeyPress)); + on(inp, "focus", function (e) { return onFocus(cm, e); }); + on(inp, "blur", function (e) { return onBlur(cm, e); }); + } + + var initHooks = []; + CodeMirror.defineInitHook = function (f) { return initHooks.push(f); }; + + // Indent the given line. The how parameter can be "smart", + // "add"/null, "subtract", or "prev". When aggressive is false + // (typically set to true for forced single-line indents), empty + // lines are not indented, and places where the mode returns Pass + // are left alone. + function indentLine(cm, n, how, aggressive) { + var doc = cm.doc, state; + if (how == null) { how = "add"; } + if (how == "smart") { + // Fall back to "prev" when the mode doesn't have an indentation + // method. + if (!doc.mode.indent) { how = "prev"; } + else { state = getContextBefore(cm, n).state; } + } + + var tabSize = cm.options.tabSize; + var line = getLine(doc, n), curSpace = countColumn(line.text, null, tabSize); + if (line.stateAfter) { line.stateAfter = null; } + var curSpaceString = line.text.match(/^\s*/)[0], indentation; + if (!aggressive && !/\S/.test(line.text)) { + indentation = 0; + how = "not"; + } else if (how == "smart") { + indentation = doc.mode.indent(state, line.text.slice(curSpaceString.length), line.text); + if (indentation == Pass || indentation > 150) { + if (!aggressive) { return } + how = "prev"; + } + } + if (how == "prev") { + if (n > doc.first) { indentation = countColumn(getLine(doc, n-1).text, null, tabSize); } + else { indentation = 0; } + } else if (how == "add") { + indentation = curSpace + cm.options.indentUnit; + } else if (how == "subtract") { + indentation = curSpace - cm.options.indentUnit; + } else if (typeof how == "number") { + indentation = curSpace + how; + } + indentation = Math.max(0, indentation); + + var indentString = "", pos = 0; + if (cm.options.indentWithTabs) + { for (var i = Math.floor(indentation / tabSize); i; --i) {pos += tabSize; indentString += "\t";} } + if (pos < indentation) { indentString += spaceStr(indentation - pos); } + + if (indentString != curSpaceString) { + replaceRange(doc, indentString, Pos(n, 0), Pos(n, curSpaceString.length), "+input"); + line.stateAfter = null; + return true + } else { + // Ensure that, if the cursor was in the whitespace at the start + // of the line, it is moved to the end of that space. + for (var i$1 = 0; i$1 < doc.sel.ranges.length; i$1++) { + var range = doc.sel.ranges[i$1]; + if (range.head.line == n && range.head.ch < curSpaceString.length) { + var pos$1 = Pos(n, curSpaceString.length); + replaceOneSelection(doc, i$1, new Range(pos$1, pos$1)); + break + } + } + } + } + + // This will be set to a {lineWise: bool, text: [string]} object, so + // that, when pasting, we know what kind of selections the copied + // text was made out of. + var lastCopied = null; + + function setLastCopied(newLastCopied) { + lastCopied = newLastCopied; + } + + function applyTextInput(cm, inserted, deleted, sel, origin) { + var doc = cm.doc; + cm.display.shift = false; + if (!sel) { sel = doc.sel; } + + var recent = +new Date - 200; + var paste = origin == "paste" || cm.state.pasteIncoming > recent; + var textLines = splitLinesAuto(inserted), multiPaste = null; + // When pasting N lines into N selections, insert one line per selection + if (paste && sel.ranges.length > 1) { + if (lastCopied && lastCopied.text.join("\n") == inserted) { + if (sel.ranges.length % lastCopied.text.length == 0) { + multiPaste = []; + for (var i = 0; i < lastCopied.text.length; i++) + { multiPaste.push(doc.splitLines(lastCopied.text[i])); } + } + } else if (textLines.length == sel.ranges.length && cm.options.pasteLinesPerSelection) { + multiPaste = map(textLines, function (l) { return [l]; }); + } + } + + var updateInput = cm.curOp.updateInput; + // Normal behavior is to insert the new text into every selection + for (var i$1 = sel.ranges.length - 1; i$1 >= 0; i$1--) { + var range$$1 = sel.ranges[i$1]; + var from = range$$1.from(), to = range$$1.to(); + if (range$$1.empty()) { + if (deleted && deleted > 0) // Handle deletion + { from = Pos(from.line, from.ch - deleted); } + else if (cm.state.overwrite && !paste) // Handle overwrite + { to = Pos(to.line, Math.min(getLine(doc, to.line).text.length, to.ch + lst(textLines).length)); } + else if (paste && lastCopied && lastCopied.lineWise && lastCopied.text.join("\n") == inserted) + { from = to = Pos(from.line, 0); } + } + var changeEvent = {from: from, to: to, text: multiPaste ? multiPaste[i$1 % multiPaste.length] : textLines, + origin: origin || (paste ? "paste" : cm.state.cutIncoming > recent ? "cut" : "+input")}; + makeChange(cm.doc, changeEvent); + signalLater(cm, "inputRead", cm, changeEvent); + } + if (inserted && !paste) + { triggerElectric(cm, inserted); } + + ensureCursorVisible(cm); + if (cm.curOp.updateInput < 2) { cm.curOp.updateInput = updateInput; } + cm.curOp.typing = true; + cm.state.pasteIncoming = cm.state.cutIncoming = -1; + } + + function handlePaste(e, cm) { + var pasted = e.clipboardData && e.clipboardData.getData("Text"); + if (pasted) { + e.preventDefault(); + if (!cm.isReadOnly() && !cm.options.disableInput) + { runInOp(cm, function () { return applyTextInput(cm, pasted, 0, null, "paste"); }); } + return true + } + } + + function triggerElectric(cm, inserted) { + // When an 'electric' character is inserted, immediately trigger a reindent + if (!cm.options.electricChars || !cm.options.smartIndent) { return } + var sel = cm.doc.sel; + + for (var i = sel.ranges.length - 1; i >= 0; i--) { + var range$$1 = sel.ranges[i]; + if (range$$1.head.ch > 100 || (i && sel.ranges[i - 1].head.line == range$$1.head.line)) { continue } + var mode = cm.getModeAt(range$$1.head); + var indented = false; + if (mode.electricChars) { + for (var j = 0; j < mode.electricChars.length; j++) + { if (inserted.indexOf(mode.electricChars.charAt(j)) > -1) { + indented = indentLine(cm, range$$1.head.line, "smart"); + break + } } + } else if (mode.electricInput) { + if (mode.electricInput.test(getLine(cm.doc, range$$1.head.line).text.slice(0, range$$1.head.ch))) + { indented = indentLine(cm, range$$1.head.line, "smart"); } + } + if (indented) { signalLater(cm, "electricInput", cm, range$$1.head.line); } + } + } + + function copyableRanges(cm) { + var text = [], ranges = []; + for (var i = 0; i < cm.doc.sel.ranges.length; i++) { + var line = cm.doc.sel.ranges[i].head.line; + var lineRange = {anchor: Pos(line, 0), head: Pos(line + 1, 0)}; + ranges.push(lineRange); + text.push(cm.getRange(lineRange.anchor, lineRange.head)); + } + return {text: text, ranges: ranges} + } + + function disableBrowserMagic(field, spellcheck, autocorrect, autocapitalize) { + field.setAttribute("autocorrect", autocorrect ? "" : "off"); + field.setAttribute("autocapitalize", autocapitalize ? "" : "off"); + field.setAttribute("spellcheck", !!spellcheck); + } + + function hiddenTextarea() { + var te = elt("textarea", null, null, "position: absolute; bottom: -1em; padding: 0; width: 1px; height: 1em; outline: none"); + var div = elt("div", [te], null, "overflow: hidden; position: relative; width: 3px; height: 0px;"); + // The textarea is kept positioned near the cursor to prevent the + // fact that it'll be scrolled into view on input from scrolling + // our fake cursor out of view. On webkit, when wrap=off, paste is + // very slow. So make the area wide instead. + if (webkit) { te.style.width = "1000px"; } + else { te.setAttribute("wrap", "off"); } + // If border: 0; -- iOS fails to open keyboard (issue #1287) + if (ios) { te.style.border = "1px solid black"; } + disableBrowserMagic(te); + return div + } + + // The publicly visible API. Note that methodOp(f) means + // 'wrap f in an operation, performed on its `this` parameter'. + + // This is not the complete set of editor methods. Most of the + // methods defined on the Doc type are also injected into + // CodeMirror.prototype, for backwards compatibility and + // convenience. + + function addEditorMethods(CodeMirror) { + var optionHandlers = CodeMirror.optionHandlers; + + var helpers = CodeMirror.helpers = {}; + + CodeMirror.prototype = { + constructor: CodeMirror, + focus: function(){window.focus(); this.display.input.focus();}, + + setOption: function(option, value) { + var options = this.options, old = options[option]; + if (options[option] == value && option != "mode") { return } + options[option] = value; + if (optionHandlers.hasOwnProperty(option)) + { operation(this, optionHandlers[option])(this, value, old); } + signal(this, "optionChange", this, option); + }, + + getOption: function(option) {return this.options[option]}, + getDoc: function() {return this.doc}, + + addKeyMap: function(map$$1, bottom) { + this.state.keyMaps[bottom ? "push" : "unshift"](getKeyMap(map$$1)); + }, + removeKeyMap: function(map$$1) { + var maps = this.state.keyMaps; + for (var i = 0; i < maps.length; ++i) + { if (maps[i] == map$$1 || maps[i].name == map$$1) { + maps.splice(i, 1); + return true + } } + }, + + addOverlay: methodOp(function(spec, options) { + var mode = spec.token ? spec : CodeMirror.getMode(this.options, spec); + if (mode.startState) { throw new Error("Overlays may not be stateful.") } + insertSorted(this.state.overlays, + {mode: mode, modeSpec: spec, opaque: options && options.opaque, + priority: (options && options.priority) || 0}, + function (overlay) { return overlay.priority; }); + this.state.modeGen++; + regChange(this); + }), + removeOverlay: methodOp(function(spec) { + var this$1 = this; + + var overlays = this.state.overlays; + for (var i = 0; i < overlays.length; ++i) { + var cur = overlays[i].modeSpec; + if (cur == spec || typeof spec == "string" && cur.name == spec) { + overlays.splice(i, 1); + this$1.state.modeGen++; + regChange(this$1); + return + } + } + }), + + indentLine: methodOp(function(n, dir, aggressive) { + if (typeof dir != "string" && typeof dir != "number") { + if (dir == null) { dir = this.options.smartIndent ? "smart" : "prev"; } + else { dir = dir ? "add" : "subtract"; } + } + if (isLine(this.doc, n)) { indentLine(this, n, dir, aggressive); } + }), + indentSelection: methodOp(function(how) { + var this$1 = this; + + var ranges = this.doc.sel.ranges, end = -1; + for (var i = 0; i < ranges.length; i++) { + var range$$1 = ranges[i]; + if (!range$$1.empty()) { + var from = range$$1.from(), to = range$$1.to(); + var start = Math.max(end, from.line); + end = Math.min(this$1.lastLine(), to.line - (to.ch ? 0 : 1)) + 1; + for (var j = start; j < end; ++j) + { indentLine(this$1, j, how); } + var newRanges = this$1.doc.sel.ranges; + if (from.ch == 0 && ranges.length == newRanges.length && newRanges[i].from().ch > 0) + { replaceOneSelection(this$1.doc, i, new Range(from, newRanges[i].to()), sel_dontScroll); } + } else if (range$$1.head.line > end) { + indentLine(this$1, range$$1.head.line, how, true); + end = range$$1.head.line; + if (i == this$1.doc.sel.primIndex) { ensureCursorVisible(this$1); } + } + } + }), + + // Fetch the parser token for a given character. Useful for hacks + // that want to inspect the mode state (say, for completion). + getTokenAt: function(pos, precise) { + return takeToken(this, pos, precise) + }, + + getLineTokens: function(line, precise) { + return takeToken(this, Pos(line), precise, true) + }, + + getTokenTypeAt: function(pos) { + pos = clipPos(this.doc, pos); + var styles = getLineStyles(this, getLine(this.doc, pos.line)); + var before = 0, after = (styles.length - 1) / 2, ch = pos.ch; + var type; + if (ch == 0) { type = styles[2]; } + else { for (;;) { + var mid = (before + after) >> 1; + if ((mid ? styles[mid * 2 - 1] : 0) >= ch) { after = mid; } + else if (styles[mid * 2 + 1] < ch) { before = mid + 1; } + else { type = styles[mid * 2 + 2]; break } + } } + var cut = type ? type.indexOf("overlay ") : -1; + return cut < 0 ? type : cut == 0 ? null : type.slice(0, cut - 1) + }, + + getModeAt: function(pos) { + var mode = this.doc.mode; + if (!mode.innerMode) { return mode } + return CodeMirror.innerMode(mode, this.getTokenAt(pos).state).mode + }, + + getHelper: function(pos, type) { + return this.getHelpers(pos, type)[0] + }, + + getHelpers: function(pos, type) { + var this$1 = this; + + var found = []; + if (!helpers.hasOwnProperty(type)) { return found } + var help = helpers[type], mode = this.getModeAt(pos); + if (typeof mode[type] == "string") { + if (help[mode[type]]) { found.push(help[mode[type]]); } + } else if (mode[type]) { + for (var i = 0; i < mode[type].length; i++) { + var val = help[mode[type][i]]; + if (val) { found.push(val); } + } + } else if (mode.helperType && help[mode.helperType]) { + found.push(help[mode.helperType]); + } else if (help[mode.name]) { + found.push(help[mode.name]); + } + for (var i$1 = 0; i$1 < help._global.length; i$1++) { + var cur = help._global[i$1]; + if (cur.pred(mode, this$1) && indexOf(found, cur.val) == -1) + { found.push(cur.val); } + } + return found + }, + + getStateAfter: function(line, precise) { + var doc = this.doc; + line = clipLine(doc, line == null ? doc.first + doc.size - 1: line); + return getContextBefore(this, line + 1, precise).state + }, + + cursorCoords: function(start, mode) { + var pos, range$$1 = this.doc.sel.primary(); + if (start == null) { pos = range$$1.head; } + else if (typeof start == "object") { pos = clipPos(this.doc, start); } + else { pos = start ? range$$1.from() : range$$1.to(); } + return cursorCoords(this, pos, mode || "page") + }, + + charCoords: function(pos, mode) { + return charCoords(this, clipPos(this.doc, pos), mode || "page") + }, + + coordsChar: function(coords, mode) { + coords = fromCoordSystem(this, coords, mode || "page"); + return coordsChar(this, coords.left, coords.top) + }, + + lineAtHeight: function(height, mode) { + height = fromCoordSystem(this, {top: height, left: 0}, mode || "page").top; + return lineAtHeight(this.doc, height + this.display.viewOffset) + }, + heightAtLine: function(line, mode, includeWidgets) { + var end = false, lineObj; + if (typeof line == "number") { + var last = this.doc.first + this.doc.size - 1; + if (line < this.doc.first) { line = this.doc.first; } + else if (line > last) { line = last; end = true; } + lineObj = getLine(this.doc, line); + } else { + lineObj = line; + } + return intoCoordSystem(this, lineObj, {top: 0, left: 0}, mode || "page", includeWidgets || end).top + + (end ? this.doc.height - heightAtLine(lineObj) : 0) + }, + + defaultTextHeight: function() { return textHeight(this.display) }, + defaultCharWidth: function() { return charWidth(this.display) }, + + getViewport: function() { return {from: this.display.viewFrom, to: this.display.viewTo}}, + + addWidget: function(pos, node, scroll, vert, horiz) { + var display = this.display; + pos = cursorCoords(this, clipPos(this.doc, pos)); + var top = pos.bottom, left = pos.left; + node.style.position = "absolute"; + node.setAttribute("cm-ignore-events", "true"); + this.display.input.setUneditable(node); + display.sizer.appendChild(node); + if (vert == "over") { + top = pos.top; + } else if (vert == "above" || vert == "near") { + var vspace = Math.max(display.wrapper.clientHeight, this.doc.height), + hspace = Math.max(display.sizer.clientWidth, display.lineSpace.clientWidth); + // Default to positioning above (if specified and possible); otherwise default to positioning below + if ((vert == 'above' || pos.bottom + node.offsetHeight > vspace) && pos.top > node.offsetHeight) + { top = pos.top - node.offsetHeight; } + else if (pos.bottom + node.offsetHeight <= vspace) + { top = pos.bottom; } + if (left + node.offsetWidth > hspace) + { left = hspace - node.offsetWidth; } + } + node.style.top = top + "px"; + node.style.left = node.style.right = ""; + if (horiz == "right") { + left = display.sizer.clientWidth - node.offsetWidth; + node.style.right = "0px"; + } else { + if (horiz == "left") { left = 0; } + else if (horiz == "middle") { left = (display.sizer.clientWidth - node.offsetWidth) / 2; } + node.style.left = left + "px"; + } + if (scroll) + { scrollIntoView(this, {left: left, top: top, right: left + node.offsetWidth, bottom: top + node.offsetHeight}); } + }, + + triggerOnKeyDown: methodOp(onKeyDown), + triggerOnKeyPress: methodOp(onKeyPress), + triggerOnKeyUp: onKeyUp, + triggerOnMouseDown: methodOp(onMouseDown), + + execCommand: function(cmd) { + if (commands.hasOwnProperty(cmd)) + { return commands[cmd].call(null, this) } + }, + + triggerElectric: methodOp(function(text) { triggerElectric(this, text); }), + + findPosH: function(from, amount, unit, visually) { + var this$1 = this; + + var dir = 1; + if (amount < 0) { dir = -1; amount = -amount; } + var cur = clipPos(this.doc, from); + for (var i = 0; i < amount; ++i) { + cur = findPosH(this$1.doc, cur, dir, unit, visually); + if (cur.hitSide) { break } + } + return cur + }, + + moveH: methodOp(function(dir, unit) { + var this$1 = this; + + this.extendSelectionsBy(function (range$$1) { + if (this$1.display.shift || this$1.doc.extend || range$$1.empty()) + { return findPosH(this$1.doc, range$$1.head, dir, unit, this$1.options.rtlMoveVisually) } + else + { return dir < 0 ? range$$1.from() : range$$1.to() } + }, sel_move); + }), + + deleteH: methodOp(function(dir, unit) { + var sel = this.doc.sel, doc = this.doc; + if (sel.somethingSelected()) + { doc.replaceSelection("", null, "+delete"); } + else + { deleteNearSelection(this, function (range$$1) { + var other = findPosH(doc, range$$1.head, dir, unit, false); + return dir < 0 ? {from: other, to: range$$1.head} : {from: range$$1.head, to: other} + }); } + }), + + findPosV: function(from, amount, unit, goalColumn) { + var this$1 = this; + + var dir = 1, x = goalColumn; + if (amount < 0) { dir = -1; amount = -amount; } + var cur = clipPos(this.doc, from); + for (var i = 0; i < amount; ++i) { + var coords = cursorCoords(this$1, cur, "div"); + if (x == null) { x = coords.left; } + else { coords.left = x; } + cur = findPosV(this$1, coords, dir, unit); + if (cur.hitSide) { break } + } + return cur + }, + + moveV: methodOp(function(dir, unit) { + var this$1 = this; + + var doc = this.doc, goals = []; + var collapse = !this.display.shift && !doc.extend && doc.sel.somethingSelected(); + doc.extendSelectionsBy(function (range$$1) { + if (collapse) + { return dir < 0 ? range$$1.from() : range$$1.to() } + var headPos = cursorCoords(this$1, range$$1.head, "div"); + if (range$$1.goalColumn != null) { headPos.left = range$$1.goalColumn; } + goals.push(headPos.left); + var pos = findPosV(this$1, headPos, dir, unit); + if (unit == "page" && range$$1 == doc.sel.primary()) + { addToScrollTop(this$1, charCoords(this$1, pos, "div").top - headPos.top); } + return pos + }, sel_move); + if (goals.length) { for (var i = 0; i < doc.sel.ranges.length; i++) + { doc.sel.ranges[i].goalColumn = goals[i]; } } + }), + + // Find the word at the given position (as returned by coordsChar). + findWordAt: function(pos) { + var doc = this.doc, line = getLine(doc, pos.line).text; + var start = pos.ch, end = pos.ch; + if (line) { + var helper = this.getHelper(pos, "wordChars"); + if ((pos.sticky == "before" || end == line.length) && start) { --start; } else { ++end; } + var startChar = line.charAt(start); + var check = isWordChar(startChar, helper) + ? function (ch) { return isWordChar(ch, helper); } + : /\s/.test(startChar) ? function (ch) { return /\s/.test(ch); } + : function (ch) { return (!/\s/.test(ch) && !isWordChar(ch)); }; + while (start > 0 && check(line.charAt(start - 1))) { --start; } + while (end < line.length && check(line.charAt(end))) { ++end; } + } + return new Range(Pos(pos.line, start), Pos(pos.line, end)) + }, + + toggleOverwrite: function(value) { + if (value != null && value == this.state.overwrite) { return } + if (this.state.overwrite = !this.state.overwrite) + { addClass(this.display.cursorDiv, "CodeMirror-overwrite"); } + else + { rmClass(this.display.cursorDiv, "CodeMirror-overwrite"); } + + signal(this, "overwriteToggle", this, this.state.overwrite); + }, + hasFocus: function() { return this.display.input.getField() == activeElt() }, + isReadOnly: function() { return !!(this.options.readOnly || this.doc.cantEdit) }, + + scrollTo: methodOp(function (x, y) { scrollToCoords(this, x, y); }), + getScrollInfo: function() { + var scroller = this.display.scroller; + return {left: scroller.scrollLeft, top: scroller.scrollTop, + height: scroller.scrollHeight - scrollGap(this) - this.display.barHeight, + width: scroller.scrollWidth - scrollGap(this) - this.display.barWidth, + clientHeight: displayHeight(this), clientWidth: displayWidth(this)} + }, + + scrollIntoView: methodOp(function(range$$1, margin) { + if (range$$1 == null) { + range$$1 = {from: this.doc.sel.primary().head, to: null}; + if (margin == null) { margin = this.options.cursorScrollMargin; } + } else if (typeof range$$1 == "number") { + range$$1 = {from: Pos(range$$1, 0), to: null}; + } else if (range$$1.from == null) { + range$$1 = {from: range$$1, to: null}; + } + if (!range$$1.to) { range$$1.to = range$$1.from; } + range$$1.margin = margin || 0; + + if (range$$1.from.line != null) { + scrollToRange(this, range$$1); + } else { + scrollToCoordsRange(this, range$$1.from, range$$1.to, range$$1.margin); + } + }), + + setSize: methodOp(function(width, height) { + var this$1 = this; + + var interpret = function (val) { return typeof val == "number" || /^\d+$/.test(String(val)) ? val + "px" : val; }; + if (width != null) { this.display.wrapper.style.width = interpret(width); } + if (height != null) { this.display.wrapper.style.height = interpret(height); } + if (this.options.lineWrapping) { clearLineMeasurementCache(this); } + var lineNo$$1 = this.display.viewFrom; + this.doc.iter(lineNo$$1, this.display.viewTo, function (line) { + if (line.widgets) { for (var i = 0; i < line.widgets.length; i++) + { if (line.widgets[i].noHScroll) { regLineChange(this$1, lineNo$$1, "widget"); break } } } + ++lineNo$$1; + }); + this.curOp.forceUpdate = true; + signal(this, "refresh", this); + }), + + operation: function(f){return runInOp(this, f)}, + startOperation: function(){return startOperation(this)}, + endOperation: function(){return endOperation(this)}, + + refresh: methodOp(function() { + var oldHeight = this.display.cachedTextHeight; + regChange(this); + this.curOp.forceUpdate = true; + clearCaches(this); + scrollToCoords(this, this.doc.scrollLeft, this.doc.scrollTop); + updateGutterSpace(this.display); + if (oldHeight == null || Math.abs(oldHeight - textHeight(this.display)) > .5) + { estimateLineHeights(this); } + signal(this, "refresh", this); + }), + + swapDoc: methodOp(function(doc) { + var old = this.doc; + old.cm = null; + // Cancel the current text selection if any (#5821) + if (this.state.selectingText) { this.state.selectingText(); } + attachDoc(this, doc); + clearCaches(this); + this.display.input.reset(); + scrollToCoords(this, doc.scrollLeft, doc.scrollTop); + this.curOp.forceScroll = true; + signalLater(this, "swapDoc", this, old); + return old + }), + + phrase: function(phraseText) { + var phrases = this.options.phrases; + return phrases && Object.prototype.hasOwnProperty.call(phrases, phraseText) ? phrases[phraseText] : phraseText + }, + + getInputField: function(){return this.display.input.getField()}, + getWrapperElement: function(){return this.display.wrapper}, + getScrollerElement: function(){return this.display.scroller}, + getGutterElement: function(){return this.display.gutters} + }; + eventMixin(CodeMirror); + + CodeMirror.registerHelper = function(type, name, value) { + if (!helpers.hasOwnProperty(type)) { helpers[type] = CodeMirror[type] = {_global: []}; } + helpers[type][name] = value; + }; + CodeMirror.registerGlobalHelper = function(type, name, predicate, value) { + CodeMirror.registerHelper(type, name, value); + helpers[type]._global.push({pred: predicate, val: value}); + }; + } + + // Used for horizontal relative motion. Dir is -1 or 1 (left or + // right), unit can be "char", "column" (like char, but doesn't + // cross line boundaries), "word" (across next word), or "group" (to + // the start of next group of word or non-word-non-whitespace + // chars). The visually param controls whether, in right-to-left + // text, direction 1 means to move towards the next index in the + // string, or towards the character to the right of the current + // position. The resulting position will have a hitSide=true + // property if it reached the end of the document. + function findPosH(doc, pos, dir, unit, visually) { + var oldPos = pos; + var origDir = dir; + var lineObj = getLine(doc, pos.line); + function findNextLine() { + var l = pos.line + dir; + if (l < doc.first || l >= doc.first + doc.size) { return false } + pos = new Pos(l, pos.ch, pos.sticky); + return lineObj = getLine(doc, l) + } + function moveOnce(boundToLine) { + var next; + if (visually) { + next = moveVisually(doc.cm, lineObj, pos, dir); + } else { + next = moveLogically(lineObj, pos, dir); + } + if (next == null) { + if (!boundToLine && findNextLine()) + { pos = endOfLine(visually, doc.cm, lineObj, pos.line, dir); } + else + { return false } + } else { + pos = next; + } + return true + } + + if (unit == "char") { + moveOnce(); + } else if (unit == "column") { + moveOnce(true); + } else if (unit == "word" || unit == "group") { + var sawType = null, group = unit == "group"; + var helper = doc.cm && doc.cm.getHelper(pos, "wordChars"); + for (var first = true;; first = false) { + if (dir < 0 && !moveOnce(!first)) { break } + var cur = lineObj.text.charAt(pos.ch) || "\n"; + var type = isWordChar(cur, helper) ? "w" + : group && cur == "\n" ? "n" + : !group || /\s/.test(cur) ? null + : "p"; + if (group && !first && !type) { type = "s"; } + if (sawType && sawType != type) { + if (dir < 0) {dir = 1; moveOnce(); pos.sticky = "after";} + break + } + + if (type) { sawType = type; } + if (dir > 0 && !moveOnce(!first)) { break } + } + } + var result = skipAtomic(doc, pos, oldPos, origDir, true); + if (equalCursorPos(oldPos, result)) { result.hitSide = true; } + return result + } + + // For relative vertical movement. Dir may be -1 or 1. Unit can be + // "page" or "line". The resulting position will have a hitSide=true + // property if it reached the end of the document. + function findPosV(cm, pos, dir, unit) { + var doc = cm.doc, x = pos.left, y; + if (unit == "page") { + var pageSize = Math.min(cm.display.wrapper.clientHeight, window.innerHeight || document.documentElement.clientHeight); + var moveAmount = Math.max(pageSize - .5 * textHeight(cm.display), 3); + y = (dir > 0 ? pos.bottom : pos.top) + dir * moveAmount; + + } else if (unit == "line") { + y = dir > 0 ? pos.bottom + 3 : pos.top - 3; + } + var target; + for (;;) { + target = coordsChar(cm, x, y); + if (!target.outside) { break } + if (dir < 0 ? y <= 0 : y >= doc.height) { target.hitSide = true; break } + y += dir * 5; + } + return target + } + + // CONTENTEDITABLE INPUT STYLE + + var ContentEditableInput = function(cm) { + this.cm = cm; + this.lastAnchorNode = this.lastAnchorOffset = this.lastFocusNode = this.lastFocusOffset = null; + this.polling = new Delayed(); + this.composing = null; + this.gracePeriod = false; + this.readDOMTimeout = null; + }; + + ContentEditableInput.prototype.init = function (display) { + var this$1 = this; + + var input = this, cm = input.cm; + var div = input.div = display.lineDiv; + disableBrowserMagic(div, cm.options.spellcheck, cm.options.autocorrect, cm.options.autocapitalize); + + on(div, "paste", function (e) { + if (signalDOMEvent(cm, e) || handlePaste(e, cm)) { return } + // IE doesn't fire input events, so we schedule a read for the pasted content in this way + if (ie_version <= 11) { setTimeout(operation(cm, function () { return this$1.updateFromDOM(); }), 20); } + }); + + on(div, "compositionstart", function (e) { + this$1.composing = {data: e.data, done: false}; + }); + on(div, "compositionupdate", function (e) { + if (!this$1.composing) { this$1.composing = {data: e.data, done: false}; } + }); + on(div, "compositionend", function (e) { + if (this$1.composing) { + if (e.data != this$1.composing.data) { this$1.readFromDOMSoon(); } + this$1.composing.done = true; + } + }); + + on(div, "touchstart", function () { return input.forceCompositionEnd(); }); + + on(div, "input", function () { + if (!this$1.composing) { this$1.readFromDOMSoon(); } + }); + + function onCopyCut(e) { + if (signalDOMEvent(cm, e)) { return } + if (cm.somethingSelected()) { + setLastCopied({lineWise: false, text: cm.getSelections()}); + if (e.type == "cut") { cm.replaceSelection("", null, "cut"); } + } else if (!cm.options.lineWiseCopyCut) { + return + } else { + var ranges = copyableRanges(cm); + setLastCopied({lineWise: true, text: ranges.text}); + if (e.type == "cut") { + cm.operation(function () { + cm.setSelections(ranges.ranges, 0, sel_dontScroll); + cm.replaceSelection("", null, "cut"); + }); + } + } + if (e.clipboardData) { + e.clipboardData.clearData(); + var content = lastCopied.text.join("\n"); + // iOS exposes the clipboard API, but seems to discard content inserted into it + e.clipboardData.setData("Text", content); + if (e.clipboardData.getData("Text") == content) { + e.preventDefault(); + return + } + } + // Old-fashioned briefly-focus-a-textarea hack + var kludge = hiddenTextarea(), te = kludge.firstChild; + cm.display.lineSpace.insertBefore(kludge, cm.display.lineSpace.firstChild); + te.value = lastCopied.text.join("\n"); + var hadFocus = document.activeElement; + selectInput(te); + setTimeout(function () { + cm.display.lineSpace.removeChild(kludge); + hadFocus.focus(); + if (hadFocus == div) { input.showPrimarySelection(); } + }, 50); + } + on(div, "copy", onCopyCut); + on(div, "cut", onCopyCut); + }; + + ContentEditableInput.prototype.prepareSelection = function () { + var result = prepareSelection(this.cm, false); + result.focus = this.cm.state.focused; + return result + }; + + ContentEditableInput.prototype.showSelection = function (info, takeFocus) { + if (!info || !this.cm.display.view.length) { return } + if (info.focus || takeFocus) { this.showPrimarySelection(); } + this.showMultipleSelections(info); + }; + + ContentEditableInput.prototype.getSelection = function () { + return this.cm.display.wrapper.ownerDocument.getSelection() + }; + + ContentEditableInput.prototype.showPrimarySelection = function () { + var sel = this.getSelection(), cm = this.cm, prim = cm.doc.sel.primary(); + var from = prim.from(), to = prim.to(); + + if (cm.display.viewTo == cm.display.viewFrom || from.line >= cm.display.viewTo || to.line < cm.display.viewFrom) { + sel.removeAllRanges(); + return + } + + var curAnchor = domToPos(cm, sel.anchorNode, sel.anchorOffset); + var curFocus = domToPos(cm, sel.focusNode, sel.focusOffset); + if (curAnchor && !curAnchor.bad && curFocus && !curFocus.bad && + cmp(minPos(curAnchor, curFocus), from) == 0 && + cmp(maxPos(curAnchor, curFocus), to) == 0) + { return } + + var view = cm.display.view; + var start = (from.line >= cm.display.viewFrom && posToDOM(cm, from)) || + {node: view[0].measure.map[2], offset: 0}; + var end = to.line < cm.display.viewTo && posToDOM(cm, to); + if (!end) { + var measure = view[view.length - 1].measure; + var map$$1 = measure.maps ? measure.maps[measure.maps.length - 1] : measure.map; + end = {node: map$$1[map$$1.length - 1], offset: map$$1[map$$1.length - 2] - map$$1[map$$1.length - 3]}; + } + + if (!start || !end) { + sel.removeAllRanges(); + return + } + + var old = sel.rangeCount && sel.getRangeAt(0), rng; + try { rng = range(start.node, start.offset, end.offset, end.node); } + catch(e) {} // Our model of the DOM might be outdated, in which case the range we try to set can be impossible + if (rng) { + if (!gecko && cm.state.focused) { + sel.collapse(start.node, start.offset); + if (!rng.collapsed) { + sel.removeAllRanges(); + sel.addRange(rng); + } + } else { + sel.removeAllRanges(); + sel.addRange(rng); + } + if (old && sel.anchorNode == null) { sel.addRange(old); } + else if (gecko) { this.startGracePeriod(); } + } + this.rememberSelection(); + }; + + ContentEditableInput.prototype.startGracePeriod = function () { + var this$1 = this; + + clearTimeout(this.gracePeriod); + this.gracePeriod = setTimeout(function () { + this$1.gracePeriod = false; + if (this$1.selectionChanged()) + { this$1.cm.operation(function () { return this$1.cm.curOp.selectionChanged = true; }); } + }, 20); + }; + + ContentEditableInput.prototype.showMultipleSelections = function (info) { + removeChildrenAndAdd(this.cm.display.cursorDiv, info.cursors); + removeChildrenAndAdd(this.cm.display.selectionDiv, info.selection); + }; + + ContentEditableInput.prototype.rememberSelection = function () { + var sel = this.getSelection(); + this.lastAnchorNode = sel.anchorNode; this.lastAnchorOffset = sel.anchorOffset; + this.lastFocusNode = sel.focusNode; this.lastFocusOffset = sel.focusOffset; + }; + + ContentEditableInput.prototype.selectionInEditor = function () { + var sel = this.getSelection(); + if (!sel.rangeCount) { return false } + var node = sel.getRangeAt(0).commonAncestorContainer; + return contains(this.div, node) + }; + + ContentEditableInput.prototype.focus = function () { + if (this.cm.options.readOnly != "nocursor") { + if (!this.selectionInEditor()) + { this.showSelection(this.prepareSelection(), true); } + this.div.focus(); + } + }; + ContentEditableInput.prototype.blur = function () { this.div.blur(); }; + ContentEditableInput.prototype.getField = function () { return this.div }; + + ContentEditableInput.prototype.supportsTouch = function () { return true }; + + ContentEditableInput.prototype.receivedFocus = function () { + var input = this; + if (this.selectionInEditor()) + { this.pollSelection(); } + else + { runInOp(this.cm, function () { return input.cm.curOp.selectionChanged = true; }); } + + function poll() { + if (input.cm.state.focused) { + input.pollSelection(); + input.polling.set(input.cm.options.pollInterval, poll); + } + } + this.polling.set(this.cm.options.pollInterval, poll); + }; + + ContentEditableInput.prototype.selectionChanged = function () { + var sel = this.getSelection(); + return sel.anchorNode != this.lastAnchorNode || sel.anchorOffset != this.lastAnchorOffset || + sel.focusNode != this.lastFocusNode || sel.focusOffset != this.lastFocusOffset + }; + + ContentEditableInput.prototype.pollSelection = function () { + if (this.readDOMTimeout != null || this.gracePeriod || !this.selectionChanged()) { return } + var sel = this.getSelection(), cm = this.cm; + // On Android Chrome (version 56, at least), backspacing into an + // uneditable block element will put the cursor in that element, + // and then, because it's not editable, hide the virtual keyboard. + // Because Android doesn't allow us to actually detect backspace + // presses in a sane way, this code checks for when that happens + // and simulates a backspace press in this case. + if (android && chrome && this.cm.display.gutterSpecs.length && isInGutter(sel.anchorNode)) { + this.cm.triggerOnKeyDown({type: "keydown", keyCode: 8, preventDefault: Math.abs}); + this.blur(); + this.focus(); + return + } + if (this.composing) { return } + this.rememberSelection(); + var anchor = domToPos(cm, sel.anchorNode, sel.anchorOffset); + var head = domToPos(cm, sel.focusNode, sel.focusOffset); + if (anchor && head) { runInOp(cm, function () { + setSelection(cm.doc, simpleSelection(anchor, head), sel_dontScroll); + if (anchor.bad || head.bad) { cm.curOp.selectionChanged = true; } + }); } + }; + + ContentEditableInput.prototype.pollContent = function () { + if (this.readDOMTimeout != null) { + clearTimeout(this.readDOMTimeout); + this.readDOMTimeout = null; + } + + var cm = this.cm, display = cm.display, sel = cm.doc.sel.primary(); + var from = sel.from(), to = sel.to(); + if (from.ch == 0 && from.line > cm.firstLine()) + { from = Pos(from.line - 1, getLine(cm.doc, from.line - 1).length); } + if (to.ch == getLine(cm.doc, to.line).text.length && to.line < cm.lastLine()) + { to = Pos(to.line + 1, 0); } + if (from.line < display.viewFrom || to.line > display.viewTo - 1) { return false } + + var fromIndex, fromLine, fromNode; + if (from.line == display.viewFrom || (fromIndex = findViewIndex(cm, from.line)) == 0) { + fromLine = lineNo(display.view[0].line); + fromNode = display.view[0].node; + } else { + fromLine = lineNo(display.view[fromIndex].line); + fromNode = display.view[fromIndex - 1].node.nextSibling; + } + var toIndex = findViewIndex(cm, to.line); + var toLine, toNode; + if (toIndex == display.view.length - 1) { + toLine = display.viewTo - 1; + toNode = display.lineDiv.lastChild; + } else { + toLine = lineNo(display.view[toIndex + 1].line) - 1; + toNode = display.view[toIndex + 1].node.previousSibling; + } + + if (!fromNode) { return false } + var newText = cm.doc.splitLines(domTextBetween(cm, fromNode, toNode, fromLine, toLine)); + var oldText = getBetween(cm.doc, Pos(fromLine, 0), Pos(toLine, getLine(cm.doc, toLine).text.length)); + while (newText.length > 1 && oldText.length > 1) { + if (lst(newText) == lst(oldText)) { newText.pop(); oldText.pop(); toLine--; } + else if (newText[0] == oldText[0]) { newText.shift(); oldText.shift(); fromLine++; } + else { break } + } + + var cutFront = 0, cutEnd = 0; + var newTop = newText[0], oldTop = oldText[0], maxCutFront = Math.min(newTop.length, oldTop.length); + while (cutFront < maxCutFront && newTop.charCodeAt(cutFront) == oldTop.charCodeAt(cutFront)) + { ++cutFront; } + var newBot = lst(newText), oldBot = lst(oldText); + var maxCutEnd = Math.min(newBot.length - (newText.length == 1 ? cutFront : 0), + oldBot.length - (oldText.length == 1 ? cutFront : 0)); + while (cutEnd < maxCutEnd && + newBot.charCodeAt(newBot.length - cutEnd - 1) == oldBot.charCodeAt(oldBot.length - cutEnd - 1)) + { ++cutEnd; } + // Try to move start of change to start of selection if ambiguous + if (newText.length == 1 && oldText.length == 1 && fromLine == from.line) { + while (cutFront && cutFront > from.ch && + newBot.charCodeAt(newBot.length - cutEnd - 1) == oldBot.charCodeAt(oldBot.length - cutEnd - 1)) { + cutFront--; + cutEnd++; + } + } + + newText[newText.length - 1] = newBot.slice(0, newBot.length - cutEnd).replace(/^\u200b+/, ""); + newText[0] = newText[0].slice(cutFront).replace(/\u200b+$/, ""); + + var chFrom = Pos(fromLine, cutFront); + var chTo = Pos(toLine, oldText.length ? lst(oldText).length - cutEnd : 0); + if (newText.length > 1 || newText[0] || cmp(chFrom, chTo)) { + replaceRange(cm.doc, newText, chFrom, chTo, "+input"); + return true + } + }; + + ContentEditableInput.prototype.ensurePolled = function () { + this.forceCompositionEnd(); + }; + ContentEditableInput.prototype.reset = function () { + this.forceCompositionEnd(); + }; + ContentEditableInput.prototype.forceCompositionEnd = function () { + if (!this.composing) { return } + clearTimeout(this.readDOMTimeout); + this.composing = null; + this.updateFromDOM(); + this.div.blur(); + this.div.focus(); + }; + ContentEditableInput.prototype.readFromDOMSoon = function () { + var this$1 = this; + + if (this.readDOMTimeout != null) { return } + this.readDOMTimeout = setTimeout(function () { + this$1.readDOMTimeout = null; + if (this$1.composing) { + if (this$1.composing.done) { this$1.composing = null; } + else { return } + } + this$1.updateFromDOM(); + }, 80); + }; + + ContentEditableInput.prototype.updateFromDOM = function () { + var this$1 = this; + + if (this.cm.isReadOnly() || !this.pollContent()) + { runInOp(this.cm, function () { return regChange(this$1.cm); }); } + }; + + ContentEditableInput.prototype.setUneditable = function (node) { + node.contentEditable = "false"; + }; + + ContentEditableInput.prototype.onKeyPress = function (e) { + if (e.charCode == 0 || this.composing) { return } + e.preventDefault(); + if (!this.cm.isReadOnly()) + { operation(this.cm, applyTextInput)(this.cm, String.fromCharCode(e.charCode == null ? e.keyCode : e.charCode), 0); } + }; + + ContentEditableInput.prototype.readOnlyChanged = function (val) { + this.div.contentEditable = String(val != "nocursor"); + }; + + ContentEditableInput.prototype.onContextMenu = function () {}; + ContentEditableInput.prototype.resetPosition = function () {}; + + ContentEditableInput.prototype.needsContentAttribute = true; + + function posToDOM(cm, pos) { + var view = findViewForLine(cm, pos.line); + if (!view || view.hidden) { return null } + var line = getLine(cm.doc, pos.line); + var info = mapFromLineView(view, line, pos.line); + + var order = getOrder(line, cm.doc.direction), side = "left"; + if (order) { + var partPos = getBidiPartAt(order, pos.ch); + side = partPos % 2 ? "right" : "left"; + } + var result = nodeAndOffsetInLineMap(info.map, pos.ch, side); + result.offset = result.collapse == "right" ? result.end : result.start; + return result + } + + function isInGutter(node) { + for (var scan = node; scan; scan = scan.parentNode) + { if (/CodeMirror-gutter-wrapper/.test(scan.className)) { return true } } + return false + } + + function badPos(pos, bad) { if (bad) { pos.bad = true; } return pos } + + function domTextBetween(cm, from, to, fromLine, toLine) { + var text = "", closing = false, lineSep = cm.doc.lineSeparator(), extraLinebreak = false; + function recognizeMarker(id) { return function (marker) { return marker.id == id; } } + function close() { + if (closing) { + text += lineSep; + if (extraLinebreak) { text += lineSep; } + closing = extraLinebreak = false; + } + } + function addText(str) { + if (str) { + close(); + text += str; + } + } + function walk(node) { + if (node.nodeType == 1) { + var cmText = node.getAttribute("cm-text"); + if (cmText) { + addText(cmText); + return + } + var markerID = node.getAttribute("cm-marker"), range$$1; + if (markerID) { + var found = cm.findMarks(Pos(fromLine, 0), Pos(toLine + 1, 0), recognizeMarker(+markerID)); + if (found.length && (range$$1 = found[0].find(0))) + { addText(getBetween(cm.doc, range$$1.from, range$$1.to).join(lineSep)); } + return + } + if (node.getAttribute("contenteditable") == "false") { return } + var isBlock = /^(pre|div|p|li|table|br)$/i.test(node.nodeName); + if (!/^br$/i.test(node.nodeName) && node.textContent.length == 0) { return } + + if (isBlock) { close(); } + for (var i = 0; i < node.childNodes.length; i++) + { walk(node.childNodes[i]); } + + if (/^(pre|p)$/i.test(node.nodeName)) { extraLinebreak = true; } + if (isBlock) { closing = true; } + } else if (node.nodeType == 3) { + addText(node.nodeValue.replace(/\u200b/g, "").replace(/\u00a0/g, " ")); + } + } + for (;;) { + walk(from); + if (from == to) { break } + from = from.nextSibling; + extraLinebreak = false; + } + return text + } + + function domToPos(cm, node, offset) { + var lineNode; + if (node == cm.display.lineDiv) { + lineNode = cm.display.lineDiv.childNodes[offset]; + if (!lineNode) { return badPos(cm.clipPos(Pos(cm.display.viewTo - 1)), true) } + node = null; offset = 0; + } else { + for (lineNode = node;; lineNode = lineNode.parentNode) { + if (!lineNode || lineNode == cm.display.lineDiv) { return null } + if (lineNode.parentNode && lineNode.parentNode == cm.display.lineDiv) { break } + } + } + for (var i = 0; i < cm.display.view.length; i++) { + var lineView = cm.display.view[i]; + if (lineView.node == lineNode) + { return locateNodeInLineView(lineView, node, offset) } + } + } + + function locateNodeInLineView(lineView, node, offset) { + var wrapper = lineView.text.firstChild, bad = false; + if (!node || !contains(wrapper, node)) { return badPos(Pos(lineNo(lineView.line), 0), true) } + if (node == wrapper) { + bad = true; + node = wrapper.childNodes[offset]; + offset = 0; + if (!node) { + var line = lineView.rest ? lst(lineView.rest) : lineView.line; + return badPos(Pos(lineNo(line), line.text.length), bad) + } + } + + var textNode = node.nodeType == 3 ? node : null, topNode = node; + if (!textNode && node.childNodes.length == 1 && node.firstChild.nodeType == 3) { + textNode = node.firstChild; + if (offset) { offset = textNode.nodeValue.length; } + } + while (topNode.parentNode != wrapper) { topNode = topNode.parentNode; } + var measure = lineView.measure, maps = measure.maps; + + function find(textNode, topNode, offset) { + for (var i = -1; i < (maps ? maps.length : 0); i++) { + var map$$1 = i < 0 ? measure.map : maps[i]; + for (var j = 0; j < map$$1.length; j += 3) { + var curNode = map$$1[j + 2]; + if (curNode == textNode || curNode == topNode) { + var line = lineNo(i < 0 ? lineView.line : lineView.rest[i]); + var ch = map$$1[j] + offset; + if (offset < 0 || curNode != textNode) { ch = map$$1[j + (offset ? 1 : 0)]; } + return Pos(line, ch) + } + } + } + } + var found = find(textNode, topNode, offset); + if (found) { return badPos(found, bad) } + + // FIXME this is all really shaky. might handle the few cases it needs to handle, but likely to cause problems + for (var after = topNode.nextSibling, dist = textNode ? textNode.nodeValue.length - offset : 0; after; after = after.nextSibling) { + found = find(after, after.firstChild, 0); + if (found) + { return badPos(Pos(found.line, found.ch - dist), bad) } + else + { dist += after.textContent.length; } + } + for (var before = topNode.previousSibling, dist$1 = offset; before; before = before.previousSibling) { + found = find(before, before.firstChild, -1); + if (found) + { return badPos(Pos(found.line, found.ch + dist$1), bad) } + else + { dist$1 += before.textContent.length; } + } + } + + // TEXTAREA INPUT STYLE + + var TextareaInput = function(cm) { + this.cm = cm; + // See input.poll and input.reset + this.prevInput = ""; + + // Flag that indicates whether we expect input to appear real soon + // now (after some event like 'keypress' or 'input') and are + // polling intensively. + this.pollingFast = false; + // Self-resetting timeout for the poller + this.polling = new Delayed(); + // Used to work around IE issue with selection being forgotten when focus moves away from textarea + this.hasSelection = false; + this.composing = null; + }; + + TextareaInput.prototype.init = function (display) { + var this$1 = this; + + var input = this, cm = this.cm; + this.createField(display); + var te = this.textarea; + + display.wrapper.insertBefore(this.wrapper, display.wrapper.firstChild); + + // Needed to hide big blue blinking cursor on Mobile Safari (doesn't seem to work in iOS 8 anymore) + if (ios) { te.style.width = "0px"; } + + on(te, "input", function () { + if (ie && ie_version >= 9 && this$1.hasSelection) { this$1.hasSelection = null; } + input.poll(); + }); + + on(te, "paste", function (e) { + if (signalDOMEvent(cm, e) || handlePaste(e, cm)) { return } + + cm.state.pasteIncoming = +new Date; + input.fastPoll(); + }); + + function prepareCopyCut(e) { + if (signalDOMEvent(cm, e)) { return } + if (cm.somethingSelected()) { + setLastCopied({lineWise: false, text: cm.getSelections()}); + } else if (!cm.options.lineWiseCopyCut) { + return + } else { + var ranges = copyableRanges(cm); + setLastCopied({lineWise: true, text: ranges.text}); + if (e.type == "cut") { + cm.setSelections(ranges.ranges, null, sel_dontScroll); + } else { + input.prevInput = ""; + te.value = ranges.text.join("\n"); + selectInput(te); + } + } + if (e.type == "cut") { cm.state.cutIncoming = +new Date; } + } + on(te, "cut", prepareCopyCut); + on(te, "copy", prepareCopyCut); + + on(display.scroller, "paste", function (e) { + if (eventInWidget(display, e) || signalDOMEvent(cm, e)) { return } + if (!te.dispatchEvent) { + cm.state.pasteIncoming = +new Date; + input.focus(); + return + } + + // Pass the `paste` event to the textarea so it's handled by its event listener. + var event = new Event("paste"); + event.clipboardData = e.clipboardData; + te.dispatchEvent(event); + }); + + // Prevent normal selection in the editor (we handle our own) + on(display.lineSpace, "selectstart", function (e) { + if (!eventInWidget(display, e)) { e_preventDefault(e); } + }); + + on(te, "compositionstart", function () { + var start = cm.getCursor("from"); + if (input.composing) { input.composing.range.clear(); } + input.composing = { + start: start, + range: cm.markText(start, cm.getCursor("to"), {className: "CodeMirror-composing"}) + }; + }); + on(te, "compositionend", function () { + if (input.composing) { + input.poll(); + input.composing.range.clear(); + input.composing = null; + } + }); + }; + + TextareaInput.prototype.createField = function (_display) { + // Wraps and hides input textarea + this.wrapper = hiddenTextarea(); + // The semihidden textarea that is focused when the editor is + // focused, and receives input. + this.textarea = this.wrapper.firstChild; + }; + + TextareaInput.prototype.prepareSelection = function () { + // Redraw the selection and/or cursor + var cm = this.cm, display = cm.display, doc = cm.doc; + var result = prepareSelection(cm); + + // Move the hidden textarea near the cursor to prevent scrolling artifacts + if (cm.options.moveInputWithCursor) { + var headPos = cursorCoords(cm, doc.sel.primary().head, "div"); + var wrapOff = display.wrapper.getBoundingClientRect(), lineOff = display.lineDiv.getBoundingClientRect(); + result.teTop = Math.max(0, Math.min(display.wrapper.clientHeight - 10, + headPos.top + lineOff.top - wrapOff.top)); + result.teLeft = Math.max(0, Math.min(display.wrapper.clientWidth - 10, + headPos.left + lineOff.left - wrapOff.left)); + } + + return result + }; + + TextareaInput.prototype.showSelection = function (drawn) { + var cm = this.cm, display = cm.display; + removeChildrenAndAdd(display.cursorDiv, drawn.cursors); + removeChildrenAndAdd(display.selectionDiv, drawn.selection); + if (drawn.teTop != null) { + this.wrapper.style.top = drawn.teTop + "px"; + this.wrapper.style.left = drawn.teLeft + "px"; + } + }; + + // Reset the input to correspond to the selection (or to be empty, + // when not typing and nothing is selected) + TextareaInput.prototype.reset = function (typing) { + if (this.contextMenuPending || this.composing) { return } + var cm = this.cm; + if (cm.somethingSelected()) { + this.prevInput = ""; + var content = cm.getSelection(); + this.textarea.value = content; + if (cm.state.focused) { selectInput(this.textarea); } + if (ie && ie_version >= 9) { this.hasSelection = content; } + } else if (!typing) { + this.prevInput = this.textarea.value = ""; + if (ie && ie_version >= 9) { this.hasSelection = null; } + } + }; + + TextareaInput.prototype.getField = function () { return this.textarea }; + + TextareaInput.prototype.supportsTouch = function () { return false }; + + TextareaInput.prototype.focus = function () { + if (this.cm.options.readOnly != "nocursor" && (!mobile || activeElt() != this.textarea)) { + try { this.textarea.focus(); } + catch (e) {} // IE8 will throw if the textarea is display: none or not in DOM + } + }; + + TextareaInput.prototype.blur = function () { this.textarea.blur(); }; + + TextareaInput.prototype.resetPosition = function () { + this.wrapper.style.top = this.wrapper.style.left = 0; + }; + + TextareaInput.prototype.receivedFocus = function () { this.slowPoll(); }; + + // Poll for input changes, using the normal rate of polling. This + // runs as long as the editor is focused. + TextareaInput.prototype.slowPoll = function () { + var this$1 = this; + + if (this.pollingFast) { return } + this.polling.set(this.cm.options.pollInterval, function () { + this$1.poll(); + if (this$1.cm.state.focused) { this$1.slowPoll(); } + }); + }; + + // When an event has just come in that is likely to add or change + // something in the input textarea, we poll faster, to ensure that + // the change appears on the screen quickly. + TextareaInput.prototype.fastPoll = function () { + var missed = false, input = this; + input.pollingFast = true; + function p() { + var changed = input.poll(); + if (!changed && !missed) {missed = true; input.polling.set(60, p);} + else {input.pollingFast = false; input.slowPoll();} + } + input.polling.set(20, p); + }; + + // Read input from the textarea, and update the document to match. + // When something is selected, it is present in the textarea, and + // selected (unless it is huge, in which case a placeholder is + // used). When nothing is selected, the cursor sits after previously + // seen text (can be empty), which is stored in prevInput (we must + // not reset the textarea when typing, because that breaks IME). + TextareaInput.prototype.poll = function () { + var this$1 = this; + + var cm = this.cm, input = this.textarea, prevInput = this.prevInput; + // Since this is called a *lot*, try to bail out as cheaply as + // possible when it is clear that nothing happened. hasSelection + // will be the case when there is a lot of text in the textarea, + // in which case reading its value would be expensive. + if (this.contextMenuPending || !cm.state.focused || + (hasSelection(input) && !prevInput && !this.composing) || + cm.isReadOnly() || cm.options.disableInput || cm.state.keySeq) + { return false } + + var text = input.value; + // If nothing changed, bail. + if (text == prevInput && !cm.somethingSelected()) { return false } + // Work around nonsensical selection resetting in IE9/10, and + // inexplicable appearance of private area unicode characters on + // some key combos in Mac (#2689). + if (ie && ie_version >= 9 && this.hasSelection === text || + mac && /[\uf700-\uf7ff]/.test(text)) { + cm.display.input.reset(); + return false + } + + if (cm.doc.sel == cm.display.selForContextMenu) { + var first = text.charCodeAt(0); + if (first == 0x200b && !prevInput) { prevInput = "\u200b"; } + if (first == 0x21da) { this.reset(); return this.cm.execCommand("undo") } + } + // Find the part of the input that is actually new + var same = 0, l = Math.min(prevInput.length, text.length); + while (same < l && prevInput.charCodeAt(same) == text.charCodeAt(same)) { ++same; } + + runInOp(cm, function () { + applyTextInput(cm, text.slice(same), prevInput.length - same, + null, this$1.composing ? "*compose" : null); + + // Don't leave long text in the textarea, since it makes further polling slow + if (text.length > 1000 || text.indexOf("\n") > -1) { input.value = this$1.prevInput = ""; } + else { this$1.prevInput = text; } + + if (this$1.composing) { + this$1.composing.range.clear(); + this$1.composing.range = cm.markText(this$1.composing.start, cm.getCursor("to"), + {className: "CodeMirror-composing"}); + } + }); + return true + }; + + TextareaInput.prototype.ensurePolled = function () { + if (this.pollingFast && this.poll()) { this.pollingFast = false; } + }; + + TextareaInput.prototype.onKeyPress = function () { + if (ie && ie_version >= 9) { this.hasSelection = null; } + this.fastPoll(); + }; + + TextareaInput.prototype.onContextMenu = function (e) { + var input = this, cm = input.cm, display = cm.display, te = input.textarea; + if (input.contextMenuPending) { input.contextMenuPending(); } + var pos = posFromMouse(cm, e), scrollPos = display.scroller.scrollTop; + if (!pos || presto) { return } // Opera is difficult. + + // Reset the current text selection only if the click is done outside of the selection + // and 'resetSelectionOnContextMenu' option is true. + var reset = cm.options.resetSelectionOnContextMenu; + if (reset && cm.doc.sel.contains(pos) == -1) + { operation(cm, setSelection)(cm.doc, simpleSelection(pos), sel_dontScroll); } + + var oldCSS = te.style.cssText, oldWrapperCSS = input.wrapper.style.cssText; + var wrapperBox = input.wrapper.offsetParent.getBoundingClientRect(); + input.wrapper.style.cssText = "position: static"; + te.style.cssText = "position: absolute; width: 30px; height: 30px;\n top: " + (e.clientY - wrapperBox.top - 5) + "px; left: " + (e.clientX - wrapperBox.left - 5) + "px;\n z-index: 1000; background: " + (ie ? "rgba(255, 255, 255, .05)" : "transparent") + ";\n outline: none; border-width: 0; outline: none; overflow: hidden; opacity: .05; filter: alpha(opacity=5);"; + var oldScrollY; + if (webkit) { oldScrollY = window.scrollY; } // Work around Chrome issue (#2712) + display.input.focus(); + if (webkit) { window.scrollTo(null, oldScrollY); } + display.input.reset(); + // Adds "Select all" to context menu in FF + if (!cm.somethingSelected()) { te.value = input.prevInput = " "; } + input.contextMenuPending = rehide; + display.selForContextMenu = cm.doc.sel; + clearTimeout(display.detectingSelectAll); + + // Select-all will be greyed out if there's nothing to select, so + // this adds a zero-width space so that we can later check whether + // it got selected. + function prepareSelectAllHack() { + if (te.selectionStart != null) { + var selected = cm.somethingSelected(); + var extval = "\u200b" + (selected ? te.value : ""); + te.value = "\u21da"; // Used to catch context-menu undo + te.value = extval; + input.prevInput = selected ? "" : "\u200b"; + te.selectionStart = 1; te.selectionEnd = extval.length; + // Re-set this, in case some other handler touched the + // selection in the meantime. + display.selForContextMenu = cm.doc.sel; + } + } + function rehide() { + if (input.contextMenuPending != rehide) { return } + input.contextMenuPending = false; + input.wrapper.style.cssText = oldWrapperCSS; + te.style.cssText = oldCSS; + if (ie && ie_version < 9) { display.scrollbars.setScrollTop(display.scroller.scrollTop = scrollPos); } + + // Try to detect the user choosing select-all + if (te.selectionStart != null) { + if (!ie || (ie && ie_version < 9)) { prepareSelectAllHack(); } + var i = 0, poll = function () { + if (display.selForContextMenu == cm.doc.sel && te.selectionStart == 0 && + te.selectionEnd > 0 && input.prevInput == "\u200b") { + operation(cm, selectAll)(cm); + } else if (i++ < 10) { + display.detectingSelectAll = setTimeout(poll, 500); + } else { + display.selForContextMenu = null; + display.input.reset(); + } + }; + display.detectingSelectAll = setTimeout(poll, 200); + } + } + + if (ie && ie_version >= 9) { prepareSelectAllHack(); } + if (captureRightClick) { + e_stop(e); + var mouseup = function () { + off(window, "mouseup", mouseup); + setTimeout(rehide, 20); + }; + on(window, "mouseup", mouseup); + } else { + setTimeout(rehide, 50); + } + }; + + TextareaInput.prototype.readOnlyChanged = function (val) { + if (!val) { this.reset(); } + this.textarea.disabled = val == "nocursor"; + }; + + TextareaInput.prototype.setUneditable = function () {}; + + TextareaInput.prototype.needsContentAttribute = false; + + function fromTextArea(textarea, options) { + options = options ? copyObj(options) : {}; + options.value = textarea.value; + if (!options.tabindex && textarea.tabIndex) + { options.tabindex = textarea.tabIndex; } + if (!options.placeholder && textarea.placeholder) + { options.placeholder = textarea.placeholder; } + // Set autofocus to true if this textarea is focused, or if it has + // autofocus and no other element is focused. + if (options.autofocus == null) { + var hasFocus = activeElt(); + options.autofocus = hasFocus == textarea || + textarea.getAttribute("autofocus") != null && hasFocus == document.body; + } + + function save() {textarea.value = cm.getValue();} + + var realSubmit; + if (textarea.form) { + on(textarea.form, "submit", save); + // Deplorable hack to make the submit method do the right thing. + if (!options.leaveSubmitMethodAlone) { + var form = textarea.form; + realSubmit = form.submit; + try { + var wrappedSubmit = form.submit = function () { + save(); + form.submit = realSubmit; + form.submit(); + form.submit = wrappedSubmit; + }; + } catch(e) {} + } + } + + options.finishInit = function (cm) { + cm.save = save; + cm.getTextArea = function () { return textarea; }; + cm.toTextArea = function () { + cm.toTextArea = isNaN; // Prevent this from being ran twice + save(); + textarea.parentNode.removeChild(cm.getWrapperElement()); + textarea.style.display = ""; + if (textarea.form) { + off(textarea.form, "submit", save); + if (!options.leaveSubmitMethodAlone && typeof textarea.form.submit == "function") + { textarea.form.submit = realSubmit; } + } + }; + }; + + textarea.style.display = "none"; + var cm = CodeMirror(function (node) { return textarea.parentNode.insertBefore(node, textarea.nextSibling); }, + options); + return cm + } + + function addLegacyProps(CodeMirror) { + CodeMirror.off = off; + CodeMirror.on = on; + CodeMirror.wheelEventPixels = wheelEventPixels; + CodeMirror.Doc = Doc; + CodeMirror.splitLines = splitLinesAuto; + CodeMirror.countColumn = countColumn; + CodeMirror.findColumn = findColumn; + CodeMirror.isWordChar = isWordCharBasic; + CodeMirror.Pass = Pass; + CodeMirror.signal = signal; + CodeMirror.Line = Line; + CodeMirror.changeEnd = changeEnd; + CodeMirror.scrollbarModel = scrollbarModel; + CodeMirror.Pos = Pos; + CodeMirror.cmpPos = cmp; + CodeMirror.modes = modes; + CodeMirror.mimeModes = mimeModes; + CodeMirror.resolveMode = resolveMode; + CodeMirror.getMode = getMode; + CodeMirror.modeExtensions = modeExtensions; + CodeMirror.extendMode = extendMode; + CodeMirror.copyState = copyState; + CodeMirror.startState = startState; + CodeMirror.innerMode = innerMode; + CodeMirror.commands = commands; + CodeMirror.keyMap = keyMap; + CodeMirror.keyName = keyName; + CodeMirror.isModifierKey = isModifierKey; + CodeMirror.lookupKey = lookupKey; + CodeMirror.normalizeKeyMap = normalizeKeyMap; + CodeMirror.StringStream = StringStream; + CodeMirror.SharedTextMarker = SharedTextMarker; + CodeMirror.TextMarker = TextMarker; + CodeMirror.LineWidget = LineWidget; + CodeMirror.e_preventDefault = e_preventDefault; + CodeMirror.e_stopPropagation = e_stopPropagation; + CodeMirror.e_stop = e_stop; + CodeMirror.addClass = addClass; + CodeMirror.contains = contains; + CodeMirror.rmClass = rmClass; + CodeMirror.keyNames = keyNames; + } + + // EDITOR CONSTRUCTOR + + defineOptions(CodeMirror); + + addEditorMethods(CodeMirror); + + // Set up methods on CodeMirror's prototype to redirect to the editor's document. + var dontDelegate = "iter insert remove copy getEditor constructor".split(" "); + for (var prop in Doc.prototype) { if (Doc.prototype.hasOwnProperty(prop) && indexOf(dontDelegate, prop) < 0) + { CodeMirror.prototype[prop] = (function(method) { + return function() {return method.apply(this.doc, arguments)} + })(Doc.prototype[prop]); } } + + eventMixin(Doc); + CodeMirror.inputStyles = {"textarea": TextareaInput, "contenteditable": ContentEditableInput}; + + // Extra arguments are stored as the mode's dependencies, which is + // used by (legacy) mechanisms like loadmode.js to automatically + // load a mode. (Preferred mechanism is the require/define calls.) + CodeMirror.defineMode = function(name/*, mode, …*/) { + if (!CodeMirror.defaults.mode && name != "null") { CodeMirror.defaults.mode = name; } + defineMode.apply(this, arguments); + }; + + CodeMirror.defineMIME = defineMIME; + + // Minimal default mode. + CodeMirror.defineMode("null", function () { return ({token: function (stream) { return stream.skipToEnd(); }}); }); + CodeMirror.defineMIME("text/plain", "null"); + + // EXTENSIONS + + CodeMirror.defineExtension = function (name, func) { + CodeMirror.prototype[name] = func; + }; + CodeMirror.defineDocExtension = function (name, func) { + Doc.prototype[name] = func; + }; + + CodeMirror.fromTextArea = fromTextArea; + + addLegacyProps(CodeMirror); + + CodeMirror.version = "5.49.0"; + + return CodeMirror; + +}))); diff --git a/web/public/js/codeEditor/comment.js b/web/public/js/codeEditor/comment.js new file mode 100644 index 0000000..319fc8a --- /dev/null +++ b/web/public/js/codeEditor/comment.js @@ -0,0 +1,211 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/5/LICENSE + +(function (mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function (CodeMirror) { + "use strict"; + + var noOptions = {}; + var nonWS = /[^\s\u00a0]/; + var Pos = CodeMirror.Pos, cmp = CodeMirror.cmpPos; + + function firstNonWS(str) { + var found = str.search(nonWS); + return found == -1 ? 0 : found; + } + + CodeMirror.commands.toggleComment = function (cm) { + cm.toggleComment(); + }; + + CodeMirror.defineExtension("toggleComment", function (options) { + if (!options) options = noOptions; + var cm = this; + var minLine = Infinity, ranges = this.listSelections(), mode = null; + for (var i = ranges.length - 1; i >= 0; i--) { + var from = ranges[i].from(), to = ranges[i].to(); + if (from.line >= minLine) continue; + if (to.line >= minLine) to = Pos(minLine, 0); + minLine = from.line; + if (mode == null) { + if (cm.uncomment(from, to, options)) mode = "un"; + else { cm.lineComment(from, to, options); mode = "line"; } + } else if (mode == "un") { + cm.uncomment(from, to, options); + } else { + cm.lineComment(from, to, options); + } + } + }); + + // Rough heuristic to try and detect lines that are part of multi-line string + function probablyInsideString(cm, pos, line) { + return /\bstring\b/.test(cm.getTokenTypeAt(Pos(pos.line, 0))) && !/^[\'\"\`]/.test(line); + } + + function getMode(cm, pos) { + var mode = cm.getMode(); + return mode.useInnerComments === false || !mode.innerMode ? mode : cm.getModeAt(pos); + } + + CodeMirror.defineExtension("lineComment", function (from, to, options) { + if (!options) options = noOptions; + var self = this, mode = getMode(self, from); + var firstLine = self.getLine(from.line); + if (firstLine == null || probablyInsideString(self, from, firstLine)) return; + + var commentString = options.lineComment || mode.lineComment; + if (!commentString) { + if (options.blockCommentStart || mode.blockCommentStart) { + options.fullLines = true; + self.blockComment(from, to, options); + } + return; + } + + var end = Math.min(to.ch != 0 || to.line == from.line ? to.line + 1 : to.line, self.lastLine() + 1); + var pad = options.padding == null ? " " : options.padding; + var blankLines = options.commentBlankLines || from.line == to.line; + + self.operation(function () { + if (options.indent) { + var baseString = null; + for (var i = from.line; i < end; ++i) { + var line = self.getLine(i); + var whitespace = line.search(nonWS) === -1 ? line : line.slice(0, firstNonWS(line)); + if (baseString == null || baseString.length > whitespace.length) { + baseString = whitespace; + } + } + for (var i = from.line; i < end; ++i) { + var line = self.getLine(i), cut = baseString.length; + if (!blankLines && !nonWS.test(line)) continue; + if (line.slice(0, cut) != baseString) cut = firstNonWS(line); + self.replaceRange(baseString + commentString + pad, Pos(i, 0), Pos(i, cut)); + } + } else { + for (var i = from.line; i < end; ++i) { + if (blankLines || nonWS.test(self.getLine(i))) + self.replaceRange(commentString + pad, Pos(i, 0)); + } + } + }); + }); + + CodeMirror.defineExtension("blockComment", function (from, to, options) { + if (!options) options = noOptions; + var self = this, mode = getMode(self, from); + var startString = options.blockCommentStart || mode.blockCommentStart; + var endString = options.blockCommentEnd || mode.blockCommentEnd; + if (!startString || !endString) { + if ((options.lineComment || mode.lineComment) && options.fullLines != false) + self.lineComment(from, to, options); + return; + } + if (/\bcomment\b/.test(self.getTokenTypeAt(Pos(from.line, 0)))) return; + + var end = Math.min(to.line, self.lastLine()); + if (end != from.line && to.ch == 0 && nonWS.test(self.getLine(end))) --end; + + var pad = options.padding == null ? " " : options.padding; + if (from.line > end) return; + + self.operation(function () { + if (options.fullLines != false) { + var lastLineHasText = nonWS.test(self.getLine(end)); + self.replaceRange(pad + endString, Pos(end)); + self.replaceRange(startString + pad, Pos(from.line, 0)); + var lead = options.blockCommentLead || mode.blockCommentLead; + if (lead != null) for (var i = from.line + 1; i <= end; ++i) + if (i != end || lastLineHasText) + self.replaceRange(lead + pad, Pos(i, 0)); + } else { + var atCursor = cmp(self.getCursor("to"), to) == 0, empty = !self.somethingSelected(); + self.replaceRange(endString, to); + if (atCursor) self.setSelection(empty ? to : self.getCursor("from"), to); + self.replaceRange(startString, from); + } + }); + }); + + CodeMirror.defineExtension("uncomment", function (from, to, options) { + if (!options) options = noOptions; + var self = this, mode = getMode(self, from); + var end = Math.min(to.ch != 0 || to.line == from.line ? to.line : to.line - 1, self.lastLine()), start = Math.min(from.line, end); + + // Try finding line comments + var lineString = options.lineComment || mode.lineComment, lines = []; + var pad = options.padding == null ? " " : options.padding, didSomething; + lineComment: { + if (!lineString) break lineComment; + for (var i = start; i <= end; ++i) { + var line = self.getLine(i); + var found = line.indexOf(lineString); + if (found > -1 && !/comment/.test(self.getTokenTypeAt(Pos(i, found + 1)))) found = -1; + if (found == -1 && nonWS.test(line)) break lineComment; + if (found > -1 && nonWS.test(line.slice(0, found))) break lineComment; + lines.push(line); + } + self.operation(function () { + for (var i = start; i <= end; ++i) { + var line = lines[i - start]; + var pos = line.indexOf(lineString), endPos = pos + lineString.length; + if (pos < 0) continue; + if (line.slice(endPos, endPos + pad.length) == pad) endPos += pad.length; + didSomething = true; + self.replaceRange("", Pos(i, pos), Pos(i, endPos)); + } + }); + if (didSomething) return true; + } + + // Try block comments + var startString = options.blockCommentStart || mode.blockCommentStart; + var endString = options.blockCommentEnd || mode.blockCommentEnd; + if (!startString || !endString) return false; + var lead = options.blockCommentLead || mode.blockCommentLead; + var startLine = self.getLine(start), open = startLine.indexOf(startString); + if (open == -1) return false; + var endLine = end == start ? startLine : self.getLine(end); + var close = endLine.indexOf(endString, end == start ? open + startString.length : 0); + var insideStart = Pos(start, open + 1), insideEnd = Pos(end, close + 1); + if (close == -1 || + !/comment/.test(self.getTokenTypeAt(insideStart)) || + !/comment/.test(self.getTokenTypeAt(insideEnd)) || + self.getRange(insideStart, insideEnd, "\n").indexOf(endString) > -1) + return false; + + // Avoid killing block comments completely outside the selection. + // Positions of the last startString before the start of the selection, and the first endString after it. + var lastStart = startLine.lastIndexOf(startString, from.ch); + var firstEnd = lastStart == -1 ? -1 : startLine.slice(0, from.ch).indexOf(endString, lastStart + startString.length); + if (lastStart != -1 && firstEnd != -1 && firstEnd + endString.length != from.ch) return false; + // Positions of the first endString after the end of the selection, and the last startString before it. + firstEnd = endLine.indexOf(endString, to.ch); + var almostLastStart = endLine.slice(to.ch).lastIndexOf(startString, firstEnd - to.ch); + lastStart = (firstEnd == -1 || almostLastStart == -1) ? -1 : to.ch + almostLastStart; + if (firstEnd != -1 && lastStart != -1 && lastStart != to.ch) return false; + + self.operation(function () { + self.replaceRange("", Pos(end, close - (pad && endLine.slice(close - pad.length, close) == pad ? pad.length : 0)), + Pos(end, close + endString.length)); + var openEnd = open + startString.length; + if (pad && startLine.slice(openEnd, openEnd + pad.length) == pad) openEnd += pad.length; + self.replaceRange("", Pos(start, open), Pos(start, openEnd)); + if (lead) for (var i = start + 1; i <= end; ++i) { + var line = self.getLine(i), found = line.indexOf(lead); + if (found == -1 || nonWS.test(line.slice(0, found))) continue; + var foundEnd = found + lead.length; + if (pad && line.slice(foundEnd, foundEnd + pad.length) == pad) foundEnd += pad.length; + self.replaceRange("", Pos(i, found), Pos(i, foundEnd)); + } + }); + return true; + }); +}); diff --git a/web/public/js/codeEditor/dialog.js b/web/public/js/codeEditor/dialog.js new file mode 100644 index 0000000..dac20e0 --- /dev/null +++ b/web/public/js/codeEditor/dialog.js @@ -0,0 +1,163 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/5/LICENSE + +// Open simple dialogs on top of an editor. Relies on dialog.css. + +(function (mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function (CodeMirror) { + function dialogDiv(cm, template, bottom) { + var wrap = cm.getWrapperElement(); + var dialog; + dialog = wrap.appendChild(document.createElement("div")); + if (bottom) + dialog.className = "CodeMirror-dialog CodeMirror-dialog-bottom"; + else + dialog.className = "CodeMirror-dialog CodeMirror-dialog-top"; + + if (typeof template == "string") { + dialog.innerHTML = template; + } else { // Assuming it's a detached DOM element. + dialog.appendChild(template); + } + CodeMirror.addClass(wrap, 'dialog-opened'); + return dialog; + } + + function closeNotification(cm, newVal) { + if (cm.state.currentNotificationClose) + cm.state.currentNotificationClose(); + cm.state.currentNotificationClose = newVal; + } + + CodeMirror.defineExtension("openDialog", function (template, callback, options) { + if (!options) options = {}; + + closeNotification(this, null); + + var dialog = dialogDiv(this, template, options.bottom); + var closed = false, me = this; + function close(newVal) { + if (typeof newVal == 'string') { + inp.value = newVal; + } else { + if (closed) return; + closed = true; + CodeMirror.rmClass(dialog.parentNode, 'dialog-opened'); + dialog.parentNode.removeChild(dialog); + me.focus(); + + if (options.onClose) options.onClose(dialog); + } + } + + var inp = dialog.getElementsByTagName("input")[0], button; + if (inp) { + inp.focus(); + + if (options.value) { + inp.value = options.value; + if (options.selectValueOnOpen !== false) { + inp.select(); + } + } + + if (options.onInput) + CodeMirror.on(inp, "input", function (e) { options.onInput(e, inp.value, close); }); + if (options.onKeyUp) + CodeMirror.on(inp, "keyup", function (e) { options.onKeyUp(e, inp.value, close); }); + + CodeMirror.on(inp, "keydown", function (e) { + if (options && options.onKeyDown && options.onKeyDown(e, inp.value, close)) { return; } + if (e.keyCode == 27 || (options.closeOnEnter !== false && e.keyCode == 13)) { + inp.blur(); + CodeMirror.e_stop(e); + close(); + } + if (e.keyCode == 13) callback(inp.value, e); + }); + + if (options.closeOnBlur !== false) CodeMirror.on(dialog, "focusout", function (evt) { + if (evt.relatedTarget !== null) close(); + }); + } else if (button = dialog.getElementsByTagName("button")[0]) { + CodeMirror.on(button, "click", function () { + close(); + me.focus(); + }); + + if (options.closeOnBlur !== false) CodeMirror.on(button, "blur", close); + + button.focus(); + } + return close; + }); + + CodeMirror.defineExtension("openConfirm", function (template, callbacks, options) { + closeNotification(this, null); + var dialog = dialogDiv(this, template, options && options.bottom); + var buttons = dialog.getElementsByTagName("button"); + var closed = false, me = this, blurring = 1; + function close() { + if (closed) return; + closed = true; + CodeMirror.rmClass(dialog.parentNode, 'dialog-opened'); + dialog.parentNode.removeChild(dialog); + me.focus(); + } + buttons[0].focus(); + for (var i = 0; i < buttons.length; ++i) { + var b = buttons[i]; + (function (callback) { + CodeMirror.on(b, "click", function (e) { + CodeMirror.e_preventDefault(e); + close(); + if (callback) callback(me); + }); + })(callbacks[i]); + CodeMirror.on(b, "blur", function () { + --blurring; + setTimeout(function () { if (blurring <= 0) close(); }, 200); + }); + CodeMirror.on(b, "focus", function () { ++blurring; }); + } + }); + + /* + * openNotification + * Opens a notification, that can be closed with an optional timer + * (default 5000ms timer) and always closes on click. + * + * If a notification is opened while another is opened, it will close the + * currently opened one and open the new one immediately. + */ + CodeMirror.defineExtension("openNotification", function (template, options) { + closeNotification(this, close); + var dialog = dialogDiv(this, template, options && options.bottom); + var closed = false, doneTimer; + var duration = options && typeof options.duration !== "undefined" ? options.duration : 5000; + + function close() { + if (closed) return; + closed = true; + clearTimeout(doneTimer); + CodeMirror.rmClass(dialog.parentNode, 'dialog-opened'); + dialog.parentNode.removeChild(dialog); + } + + CodeMirror.on(dialog, 'click', function (e) { + CodeMirror.e_preventDefault(e); + close(); + }); + + if (duration) + doneTimer = setTimeout(close, duration); + + return close; + }); +}); diff --git a/web/public/js/codeEditor/mode/fivem-cfg.js b/web/public/js/codeEditor/mode/fivem-cfg.js new file mode 100644 index 0000000..df261f4 --- /dev/null +++ b/web/public/js/codeEditor/mode/fivem-cfg.js @@ -0,0 +1,32 @@ +// CodeMirror fivem-cfg syntax highlight +// Written by Tabarra for https://github.com/tabarra/txAdmin + +CodeMirror.defineSimpleMode('fivem-cfg', { + // The start state contains the rules that are intially used + start: [ + // The regex matches the token, the token property contains the type + {regex: /(["'])(?:[^\\]|\\.)*?(?:\1|$)/, token: 'string'}, + + // Rules are matched in the order in which they appear, so there is + // no ambiguity between this one and the one above + {regex: /(?:start|stop|ensure|restart|refresh|exec|quit|set|seta|setr|sets)\b/i, token: 'def'}, + {regex: /(?:endpoint_add_tcp|endpoint_add_udp|load_server_icon|sv_authMaxVariance|sv_authMinTrust|sv_endpointPrivacy|sv_hostname|sv_licenseKey|sv_master1|sv_maxClients|rcon_password|sv_scriptHookAllowed|gamename|onesync|sv_enforceGameBuild)\b/i, token: 'keyword'}, + {regex: /(?:add_ace|add_principal|remove_ace|remove_principal|test_ace)\b/i, token: 'variable-2'}, + {regex: /banner_connecting|banner_detail|locale|steam_webApiKey|tags|mysql_connection_string|sv_projectName|sv_projectDesc/i, token: 'atom'}, + {regex: /0x[a-f\d]+|[-+]?(?:\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/i, token: 'number'}, + {regex: /\/\/.*/, token: 'comment'}, + {regex: /#.*/, token: 'comment'}, + {regex: /\/(?:[^\\]|\\.)*?\//, token: 'variable-3'}, + + // A next property will cause the mode to move to a different state + {regex: /\/\*/, token: 'comment', next: 'comment'}, + + {regex: /[a-z$][\w$]*/, token: 'variable'}, + ], + + // The multi-line comment state. + comment: [ + {regex: /.*?\*\//, token: 'comment', next: 'start'}, + {regex: /.*/, token: 'comment'}, + ], +}); diff --git a/web/public/js/codeEditor/mode/simple.js b/web/public/js/codeEditor/mode/simple.js new file mode 100644 index 0000000..655f991 --- /dev/null +++ b/web/public/js/codeEditor/mode/simple.js @@ -0,0 +1,216 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { + "use strict"; + + CodeMirror.defineSimpleMode = function(name, states) { + CodeMirror.defineMode(name, function(config) { + return CodeMirror.simpleMode(config, states); + }); + }; + + CodeMirror.simpleMode = function(config, states) { + ensureState(states, "start"); + var states_ = {}, meta = states.meta || {}, hasIndentation = false; + for (var state in states) if (state != meta && states.hasOwnProperty(state)) { + var list = states_[state] = [], orig = states[state]; + for (var i = 0; i < orig.length; i++) { + var data = orig[i]; + list.push(new Rule(data, states)); + if (data.indent || data.dedent) hasIndentation = true; + } + } + var mode = { + startState: function() { + return {state: "start", pending: null, + local: null, localState: null, + indent: hasIndentation ? [] : null}; + }, + copyState: function(state) { + var s = {state: state.state, pending: state.pending, + local: state.local, localState: null, + indent: state.indent && state.indent.slice(0)}; + if (state.localState) + s.localState = CodeMirror.copyState(state.local.mode, state.localState); + if (state.stack) + s.stack = state.stack.slice(0); + for (var pers = state.persistentStates; pers; pers = pers.next) + s.persistentStates = {mode: pers.mode, + spec: pers.spec, + state: pers.state == state.localState ? s.localState : CodeMirror.copyState(pers.mode, pers.state), + next: s.persistentStates}; + return s; + }, + token: tokenFunction(states_, config), + innerMode: function(state) { return state.local && {mode: state.local.mode, state: state.localState}; }, + indent: indentFunction(states_, meta) + }; + if (meta) for (var prop in meta) if (meta.hasOwnProperty(prop)) + mode[prop] = meta[prop]; + return mode; + }; + + function ensureState(states, name) { + if (!states.hasOwnProperty(name)) + throw new Error("Undefined state " + name + " in simple mode"); + } + + function toRegex(val, caret) { + if (!val) return /(?:)/; + var flags = ""; + if (val instanceof RegExp) { + if (val.ignoreCase) flags = "i"; + val = val.source; + } else { + val = String(val); + } + return new RegExp((caret === false ? "" : "^") + "(?:" + val + ")", flags); + } + + function asToken(val) { + if (!val) return null; + if (val.apply) return val + if (typeof val == "string") return val.replace(/\./g, " "); + var result = []; + for (var i = 0; i < val.length; i++) + result.push(val[i] && val[i].replace(/\./g, " ")); + return result; + } + + function Rule(data, states) { + if (data.next || data.push) ensureState(states, data.next || data.push); + this.regex = toRegex(data.regex); + this.token = asToken(data.token); + this.data = data; + } + + function tokenFunction(states, config) { + return function(stream, state) { + if (state.pending) { + var pend = state.pending.shift(); + if (state.pending.length == 0) state.pending = null; + stream.pos += pend.text.length; + return pend.token; + } + + if (state.local) { + if (state.local.end && stream.match(state.local.end)) { + var tok = state.local.endToken || null; + state.local = state.localState = null; + return tok; + } else { + var tok = state.local.mode.token(stream, state.localState), m; + if (state.local.endScan && (m = state.local.endScan.exec(stream.current()))) + stream.pos = stream.start + m.index; + return tok; + } + } + + var curState = states[state.state]; + for (var i = 0; i < curState.length; i++) { + var rule = curState[i]; + var matches = (!rule.data.sol || stream.sol()) && stream.match(rule.regex); + if (matches) { + if (rule.data.next) { + state.state = rule.data.next; + } else if (rule.data.push) { + (state.stack || (state.stack = [])).push(state.state); + state.state = rule.data.push; + } else if (rule.data.pop && state.stack && state.stack.length) { + state.state = state.stack.pop(); + } + + if (rule.data.mode) + enterLocalMode(config, state, rule.data.mode, rule.token); + if (rule.data.indent) + state.indent.push(stream.indentation() + config.indentUnit); + if (rule.data.dedent) + state.indent.pop(); + var token = rule.token + if (token && token.apply) token = token(matches) + if (matches.length > 2 && rule.token && typeof rule.token != "string") { + state.pending = []; + for (var j = 2; j < matches.length; j++) + if (matches[j]) + state.pending.push({text: matches[j], token: rule.token[j - 1]}); + stream.backUp(matches[0].length - (matches[1] ? matches[1].length : 0)); + return token[0]; + } else if (token && token.join) { + return token[0]; + } else { + return token; + } + } + } + stream.next(); + return null; + }; + } + + function cmp(a, b) { + if (a === b) return true; + if (!a || typeof a != "object" || !b || typeof b != "object") return false; + var props = 0; + for (var prop in a) if (a.hasOwnProperty(prop)) { + if (!b.hasOwnProperty(prop) || !cmp(a[prop], b[prop])) return false; + props++; + } + for (var prop in b) if (b.hasOwnProperty(prop)) props--; + return props == 0; + } + + function enterLocalMode(config, state, spec, token) { + var pers; + if (spec.persistent) for (var p = state.persistentStates; p && !pers; p = p.next) + if (spec.spec ? cmp(spec.spec, p.spec) : spec.mode == p.mode) pers = p; + var mode = pers ? pers.mode : spec.mode || CodeMirror.getMode(config, spec.spec); + var lState = pers ? pers.state : CodeMirror.startState(mode); + if (spec.persistent && !pers) + state.persistentStates = {mode: mode, spec: spec.spec, state: lState, next: state.persistentStates}; + + state.localState = lState; + state.local = {mode: mode, + end: spec.end && toRegex(spec.end), + endScan: spec.end && spec.forceEnd !== false && toRegex(spec.end, false), + endToken: token && token.join ? token[token.length - 1] : token}; + } + + function indexOf(val, arr) { + for (var i = 0; i < arr.length; i++) if (arr[i] === val) return true; + } + + function indentFunction(states, meta) { + return function(state, textAfter, line) { + if (state.local && state.local.mode.indent) + return state.local.mode.indent(state.localState, textAfter, line); + if (state.indent == null || state.local || meta.dontIndentStates && indexOf(state.state, meta.dontIndentStates) > -1) + return CodeMirror.Pass; + + var pos = state.indent.length - 1, rules = states[state.state]; + scan: for (;;) { + for (var i = 0; i < rules.length; i++) { + var rule = rules[i]; + if (rule.data.dedent && rule.data.dedentIfLineStart !== false) { + var m = rule.regex.exec(textAfter); + if (m && m[0]) { + pos--; + if (rule.next || rule.push) rules = states[rule.next || rule.push]; + textAfter = textAfter.slice(m[0].length); + continue scan; + } + } + } + break; + } + return pos < 0 ? 0 : state.indent[pos]; + }; + } +}); diff --git a/web/public/js/codeEditor/mode/yaml.js b/web/public/js/codeEditor/mode/yaml.js new file mode 100644 index 0000000..cd3dfb9 --- /dev/null +++ b/web/public/js/codeEditor/mode/yaml.js @@ -0,0 +1,120 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); + })(function(CodeMirror) { + "use strict"; + + CodeMirror.defineMode("yaml", function() { + + var cons = ['true', 'false', 'on', 'off', 'yes', 'no']; + var keywordRegex = new RegExp("\\b(("+cons.join(")|(")+"))$", 'i'); + + return { + token: function(stream, state) { + var ch = stream.peek(); + var esc = state.escaped; + state.escaped = false; + /* comments */ + if (ch == "#" && (stream.pos == 0 || /\s/.test(stream.string.charAt(stream.pos - 1)))) { + stream.skipToEnd(); + return "comment"; + } + + if (stream.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/)) + return "string"; + + if (state.literal && stream.indentation() > state.keyCol) { + stream.skipToEnd(); return "string"; + } else if (state.literal) { state.literal = false; } + if (stream.sol()) { + state.keyCol = 0; + state.pair = false; + state.pairStart = false; + /* document start */ + if(stream.match(/---/)) { return "def"; } + /* document end */ + if (stream.match(/\.\.\./)) { return "def"; } + /* array list item */ + if (stream.match(/\s*-\s+/)) { return 'meta'; } + } + /* inline pairs/lists */ + if (stream.match(/^(\{|\}|\[|\])/)) { + if (ch == '{') + state.inlinePairs++; + else if (ch == '}') + state.inlinePairs--; + else if (ch == '[') + state.inlineList++; + else + state.inlineList--; + return 'meta'; + } + + /* list seperator */ + if (state.inlineList > 0 && !esc && ch == ',') { + stream.next(); + return 'meta'; + } + /* pairs seperator */ + if (state.inlinePairs > 0 && !esc && ch == ',') { + state.keyCol = 0; + state.pair = false; + state.pairStart = false; + stream.next(); + return 'meta'; + } + + /* start of value of a pair */ + if (state.pairStart) { + /* block literals */ + if (stream.match(/^\s*(\||\>)\s*/)) { state.literal = true; return 'meta'; }; + /* references */ + if (stream.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i)) { return 'variable-2'; } + /* numbers */ + if (state.inlinePairs == 0 && stream.match(/^\s*-?[0-9\.\,]+\s?$/)) { return 'number'; } + if (state.inlinePairs > 0 && stream.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/)) { return 'number'; } + /* keywords */ + if (stream.match(keywordRegex)) { return 'keyword'; } + } + + /* pairs (associative arrays) -> key */ + if (!state.pair && stream.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^,\[\]{}#&*!|>'"%@`])[^#]*?(?=\s*:($|\s))/)) { + state.pair = true; + state.keyCol = stream.indentation(); + return "atom"; + } + if (state.pair && stream.match(/^:\s*/)) { state.pairStart = true; return 'meta'; } + + /* nothing found, continue */ + state.pairStart = false; + state.escaped = (ch == '\\'); + stream.next(); + return null; + }, + startState: function() { + return { + pair: false, + pairStart: false, + keyCol: 0, + inlinePairs: 0, + inlineList: 0, + literal: false, + escaped: false + }; + }, + lineComment: "#", + fold: "indent" + }; + }); + + CodeMirror.defineMIME("text/x-yaml", "yaml"); + CodeMirror.defineMIME("text/yaml", "yaml"); + + }); diff --git a/web/public/js/codeEditor/search.js b/web/public/js/codeEditor/search.js new file mode 100644 index 0000000..b12fc47 --- /dev/null +++ b/web/public/js/codeEditor/search.js @@ -0,0 +1,303 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/5/LICENSE + +// Define search commands. Depends on dialog.js or another +// implementation of the openDialog method. + +// Replace works a little oddly -- it will do the replace on the next +// Ctrl-G (or whatever is bound to findNext) press. You prevent a +// replace by making sure the match is no longer selected when hitting +// Ctrl-G. + +(function (mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror"), require("./searchcursor"), require("../dialog/dialog")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror", "./searchcursor", "../dialog/dialog"], mod); + else // Plain browser env + mod(CodeMirror); +})(function (CodeMirror) { + "use strict"; + + // default search panel location + CodeMirror.defineOption("search", { bottom: false }); + + function searchOverlay(query, caseInsensitive) { + if (typeof query == "string") + query = new RegExp(query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"), caseInsensitive ? "gi" : "g"); + else if (!query.global) + query = new RegExp(query.source, query.ignoreCase ? "gi" : "g"); + + return { + token: function (stream) { + query.lastIndex = stream.pos; + var match = query.exec(stream.string); + if (match && match.index == stream.pos) { + stream.pos += match[0].length || 1; + return "searching"; + } else if (match) { + stream.pos = match.index; + } else { + stream.skipToEnd(); + } + } + }; + } + + function SearchState() { + this.posFrom = this.posTo = this.lastQuery = this.query = null; + this.overlay = null; + } + + function getSearchState(cm) { + return cm.state.search || (cm.state.search = new SearchState()); + } + + function queryCaseInsensitive(query) { + return typeof query == "string" && query == query.toLowerCase(); + } + + function getSearchCursor(cm, query, pos) { + // Heuristic: if the query string is all lowercase, do a case insensitive search. + return cm.getSearchCursor(query, pos, { caseFold: queryCaseInsensitive(query), multiline: true }); + } + + function persistentDialog(cm, text, deflt, onEnter, onKeyDown) { + cm.openDialog(text, onEnter, { + value: deflt, + selectValueOnOpen: true, + closeOnEnter: false, + onClose: function () { clearSearch(cm); }, + onKeyDown: onKeyDown, + bottom: cm.options.search.bottom + }); + } + + function dialog(cm, text, shortText, deflt, f) { + if (cm.openDialog) cm.openDialog(text, f, { value: deflt, selectValueOnOpen: true, bottom: cm.options.search.bottom }); + else f(prompt(shortText, deflt)); + } + + function confirmDialog(cm, text, shortText, fs) { + if (cm.openConfirm) cm.openConfirm(text, fs); + else if (confirm(shortText)) fs[0](); + } + + function parseString(string) { + return string.replace(/\\([nrt\\])/g, function (match, ch) { + if (ch == "n") return "\n"; + if (ch == "r") return "\r"; + if (ch == "t") return "\t"; + if (ch == "\\") return "\\"; + return match; + }); + } + + function parseQuery(query) { + var isRE = query.match(/^\/(.*)\/([a-z]*)$/); + if (isRE) { + try { query = new RegExp(isRE[1], isRE[2].indexOf("i") == -1 ? "" : "i"); } + catch (e) { } // Not a regular expression after all, do a string search + } else { + query = parseString(query); + } + if (typeof query == "string" ? query == "" : query.test("")) + query = /x^/; + return query; + } + + function startSearch(cm, state, query) { + state.queryText = query; + state.query = parseQuery(query); + cm.removeOverlay(state.overlay, queryCaseInsensitive(state.query)); + state.overlay = searchOverlay(state.query, queryCaseInsensitive(state.query)); + cm.addOverlay(state.overlay); + if (cm.showMatchesOnScrollbar) { + if (state.annotate) { state.annotate.clear(); state.annotate = null; } + state.annotate = cm.showMatchesOnScrollbar(state.query, queryCaseInsensitive(state.query)); + } + } + + function doSearch(cm, rev, persistent, immediate) { + var state = getSearchState(cm); + if (state.query) return findNext(cm, rev); + var q = cm.getSelection() || state.lastQuery; + if (q instanceof RegExp && q.source == "x^") q = null; + if (persistent && cm.openDialog) { + var hiding = null; + var searchNext = function (query, event) { + CodeMirror.e_stop(event); + if (!query) return; + if (query != state.queryText) { + startSearch(cm, state, query); + state.posFrom = state.posTo = cm.getCursor(); + } + if (hiding) hiding.style.opacity = 1; + findNext(cm, event.shiftKey, function (_, to) { + var dialog; + if (to.line < 3 && document.querySelector && + (dialog = cm.display.wrapper.querySelector(".CodeMirror-dialog")) && + dialog.getBoundingClientRect().bottom - 4 > cm.cursorCoords(to, "window").top) + (hiding = dialog).style.opacity = .4; + }); + }; + persistentDialog(cm, getQueryDialog(cm), q, searchNext, function (event, query) { + var keyName = CodeMirror.keyName(event); + var extra = cm.getOption('extraKeys'), cmd = (extra && extra[keyName]) || CodeMirror.keyMap[cm.getOption("keyMap")][keyName]; + if (cmd == "findNext" || cmd == "findPrev" || + cmd == "findPersistentNext" || cmd == "findPersistentPrev") { + CodeMirror.e_stop(event); + startSearch(cm, getSearchState(cm), query); + cm.execCommand(cmd); + } else if (cmd == "find" || cmd == "findPersistent") { + CodeMirror.e_stop(event); + searchNext(query, event); + } + }); + if (immediate && q) { + startSearch(cm, state, q); + findNext(cm, rev); + } + } else { + dialog(cm, getQueryDialog(cm), "Search for:", q, function (query) { + if (query && !state.query) cm.operation(function () { + startSearch(cm, state, query); + state.posFrom = state.posTo = cm.getCursor(); + findNext(cm, rev); + }); + }); + } + } + + function findNext(cm, rev, callback) { + cm.operation(function () { + var state = getSearchState(cm); + var cursor = getSearchCursor(cm, state.query, rev ? state.posFrom : state.posTo); + if (!cursor.find(rev)) { + cursor = getSearchCursor(cm, state.query, rev ? CodeMirror.Pos(cm.lastLine()) : CodeMirror.Pos(cm.firstLine(), 0)); + if (!cursor.find(rev)) return; + } + cm.setSelection(cursor.from(), cursor.to()); + cm.scrollIntoView({ from: cursor.from(), to: cursor.to() }, 20); + state.posFrom = cursor.from(); state.posTo = cursor.to(); + if (callback) callback(cursor.from(), cursor.to()); + }); + } + + function clearSearch(cm) { + cm.operation(function () { + var state = getSearchState(cm); + state.lastQuery = state.query; + if (!state.query) return; + state.query = state.queryText = null; + cm.removeOverlay(state.overlay); + if (state.annotate) { state.annotate.clear(); state.annotate = null; } + }); + } + + function el(tag, attrs) { + var element = tag ? document.createElement(tag) : document.createDocumentFragment(); + for (var key in attrs) { + element[key] = attrs[key]; + } + for (var i = 2; i < arguments.length; i++) { + var child = arguments[i]; + element.appendChild(typeof child == "string" ? document.createTextNode(child) : child); + } + return element; + } + + function getQueryDialog(cm) { + var label = el("label", { className: "CodeMirror-search-label" }, + cm.phrase("Search:"), + el("input", { + type: "text", "style": "width: 10em", className: "CodeMirror-search-field", + id: "CodeMirror-search-field" + })); + label.setAttribute("for", "CodeMirror-search-field"); + return el("", null, label, " ", + el("span", { style: "color: #666", className: "CodeMirror-search-hint" }, + cm.phrase("(Use /re/ syntax for regexp search)"))); + } + function getReplaceQueryDialog(cm) { + return el("", null, " ", + el("input", { type: "text", "style": "width: 10em", className: "CodeMirror-search-field" }), " ", + el("span", { style: "color: #666", className: "CodeMirror-search-hint" }, + cm.phrase("(Use /re/ syntax for regexp search)"))); + } + function getReplacementQueryDialog(cm) { + return el("", null, + el("span", { className: "CodeMirror-search-label" }, cm.phrase("With:")), " ", + el("input", { type: "text", "style": "width: 10em", className: "CodeMirror-search-field" })); + } + function getDoReplaceConfirm(cm) { + return el("", null, + el("span", { className: "CodeMirror-search-label" }, cm.phrase("Replace?")), " ", + el("button", {}, cm.phrase("Yes")), " ", + el("button", {}, cm.phrase("No")), " ", + el("button", {}, cm.phrase("All")), " ", + el("button", {}, cm.phrase("Stop"))); + } + + function replaceAll(cm, query, text) { + cm.operation(function () { + for (var cursor = getSearchCursor(cm, query); cursor.findNext();) { + if (typeof query != "string") { + var match = cm.getRange(cursor.from(), cursor.to()).match(query); + cursor.replace(text.replace(/\$(\d)/g, function (_, i) { return match[i]; })); + } else cursor.replace(text); + } + }); + } + + function replace(cm, all) { + if (cm.getOption("readOnly")) return; + var query = cm.getSelection() || getSearchState(cm).lastQuery; + var dialogText = all ? cm.phrase("Replace all:") : cm.phrase("Replace:"); + var fragment = el("", null, + el("span", { className: "CodeMirror-search-label" }, dialogText), + getReplaceQueryDialog(cm)); + dialog(cm, fragment, dialogText, query, function (query) { + if (!query) return; + query = parseQuery(query); + dialog(cm, getReplacementQueryDialog(cm), cm.phrase("Replace with:"), "", function (text) { + text = parseString(text); + if (all) { + replaceAll(cm, query, text); + } else { + clearSearch(cm); + var cursor = getSearchCursor(cm, query, cm.getCursor("from")); + var advance = function () { + var start = cursor.from(), match; + if (!(match = cursor.findNext())) { + cursor = getSearchCursor(cm, query); + if (!(match = cursor.findNext()) || + (start && cursor.from().line == start.line && cursor.from().ch == start.ch)) return; + } + cm.setSelection(cursor.from(), cursor.to()); + cm.scrollIntoView({ from: cursor.from(), to: cursor.to() }); + confirmDialog(cm, getDoReplaceConfirm(cm), cm.phrase("Replace?"), + [function () { doReplace(match); }, advance, + function () { replaceAll(cm, query, text); }]); + }; + var doReplace = function (match) { + cursor.replace(typeof query == "string" ? text : + text.replace(/\$(\d)/g, function (_, i) { return match[i]; })); + advance(); + }; + advance(); + } + }); + }); + } + + CodeMirror.commands.find = function (cm) { clearSearch(cm); doSearch(cm); }; + CodeMirror.commands.findPersistent = function (cm) { clearSearch(cm); doSearch(cm, false, true); }; + CodeMirror.commands.findPersistentNext = function (cm) { doSearch(cm, false, true, true); }; + CodeMirror.commands.findPersistentPrev = function (cm) { doSearch(cm, true, true, true); }; + CodeMirror.commands.findNext = doSearch; + CodeMirror.commands.findPrev = function (cm) { doSearch(cm, true); }; + CodeMirror.commands.clearSearch = clearSearch; + CodeMirror.commands.replace = replace; + CodeMirror.commands.replaceAll = function (cm) { replace(cm, true); }; +}); diff --git a/web/public/js/codeEditor/searchcursor.js b/web/public/js/codeEditor/searchcursor.js new file mode 100644 index 0000000..94f9009 --- /dev/null +++ b/web/public/js/codeEditor/searchcursor.js @@ -0,0 +1,321 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/5/LICENSE + +(function (mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function (CodeMirror) { + "use strict"; + var Pos = CodeMirror.Pos; + + function regexpFlags(regexp) { + var flags = regexp.flags; + return flags != null ? flags : (regexp.ignoreCase ? "i" : "") + + (regexp.global ? "g" : "") + + (regexp.multiline ? "m" : ""); + } + + function ensureFlags(regexp, flags) { + var current = regexpFlags(regexp), target = current; + for (var i = 0; i < flags.length; i++) if (target.indexOf(flags.charAt(i)) == -1) + target += flags.charAt(i); + return current == target ? regexp : new RegExp(regexp.source, target); + } + + function maybeMultiline(regexp) { + return /\\s|\\n|\n|\\W|\\D|\[\^/.test(regexp.source); + } + + function searchRegexpForward(doc, regexp, start) { + regexp = ensureFlags(regexp, "g"); + for (var line = start.line, ch = start.ch, last = doc.lastLine(); line <= last; line++, ch = 0) { + regexp.lastIndex = ch; + var string = doc.getLine(line), match = regexp.exec(string); + if (match) + return { + from: Pos(line, match.index), + to: Pos(line, match.index + match[0].length), + match: match + }; + } + } + + function searchRegexpForwardMultiline(doc, regexp, start) { + if (!maybeMultiline(regexp)) return searchRegexpForward(doc, regexp, start); + + regexp = ensureFlags(regexp, "gm"); + var string, chunk = 1; + for (var line = start.line, last = doc.lastLine(); line <= last;) { + // This grows the search buffer in exponentially-sized chunks + // between matches, so that nearby matches are fast and don't + // require concatenating the whole document (in case we're + // searching for something that has tons of matches), but at the + // same time, the amount of retries is limited. + for (var i = 0; i < chunk; i++) { + if (line > last) break; + var curLine = doc.getLine(line++); + string = string == null ? curLine : string + "\n" + curLine; + } + chunk = chunk * 2; + regexp.lastIndex = start.ch; + var match = regexp.exec(string); + if (match) { + var before = string.slice(0, match.index).split("\n"), inside = match[0].split("\n"); + var startLine = start.line + before.length - 1, startCh = before[before.length - 1].length; + return { + from: Pos(startLine, startCh), + to: Pos(startLine + inside.length - 1, + inside.length == 1 ? startCh + inside[0].length : inside[inside.length - 1].length), + match: match + }; + } + } + } + + function lastMatchIn(string, regexp, endMargin) { + var match, from = 0; + while (from <= string.length) { + regexp.lastIndex = from; + var newMatch = regexp.exec(string); + if (!newMatch) break; + var end = newMatch.index + newMatch[0].length; + if (end > string.length - endMargin) break; + if (!match || end > match.index + match[0].length) + match = newMatch; + from = newMatch.index + 1; + } + return match; + } + + function searchRegexpBackward(doc, regexp, start) { + regexp = ensureFlags(regexp, "g"); + for (var line = start.line, ch = start.ch, first = doc.firstLine(); line >= first; line--, ch = -1) { + var string = doc.getLine(line); + var match = lastMatchIn(string, regexp, ch < 0 ? 0 : string.length - ch); + if (match) + return { + from: Pos(line, match.index), + to: Pos(line, match.index + match[0].length), + match: match + }; + } + } + + function searchRegexpBackwardMultiline(doc, regexp, start) { + if (!maybeMultiline(regexp)) return searchRegexpBackward(doc, regexp, start); + regexp = ensureFlags(regexp, "gm"); + var string, chunkSize = 1, endMargin = doc.getLine(start.line).length - start.ch; + for (var line = start.line, first = doc.firstLine(); line >= first;) { + for (var i = 0; i < chunkSize && line >= first; i++) { + var curLine = doc.getLine(line--); + string = string == null ? curLine : curLine + "\n" + string; + } + chunkSize *= 2; + + var match = lastMatchIn(string, regexp, endMargin); + if (match) { + var before = string.slice(0, match.index).split("\n"), inside = match[0].split("\n"); + var startLine = line + before.length, startCh = before[before.length - 1].length; + return { + from: Pos(startLine, startCh), + to: Pos(startLine + inside.length - 1, + inside.length == 1 ? startCh + inside[0].length : inside[inside.length - 1].length), + match: match + }; + } + } + } + + var doFold, noFold; + if (String.prototype.normalize) { + doFold = function (str) { return str.normalize("NFD").toLowerCase(); }; + noFold = function (str) { return str.normalize("NFD"); }; + } else { + doFold = function (str) { return str.toLowerCase(); }; + noFold = function (str) { return str; }; + } + + // Maps a position in a case-folded line back to a position in the original line + // (compensating for codepoints increasing in number during folding) + function adjustPos(orig, folded, pos, foldFunc) { + if (orig.length == folded.length) return pos; + for (var min = 0, max = pos + Math.max(0, orig.length - folded.length); ;) { + if (min == max) return min; + var mid = (min + max) >> 1; + var len = foldFunc(orig.slice(0, mid)).length; + if (len == pos) return mid; + else if (len > pos) max = mid; + else min = mid + 1; + } + } + + function searchStringForward(doc, query, start, caseFold) { + // Empty string would match anything and never progress, so we + // define it to match nothing instead. + if (!query.length) return null; + var fold = caseFold ? doFold : noFold; + var lines = fold(query).split(/\r|\n\r?/); + + search: for (var line = start.line, ch = start.ch, last = doc.lastLine() + 1 - lines.length; line <= last; line++, ch = 0) { + var orig = doc.getLine(line).slice(ch), string = fold(orig); + if (lines.length == 1) { + var found = string.indexOf(lines[0]); + if (found == -1) continue search; + var start = adjustPos(orig, string, found, fold) + ch; + return { + from: Pos(line, adjustPos(orig, string, found, fold) + ch), + to: Pos(line, adjustPos(orig, string, found + lines[0].length, fold) + ch) + }; + } else { + var cutFrom = string.length - lines[0].length; + if (string.slice(cutFrom) != lines[0]) continue search; + for (var i = 1; i < lines.length - 1; i++) + if (fold(doc.getLine(line + i)) != lines[i]) continue search; + var end = doc.getLine(line + lines.length - 1), endString = fold(end), lastLine = lines[lines.length - 1]; + if (endString.slice(0, lastLine.length) != lastLine) continue search; + return { + from: Pos(line, adjustPos(orig, string, cutFrom, fold) + ch), + to: Pos(line + lines.length - 1, adjustPos(end, endString, lastLine.length, fold)) + }; + } + } + } + + function searchStringBackward(doc, query, start, caseFold) { + if (!query.length) return null; + var fold = caseFold ? doFold : noFold; + var lines = fold(query).split(/\r|\n\r?/); + + search: for (var line = start.line, ch = start.ch, first = doc.firstLine() - 1 + lines.length; line >= first; line--, ch = -1) { + var orig = doc.getLine(line); + if (ch > -1) orig = orig.slice(0, ch); + var string = fold(orig); + if (lines.length == 1) { + var found = string.lastIndexOf(lines[0]); + if (found == -1) continue search; + return { + from: Pos(line, adjustPos(orig, string, found, fold)), + to: Pos(line, adjustPos(orig, string, found + lines[0].length, fold)) + }; + } else { + var lastLine = lines[lines.length - 1]; + if (string.slice(0, lastLine.length) != lastLine) continue search; + for (var i = 1, start = line - lines.length + 1; i < lines.length - 1; i++) + if (fold(doc.getLine(start + i)) != lines[i]) continue search; + var top = doc.getLine(line + 1 - lines.length), topString = fold(top); + if (topString.slice(topString.length - lines[0].length) != lines[0]) continue search; + return { + from: Pos(line + 1 - lines.length, adjustPos(top, topString, top.length - lines[0].length, fold)), + to: Pos(line, adjustPos(orig, string, lastLine.length, fold)) + }; + } + } + } + + function SearchCursor(doc, query, pos, options) { + this.atOccurrence = false; + this.afterEmptyMatch = false; + this.doc = doc; + pos = pos ? doc.clipPos(pos) : Pos(0, 0); + this.pos = { from: pos, to: pos }; + + var caseFold; + if (typeof options == "object") { + caseFold = options.caseFold; + } else { // Backwards compat for when caseFold was the 4th argument + caseFold = options; + options = null; + } + + if (typeof query == "string") { + if (caseFold == null) caseFold = false; + this.matches = function (reverse, pos) { + return (reverse ? searchStringBackward : searchStringForward)(doc, query, pos, caseFold); + }; + } else { + query = ensureFlags(query, "gm"); + if (!options || options.multiline !== false) + this.matches = function (reverse, pos) { + return (reverse ? searchRegexpBackwardMultiline : searchRegexpForwardMultiline)(doc, query, pos); + }; + else + this.matches = function (reverse, pos) { + return (reverse ? searchRegexpBackward : searchRegexpForward)(doc, query, pos); + }; + } + } + + SearchCursor.prototype = { + findNext: function () { return this.find(false); }, + findPrevious: function () { return this.find(true); }, + + find: function (reverse) { + var head = this.doc.clipPos(reverse ? this.pos.from : this.pos.to); + if (this.afterEmptyMatch && this.atOccurrence) { + // do not return the same 0 width match twice + head = Pos(head.line, head.ch); + if (reverse) { + head.ch--; + if (head.ch < 0) { + head.line--; + head.ch = (this.doc.getLine(head.line) || "").length; + } + } else { + head.ch++; + if (head.ch > (this.doc.getLine(head.line) || "").length) { + head.ch = 0; + head.line++; + } + } + if (CodeMirror.cmpPos(head, this.doc.clipPos(head)) != 0) { + return this.atOccurrence = false; + } + } + var result = this.matches(reverse, head); + this.afterEmptyMatch = result && CodeMirror.cmpPos(result.from, result.to) == 0; + + if (result) { + this.pos = result; + this.atOccurrence = true; + return this.pos.match || true; + } else { + var end = Pos(reverse ? this.doc.firstLine() : this.doc.lastLine() + 1, 0); + this.pos = { from: end, to: end }; + return this.atOccurrence = false; + } + }, + + from: function () { if (this.atOccurrence) return this.pos.from; }, + to: function () { if (this.atOccurrence) return this.pos.to; }, + + replace: function (newText, origin) { + if (!this.atOccurrence) return; + var lines = CodeMirror.splitLines(newText); + this.doc.replaceRange(lines, this.pos.from, this.pos.to, origin); + this.pos.to = Pos(this.pos.from.line + lines.length - 1, + lines[lines.length - 1].length + (lines.length == 1 ? this.pos.from.ch : 0)); + } + }; + + CodeMirror.defineExtension("getSearchCursor", function (query, pos, caseFold) { + return new SearchCursor(this.doc, query, pos, caseFold); + }); + CodeMirror.defineDocExtension("getSearchCursor", function (query, pos, caseFold) { + return new SearchCursor(this, query, pos, caseFold); + }); + + CodeMirror.defineExtension("selectMatches", function (query, caseFold) { + var ranges = []; + var cur = this.getSearchCursor(query, this.getCursor("from"), caseFold); + while (cur.findNext()) { + if (CodeMirror.cmpPos(cur.to(), this.getCursor("to")) > 0) break; + ranges.push({ anchor: cur.from(), head: cur.to() }); + } + if (ranges.length) + this.setSelections(ranges, 0); + }); +}); diff --git a/web/public/js/coreui.bundle.min.js b/web/public/js/coreui.bundle.min.js new file mode 100644 index 0000000..501db25 --- /dev/null +++ b/web/public/js/coreui.bundle.min.js @@ -0,0 +1,12 @@ +/*! + * CoreUI v3.4.0 (https://coreui.io) + * Copyright 2020 creativeLabs Łukasz Holeczek + * Licensed under MIT (https://coreui.io) + */ +!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).coreui=e()}(this,(function(){"use strict";function t(t,e){for(var n=0;n-1||(r=t),[i,o,r]}function q(t,e,n,i,o){if("string"==typeof e&&t){n||(n=i,i=null);var r=U(e,n,i),s=r[0],a=r[1],l=r[2],c=X(t),u=c[l]||(c[l]={}),f=B(u,a,s?n:null);if(f)f.oneOff=f.oneOff&&o;else{var h=Y(a,e.replace(N,"")),d=s?function(t,e,n){return function i(o){for(var r=t.querySelectorAll(e),s=o.target;s&&s!==this;s=s.parentNode)for(var a=r.length;a--;)if(r[a]===s)return o.delegateTarget=s,i.oneOff&&F.off(t,o.type,n),n.apply(s,[o]);return null}}(t,n,i):function(t,e){return function n(i){return i.delegateTarget=t,n.oneOff&&F.off(t,i.type,e),e.apply(t,[i])}}(t,n);d.delegationSelector=s?n:null,d.originalHandler=a,d.oneOff=o,d.uidEvent=h,u[h]=d,t.addEventListener(l,d,s)}}}function Q(t,e,n,i,o){var r=B(e[n],i,o);r&&(t.removeEventListener(n,r,Boolean(o)),delete e[n][r.uidEvent])}var F={on:function(t,e,n,i){q(t,e,n,i,!1)},one:function(t,e,n,i){q(t,e,n,i,!0)},off:function(t,e,n,i){if("string"==typeof e&&t){var o=U(e,n,i),r=o[0],s=o[1],a=o[2],l=a!==e,c=X(t),u="."===e.charAt(0);if("undefined"==typeof s){u&&Object.keys(c).forEach((function(n){!function(t,e,n,i){var o=e[n]||{};Object.keys(o).forEach((function(r){if(r.indexOf(i)>-1){var s=o[r];Q(t,e,n,s.originalHandler,s.delegationSelector)}}))}(t,c,n,e.slice(1))}));var f=c[a]||{};Object.keys(f).forEach((function(n){var i=n.replace(P,"");if(!l||e.indexOf(i)>-1){var o=f[n];Q(t,c,a,o.originalHandler,o.delegationSelector)}}))}else{if(!c||!c[a])return;Q(t,c,a,s,r?n:null)}}},trigger:function(t,e,n){if("string"!=typeof e||!t)return null;var i,o=e.replace(I,""),r=e!==o,s=W.indexOf(o)>-1,a=!0,l=!0,c=!1,u=null;return r&&j&&(i=j.Event(e,n),j(t).trigger(i),a=!i.isPropagationStopped(),l=!i.isImmediatePropagationStopped(),c=i.isDefaultPrevented()),s?(u=document.createEvent("HTMLEvents")).initEvent(o,a,!0):u=new CustomEvent(e,{bubbles:a,cancelable:!0}),"undefined"!=typeof n&&Object.keys(n).forEach((function(t){Object.defineProperty(u,t,{get:function(){return n[t]}})})),c&&(u.preventDefault(),x||Object.defineProperty(u,"defaultPrevented",{get:function(){return!0}})),l&&t.dispatchEvent(u),u.defaultPrevented&&"undefined"!=typeof i&&i.preventDefault(),u}},V="asyncLoad",z="coreui.asyncLoad",K="c-active",$="c-show",G=".c-sidebar-nav-dropdown",J=".c-xhr-link, .c-sidebar-nav-link",Z={defaultPage:"main.html",errorPage:"404.html",subpagesDirectory:"views/"},tt=function(){function t(t,e){this._config=this._getConfig(e),this._element=t;var n=location.hash.replace(/^#/,"");""!==n?this._setUpUrl(n):this._setUpUrl(this._config.defaultPage),this._addEventListeners()}var n=t.prototype;return n._getConfig=function(t){return t=o(o({},Z),t)},n._loadPage=function(t){var e=this,n=this._element,i=this._config,o=function t(n,i){void 0===i&&(i=0);var o=document.createElement("script");o.type="text/javascript",o.src=n[i],o.className="view-script",o.onload=o.onreadystatechange=function(){e.readyState&&"complete"!==e.readyState||n.length>i+1&&t(n,i+1)},document.getElementsByTagName("body")[0].appendChild(o)},r=new XMLHttpRequest;r.open("GET",i.subpagesDirectory+t);var s=new CustomEvent("xhr",{detail:{url:t,status:r.status}});n.dispatchEvent(s),r.onload=function(e){if(200===r.status){s=new CustomEvent("xhr",{detail:{url:t,status:r.status}}),n.dispatchEvent(s);var a=document.createElement("div");a.innerHTML=e.target.response;var l=Array.from(a.querySelectorAll("script")).map((function(t){return t.attributes.getNamedItem("src").nodeValue}));a.querySelectorAll("script").forEach((function(t){return t.remove(t)})),window.scrollTo(0,0),n.innerHTML="",n.appendChild(a),(c=document.querySelectorAll(".view-script")).length&&c.forEach((function(t){t.remove()})),l.length&&o(l),window.location.hash=t}else window.location.href=i.errorPage;var c},r.send()},n._setUpUrl=function(t){t=t.replace(/^\//,"").split("?")[0],Array.from(document.querySelectorAll(J)).forEach((function(t){t.classList.remove(K)})),Array.from(document.querySelectorAll(J)).forEach((function(t){t.classList.remove(K)})),Array.from(document.querySelectorAll(G)).forEach((function(t){t.classList.remove($)})),Array.from(document.querySelectorAll(G)).forEach((function(e){Array.from(e.querySelectorAll('a[href*="'+t+'"]')).length>0&&e.classList.add($)})),Array.from(document.querySelectorAll('.c-sidebar-nav-item a[href*="'+t+'"]')).forEach((function(t){t.classList.add(K)})),this._loadPage(t)},n._loadBlank=function(t){window.open(t)},n._loadTop=function(t){window.location=t},n._update=function(t){"#"!==t.href&&("undefined"!=typeof t.dataset.toggle&&"null"!==t.dataset.toggle||("_top"===t.target?this._loadTop(t.href):"_blank"===t.target?this._loadBlank(t.href):this._setUpUrl(t.getAttribute("href"))))},n._addEventListeners=function(){var t=this;F.on(document,"click.coreui.asyncLoad.data-api",J,(function(e){e.preventDefault();var n=e.target;n.classList.contains("c-sidebar-nav-link")||(n=n.closest(J)),n.classList.contains("c-sidebar-nav-dropdown-toggle")||"#"===n.getAttribute("href")||t._update(n)}))},t._asyncLoadInterface=function(e,n){var i=O(e,z);if(i||(i=new t(e,"object"==typeof n&&n)),"string"==typeof n){if("undefined"==typeof i[n])throw new TypeError('No method named "'+n+'"');i[n]()}},t.jQueryInterface=function(e){return this.each((function(){t._asyncLoadInterface(this,e)}))},e(t,null,[{key:"VERSION",get:function(){return"3.2.2"}},{key:"Default",get:function(){return Z}}]),t}(),et=L();if(et){var nt=et.fn[V];et.fn[V]=tt.jQueryInterface,et.fn[V].Constructor=tt,et.fn[V].noConflict=function(){return et.fn[V]=nt,tt.jQueryInterface}}var it="coreui.alert",ot=function(){function t(t){this._element=t,this._element&&k(t,it,this)}var n=t.prototype;return n.close=function(t){var e=t?this._getRootElement(t):this._element,n=this._triggerCloseEvent(e);null===n||n.defaultPrevented||this._removeElement(e)},n.dispose=function(){C(this._element,it),this._element=null},n._getRootElement=function(t){return d(t)||t.closest(".alert")},n._triggerCloseEvent=function(t){return F.trigger(t,"close.coreui.alert")},n._removeElement=function(t){var e=this;if(t.classList.remove("show"),t.classList.contains("fade")){var n=p(t);F.one(t,c,(function(){return e._destroyElement(t)})),v(t,n)}else this._destroyElement(t)},n._destroyElement=function(t){t.parentNode&&t.parentNode.removeChild(t),F.trigger(t,"closed.coreui.alert")},t.jQueryInterface=function(e){return this.each((function(){var n=O(this,it);n||(n=new t(this)),"close"===e&&n[e](this)}))},t.handleDismiss=function(t){return function(e){e&&e.preventDefault(),t.close(this)}},t.getInstance=function(t){return O(t,it)},e(t,null,[{key:"VERSION",get:function(){return"3.2.2"}}]),t}();F.on(document,"click.coreui.alert.data-api",'[data-dismiss="alert"]',ot.handleDismiss(new ot));var rt=L();if(rt){var st=rt.fn.alert;rt.fn.alert=ot.jQueryInterface,rt.fn.alert.Constructor=ot,rt.fn.alert.noConflict=function(){return rt.fn.alert=st,ot.jQueryInterface}}var at={matches:function(t,e){return t.matches(e)},find:function(t,e){var n;return void 0===e&&(e=document.documentElement),(n=[]).concat.apply(n,S.call(e,t))},findOne:function(t,e){return void 0===e&&(e=document.documentElement),A.call(e,t)},children:function(t,e){var n,i=(n=[]).concat.apply(n,t.children);return i.filter((function(t){return t.matches(e)}))},parents:function(t,e){for(var n=[],i=t.parentNode;i&&i.nodeType===Node.ELEMENT_NODE&&3!==i.nodeType;)this.matches(i,e)&&n.push(i),i=i.parentNode;return n},prev:function(t,e){for(var n=t.previousElementSibling;n;){if(n.matches(e))return[n];n=n.previousElementSibling}return[]},next:function(t,e){for(var n=t.nextElementSibling;n;){if(this.matches(n,e))return[n];n=n.nextElementSibling}return[]}},lt="coreui.button",ct="active",ut="disabled",ft="focus",ht='[data-toggle^="button"]',dt=".btn",pt=function(){function t(t){this._element=t,k(t,lt,this)}var n=t.prototype;return n.toggle=function(){var t=!0,e=!0,n=this._element.closest('[data-toggle="buttons"]');if(n){var i=at.findOne('input:not([type="hidden"])',this._element);if(i&&"radio"===i.type){if(i.checked&&this._element.classList.contains(ct))t=!1;else{var o=at.findOne(".active",n);o&&o.classList.remove(ct)}if(t){if(i.hasAttribute("disabled")||n.hasAttribute("disabled")||i.classList.contains(ut)||n.classList.contains(ut))return;i.checked=!this._element.classList.contains(ct),F.trigger(i,"change")}i.focus(),e=!1}}e&&this._element.setAttribute("aria-pressed",!this._element.classList.contains(ct)),t&&this._element.classList.toggle(ct)},n.dispose=function(){C(this._element,lt),this._element=null},t.jQueryInterface=function(e){return this.each((function(){var n=O(this,lt);n||(n=new t(this)),"toggle"===e&&n[e]()}))},t.getInstance=function(t){return O(t,lt)},e(t,null,[{key:"VERSION",get:function(){return"3.2.2"}}]),t}();F.on(document,"click.coreui.button.data-api",ht,(function(t){t.preventDefault();var e=t.target.closest(dt),n=O(e,lt);n||(n=new pt(e)),n.toggle()})),F.on(document,"focus.coreui.button.data-api",ht,(function(t){var e=t.target.closest(dt);e&&e.classList.add(ft)})),F.on(document,"blur.coreui.button.data-api",ht,(function(t){var e=t.target.closest(dt);e&&e.classList.remove(ft)}));var gt=L();if(gt){var mt=gt.fn.button;gt.fn.button=pt.jQueryInterface,gt.fn.button.Constructor=pt,gt.fn.button.noConflict=function(){return gt.fn.button=mt,pt.jQueryInterface}}function vt(t){return"true"===t||"false"!==t&&(t===Number(t).toString()?Number(t):""===t||"null"===t?null:t)}function _t(t){return t.replace(/[A-Z]/g,(function(t){return"-"+t.toLowerCase()}))}var bt={setDataAttribute:function(t,e,n){t.setAttribute("data-"+_t(e),n)},removeDataAttribute:function(t,e){t.removeAttribute("data-"+_t(e))},getDataAttributes:function(t){if(!t)return{};var e=o({},t.dataset);return Object.keys(e).forEach((function(t){e[t]=vt(e[t])})),e},getDataAttribute:function(t,e){return vt(t.getAttribute("data-"+_t(e)))},offset:function(t){var e=t.getBoundingClientRect();return{top:e.top+document.body.scrollTop,left:e.left+document.body.scrollLeft}},position:function(t){return{top:t.offsetTop,left:t.offsetLeft}},toggleClass:function(t,e){t&&(t.classList.contains(e)?t.classList.remove(e):t.classList.add(e))}},yt="carousel",wt="coreui.carousel",Et="."+wt,Lt={interval:5e3,keyboard:!0,slide:!1,pause:"hover",wrap:!0,touch:!0},Tt={interval:"(number|boolean)",keyboard:"boolean",slide:"(boolean|string)",pause:"(string|boolean)",wrap:"boolean",touch:"boolean"},kt="next",Ot="prev",Ct="slid"+Et,St="active",At=".active.carousel-item",xt={TOUCH:"touch",PEN:"pen"},Dt=function(){function t(t,e){this._items=null,this._interval=null,this._activeElement=null,this._isPaused=!1,this._isSliding=!1,this.touchTimeout=null,this.touchStartX=0,this.touchDeltaX=0,this._config=this._getConfig(e),this._element=t,this._indicatorsElement=at.findOne(".carousel-indicators",this._element),this._touchSupported="ontouchstart"in document.documentElement||navigator.maxTouchPoints>0,this._pointerEvent=Boolean(window.PointerEvent||window.MSPointerEvent),this._addEventListeners(),k(t,wt,this)}var n=t.prototype;return n.next=function(){this._isSliding||this._slide(kt)},n.nextWhenVisible=function(){!document.hidden&&b(this._element)&&this.next()},n.prev=function(){this._isSliding||this._slide(Ot)},n.pause=function(t){t||(this._isPaused=!0),at.findOne(".carousel-item-next, .carousel-item-prev",this._element)&&(g(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null},n.cycle=function(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config&&this._config.interval&&!this._isPaused&&(this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))},n.to=function(t){var e=this;this._activeElement=at.findOne(At,this._element);var n=this._getItemIndex(this._activeElement);if(!(t>this._items.length-1||t<0))if(this._isSliding)F.one(this._element,Ct,(function(){return e.to(t)}));else{if(n===t)return this.pause(),void this.cycle();var i=t>n?kt:Ot;this._slide(i,this._items[t])}},n.dispose=function(){F.off(this._element,Et),C(this._element,wt),this._items=null,this._config=null,this._element=null,this._interval=null,this._isPaused=null,this._isSliding=null,this._activeElement=null,this._indicatorsElement=null},n._getConfig=function(t){return t=o(o({},Lt),t),_(yt,t,Tt),t},n._handleSwipe=function(){var t=Math.abs(this.touchDeltaX);if(!(t<=40)){var e=t/this.touchDeltaX;this.touchDeltaX=0,e>0&&this.prev(),e<0&&this.next()}},n._addEventListeners=function(){var t=this;this._config.keyboard&&F.on(this._element,"keydown.coreui.carousel",(function(e){return t._keydown(e)})),"hover"===this._config.pause&&(F.on(this._element,"mouseenter.coreui.carousel",(function(e){return t.pause(e)})),F.on(this._element,"mouseleave.coreui.carousel",(function(e){return t.cycle(e)}))),this._config.touch&&this._touchSupported&&this._addTouchEventListeners()},n._addTouchEventListeners=function(){var t=this,e=function(e){t._pointerEvent&&xt[e.pointerType.toUpperCase()]?t.touchStartX=e.clientX:t._pointerEvent||(t.touchStartX=e.touches[0].clientX)},n=function(e){t._pointerEvent&&xt[e.pointerType.toUpperCase()]&&(t.touchDeltaX=e.clientX-t.touchStartX),t._handleSwipe(),"hover"===t._config.pause&&(t.pause(),t.touchTimeout&&clearTimeout(t.touchTimeout),t.touchTimeout=setTimeout((function(e){return t.cycle(e)}),500+t._config.interval))};at.find(".carousel-item img",this._element).forEach((function(t){F.on(t,"dragstart.coreui.carousel",(function(t){return t.preventDefault()}))})),this._pointerEvent?(F.on(this._element,"pointerdown.coreui.carousel",(function(t){return e(t)})),F.on(this._element,"pointerup.coreui.carousel",(function(t){return n(t)})),this._element.classList.add("pointer-event")):(F.on(this._element,"touchstart.coreui.carousel",(function(t){return e(t)})),F.on(this._element,"touchmove.coreui.carousel",(function(e){return function(e){e.touches&&e.touches.length>1?t.touchDeltaX=0:t.touchDeltaX=e.touches[0].clientX-t.touchStartX}(e)})),F.on(this._element,"touchend.coreui.carousel",(function(t){return n(t)})))},n._keydown=function(t){if(!/input|textarea/i.test(t.target.tagName))switch(t.key){case"ArrowLeft":t.preventDefault(),this.prev();break;case"ArrowRight":t.preventDefault(),this.next()}},n._getItemIndex=function(t){return this._items=t&&t.parentNode?at.find(".carousel-item",t.parentNode):[],this._items.indexOf(t)},n._getItemByDirection=function(t,e){var n=t===kt,i=t===Ot,o=this._getItemIndex(e),r=this._items.length-1;if((i&&0===o||n&&o===r)&&!this._config.wrap)return e;var s=(o+(t===Ot?-1:1))%this._items.length;return-1===s?this._items[this._items.length-1]:this._items[s]},n._triggerSlideEvent=function(t,e){var n=this._getItemIndex(t),i=this._getItemIndex(at.findOne(At,this._element));return F.trigger(this._element,"slide.coreui.carousel",{relatedTarget:t,direction:e,from:i,to:n})},n._setActiveIndicatorElement=function(t){if(this._indicatorsElement){for(var e=at.find(".active",this._indicatorsElement),n=0;n0})).filter((function(e){return t.includes(e)}))[0]},n._breakpoints=function(t){var e=this._config.breakpoints;return e.slice(0,e.indexOf(e.filter((function(t){return t.length>0})).filter((function(e){return t.includes(e)}))[0])+1)},n._updateResponsiveClassNames=function(t){var e=this._breakpoint(t);return this._breakpoints(t).map((function(n){return n.length>0?t.replace(e,n):t.replace("-"+e,n)}))},n._includesResponsiveClass=function(t){var e=this;return this._updateResponsiveClassNames(t).filter((function(t){return e._config.target.contains(t)}))},n._getConfig=function(t){return t=o(o(o({},this.constructor.Default),bt.getDataAttributes(this._element)),t),_(It,t,this.constructor.DefaultType),t},t.classTogglerInterface=function(e,n){var i=O(e,Pt);if(i||(i=new t(e,"object"==typeof n&&n)),"string"==typeof n){if("undefined"==typeof i[n])throw new TypeError('No method named "'+n+'"');i[n]()}},t.jQueryInterface=function(e){return this.each((function(){t.classTogglerInterface(this,e)}))},e(t,null,[{key:"VERSION",get:function(){return"3.2.2"}},{key:"Default",get:function(){return Mt}},{key:"DefaultType",get:function(){return Rt}}]),t}();F.on(document,"click.coreui.class-toggler.data-api",Yt,(function(t){t.preventDefault(),t.stopPropagation();var e=t.target;e.classList.contains("c-class-toggler")||(e=e.closest(Yt)),"undefined"!=typeof e.dataset.addClass&&Xt.classTogglerInterface(e,"add"),"undefined"!=typeof e.dataset.removeClass&&Xt.classTogglerInterface(e,"remove"),"undefined"!=typeof e.dataset.toggleClass&&Xt.classTogglerInterface(e,"toggle"),"undefined"!=typeof e.dataset.class&&Xt.classTogglerInterface(e,"class")}));var Bt=L();if(Bt){var Ut=Bt.fn[It];Bt.fn[It]=Xt.jQueryInterface,Bt.fn[It].Constructor=Xt,Bt.fn[It].noConflict=function(){return Bt.fn[It]=Ut,Xt.jQueryInterface}}var qt="collapse",Qt="coreui.collapse",Ft={toggle:!0,parent:""},Vt={toggle:"boolean",parent:"(string|element)"},zt="show",Kt="collapse",$t="collapsing",Gt="collapsed",Jt="width",Zt='[data-toggle="collapse"]',te=function(){function t(t,e){this._isTransitioning=!1,this._element=t,this._config=this._getConfig(e),this._triggerArray=at.find(Zt+'[href="#'+t.id+'"],[data-toggle="collapse"][data-target="#'+t.id+'"]');for(var n=at.find(Zt),i=0,o=n.length;i0)for(var i=0;i=0}function ke(t){return((_e(t)?t.ownerDocument:t.document)||window.document).documentElement}function Oe(t){return"html"===me(t)?t:t.assignedSlot||t.parentNode||t.host||ke(t)}function Ce(t){if(!be(t)||"fixed"===Le(t).position)return null;var e=t.offsetParent;if(e){var n=ke(e);if("body"===me(e)&&"static"===Le(e).position&&"static"!==Le(n).position)return n}return e}function Se(t){for(var e=ve(t),n=Ce(t);n&&Te(n)&&"static"===Le(n).position;)n=Ce(n);return n&&"body"===me(n)&&"static"===Le(n).position?e:n||function(t){for(var e=Oe(t);be(e)&&["html","body"].indexOf(me(e))<0;){var n=Le(e);if("none"!==n.transform||"none"!==n.perspective||n.willChange&&"auto"!==n.willChange)return e;e=e.parentNode}return null}(t)||e}function Ae(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function xe(t,e,n){return Math.max(t,Math.min(e,n))}function De(t){return Object.assign(Object.assign({},{top:0,right:0,bottom:0,left:0}),t)}function je(t,e){return e.reduce((function(e,n){return e[n]=t,e}),{})}var Ne={top:"auto",right:"auto",bottom:"auto",left:"auto"};function Ie(t){var e,n=t.popper,i=t.popperRect,o=t.placement,r=t.offsets,s=t.position,a=t.gpuAcceleration,l=t.adaptive,c=function(t){var e=t.x,n=t.y,i=window.devicePixelRatio||1;return{x:Math.round(e*i)/i||0,y:Math.round(n*i)/i||0}}(r),u=c.x,f=c.y,h=r.hasOwnProperty("x"),d=r.hasOwnProperty("y"),p=se,g=ie,m=window;if(l){var v=Se(n);v===ve(n)&&(v=ke(n)),o===ie&&(g=oe,f-=v.clientHeight-i.height,f*=a?1:-1),o===se&&(p=re,u-=v.clientWidth-i.width,u*=a?1:-1)}var _,b=Object.assign({position:s},l&&Ne);return a?Object.assign(Object.assign({},b),{},((_={})[g]=d?"0":"",_[p]=h?"0":"",_.transform=(m.devicePixelRatio||1)<2?"translate("+u+"px, "+f+"px)":"translate3d("+u+"px, "+f+"px, 0)",_)):Object.assign(Object.assign({},b),{},((e={})[g]=d?f+"px":"",e[p]=h?u+"px":"",e.transform="",e))}var Pe={passive:!0};var Re={left:"right",right:"left",bottom:"top",top:"bottom"};function Me(t){return t.replace(/left|right|bottom|top/g,(function(t){return Re[t]}))}var He={start:"end",end:"start"};function We(t){return t.replace(/start|end/g,(function(t){return He[t]}))}function Ye(t){var e=t.getBoundingClientRect();return{width:e.width,height:e.height,top:e.top,right:e.right,bottom:e.bottom,left:e.left,x:e.left,y:e.top}}function Xe(t){var e=ve(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Be(t){return Ye(ke(t)).left+Xe(t).scrollLeft}function Ue(t){var e=Le(t),n=e.overflow,i=e.overflowX,o=e.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+i)}function qe(t){return["html","body","#document"].indexOf(me(t))>=0?t.ownerDocument.body:be(t)&&Ue(t)?t:qe(Oe(t))}function Qe(t,e){void 0===e&&(e=[]);var n=qe(t),i="body"===me(n),o=ve(n),r=i?[o].concat(o.visualViewport||[],Ue(n)?n:[]):n,s=e.concat(r);return i?s:s.concat(Qe(Oe(r)))}function Fe(t){return Object.assign(Object.assign({},t),{},{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function Ve(t,e){return e===fe?Fe(function(t){var e=ve(t),n=ke(t),i=e.visualViewport,o=n.clientWidth,r=n.clientHeight,s=0,a=0;return i&&(o=i.width,r=i.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(s=i.offsetLeft,a=i.offsetTop)),{width:o,height:r,x:s+Be(t),y:a}}(t)):be(e)?function(t){var e=Ye(t);return e.top=e.top+t.clientTop,e.left=e.left+t.clientLeft,e.bottom=e.top+t.clientHeight,e.right=e.left+t.clientWidth,e.width=t.clientWidth,e.height=t.clientHeight,e.x=e.left,e.y=e.top,e}(e):Fe(function(t){var e=ke(t),n=Xe(t),i=t.ownerDocument.body,o=Math.max(e.scrollWidth,e.clientWidth,i?i.scrollWidth:0,i?i.clientWidth:0),r=Math.max(e.scrollHeight,e.clientHeight,i?i.scrollHeight:0,i?i.clientHeight:0),s=-n.scrollLeft+Be(t),a=-n.scrollTop;return"rtl"===Le(i||e).direction&&(s+=Math.max(e.clientWidth,i?i.clientWidth:0)-o),{width:o,height:r,x:s,y:a}}(ke(t)))}function ze(t,e,n){var i="clippingParents"===e?function(t){var e=Qe(Oe(t)),n=["absolute","fixed"].indexOf(Le(t).position)>=0&&be(t)?Se(t):t;return _e(n)?e.filter((function(t){return _e(t)&&Ee(t,n)&&"body"!==me(t)})):[]}(t):[].concat(e),o=[].concat(i,[n]),r=o[0],s=o.reduce((function(e,n){var i=Ve(t,n);return e.top=Math.max(i.top,e.top),e.right=Math.min(i.right,e.right),e.bottom=Math.min(i.bottom,e.bottom),e.left=Math.max(i.left,e.left),e}),Ve(t,r));return s.width=s.right-s.left,s.height=s.bottom-s.top,s.x=s.left,s.y=s.top,s}function Ke(t){return t.split("-")[1]}function $e(t){var e,n=t.reference,i=t.element,o=t.placement,r=o?ye(o):null,s=o?Ke(o):null,a=n.x+n.width/2-i.width/2,l=n.y+n.height/2-i.height/2;switch(r){case ie:e={x:a,y:n.y-i.height};break;case oe:e={x:a,y:n.y+n.height};break;case re:e={x:n.x+n.width,y:l};break;case se:e={x:n.x-i.width,y:l};break;default:e={x:n.x,y:n.y}}var c=r?Ae(r):null;if(null!=c){var u="y"===c?"height":"width";switch(s){case ce:e[c]=Math.floor(e[c])-Math.floor(n[u]/2-i[u]/2);break;case ue:e[c]=Math.floor(e[c])+Math.ceil(n[u]/2-i[u]/2)}}return e}function Ge(t,e){void 0===e&&(e={});var n=e,i=n.placement,o=void 0===i?t.placement:i,r=n.boundary,s=void 0===r?"clippingParents":r,a=n.rootBoundary,l=void 0===a?fe:a,c=n.elementContext,u=void 0===c?he:c,f=n.altBoundary,h=void 0!==f&&f,d=n.padding,p=void 0===d?0:d,g=De("number"!=typeof p?p:je(p,le)),m=u===he?"reference":he,v=t.elements.reference,_=t.rects.popper,b=t.elements[h?m:u],y=ze(_e(b)?b:b.contextElement||ke(t.elements.popper),s,l),w=Ye(v),E=$e({reference:w,element:_,strategy:"absolute",placement:o}),L=Fe(Object.assign(Object.assign({},_),E)),T=u===he?L:w,k={top:y.top-T.top+g.top,bottom:T.bottom-y.bottom+g.bottom,left:y.left-T.left+g.left,right:T.right-y.right+g.right},O=t.modifiersData.offset;if(u===he&&O){var C=O[o];Object.keys(k).forEach((function(t){var e=[re,oe].indexOf(t)>=0?1:-1,n=[ie,oe].indexOf(t)>=0?"y":"x";k[t]+=C[n]*e}))}return k}function Je(t,e){void 0===e&&(e={});var n=e,i=n.placement,o=n.boundary,r=n.rootBoundary,s=n.padding,a=n.flipVariations,l=n.allowedAutoPlacements,c=void 0===l?pe:l,u=Ke(i),f=u?a?de:de.filter((function(t){return Ke(t)===u})):le,h=f.filter((function(t){return c.indexOf(t)>=0}));0===h.length&&(h=f);var d=h.reduce((function(e,n){return e[n]=Ge(t,{placement:n,boundary:o,rootBoundary:r,padding:s})[ye(n)],e}),{});return Object.keys(d).sort((function(t,e){return d[t]-d[e]}))}function Ze(t,e,n){return void 0===n&&(n={x:0,y:0}),{top:t.top-e.height-n.y,right:t.right-e.width+n.x,bottom:t.bottom-e.height+n.y,left:t.left-e.width-n.x}}function tn(t){return[ie,re,oe,se].some((function(e){return t[e]>=0}))}function en(t,e,n){void 0===n&&(n=!1);var i,o=ke(e),r=Ye(t),s=be(e),a={scrollLeft:0,scrollTop:0},l={x:0,y:0};return(s||!s&&!n)&&(("body"!==me(e)||Ue(o))&&(a=(i=e)!==ve(i)&&be(i)?function(t){return{scrollLeft:t.scrollLeft,scrollTop:t.scrollTop}}(i):Xe(i)),be(e)?((l=Ye(e)).x+=e.clientLeft,l.y+=e.clientTop):o&&(l.x=Be(o))),{x:r.left+a.scrollLeft-l.x,y:r.top+a.scrollTop-l.y,width:r.width,height:r.height}}function nn(t){var e=new Map,n=new Set,i=[];function o(t){n.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!n.has(t)){var i=e.get(t);i&&o(i)}})),i.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){n.has(t.name)||o(t)})),i}var on={placement:"bottom",modifiers:[],strategy:"absolute"};function rn(){for(var t=arguments.length,e=new Array(t),n=0;n=0?-1:1,r="function"==typeof n?n(Object.assign(Object.assign({},e),{},{placement:t})):n,s=r[0],a=r[1];return s=s||0,a=(a||0)*o,[se,re].indexOf(i)>=0?{x:a,y:s}:{x:s,y:a}}(n,e.rects,r),t}),{}),a=s[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[i]=s}},{name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,n=t.options,i=t.name;if(!e.modifiersData[i]._skip){for(var o=n.mainAxis,r=void 0===o||o,s=n.altAxis,a=void 0===s||s,l=n.fallbackPlacements,c=n.padding,u=n.boundary,f=n.rootBoundary,h=n.altBoundary,d=n.flipVariations,p=void 0===d||d,g=n.allowedAutoPlacements,m=e.options.placement,v=ye(m),_=l||(v===m||!p?[Me(m)]:function(t){if(ye(t)===ae)return[];var e=Me(t);return[We(t),e,We(e)]}(m)),b=[m].concat(_).reduce((function(t,n){return t.concat(ye(n)===ae?Je(e,{placement:n,boundary:u,rootBoundary:f,padding:c,flipVariations:p,allowedAutoPlacements:g}):n)}),[]),y=e.rects.reference,w=e.rects.popper,E=new Map,L=!0,T=b[0],k=0;k=0,x=A?"width":"height",D=Ge(e,{placement:O,boundary:u,rootBoundary:f,altBoundary:h,padding:c}),j=A?S?re:se:S?oe:ie;y[x]>w[x]&&(j=Me(j));var N=Me(j),I=[];if(r&&I.push(D[C]<=0),a&&I.push(D[j]<=0,D[N]<=0),I.every((function(t){return t}))){T=O,L=!1;break}E.set(O,I)}if(L)for(var P=function(t){var e=b.find((function(e){var n=E.get(e);if(n)return n.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},R=p?3:1;R>0;R--){if("break"===P(R))break}e.placement!==T&&(e.modifiersData[i]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}},{name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,n=t.options,i=t.name,o=n.mainAxis,r=void 0===o||o,s=n.altAxis,a=void 0!==s&&s,l=n.boundary,c=n.rootBoundary,u=n.altBoundary,f=n.padding,h=n.tether,d=void 0===h||h,p=n.tetherOffset,g=void 0===p?0:p,m=Ge(e,{boundary:l,rootBoundary:c,padding:f,altBoundary:u}),v=ye(e.placement),_=Ke(e.placement),b=!_,y=Ae(v),w="x"===y?"y":"x",E=e.modifiersData.popperOffsets,L=e.rects.reference,T=e.rects.popper,k="function"==typeof g?g(Object.assign(Object.assign({},e.rects),{},{placement:e.placement})):g,O={x:0,y:0};if(E){if(r){var C="y"===y?ie:se,S="y"===y?oe:re,A="y"===y?"height":"width",x=E[y],D=E[y]+m[C],j=E[y]-m[S],N=d?-T[A]/2:0,I=_===ce?L[A]:T[A],P=_===ce?-T[A]:-L[A],R=e.elements.arrow,M=d&&R?we(R):{width:0,height:0},H=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},W=H[C],Y=H[S],X=xe(0,L[A],M[A]),B=b?L[A]/2-N-X-W-k:I-X-W-k,U=b?-L[A]/2+N+X+Y+k:P+X+Y+k,q=e.elements.arrow&&Se(e.elements.arrow),Q=q?"y"===y?q.clientTop||0:q.clientLeft||0:0,F=e.modifiersData.offset?e.modifiersData.offset[e.placement][y]:0,V=E[y]+B-F-Q,z=E[y]+U-F,K=xe(d?Math.min(D,V):D,x,d?Math.max(j,z):j);E[y]=K,O[y]=K-x}if(a){var $="x"===y?ie:se,G="x"===y?oe:re,J=E[w],Z=xe(J+m[$],J,J-m[G]);E[w]=Z,O[w]=Z-J}e.modifiersData[i]=O}},requiresIfExists:["offset"]},{name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,n=t.state,i=t.name,o=n.elements.arrow,r=n.modifiersData.popperOffsets,s=ye(n.placement),a=Ae(s),l=[se,re].indexOf(s)>=0?"height":"width";if(o&&r){var c=n.modifiersData[i+"#persistent"].padding,u=we(o),f="y"===a?ie:se,h="y"===a?oe:re,d=n.rects.reference[l]+n.rects.reference[a]-r[a]-n.rects.popper[l],p=r[a]-n.rects.reference[a],g=Se(o),m=g?"y"===a?g.clientHeight||0:g.clientWidth||0:0,v=d/2-p/2,_=c[f],b=m-u[l]-c[h],y=m/2-u[l]/2+v,w=xe(_,y,b),E=a;n.modifiersData[i]=((e={})[E]=w,e.centerOffset=w-y,e)}},effect:function(t){var e=t.state,n=t.options,i=t.name,o=n.element,r=void 0===o?"[data-popper-arrow]":o,s=n.padding,a=void 0===s?0:s;null!=r&&("string"!=typeof r||(r=e.elements.popper.querySelector(r)))&&Ee(e.elements.popper,r)&&(e.elements.arrow=r,e.modifiersData[i+"#persistent"]={padding:De("number"!=typeof a?a:je(a,le))})},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]},{name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,n=t.name,i=e.rects.reference,o=e.rects.popper,r=e.modifiersData.preventOverflow,s=Ge(e,{elementContext:"reference"}),a=Ge(e,{altBoundary:!0}),l=Ze(s,i),c=Ze(a,o,r),u=tn(l),f=tn(c);e.modifiersData[n]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:u,hasPopperEscaped:f},e.attributes.popper=Object.assign(Object.assign({},e.attributes.popper),{},{"data-popper-reference-hidden":u,"data-popper-escaped":f})}}]}),ln="dropdown",cn="coreui.dropdown",un="."+cn,fn="Escape",hn="Space",dn="ArrowUp",pn="ArrowDown",gn=new RegExp("ArrowUp|ArrowDown|Escape"),mn="hide"+un,vn="hidden"+un,_n="click.coreui.dropdown.data-api",bn="keydown.coreui.dropdown.data-api",yn="disabled",wn="show",En="dropdown-menu-right",Ln='[data-toggle="dropdown"]',Tn=".dropdown-menu",kn={offset:[0,0],flip:!0,boundary:"scrollParent",reference:"toggle",display:"dynamic",popperConfig:null},On={offset:"(array|function)",flip:"boolean",boundary:"(string|element)",reference:"(string|element)",display:"string",popperConfig:"(null|object)"},Cn=function(){function t(t,e){this._element=t,this._popper=null,this._config=this._getConfig(e),this._menu=this._getMenuElement(),this._inNavbar=this._detectNavbar(),this._inHeader=this._detectHeader(),this._addEventListeners(),k(t,cn,this)}var n=t.prototype;return n.toggle=function(){if(!this._element.disabled&&!this._element.classList.contains(yn)){var e=this._menu.classList.contains(wn);t.clearMenus(),e||this.show()}},n.show=function(){if(!(this._element.disabled||this._element.classList.contains(yn)||this._menu.classList.contains(wn))){var e=t.getParentFromElement(this._element),n={relatedTarget:this._element};if(!F.trigger(e,"show.coreui.dropdown",n).defaultPrevented){if(!this._inNavbar&&!this._inHeader){if("undefined"==typeof an)throw new TypeError("CoreUI's dropdowns require Popper.js (https://popper.js.org)");var i=this._element;"parent"===this._config.reference?i=e:m(this._config.reference)&&(i=this._config.reference,"undefined"!=typeof this._config.reference.jquery&&(i=this._config.reference[0])),"scrollParent"!==this._config.boundary&&e.classList.add("position-static"),this._popper=an(i,this._menu,this._getPopperConfig())}var o,r;if("ontouchstart"in document.documentElement&&!e.closest(".navbar-nav"))(o=[]).concat.apply(o,document.body.children).forEach((function(t){return F.on(t,"mouseover",null,(function(){}))}));if("ontouchstart"in document.documentElement&&!e.closest(".c-header-nav"))(r=[]).concat.apply(r,document.body.children).forEach((function(t){return F.on(t,"mouseover",null,(function(){}))}));this._element.focus(),this._element.setAttribute("aria-expanded",!0),bt.toggleClass(this._menu,wn),bt.toggleClass(e,wn),F.trigger(e,"shown.coreui.dropdown",n)}}},n.hide=function(){if(!this._element.disabled&&!this._element.classList.contains(yn)&&this._menu.classList.contains(wn)){var e=t.getParentFromElement(this._element),n={relatedTarget:this._element};F.trigger(e,mn,n).defaultPrevented||(this._popper&&this._popper.destroy(),bt.toggleClass(this._menu,wn),bt.toggleClass(e,wn),F.trigger(e,vn,n))}},n.dispose=function(){C(this._element,cn),F.off(this._element,un),this._element=null,this._menu=null,this._popper&&(this._popper.destroy(),this._popper=null)},n.update=function(){this._inNavbar=this._detectNavbar(),this._inHeader=this._detectHeader(),this._popper&&this._popper.update()},n._addEventListeners=function(){var t=this;F.on(this._element,"click.coreui.dropdown",(function(e){e.preventDefault(),e.stopPropagation(),t.toggle()}))},n._getConfig=function(t){return t=o(o(o({},this.constructor.Default),bt.getDataAttributes(this._element)),t),_(ln,t,this.constructor.DefaultType),t},n._getMenuElement=function(){var e=t.getParentFromElement(this._element);return at.findOne(Tn,e)},n._getPlacement=function(){var t=this._element.parentNode,e="bottom-start";return t.classList.contains("dropup")?(e="top-start",this._menu.classList.contains(En)&&(e="top-end")):t.classList.contains("dropright")?e="right-start":t.classList.contains("dropleft")?e="left-start":this._menu.classList.contains(En)&&(e="bottom-end"),e},n._detectNavbar=function(){return Boolean(this._element.closest(".navbar"))},n._detectHeader=function(){return Boolean(this._element.closest(".c-header"))},n._getOffset=function(){var t=this;return"function"==typeof this._config.offset?function(e){var n=e.placement,i=e.reference,o=e.popper;return t._config.offset({placement:n,reference:i,popper:o})}:this._config.offset},n._getPopperConfig=function(){var t={placement:this._getPlacement(),modifiers:[{name:"offset",options:{offset:this._getOffset()}},{name:"flip",enabled:this._config.flip},{name:"preventOverflow",options:{boundary:this._config.boundary}}]};return"static"===this._config.display&&(t.modifiers={name:"applyStyles",enabled:!1}),o(o({},t),this._config.popperConfig)},t.dropdownInterface=function(e,n){var i=O(e,cn);if(i||(i=new t(e,"object"==typeof n?n:null)),"string"==typeof n){if("undefined"==typeof i[n])throw new TypeError('No method named "'+n+'"');i[n]()}},t.jQueryInterface=function(e){return this.each((function(){t.dropdownInterface(this,e)}))},t.clearMenus=function(e){if(!e||2!==e.button&&("keyup"!==e.type||"Tab"===e.key))for(var n=at.find(Ln),i=0,o=n.length;i0&&r--,e.key===pn&&rdocument.documentElement.clientHeight;e||(this._element.style.overflowY="hidden"),this._element.classList.add(Fn);var n=p(this._dialog);F.off(this._element,c),F.one(this._element,c,(function(){t._element.classList.remove(Fn),e||(F.one(t._element,c,(function(){t._element.style.overflowY=""})),v(t._element,n))})),v(this._element,n),this._element.focus()}else this.hide()},n._adjustDialog=function(){var t=this._element.scrollHeight>document.documentElement.clientHeight;!this._isBodyOverflowing&&t&&(this._element.style.paddingLeft=this._scrollbarWidth+"px"),this._isBodyOverflowing&&!t&&(this._element.style.paddingRight=this._scrollbarWidth+"px")},n._resetAdjustments=function(){this._element.style.paddingLeft="",this._element.style.paddingRight=""},n._checkScrollbar=function(){var t=document.body.getBoundingClientRect();this._isBodyOverflowing=Math.round(t.left+t.right)
    ',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:[0,0],container:!1,boundary:"scrollParent",sanitize:!0,sanitizeFn:null,whiteList:ei,popperConfig:null},fi={HIDE:"hide"+ri,HIDDEN:"hidden"+ri,SHOW:"show"+ri,SHOWN:"shown"+ri,INSERTED:"inserted"+ri,CLICK:"click"+ri,FOCUSIN:"focusin"+ri,FOCUSOUT:"focusout"+ri,MOUSEENTER:"mouseenter"+ri,MOUSELEAVE:"mouseleave"+ri},hi="fade",di="show",pi="show",gi="out",mi="hover",vi="focus",_i=function(){function t(t,e){if("undefined"==typeof an)throw new TypeError("CoreUI's tooltips require Popper.js (https://popper.js.org)");this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this.element=t,this.config=this._getConfig(e),this.tip=null,this._setListeners(),k(t,this.constructor.DATA_KEY,this)}var n=t.prototype;return n.enable=function(){this._isEnabled=!0},n.disable=function(){this._isEnabled=!1},n.toggleEnabled=function(){this._isEnabled=!this._isEnabled},n.toggle=function(t){if(this._isEnabled)if(t){var e=this.constructor.DATA_KEY,n=O(t.delegateTarget,e);n||(n=new this.constructor(t.delegateTarget,this._getDelegateConfig()),k(t.delegateTarget,e,n)),n._activeTrigger.click=!n._activeTrigger.click,n._isWithActiveTrigger()?n._enter(null,n):n._leave(null,n)}else{if(this.getTipElement().classList.contains(di))return void this._leave(null,this);this._enter(null,this)}},n.dispose=function(){clearTimeout(this._timeout),C(this.element,this.constructor.DATA_KEY),F.off(this.element,this.constructor.EVENT_KEY),F.off(this.element.closest(".modal"),"hide.coreui.modal",this._hideModalHandler),this.tip&&this.tip.parentNode.removeChild(this.tip),this._isEnabled=null,this._timeout=null,this._hoverState=null,this._activeTrigger=null,this._popper&&this._popper.destroy(),this._popper=null,this.element=null,this.config=null,this.tip=null},n.show=function(){var t=this;if("none"===this.element.style.display)throw new Error("Please use show on visible elements");if(this.isWithContent()&&this._isEnabled){var e=F.trigger(this.element,this.constructor.Event.SHOW),n=y(this.element),i=null===n?this.element.ownerDocument.documentElement.contains(this.element):n.contains(this.element);if(e.defaultPrevented||!i)return;var o=this.getTipElement(),r=u(this.constructor.NAME);o.setAttribute("id",r),this.element.setAttribute("aria-describedby",r),this.setContent(),this.config.animation&&o.classList.add(hi);var s,a="function"==typeof this.config.placement?this.config.placement.call(this,o,this.element):this.config.placement,l=this._getAttachment(a),f=this._getContainer();if(k(o,this.constructor.DATA_KEY,this),this.element.ownerDocument.documentElement.contains(this.tip)||f.appendChild(o),F.trigger(this.element,this.constructor.Event.INSERTED),this._popper=an(this.element,o,this._getPopperConfig(l)),o.classList.add(di),"ontouchstart"in document.documentElement)(s=[]).concat.apply(s,document.body.children).forEach((function(t){F.on(t,"mouseover",(function(){}))}));var h=function(){t.config.animation&&t._fixTransition();var e=t._hoverState;t._hoverState=null,F.trigger(t.element,t.constructor.Event.SHOWN),e===gi&&t._leave(null,t)};if(this.tip.classList.contains(hi)){var d=p(this.tip);F.one(this.tip,c,h),v(this.tip,d)}else h()}},n.hide=function(){var t=this,e=this.getTipElement(),n=function(){t._hoverState!==pi&&e.parentNode&&e.parentNode.removeChild(e),t._cleanTipClass(),t.element.removeAttribute("aria-describedby"),F.trigger(t.element,t.constructor.Event.HIDDEN),t._popper.destroy()};if(!F.trigger(this.element,this.constructor.Event.HIDE).defaultPrevented){var i;if(e.classList.remove(di),"ontouchstart"in document.documentElement)(i=[]).concat.apply(i,document.body.children).forEach((function(t){return F.off(t,"mouseover",w)}));if(this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1,this.tip.classList.contains(hi)){var o=p(e);F.one(e,c,n),v(e,o)}else n();this._hoverState=""}},n.update=function(){null!==this._popper&&this._popper.update()},n.isWithContent=function(){return Boolean(this.getTitle())},n.getTipElement=function(){if(this.tip)return this.tip;var t=document.createElement("div");return t.innerHTML=this.config.template,this.tip=t.children[0],this.tip},n.setContent=function(){var t=this.getTipElement();this.setElementContent(at.findOne(".tooltip-inner",t),this.getTitle()),t.classList.remove(hi,di)},n.setElementContent=function(t,e){if(null!==t)return"object"==typeof e&&m(e)?(e.jquery&&(e=e[0]),void(this.config.html?e.parentNode!==t&&(t.innerHTML="",t.appendChild(e)):t.textContent=e.textContent)):void(this.config.html?(this.config.sanitize&&(e=ni(e,this.config.whiteList,this.config.sanitizeFn)),t.innerHTML=e):t.textContent=e)},n.getTitle=function(){var t=this.element.getAttribute("data-original-title");return t||(t="function"==typeof this.config.title?this.config.title.call(this.element):this.config.title),t},n._getPopperConfig=function(t){var e=this;return o(o({},{placement:t,modifiers:[{name:"offset",options:{offset:this._getOffset()}},{name:"arrow",options:{element:"."+this.constructor.NAME+"-arrow"}},{name:"preventOverflow",options:{boundary:this.config.boundary}}],onFirstUpdate:function(t){t.originalPlacement!==t.placement&&e._popper.update()}}),this.config.popperConfig)},n._getOffset=function(){var t=this;return"function"==typeof this.config.offset?function(e){var n=e.placement,i=e.reference,o=e.popper;return t.config.offset({placement:n,reference:i,popper:o})}:this.config.offset},n._getContainer=function(){return!1===this.config.container?document.body:m(this.config.container)?this.config.container:at.findOne(this.config.container)},n._getAttachment=function(t){return ci[t.toUpperCase()]},n._setListeners=function(){var t=this;this.config.trigger.split(" ").forEach((function(e){if("click"===e)F.on(t.element,t.constructor.Event.CLICK,t.config.selector,(function(e){return t.toggle(e)}));else if("manual"!==e){var n=e===mi?t.constructor.Event.MOUSEENTER:t.constructor.Event.FOCUSIN,i=e===mi?t.constructor.Event.MOUSELEAVE:t.constructor.Event.FOCUSOUT;F.on(t.element,n,t.config.selector,(function(e){return t._enter(e)})),F.on(t.element,i,t.config.selector,(function(e){return t._leave(e)}))}})),this._hideModalHandler=function(){t.element&&t.hide()},F.on(this.element.closest(".modal"),"hide.coreui.modal",this._hideModalHandler),this.config.selector?this.config=o(o({},this.config),{},{trigger:"manual",selector:""}):this._fixTitle()},n._fixTitle=function(){var t=typeof this.element.getAttribute("data-original-title");(this.element.getAttribute("title")||"string"!==t)&&(this.element.setAttribute("data-original-title",this.element.getAttribute("title")||""),this.element.setAttribute("title",""))},n._enter=function(t,e){var n=this.constructor.DATA_KEY;(e=e||O(t.delegateTarget,n))||(e=new this.constructor(t.delegateTarget,this._getDelegateConfig()),k(t.delegateTarget,n,e)),t&&(e._activeTrigger["focusin"===t.type?vi:mi]=!0),e.getTipElement().classList.contains(di)||e._hoverState===pi?e._hoverState=pi:(clearTimeout(e._timeout),e._hoverState=pi,e.config.delay&&e.config.delay.show?e._timeout=setTimeout((function(){e._hoverState===pi&&e.show()}),e.config.delay.show):e.show())},n._leave=function(t,e){var n=this.constructor.DATA_KEY;(e=e||O(t.delegateTarget,n))||(e=new this.constructor(t.delegateTarget,this._getDelegateConfig()),k(t.delegateTarget,n,e)),t&&(e._activeTrigger["focusout"===t.type?vi:mi]=!1),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState=gi,e.config.delay&&e.config.delay.hide?e._timeout=setTimeout((function(){e._hoverState===gi&&e.hide()}),e.config.delay.hide):e.hide())},n._isWithActiveTrigger=function(){for(var t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1},n._getConfig=function(t){var e=bt.getDataAttributes(this.element);return Object.keys(e).forEach((function(t){-1!==ai.indexOf(t)&&delete e[t]})),t&&"object"==typeof t.container&&t.container.jquery&&(t.container=t.container[0]),"number"==typeof(t=o(o(o({},this.constructor.Default),e),"object"==typeof t&&t?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()),_(ii,t,this.constructor.DefaultType),t.sanitize&&(t.template=ni(t.template,t.whiteList,t.sanitizeFn)),t},n._getDelegateConfig=function(){var t={};if(this.config)for(var e in this.config)this.constructor.Default[e]!==this.config[e]&&(t[e]=this.config[e]);return t},n._cleanTipClass=function(){var t=this.getTipElement(),e=t.getAttribute("class").match(si);null!==e&&e.length>0&&e.map((function(t){return t.trim()})).forEach((function(e){return t.classList.remove(e)}))},n._fixTransition=function(){var t=this.getTipElement(),e=this.config.animation;null===t.getAttribute("data-popper-placement")&&(t.classList.remove(hi),this.config.animation=!1,this.hide(),this.show(),this.config.animation=e)},t.jQueryInterface=function(e){return this.each((function(){var n=O(this,oi),i="object"==typeof e&&e;if((n||!/dispose|hide/.test(e))&&(n||(n=new t(this,i)),"string"==typeof e)){if("undefined"==typeof n[e])throw new TypeError('No method named "'+e+'"');n[e]()}}))},t.getInstance=function(t){return O(t,oi)},e(t,null,[{key:"VERSION",get:function(){return"3.2.2"}},{key:"Default",get:function(){return ui}},{key:"NAME",get:function(){return ii}},{key:"DATA_KEY",get:function(){return oi}},{key:"Event",get:function(){return fi}},{key:"EVENT_KEY",get:function(){return ri}},{key:"DefaultType",get:function(){return li}}]),t}(),bi=L();if(bi){var yi=bi.fn.tooltip;bi.fn.tooltip=_i.jQueryInterface,bi.fn.tooltip.Constructor=_i,bi.fn.tooltip.noConflict=function(){return bi.fn.tooltip=yi,_i.jQueryInterface}}var wi="popover",Ei="coreui.popover",Li="."+Ei,Ti=new RegExp("(^|\\s)bs-popover\\S+","g"),ki=o(o({},_i.Default),{},{placement:"right",trigger:"click",content:"",template:''}),Oi=o(o({},_i.DefaultType),{},{content:"(string|element|function)"}),Ci={HIDE:"hide"+Li,HIDDEN:"hidden"+Li,SHOW:"show"+Li,SHOWN:"shown"+Li,INSERTED:"inserted"+Li,CLICK:"click"+Li,FOCUSIN:"focusin"+Li,FOCUSOUT:"focusout"+Li,MOUSEENTER:"mouseenter"+Li,MOUSELEAVE:"mouseleave"+Li},Si=function(t){var n,i;function o(){return t.apply(this,arguments)||this}i=t,(n=o).prototype=Object.create(i.prototype),n.prototype.constructor=n,n.__proto__=i;var r=o.prototype;return r.isWithContent=function(){return this.getTitle()||this._getContent()},r.setContent=function(){var t=this.getTipElement();this.setElementContent(at.findOne(".popover-header",t),this.getTitle());var e=this._getContent();"function"==typeof e&&(e=e.call(this.element)),this.setElementContent(at.findOne(".popover-body",t),e),t.classList.remove("fade","show")},r._addAttachmentClass=function(t){this.getTipElement().classList.add("bs-popover-"+t)},r._getContent=function(){return this.element.getAttribute("data-content")||this.config.content},r._cleanTipClass=function(){var t=this.getTipElement(),e=t.getAttribute("class").match(Ti);null!==e&&e.length>0&&e.map((function(t){return t.trim()})).forEach((function(e){return t.classList.remove(e)}))},o.jQueryInterface=function(t){return this.each((function(){var e=O(this,Ei),n="object"==typeof t?t:null;if((e||!/dispose|hide/.test(t))&&(e||(e=new o(this,n),k(this,Ei,e)),"string"==typeof t)){if("undefined"==typeof e[t])throw new TypeError('No method named "'+t+'"');e[t]()}}))},o.getInstance=function(t){return O(t,Ei)},e(o,null,[{key:"VERSION",get:function(){return"3.2.2"}},{key:"Default",get:function(){return ki}},{key:"NAME",get:function(){return wi}},{key:"DATA_KEY",get:function(){return Ei}},{key:"Event",get:function(){return Ci}},{key:"EVENT_KEY",get:function(){return Li}},{key:"DefaultType",get:function(){return Oi}}]),o}(_i),Ai=L();if(Ai){var xi=Ai.fn.popover;Ai.fn.popover=Si.jQueryInterface,Ai.fn.popover.Constructor=Si,Ai.fn.popover.noConflict=function(){return Ai.fn.popover=xi,Si.jQueryInterface}}var Di="scrollspy",ji="coreui.scrollspy",Ni="."+ji,Ii={offset:10,method:"auto",target:""},Pi={offset:"number",method:"string",target:"(string|element)"},Ri="dropdown-item",Mi="active",Hi=".nav-link",Wi="position",Yi=function(){function t(t,e){var n=this;this._element=t,this._scrollElement="BODY"===t.tagName?window:t,this._config=this._getConfig(e),this._selector=this._config.target+" "+".nav-link, "+this._config.target+" "+".list-group-item, "+this._config.target+" ."+Ri,this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,F.on(this._scrollElement,"scroll.coreui.scrollspy",(function(t){return n._process(t)})),this.refresh(),this._process(),k(t,ji,this)}var n=t.prototype;return n.refresh=function(){var t=this,e=this._scrollElement===this._scrollElement.window?"offset":Wi,n="auto"===this._config.method?e:this._config.method,i=n===Wi?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),at.find(this._selector).map((function(t){var e=h(t),o=e?at.findOne(e):null;if(o){var r=o.getBoundingClientRect();if(r.width||r.height)return[bt[n](o).top+i,e]}return null})).filter((function(t){return t})).sort((function(t,e){return t[0]-e[0]})).forEach((function(e){t._offsets.push(e[0]),t._targets.push(e[1])}))},n.dispose=function(){C(this._element,ji),F.off(this._scrollElement,Ni),this._element=null,this._scrollElement=null,this._config=null,this._selector=null,this._offsets=null,this._targets=null,this._activeTarget=null,this._scrollHeight=null},n._getConfig=function(t){if("string"!=typeof(t=o(o({},Ii),"object"==typeof t&&t?t:{})).target&&m(t.target)){var e=t.target.id;e||(e=u(Di),t.target.id=e),t.target="#"+e}return _(Di,t,Pi),t},n._getScrollTop=function(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop},n._getScrollHeight=function(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)},n._getOffsetHeight=function(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height},n._process=function(){var t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),n=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=n){var i=this._targets[this._targets.length-1];this._activeTarget!==i&&this._activate(i)}else{if(this._activeTarget&&t0)return this._activeTarget=null,void this._clear();for(var o=this._offsets.length;o--;){this._activeTarget!==this._targets[o]&&t>=this._offsets[o]&&("undefined"==typeof this._offsets[o+1]||tt[r]-t[s]-1&&(t.reach[l]="end");e&&(f.dispatchEvent(so("ps-scroll-"+l)),e<0?f.dispatchEvent(so("ps-scroll-"+c)):e>0&&f.dispatchEvent(so("ps-scroll-"+u)),i&&function(t,e){eo(t,e),no(t,e)}(t,l));t.reach[l]&&(e||o)&&f.dispatchEvent(so("ps-"+l+"-reach-"+t.reach[l]))}(t,n,r,i,o)}function lo(t){return parseInt(t,10)||0}ro.prototype.eventElement=function(t){var e=this.eventElements.filter((function(e){return e.element===t}))[0];return e||(e=new io(t),this.eventElements.push(e)),e},ro.prototype.bind=function(t,e,n){this.eventElement(t).bind(e,n)},ro.prototype.unbind=function(t,e,n){var i=this.eventElement(t);i.unbind(e,n),i.isEmpty&&this.eventElements.splice(this.eventElements.indexOf(i),1)},ro.prototype.unbindAll=function(){this.eventElements.forEach((function(t){return t.unbindAll()})),this.eventElements=[]},ro.prototype.once=function(t,e,n){var i=this.eventElement(t),o=function(t){i.unbind(e,o),n(t)};i.bind(e,o)};var co={isWebKit:"undefined"!=typeof document&&"WebkitAppearance"in document.documentElement.style,supportsTouch:"undefined"!=typeof window&&("ontouchstart"in window||"maxTouchPoints"in window.navigator&&window.navigator.maxTouchPoints>0||window.DocumentTouch&&document instanceof window.DocumentTouch),supportsIePointer:"undefined"!=typeof navigator&&navigator.msMaxTouchPoints,isChrome:"undefined"!=typeof navigator&&/Chrome/i.test(navigator&&navigator.userAgent)};function uo(t){var e=t.element,n=Math.floor(e.scrollTop),i=e.getBoundingClientRect();t.containerWidth=Math.ceil(i.width),t.containerHeight=Math.ceil(i.height),t.contentWidth=e.scrollWidth,t.contentHeight=e.scrollHeight,e.contains(t.scrollbarXRail)||(Ki(e,Ji.rail("x")).forEach((function(t){return zi(t)})),e.appendChild(t.scrollbarXRail)),e.contains(t.scrollbarYRail)||(Ki(e,Ji.rail("y")).forEach((function(t){return zi(t)})),e.appendChild(t.scrollbarYRail)),!t.settings.suppressScrollX&&t.containerWidth+t.settings.scrollXMarginOffset=t.railXWidth-t.scrollbarXWidth&&(t.scrollbarXLeft=t.railXWidth-t.scrollbarXWidth),t.scrollbarYTop>=t.railYHeight-t.scrollbarYHeight&&(t.scrollbarYTop=t.railYHeight-t.scrollbarYHeight),function(t,e){var n={width:e.railXWidth},i=Math.floor(t.scrollTop);e.isRtl?n.left=e.negativeScrollAdjustment+t.scrollLeft+e.containerWidth-e.contentWidth:n.left=t.scrollLeft;e.isScrollbarXUsingBottom?n.bottom=e.scrollbarXBottom-i:n.top=e.scrollbarXTop+i;qi(e.scrollbarXRail,n);var o={top:i,height:e.railYHeight};e.isScrollbarYUsingRight?e.isRtl?o.right=e.contentWidth-(e.negativeScrollAdjustment+t.scrollLeft)-e.scrollbarYRight-e.scrollbarYOuterWidth-9:o.right=e.scrollbarYRight-t.scrollLeft:e.isRtl?o.left=e.negativeScrollAdjustment+t.scrollLeft+2*e.containerWidth-e.contentWidth-e.scrollbarYLeft-e.scrollbarYOuterWidth:o.left=e.scrollbarYLeft+t.scrollLeft;qi(e.scrollbarYRail,o),qi(e.scrollbarX,{left:e.scrollbarXLeft,width:e.scrollbarXWidth-e.railBorderXWidth}),qi(e.scrollbarY,{top:e.scrollbarYTop,height:e.scrollbarYHeight-e.railBorderYWidth})}(e,t),t.scrollbarXActive?e.classList.add(Zi.active("x")):(e.classList.remove(Zi.active("x")),t.scrollbarXWidth=0,t.scrollbarXLeft=0,e.scrollLeft=!0===t.isRtl?t.contentWidth:0),t.scrollbarYActive?e.classList.add(Zi.active("y")):(e.classList.remove(Zi.active("y")),t.scrollbarYHeight=0,t.scrollbarYTop=0,e.scrollTop=0)}function fo(t,e){return t.settings.minScrollbarLength&&(e=Math.max(e,t.settings.minScrollbarLength)),t.settings.maxScrollbarLength&&(e=Math.min(e,t.settings.maxScrollbarLength)),e}function ho(t,e){var n=e[0],i=e[1],o=e[2],r=e[3],s=e[4],a=e[5],l=e[6],c=e[7],u=e[8],f=t.element,h=null,d=null,p=null;function g(e){e.touches&&e.touches[0]&&(e[o]=e.touches[0].pageY),f[l]=h+p*(e[o]-d),eo(t,c),uo(t),e.stopPropagation(),e.preventDefault()}function m(){no(t,c),t[u].classList.remove(Zi.clicking),t.event.unbind(t.ownerDocument,"mousemove",g)}function v(e,s){h=f[l],s&&e.touches&&(e[o]=e.touches[0].pageY),d=e[o],p=(t[i]-t[n])/(t[r]-t[a]),s?t.event.bind(t.ownerDocument,"touchmove",g):(t.event.bind(t.ownerDocument,"mousemove",g),t.event.once(t.ownerDocument,"mouseup",m),e.preventDefault()),t[u].classList.add(Zi.clicking),e.stopPropagation()}t.event.bind(t[s],"mousedown",(function(t){v(t)})),t.event.bind(t[s],"touchstart",(function(t){v(t,!0)}))}var po={"click-rail":function(t){t.element,t.event.bind(t.scrollbarY,"mousedown",(function(t){return t.stopPropagation()})),t.event.bind(t.scrollbarYRail,"mousedown",(function(e){var n=e.pageY-window.pageYOffset-t.scrollbarYRail.getBoundingClientRect().top>t.scrollbarYTop?1:-1;t.element.scrollTop+=n*t.containerHeight,uo(t),e.stopPropagation()})),t.event.bind(t.scrollbarX,"mousedown",(function(t){return t.stopPropagation()})),t.event.bind(t.scrollbarXRail,"mousedown",(function(e){var n=e.pageX-window.pageXOffset-t.scrollbarXRail.getBoundingClientRect().left>t.scrollbarXLeft?1:-1;t.element.scrollLeft+=n*t.containerWidth,uo(t),e.stopPropagation()}))},"drag-thumb":function(t){ho(t,["containerWidth","contentWidth","pageX","railXWidth","scrollbarX","scrollbarXWidth","scrollLeft","x","scrollbarXRail"]),ho(t,["containerHeight","contentHeight","pageY","railYHeight","scrollbarY","scrollbarYHeight","scrollTop","y","scrollbarYRail"])},keyboard:function(t){var e=t.element;t.event.bind(t.ownerDocument,"keydown",(function(n){if(!(n.isDefaultPrevented&&n.isDefaultPrevented()||n.defaultPrevented)&&(Vi(e,":hover")||Vi(t.scrollbarX,":focus")||Vi(t.scrollbarY,":focus"))){var i,o=document.activeElement?document.activeElement:t.ownerDocument.activeElement;if(o){if("IFRAME"===o.tagName)o=o.contentDocument.activeElement;else for(;o.shadowRoot;)o=o.shadowRoot.activeElement;if(Vi(i=o,"input,[contenteditable]")||Vi(i,"select,[contenteditable]")||Vi(i,"textarea,[contenteditable]")||Vi(i,"button,[contenteditable]"))return}var r=0,s=0;switch(n.which){case 37:r=n.metaKey?-t.contentWidth:n.altKey?-t.containerWidth:-30;break;case 38:s=n.metaKey?t.contentHeight:n.altKey?t.containerHeight:30;break;case 39:r=n.metaKey?t.contentWidth:n.altKey?t.containerWidth:30;break;case 40:s=n.metaKey?-t.contentHeight:n.altKey?-t.containerHeight:-30;break;case 32:s=n.shiftKey?t.containerHeight:-t.containerHeight;break;case 33:s=t.containerHeight;break;case 34:s=-t.containerHeight;break;case 36:s=t.contentHeight;break;case 35:s=-t.contentHeight;break;default:return}t.settings.suppressScrollX&&0!==r||t.settings.suppressScrollY&&0!==s||(e.scrollTop-=s,e.scrollLeft+=r,uo(t),function(n,i){var o=Math.floor(e.scrollTop);if(0===n){if(!t.scrollbarYActive)return!1;if(0===o&&i>0||o>=t.contentHeight-t.containerHeight&&i<0)return!t.settings.wheelPropagation}var r=e.scrollLeft;if(0===i){if(!t.scrollbarXActive)return!1;if(0===r&&n<0||r>=t.contentWidth-t.containerWidth&&n>0)return!t.settings.wheelPropagation}return!0}(r,s)&&n.preventDefault())}}))},wheel:function(t){var e=t.element;function n(n){var i=function(t){var e=t.deltaX,n=-1*t.deltaY;return"undefined"!=typeof e&&"undefined"!=typeof n||(e=-1*t.wheelDeltaX/6,n=t.wheelDeltaY/6),t.deltaMode&&1===t.deltaMode&&(e*=10,n*=10),e!=e&&n!=n&&(e=0,n=t.wheelDelta),t.shiftKey?[-n,-e]:[e,n]}(n),o=i[0],r=i[1];if(!function(t,n,i){if(!co.isWebKit&&e.querySelector("select:focus"))return!0;if(!e.contains(t))return!1;for(var o=t;o&&o!==e;){if(o.classList.contains(Ji.consuming))return!0;var r=Ui(o);if(i&&r.overflowY.match(/(scroll|auto)/)){var s=o.scrollHeight-o.clientHeight;if(s>0&&(o.scrollTop>0&&i<0||o.scrollTop0))return!0}if(n&&r.overflowX.match(/(scroll|auto)/)){var a=o.scrollWidth-o.clientWidth;if(a>0&&(o.scrollLeft>0&&n<0||o.scrollLeft0))return!0}o=o.parentNode}return!1}(n.target,o,r)){var s=!1;t.settings.useBothWheelAxes?t.scrollbarYActive&&!t.scrollbarXActive?(r?e.scrollTop-=r*t.settings.wheelSpeed:e.scrollTop+=o*t.settings.wheelSpeed,s=!0):t.scrollbarXActive&&!t.scrollbarYActive&&(o?e.scrollLeft+=o*t.settings.wheelSpeed:e.scrollLeft-=r*t.settings.wheelSpeed,s=!0):(e.scrollTop-=r*t.settings.wheelSpeed,e.scrollLeft+=o*t.settings.wheelSpeed),uo(t),(s=s||function(n,i){var o=Math.floor(e.scrollTop),r=0===e.scrollTop,s=o+e.offsetHeight===e.scrollHeight,a=0===e.scrollLeft,l=e.scrollLeft+e.offsetWidth===e.scrollWidth;return!(Math.abs(i)>Math.abs(n)?r||s:a||l)||!t.settings.wheelPropagation}(o,r))&&!n.ctrlKey&&(n.stopPropagation(),n.preventDefault())}}"undefined"!=typeof window.onwheel?t.event.bind(e,"wheel",n):"undefined"!=typeof window.onmousewheel&&t.event.bind(e,"mousewheel",n)},touch:function(t){if(co.supportsTouch||co.supportsIePointer){var e=t.element,n={},i=0,o={},r=null;co.supportsTouch?(t.event.bind(e,"touchstart",c),t.event.bind(e,"touchmove",u),t.event.bind(e,"touchend",f)):co.supportsIePointer&&(window.PointerEvent?(t.event.bind(e,"pointerdown",c),t.event.bind(e,"pointermove",u),t.event.bind(e,"pointerup",f)):window.MSPointerEvent&&(t.event.bind(e,"MSPointerDown",c),t.event.bind(e,"MSPointerMove",u),t.event.bind(e,"MSPointerUp",f)))}function s(n,i){e.scrollTop-=i,e.scrollLeft-=n,uo(t)}function a(t){return t.targetTouches?t.targetTouches[0]:t}function l(t){return(!t.pointerType||"pen"!==t.pointerType||0!==t.buttons)&&(!(!t.targetTouches||1!==t.targetTouches.length)||!(!t.pointerType||"mouse"===t.pointerType||t.pointerType===t.MSPOINTER_TYPE_MOUSE))}function c(t){if(l(t)){var e=a(t);n.pageX=e.pageX,n.pageY=e.pageY,i=(new Date).getTime(),null!==r&&clearInterval(r)}}function u(r){if(l(r)){var c=a(r),u={pageX:c.pageX,pageY:c.pageY},f=u.pageX-n.pageX,h=u.pageY-n.pageY;if(function(t,n,i){if(!e.contains(t))return!1;for(var o=t;o&&o!==e;){if(o.classList.contains(Ji.consuming))return!0;var r=Ui(o);if(i&&r.overflowY.match(/(scroll|auto)/)){var s=o.scrollHeight-o.clientHeight;if(s>0&&(o.scrollTop>0&&i<0||o.scrollTop0))return!0}if(n&&r.overflowX.match(/(scroll|auto)/)){var a=o.scrollWidth-o.clientWidth;if(a>0&&(o.scrollLeft>0&&n<0||o.scrollLeft0))return!0}o=o.parentNode}return!1}(r.target,f,h))return;s(f,h),n=u;var d=(new Date).getTime(),p=d-i;p>0&&(o.x=f/p,o.y=h/p,i=d),function(n,i){var o=Math.floor(e.scrollTop),r=e.scrollLeft,s=Math.abs(n),a=Math.abs(i);if(a>s){if(i<0&&o===t.contentHeight-t.containerHeight||i>0&&0===o)return 0===window.scrollY&&i>0&&co.isChrome}else if(s>a&&(n<0&&r===t.contentWidth-t.containerWidth||n>0&&0===r))return!0;return!0}(f,h)&&r.preventDefault()}}function f(){t.settings.swipeEasing&&(clearInterval(r),r=setInterval((function(){t.isInitialized?clearInterval(r):o.x||o.y?Math.abs(o.x)<.01&&Math.abs(o.y)<.01?clearInterval(r):(s(30*o.x,30*o.y),o.x*=.8,o.y*=.8):clearInterval(r)}),10))}}},go=function(t,e){var n=this;if(void 0===e&&(e={}),"string"==typeof t&&(t=document.querySelector(t)),!t||!t.nodeName)throw new Error("no element is specified to initialize PerfectScrollbar");for(var i in this.element=t,t.classList.add($i),this.settings={handlers:["click-rail","drag-thumb","keyboard","wheel","touch"],maxScrollbarLength:null,minScrollbarLength:null,scrollingThreshold:1e3,scrollXMarginOffset:0,scrollYMarginOffset:0,suppressScrollX:!1,suppressScrollY:!1,swipeEasing:!0,useBothWheelAxes:!1,wheelPropagation:!0,wheelSpeed:1},e)this.settings[i]=e[i];this.containerWidth=null,this.containerHeight=null,this.contentWidth=null,this.contentHeight=null;var o,r,s=function(){return t.classList.add(Zi.focus)},a=function(){return t.classList.remove(Zi.focus)};this.isRtl="rtl"===Ui(t).direction,!0===this.isRtl&&t.classList.add(Gi),this.isNegativeScroll=(r=t.scrollLeft,t.scrollLeft=-1,o=t.scrollLeft<0,t.scrollLeft=r,o),this.negativeScrollAdjustment=this.isNegativeScroll?t.scrollWidth-t.clientWidth:0,this.event=new ro,this.ownerDocument=t.ownerDocument||document,this.scrollbarXRail=Qi(Ji.rail("x")),t.appendChild(this.scrollbarXRail),this.scrollbarX=Qi(Ji.thumb("x")),this.scrollbarXRail.appendChild(this.scrollbarX),this.scrollbarX.setAttribute("tabindex",0),this.event.bind(this.scrollbarX,"focus",s),this.event.bind(this.scrollbarX,"blur",a),this.scrollbarXActive=null,this.scrollbarXWidth=null,this.scrollbarXLeft=null;var l=Ui(this.scrollbarXRail);this.scrollbarXBottom=parseInt(l.bottom,10),isNaN(this.scrollbarXBottom)?(this.isScrollbarXUsingBottom=!1,this.scrollbarXTop=lo(l.top)):this.isScrollbarXUsingBottom=!0,this.railBorderXWidth=lo(l.borderLeftWidth)+lo(l.borderRightWidth),qi(this.scrollbarXRail,{display:"block"}),this.railXMarginWidth=lo(l.marginLeft)+lo(l.marginRight),qi(this.scrollbarXRail,{display:""}),this.railXWidth=null,this.railXRatio=null,this.scrollbarYRail=Qi(Ji.rail("y")),t.appendChild(this.scrollbarYRail),this.scrollbarY=Qi(Ji.thumb("y")),this.scrollbarYRail.appendChild(this.scrollbarY),this.scrollbarY.setAttribute("tabindex",0),this.event.bind(this.scrollbarY,"focus",s),this.event.bind(this.scrollbarY,"blur",a),this.scrollbarYActive=null,this.scrollbarYHeight=null,this.scrollbarYTop=null;var c=Ui(this.scrollbarYRail);this.scrollbarYRight=parseInt(c.right,10),isNaN(this.scrollbarYRight)?(this.isScrollbarYUsingRight=!1,this.scrollbarYLeft=lo(c.left)):this.isScrollbarYUsingRight=!0,this.scrollbarYOuterWidth=this.isRtl?function(t){var e=Ui(t);return lo(e.width)+lo(e.paddingLeft)+lo(e.paddingRight)+lo(e.borderLeftWidth)+lo(e.borderRightWidth)}(this.scrollbarY):null,this.railBorderYWidth=lo(c.borderTopWidth)+lo(c.borderBottomWidth),qi(this.scrollbarYRail,{display:"block"}),this.railYMarginHeight=lo(c.marginTop)+lo(c.marginBottom),qi(this.scrollbarYRail,{display:""}),this.railYHeight=null,this.railYRatio=null,this.reach={x:t.scrollLeft<=0?"start":t.scrollLeft>=this.contentWidth-this.containerWidth?"end":null,y:t.scrollTop<=0?"start":t.scrollTop>=this.contentHeight-this.containerHeight?"end":null},this.isAlive=!0,this.settings.handlers.forEach((function(t){return po[t](n)})),this.lastScrollTop=Math.floor(t.scrollTop),this.lastScrollLeft=t.scrollLeft,this.event.bind(this.element,"scroll",(function(t){return n.onScroll(t)})),uo(this)};go.prototype.update=function(){this.isAlive&&(this.negativeScrollAdjustment=this.isNegativeScroll?this.element.scrollWidth-this.element.clientWidth:0,qi(this.scrollbarXRail,{display:"block"}),qi(this.scrollbarYRail,{display:"block"}),this.railXMarginWidth=lo(Ui(this.scrollbarXRail).marginLeft)+lo(Ui(this.scrollbarXRail).marginRight),this.railYMarginHeight=lo(Ui(this.scrollbarYRail).marginTop)+lo(Ui(this.scrollbarYRail).marginBottom),qi(this.scrollbarXRail,{display:"none"}),qi(this.scrollbarYRail,{display:"none"}),uo(this),ao(this,"top",0,!1,!0),ao(this,"left",0,!1,!0),qi(this.scrollbarXRail,{display:""}),qi(this.scrollbarYRail,{display:""}))},go.prototype.onScroll=function(t){this.isAlive&&(uo(this),ao(this,"top",this.element.scrollTop-this.lastScrollTop),ao(this,"left",this.element.scrollLeft-this.lastScrollLeft),this.lastScrollTop=Math.floor(this.element.scrollTop),this.lastScrollLeft=this.element.scrollLeft)},go.prototype.destroy=function(){this.isAlive&&(this.event.unbindAll(),zi(this.scrollbarX),zi(this.scrollbarY),zi(this.scrollbarXRail),zi(this.scrollbarYRail),this.removePsClasses(),this.element=null,this.scrollbarX=null,this.scrollbarY=null,this.scrollbarXRail=null,this.scrollbarYRail=null,this.isAlive=!1)},go.prototype.removePsClasses=function(){this.element.className=this.element.className.split(" ").filter((function(t){return!t.match(/^ps([-_].+|)$/)})).join(" ")};var mo="sidebar",vo="coreui.sidebar",_o={activeLinksExact:!0,breakpoints:{xs:"c-sidebar-show",sm:"c-sidebar-sm-show",md:"c-sidebar-md-show",lg:"c-sidebar-lg-show",xl:"c-sidebar-xl-show",xxl:"c-sidebar-xxl-show"},dropdownAccordion:!0},bo={activeLinksExact:"boolean",breakpoints:"object",dropdownAccordion:"(string|boolean)"},yo="c-active",wo="c-sidebar-nav-dropdown",Eo="c-show",Lo="c-sidebar-minimized",To="c-sidebar-unfoldable",ko="click.coreui.sidebar.data-api",Oo=".c-sidebar-nav-dropdown-toggle",Co=".c-sidebar-nav-dropdown",So=".c-sidebar-nav-link",Ao=".c-sidebar-nav",xo=".c-sidebar",Do=function(){function t(t,e){if("undefined"==typeof go)throw new TypeError("CoreUI's sidebar require Perfect Scrollbar");this._element=t,this._config=this._getConfig(e),this._open=this._isVisible(),this._mobile=this._isMobile(),this._overlaid=this._isOverlaid(),this._minimize=this._isMinimized(),this._unfoldable=this._isUnfoldable(),this._setActiveLink(),this._ps=null,this._backdrop=null,this._psInit(),this._addEventListeners(),k(t,vo,this)}var n=t.prototype;return n.open=function(t){var e=this;F.trigger(this._element,"open.coreui.sidebar"),this._isMobile()?(this._addClassName(this._firstBreakpointClassName()),this._showBackdrop(),F.one(this._element,c,(function(){e._addClickOutListener()}))):t?(this._addClassName(this._getBreakpointClassName(t)),this._isOverlaid()&&F.one(this._element,c,(function(){e._addClickOutListener()}))):(this._addClassName(this._firstBreakpointClassName()),this._isOverlaid()&&F.one(this._element,c,(function(){e._addClickOutListener()})));var n=p(this._element);F.one(this._element,c,(function(){!0===e._isVisible()&&(e._open=!0,F.trigger(e._element,"opened.coreui.sidebar"))})),v(this._element,n)},n.close=function(t){var e=this;F.trigger(this._element,"close.coreui.sidebar"),this._isMobile()?(this._element.classList.remove(this._firstBreakpointClassName()),this._removeBackdrop(),this._removeClickOutListener()):t?(this._element.classList.remove(this._getBreakpointClassName(t)),this._isOverlaid()&&this._removeClickOutListener()):(this._element.classList.remove(this._firstBreakpointClassName()),this._isOverlaid()&&this._removeClickOutListener());var n=p(this._element);F.one(this._element,c,(function(){!1===e._isVisible()&&(e._open=!1,F.trigger(e._element,"closed.coreui.sidebar"))})),v(this._element,n)},n.toggle=function(t){this._open?this.close(t):this.open(t)},n.minimize=function(){this._isMobile()||(this._addClassName(Lo),this._minimize=!0,this._psDestroy())},n.unfoldable=function(){this._isMobile()||(this._addClassName(To),this._unfoldable=!0)},n.reset=function(){this._element.classList.contains(Lo)&&(this._element.classList.remove(Lo),this._minimize=!1,F.one(this._element,c,this._psInit())),this._element.classList.contains(To)&&(this._element.classList.remove(To),this._unfoldable=!1)},n._getConfig=function(t){return t=o(o(o({},this.constructor.Default),bt.getDataAttributes(this._element)),t),_(mo,t,this.constructor.DefaultType),t},n._isMobile=function(){return Boolean(window.getComputedStyle(this._element,null).getPropertyValue("--is-mobile"))},n._isIOS=function(){var t=["iPad Simulator","iPhone Simulator","iPod Simulator","iPad","iPhone","iPod"];if(Boolean(navigator.platform))for(;t.length;)if(navigator.platform===t.pop())return!0;return!1},n._isMinimized=function(){return this._element.classList.contains(Lo)},n._isOverlaid=function(){return this._element.classList.contains("c-sidebar-overlaid")},n._isUnfoldable=function(){return this._element.classList.contains(To)},n._isVisible=function(){var t=this._element.getBoundingClientRect();return t.top>=0&&t.left>=0&&t.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&t.right<=(window.innerWidth||document.documentElement.clientWidth)},n._addClassName=function(t){this._element.classList.add(t)},n._firstBreakpointClassName=function(){return Object.keys(_o.breakpoints).map((function(t){return _o.breakpoints[t]}))[0]},n._getBreakpointClassName=function(t){return _o.breakpoints[t]},n._removeBackdrop=function(){this._backdrop&&(this._backdrop.parentNode.removeChild(this._backdrop),this._backdrop=null)},n._showBackdrop=function(){this._backdrop||(this._backdrop=document.createElement("div"),this._backdrop.className="c-sidebar-backdrop",this._backdrop.classList.add("c-fade"),document.body.appendChild(this._backdrop),E(this._backdrop),this._backdrop.classList.add(Eo))},n._clickOutListener=function(t,e){null===t.target.closest(xo)&&(t.preventDefault(),t.stopPropagation(),e.close())},n._addClickOutListener=function(){var t=this;F.on(document,ko,(function(e){t._clickOutListener(e,t)}))},n._removeClickOutListener=function(){F.off(document,ko)},n._getAllSiblings=function(t,e){var n=[];t=t.parentNode.firstChild;do{3!==t.nodeType&&8!==t.nodeType&&(e&&!e(t)||n.push(t))}while(t=t.nextSibling);return n},n._toggleDropdown=function(t,e){var n=t.target;n.classList.contains("c-sidebar-nav-dropdown-toggle")||(n=n.closest(Oo));var i=n.closest(Ao).dataset;"undefined"!=typeof i.dropdownAccordion&&(_o.dropdownAccordion=JSON.parse(i.dropdownAccordion)),!0===_o.dropdownAccordion&&this._getAllSiblings(n.parentElement,(function(t){return Boolean(t.classList.contains(wo))})).forEach((function(t){t!==n.parentNode&&t.classList.contains(wo)&&t.classList.remove(Eo)})),n.parentNode.classList.toggle(Eo),e._psUpdate()},n._psInit=function(){this._element.querySelector(Ao)&&!this._isIOS()&&(this._ps=new go(this._element.querySelector(Ao),{suppressScrollX:!0,wheelPropagation:!1}))},n._psUpdate=function(){this._ps&&this._ps.update()},n._psDestroy=function(){this._ps&&(this._ps.destroy(),this._ps=null)},n._getParents=function(t,e){for(var n=[];t&&t!==document;t=t.parentNode)e?t.matches(e)&&n.push(t):n.push(t);return n},n._setActiveLink=function(){var t=this;Array.from(this._element.querySelectorAll(So)).forEach((function(e){var n=String(window.location);(/\?.*=/.test(n)||/\?./.test(n))&&(n=n.split("?")[0]),/#./.test(n)&&(n=n.split("#")[0]);var i=e.closest(Ao).dataset;"undefined"!=typeof i.activeLinksExact&&(_o.activeLinksExact=JSON.parse(i.activeLinksExact)),_o.activeLinksExact&&e.href===n&&(e.classList.add(yo),Array.from(t._getParents(e,Co)).forEach((function(t){t.classList.add(Eo)}))),!_o.activeLinksExact&&e.href.startsWith(n)&&(e.classList.add(yo),Array.from(t._getParents(e,Co)).forEach((function(t){t.classList.add(Eo)})))}))},n._addEventListeners=function(){var t=this;this._mobile&&this._open&&this._addClickOutListener(),this._overlaid&&this._open&&this._addClickOutListener(),F.on(this._element,"classtoggle",(function(e){if(e.detail.className===Lo&&(t._element.classList.contains(Lo)?t.minimize():t.reset()),e.detail.className===To&&(t._element.classList.contains(To)?t.unfoldable():t.reset()),"undefined"!=typeof Object.keys(_o.breakpoints).find((function(t){return _o.breakpoints[t]===e.detail.className}))){var n=e.detail.className,i=Object.keys(_o.breakpoints).find((function(t){return _o.breakpoints[t]===n}));e.detail.add?t.open(i):t.close(i)}})),F.on(this._element,ko,Oo,(function(e){e.preventDefault(),t._toggleDropdown(e,t)})),F.on(this._element,ko,So,(function(){t._isMobile()&&t.close()}))},t._sidebarInterface=function(e,n){var i=O(e,vo);if(i||(i=new t(e,"object"==typeof n&&n)),"string"==typeof n){if("undefined"==typeof i[n])throw new TypeError('No method named "'+n+'"');i[n]()}},t.jQueryInterface=function(e){return this.each((function(){t._sidebarInterface(this,e)}))},t.getInstance=function(t){return O(t,vo)},e(t,null,[{key:"VERSION",get:function(){return"3.2.2"}},{key:"Default",get:function(){return _o}},{key:"DefaultType",get:function(){return bo}}]),t}();F.on(window,"load.coreui.sidebar.data-api",(function(){Array.from(document.querySelectorAll(xo)).forEach((function(t){Do._sidebarInterface(t)}))}));var jo=L();if(jo){var No=jo.fn.sidebar;jo.fn.sidebar=Do.jQueryInterface,jo.fn.sidebar.Constructor=Do,jo.fn.sidebar.noConflict=function(){return jo.fn.sidebar=No,Do.jQueryInterface}}var Io="coreui.tab",Po="active",Ro="fade",Mo="show",Ho=".active",Wo=":scope > li > .active",Yo=function(){function t(t){this._element=t,k(this._element,Io,this)}var n=t.prototype;return n.show=function(){var t=this;if(!(this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE&&this._element.classList.contains(Po)||this._element.classList.contains("disabled"))){var e,n=d(this._element),i=this._element.closest(".nav, .list-group");if(i){var o="UL"===i.nodeName||"OL"===i.nodeName?Wo:Ho;e=(e=at.find(o,i))[e.length-1]}var r=null;if(e&&(r=F.trigger(e,"hide.coreui.tab",{relatedTarget:this._element})),!(F.trigger(this._element,"show.coreui.tab",{relatedTarget:e}).defaultPrevented||null!==r&&r.defaultPrevented)){this._activate(this._element,i);var s=function(){F.trigger(e,"hidden.coreui.tab",{relatedTarget:t._element}),F.trigger(t._element,"shown.coreui.tab",{relatedTarget:e})};n?this._activate(n,n.parentNode,s):s()}}},n.dispose=function(){C(this._element,Io),this._element=null},n._activate=function(t,e,n){var i=this,o=(!e||"UL"!==e.nodeName&&"OL"!==e.nodeName?at.children(e,Ho):at.find(Wo,e))[0],r=n&&o&&o.classList.contains(Ro),s=function(){return i._transitionComplete(t,o,n)};if(o&&r){var a=p(o);o.classList.remove(Mo),F.one(o,c,s),v(o,a)}else s()},n._transitionComplete=function(t,e,n){if(e){e.classList.remove(Po);var i=at.findOne(":scope > .dropdown-menu .active",e.parentNode);i&&i.classList.remove(Po),"tab"===e.getAttribute("role")&&e.setAttribute("aria-selected",!1)}(t.classList.add(Po),"tab"===t.getAttribute("role")&&t.setAttribute("aria-selected",!0),E(t),t.classList.contains(Ro)&&t.classList.add(Mo),t.parentNode&&t.parentNode.classList.contains("dropdown-menu"))&&(t.closest(".dropdown")&&at.find(".dropdown-toggle").forEach((function(t){return t.classList.add(Po)})),t.setAttribute("aria-expanded",!0));n&&n()},t.jQueryInterface=function(e){return this.each((function(){var n=O(this,Io)||new t(this);if("string"==typeof e){if("undefined"==typeof n[e])throw new TypeError('No method named "'+e+'"');n[e]()}}))},t.getInstance=function(t){return O(t,Io)},e(t,null,[{key:"VERSION",get:function(){return"3.2.2"}}]),t}();F.on(document,"click.coreui.tab.data-api",'[data-toggle="tab"], [data-toggle="pill"], [data-toggle="list"]',(function(t){t.preventDefault(),(O(this,Io)||new Yo(this)).show()}));var Xo=L();if(Xo){var Bo=Xo.fn.tab;Xo.fn.tab=Yo.jQueryInterface,Xo.fn.tab.Constructor=Yo,Xo.fn.tab.noConflict=function(){return Xo.fn.tab=Bo,Yo.jQueryInterface}}var Uo,qo,Qo,Fo,Vo,zo="toast",Ko="coreui.toast",$o="."+Ko,Go="click.dismiss"+$o,Jo="hide",Zo="show",tr="showing",er={animation:"boolean",autohide:"boolean",delay:"number"},nr={animation:!0,autohide:!0,delay:5e3},ir=function(){function t(t,e){this._element=t,this._config=this._getConfig(e),this._timeout=null,this._setListeners(),k(t,Ko,this)}var n=t.prototype;return n.show=function(){var t=this;if(!F.trigger(this._element,"show.coreui.toast").defaultPrevented){this._clearTimeout(),this._config.animation&&this._element.classList.add("fade");var e=function(){t._element.classList.remove(tr),t._element.classList.add(Zo),F.trigger(t._element,"shown.coreui.toast"),t._config.autohide&&(t._timeout=setTimeout((function(){t.hide()}),t._config.delay))};if(this._element.classList.remove(Jo),E(this._element),this._element.classList.add(tr),this._config.animation){var n=p(this._element);F.one(this._element,c,e),v(this._element,n)}else e()}},n.hide=function(){var t=this;if(this._element.classList.contains(Zo)&&!F.trigger(this._element,"hide.coreui.toast").defaultPrevented){var e=function(){t._element.classList.add(Jo),F.trigger(t._element,"hidden.coreui.toast")};if(this._element.classList.remove(Zo),this._config.animation){var n=p(this._element);F.one(this._element,c,e),v(this._element,n)}else e()}},n.dispose=function(){this._clearTimeout(),this._element.classList.contains(Zo)&&this._element.classList.remove(Zo),F.off(this._element,Go),C(this._element,Ko),this._element=null,this._config=null},n._getConfig=function(t){return t=o(o(o({},nr),bt.getDataAttributes(this._element)),"object"==typeof t&&t?t:{}),_(zo,t,this.constructor.DefaultType),t},n._setListeners=function(){var t=this;F.on(this._element,Go,'[data-dismiss="toast"]',(function(){return t.hide()}))},n._clearTimeout=function(){clearTimeout(this._timeout),this._timeout=null},t.jQueryInterface=function(e){return this.each((function(){var n=O(this,Ko);if(n||(n=new t(this,"object"==typeof e&&e)),"string"==typeof e){if("undefined"==typeof n[e])throw new TypeError('No method named "'+e+'"');n[e](this)}}))},t.getInstance=function(t){return O(t,Ko)},e(t,null,[{key:"VERSION",get:function(){return"3.2.2"}},{key:"DefaultType",get:function(){return er}},{key:"Default",get:function(){return nr}}]),t}(),or=L();if(or){var rr=or.fn.toast;or.fn.toast=ir.jQueryInterface,or.fn.toast.Constructor=ir,or.fn.toast.noConflict=function(){return or.fn.toast=rr,ir.jQueryInterface}}return Array.from||(Array.from=(Uo=Object.prototype.toString,qo=function(t){return"function"==typeof t||"[object Function]"===Uo.call(t)},Qo=Math.pow(2,53)-1,Fo=function(t){var e=function(t){var e=Number(t);return isNaN(e)?0:0!==e&&isFinite(e)?(e>0?1:-1)*Math.floor(Math.abs(e)):e}(t);return Math.min(Math.max(e,0),Qo)},function(t){var e=this,n=Object(t);if(null==t)throw new TypeError("Array.from requires an array-like object - not null or undefined");var i,o=arguments.length>1?arguments[1]:void 0;if("undefined"!=typeof o){if(!qo(o))throw new TypeError("Array.from: when provided, the second argument must be a function");arguments.length>2&&(i=arguments[2])}for(var r,s=Fo(n.length),a=qo(e)?Object(new e(s)):new Array(s),l=0;l=0&&e.item(n)!==this;);return n>-1}),Array.prototype.find||Object.defineProperty(Array.prototype,"find",{value:function(t){if(null==this)throw new TypeError('"this" is null or not defined');var e=Object(this),n=e.length>>>0;if("function"!=typeof t)throw new TypeError("predicate must be a function");for(var i=arguments[1],o=0;o-1||(t.prototype=window.Event.prototype,window.CustomEvent=t)}(),{AsyncLoad:tt,Alert:ot,Button:pt,Carousel:Dt,ClassToggler:Xt,Collapse:te,Dropdown:Cn,Modal:Kn,Popover:Si,Scrollspy:Yi,Sidebar:Do,Tab:Yo,Toast:ir,Tooltip:_i}})); +//# sourceMappingURL=coreui.bundle.min.js.map diff --git a/web/public/js/jquery-confirm.min.js b/web/public/js/jquery-confirm.min.js new file mode 100644 index 0000000..2939dc6 --- /dev/null +++ b/web/public/js/jquery-confirm.min.js @@ -0,0 +1,10 @@ +/*! + * jquery-confirm v3.3.4 (http://craftpip.github.io/jquery-confirm/) + * Author: Boniface Pereira + * Website: www.craftpip.com + * Contact: hey@craftpip.com + * + * Copyright 2013-2019 jquery-confirm + * Licensed under MIT (https://github.com/craftpip/jquery-confirm/blob/master/LICENSE) + */ +(function(factory){if(typeof define==="function"&&define.amd){define(["jquery"],factory);}else{if(typeof module==="object"&&module.exports){module.exports=function(root,jQuery){if(jQuery===undefined){if(typeof window!=="undefined"){jQuery=require("jquery");}else{jQuery=require("jquery")(root);}}factory(jQuery);return jQuery;};}else{factory(jQuery);}}}(function($){var w=window;$.fn.confirm=function(options,option2){if(typeof options==="undefined"){options={};}if(typeof options==="string"){options={content:options,title:(option2)?option2:false};}$(this).each(function(){var $this=$(this);if($this.attr("jc-attached")){console.warn("jConfirm has already been attached to this element ",$this[0]);return;}$this.on("click",function(e){e.preventDefault();var jcOption=$.extend({},options);if($this.attr("data-title")){jcOption.title=$this.attr("data-title");}if($this.attr("data-content")){jcOption.content=$this.attr("data-content");}if(typeof jcOption.buttons==="undefined"){jcOption.buttons={};}jcOption["$target"]=$this;if($this.attr("href")&&Object.keys(jcOption.buttons).length===0){var buttons=$.extend(true,{},w.jconfirm.pluginDefaults.defaultButtons,(w.jconfirm.defaults||{}).defaultButtons||{});var firstBtn=Object.keys(buttons)[0];jcOption.buttons=buttons;jcOption.buttons[firstBtn].action=function(){location.href=$this.attr("href");};}jcOption.closeIcon=false;var instance=$.confirm(jcOption);});$this.attr("jc-attached",true);});return $(this);};$.confirm=function(options,option2){if(typeof options==="undefined"){options={};}if(typeof options==="string"){options={content:options,title:(option2)?option2:false};}var putDefaultButtons=!(options.buttons===false);if(typeof options.buttons!=="object"){options.buttons={};}if(Object.keys(options.buttons).length===0&&putDefaultButtons){var buttons=$.extend(true,{},w.jconfirm.pluginDefaults.defaultButtons,(w.jconfirm.defaults||{}).defaultButtons||{});options.buttons=buttons;}return w.jconfirm(options);};$.alert=function(options,option2){if(typeof options==="undefined"){options={};}if(typeof options==="string"){options={content:options,title:(option2)?option2:false};}var putDefaultButtons=!(options.buttons===false);if(typeof options.buttons!=="object"){options.buttons={};}if(Object.keys(options.buttons).length===0&&putDefaultButtons){var buttons=$.extend(true,{},w.jconfirm.pluginDefaults.defaultButtons,(w.jconfirm.defaults||{}).defaultButtons||{});var firstBtn=Object.keys(buttons)[0];options.buttons[firstBtn]=buttons[firstBtn];}return w.jconfirm(options);};$.dialog=function(options,option2){if(typeof options==="undefined"){options={};}if(typeof options==="string"){options={content:options,title:(option2)?option2:false,closeIcon:function(){}};}options.buttons={};if(typeof options.closeIcon==="undefined"){options.closeIcon=function(){};}options.confirmKeys=[13];return w.jconfirm(options);};w.jconfirm=function(options){if(typeof options==="undefined"){options={};}var pluginOptions=$.extend(true,{},w.jconfirm.pluginDefaults);if(w.jconfirm.defaults){pluginOptions=$.extend(true,pluginOptions,w.jconfirm.defaults);}pluginOptions=$.extend(true,{},pluginOptions,options);var instance=new w.Jconfirm(pluginOptions);w.jconfirm.instances.push(instance);return instance;};w.Jconfirm=function(options){$.extend(this,options);this._init();};w.Jconfirm.prototype={_init:function(){var that=this;if(!w.jconfirm.instances.length){w.jconfirm.lastFocused=$("body").find(":focus");}this._id=Math.round(Math.random()*99999);this.contentParsed=$(document.createElement("div"));if(!this.lazyOpen){setTimeout(function(){that.open();},0);}},_buildHTML:function(){var that=this;this._parseAnimation(this.animation,"o");this._parseAnimation(this.closeAnimation,"c");this._parseBgDismissAnimation(this.backgroundDismissAnimation);this._parseColumnClass(this.columnClass);this._parseTheme(this.theme);this._parseType(this.type);var template=$(this.template);template.find(".jconfirm-box").addClass(this.animationParsed).addClass(this.backgroundDismissAnimationParsed).addClass(this.typeParsed);if(this.typeAnimated){template.find(".jconfirm-box").addClass("jconfirm-type-animated");}if(this.useBootstrap){template.find(".jc-bs3-row").addClass(this.bootstrapClasses.row);template.find(".jc-bs3-row").addClass("justify-content-md-center justify-content-sm-center justify-content-xs-center justify-content-lg-center");template.find(".jconfirm-box-container").addClass(this.columnClassParsed);if(this.containerFluid){template.find(".jc-bs3-container").addClass(this.bootstrapClasses.containerFluid);}else{template.find(".jc-bs3-container").addClass(this.bootstrapClasses.container);}}else{template.find(".jconfirm-box").css("width",this.boxWidth);}if(this.titleClass){template.find(".jconfirm-title-c").addClass(this.titleClass);}template.addClass(this.themeParsed);var ariaLabel="jconfirm-box"+this._id;template.find(".jconfirm-box").attr("aria-labelledby",ariaLabel).attr("tabindex",-1);template.find(".jconfirm-content").attr("id",ariaLabel);if(this.bgOpacity!==null){template.find(".jconfirm-bg").css("opacity",this.bgOpacity);}if(this.rtl){template.addClass("jconfirm-rtl");}this.$el=template.appendTo(this.container);this.$jconfirmBoxContainer=this.$el.find(".jconfirm-box-container");this.$jconfirmBox=this.$body=this.$el.find(".jconfirm-box");this.$jconfirmBg=this.$el.find(".jconfirm-bg");this.$title=this.$el.find(".jconfirm-title");this.$titleContainer=this.$el.find(".jconfirm-title-c");this.$content=this.$el.find("div.jconfirm-content");this.$contentPane=this.$el.find(".jconfirm-content-pane");this.$icon=this.$el.find(".jconfirm-icon-c");this.$closeIcon=this.$el.find(".jconfirm-closeIcon");this.$holder=this.$el.find(".jconfirm-holder");this.$btnc=this.$el.find(".jconfirm-buttons");this.$scrollPane=this.$el.find(".jconfirm-scrollpane");that.setStartingPoint();this._contentReady=$.Deferred();this._modalReady=$.Deferred();this.$holder.css({"padding-top":this.offsetTop,"padding-bottom":this.offsetBottom,});this.setTitle();this.setIcon();this._setButtons();this._parseContent();this.initDraggable();if(this.isAjax){this.showLoading(false);}$.when(this._contentReady,this._modalReady).then(function(){if(that.isAjaxLoading){setTimeout(function(){that.isAjaxLoading=false;that.setContent();that.setTitle();that.setIcon();setTimeout(function(){that.hideLoading(false);that._updateContentMaxHeight();},100);if(typeof that.onContentReady==="function"){that.onContentReady();}},50);}else{that._updateContentMaxHeight();that.setTitle();that.setIcon();if(typeof that.onContentReady==="function"){that.onContentReady();}}if(that.autoClose){that._startCountDown();}}).then(function(){that._watchContent();});if(this.animation==="none"){this.animationSpeed=1;this.animationBounce=1;}this.$body.css(this._getCSS(this.animationSpeed,this.animationBounce));this.$contentPane.css(this._getCSS(this.animationSpeed,1));this.$jconfirmBg.css(this._getCSS(this.animationSpeed,1));this.$jconfirmBoxContainer.css(this._getCSS(this.animationSpeed,1));},_typePrefix:"jconfirm-type-",typeParsed:"",_parseType:function(type){this.typeParsed=this._typePrefix+type;},setType:function(type){var oldClass=this.typeParsed;this._parseType(type);this.$jconfirmBox.removeClass(oldClass).addClass(this.typeParsed);},themeParsed:"",_themePrefix:"jconfirm-",setTheme:function(theme){var previous=this.theme;this.theme=theme||this.theme;this._parseTheme(this.theme);if(previous){this.$el.removeClass(previous);}this.$el.addClass(this.themeParsed);this.theme=theme;},_parseTheme:function(theme){var that=this;theme=theme.split(",");$.each(theme,function(k,a){if(a.indexOf(that._themePrefix)===-1){theme[k]=that._themePrefix+$.trim(a);}});this.themeParsed=theme.join(" ").toLowerCase();},backgroundDismissAnimationParsed:"",_bgDismissPrefix:"jconfirm-hilight-",_parseBgDismissAnimation:function(bgDismissAnimation){var animation=bgDismissAnimation.split(",");var that=this;$.each(animation,function(k,a){if(a.indexOf(that._bgDismissPrefix)===-1){animation[k]=that._bgDismissPrefix+$.trim(a);}});this.backgroundDismissAnimationParsed=animation.join(" ").toLowerCase();},animationParsed:"",closeAnimationParsed:"",_animationPrefix:"jconfirm-animation-",setAnimation:function(animation){this.animation=animation||this.animation;this._parseAnimation(this.animation,"o");},_parseAnimation:function(animation,which){which=which||"o";var animations=animation.split(",");var that=this;$.each(animations,function(k,a){if(a.indexOf(that._animationPrefix)===-1){animations[k]=that._animationPrefix+$.trim(a);}});var a_string=animations.join(" ").toLowerCase();if(which==="o"){this.animationParsed=a_string;}else{this.closeAnimationParsed=a_string;}return a_string;},setCloseAnimation:function(closeAnimation){this.closeAnimation=closeAnimation||this.closeAnimation;this._parseAnimation(this.closeAnimation,"c");},setAnimationSpeed:function(speed){this.animationSpeed=speed||this.animationSpeed;},columnClassParsed:"",setColumnClass:function(colClass){if(!this.useBootstrap){console.warn("cannot set columnClass, useBootstrap is set to false");return;}this.columnClass=colClass||this.columnClass;this._parseColumnClass(this.columnClass);this.$jconfirmBoxContainer.addClass(this.columnClassParsed);},_updateContentMaxHeight:function(){var height=$(window).height()-(this.$jconfirmBox.outerHeight()-this.$contentPane.outerHeight())-(this.offsetTop+this.offsetBottom);this.$contentPane.css({"max-height":height+"px"});},setBoxWidth:function(width){if(this.useBootstrap){console.warn("cannot set boxWidth, useBootstrap is set to true");return;}this.boxWidth=width;this.$jconfirmBox.css("width",width);},_parseColumnClass:function(colClass){colClass=colClass.toLowerCase();var p;switch(colClass){case"xl":case"xlarge":p="col-md-12";break;case"l":case"large":p="col-md-8 col-md-offset-2";break;case"m":case"medium":p="col-md-6 col-md-offset-3";break;case"s":case"small":p="col-md-4 col-md-offset-4";break;case"xs":case"xsmall":p="col-md-2 col-md-offset-5";break;default:p=colClass;}this.columnClassParsed=p;},initDraggable:function(){var that=this;var $t=this.$titleContainer;this.resetDrag();if(this.draggable){$t.on("mousedown",function(e){$t.addClass("jconfirm-hand");that.mouseX=e.clientX;that.mouseY=e.clientY;that.isDrag=true;});$(window).on("mousemove."+this._id,function(e){if(that.isDrag){that.movingX=e.clientX-that.mouseX+that.initialX;that.movingY=e.clientY-that.mouseY+that.initialY;that.setDrag();}});$(window).on("mouseup."+this._id,function(){$t.removeClass("jconfirm-hand");if(that.isDrag){that.isDrag=false;that.initialX=that.movingX;that.initialY=that.movingY;}});}},resetDrag:function(){this.isDrag=false;this.initialX=0;this.initialY=0;this.movingX=0;this.movingY=0;this.mouseX=0;this.mouseY=0;this.$jconfirmBoxContainer.css("transform","translate("+0+"px, "+0+"px)");},setDrag:function(){if(!this.draggable){return;}this.alignMiddle=false;var boxWidth=this.$jconfirmBox.outerWidth();var boxHeight=this.$jconfirmBox.outerHeight();var windowWidth=$(window).width();var windowHeight=$(window).height();var that=this;var dragUpdate=1;if(that.movingX%dragUpdate===0||that.movingY%dragUpdate===0){if(that.dragWindowBorder){var leftDistance=(windowWidth/2)-boxWidth/2;var topDistance=(windowHeight/2)-boxHeight/2;topDistance-=that.dragWindowGap;leftDistance-=that.dragWindowGap;if(leftDistance+that.movingX<0){that.movingX=-leftDistance;}else{if(leftDistance-that.movingX<0){that.movingX=leftDistance;}}if(topDistance+that.movingY<0){that.movingY=-topDistance;}else{if(topDistance-that.movingY<0){that.movingY=topDistance;}}}that.$jconfirmBoxContainer.css("transform","translate("+that.movingX+"px, "+that.movingY+"px)");}},_scrollTop:function(){if(typeof pageYOffset!=="undefined"){return pageYOffset;}else{var B=document.body;var D=document.documentElement;D=(D.clientHeight)?D:B;return D.scrollTop;}},_watchContent:function(){var that=this;if(this._timer){clearInterval(this._timer);}var prevContentHeight=0;this._timer=setInterval(function(){if(that.smoothContent){var contentHeight=that.$content.outerHeight()||0;if(contentHeight!==prevContentHeight){prevContentHeight=contentHeight;}var wh=$(window).height();var total=that.offsetTop+that.offsetBottom+that.$jconfirmBox.height()-that.$contentPane.height()+that.$content.height();if(total').html(that.buttons[key].text).addClass(that.buttons[key].btnClass).prop("disabled",that.buttons[key].isDisabled).css("display",that.buttons[key].isHidden?"none":"").click(function(e){e.preventDefault();var res=that.buttons[key].action.apply(that,[that.buttons[key]]);that.onAction.apply(that,[key,that.buttons[key]]);that._stopCountDown();if(typeof res==="undefined"||res){that.close();}});that.buttons[key].el=button_element;that.buttons[key].setText=function(text){button_element.html(text);};that.buttons[key].addClass=function(className){button_element.addClass(className);};that.buttons[key].removeClass=function(className){button_element.removeClass(className);};that.buttons[key].disable=function(){that.buttons[key].isDisabled=true;button_element.prop("disabled",true);};that.buttons[key].enable=function(){that.buttons[key].isDisabled=false;button_element.prop("disabled",false);};that.buttons[key].show=function(){that.buttons[key].isHidden=false;button_element.css("display","");};that.buttons[key].hide=function(){that.buttons[key].isHidden=true;button_element.css("display","none");};that["$_"+key]=that["$$"+key]=button_element;that.$btnc.append(button_element);});if(total_buttons===0){this.$btnc.hide();}if(this.closeIcon===null&&total_buttons===0){this.closeIcon=true;}if(this.closeIcon){if(this.closeIconClass){var closeHtml='';this.$closeIcon.html(closeHtml);}this.$closeIcon.click(function(e){e.preventDefault();var buttonName=false;var shouldClose=false;var str;if(typeof that.closeIcon==="function"){str=that.closeIcon();}else{str=that.closeIcon;}if(typeof str==="string"&&typeof that.buttons[str]!=="undefined"){buttonName=str;shouldClose=false;}else{if(typeof str==="undefined"||!!(str)===true){shouldClose=true;}else{shouldClose=false;}}if(buttonName){var btnResponse=that.buttons[buttonName].action.apply(that);shouldClose=(typeof btnResponse==="undefined")||!!(btnResponse);}if(shouldClose){that.close();}});this.$closeIcon.show();}else{this.$closeIcon.hide();}},setTitle:function(string,force){force=force||false;if(typeof string!=="undefined"){if(typeof string==="string"){this.title=string;}else{if(typeof string==="function"){if(typeof string.promise==="function"){console.error("Promise was returned from title function, this is not supported.");}var response=string();if(typeof response==="string"){this.title=response;}else{this.title=false;}}else{this.title=false;}}}if(this.isAjaxLoading&&!force){return;}this.$title.html(this.title||"");this.updateTitleContainer();},setIcon:function(iconClass,force){force=force||false;if(typeof iconClass!=="undefined"){if(typeof iconClass==="string"){this.icon=iconClass;}else{if(typeof iconClass==="function"){var response=iconClass();if(typeof response==="string"){this.icon=response;}else{this.icon=false;}}else{this.icon=false;}}}if(this.isAjaxLoading&&!force){return;}this.$icon.html(this.icon?'':"");this.updateTitleContainer();},updateTitleContainer:function(){if(!this.title&&!this.icon){this.$titleContainer.hide();}else{this.$titleContainer.show();}},setContentPrepend:function(content,force){if(!content){return;}this.contentParsed.prepend(content);},setContentAppend:function(content){if(!content){return;}this.contentParsed.append(content);},setContent:function(content,force){force=!!force;var that=this;if(content){this.contentParsed.html("").append(content);}if(this.isAjaxLoading&&!force){return;}this.$content.html("");this.$content.append(this.contentParsed);setTimeout(function(){that.$body.find("input[autofocus]:visible:first").focus();},100);},loadingSpinner:false,showLoading:function(disableButtons){this.loadingSpinner=true;this.$jconfirmBox.addClass("loading");if(disableButtons){this.$btnc.find("button").prop("disabled",true);}},hideLoading:function(enableButtons){this.loadingSpinner=false;this.$jconfirmBox.removeClass("loading");if(enableButtons){this.$btnc.find("button").prop("disabled",false);}},ajaxResponse:false,contentParsed:"",isAjax:false,isAjaxLoading:false,_parseContent:function(){var that=this;var e=" ";if(typeof this.content==="function"){var res=this.content.apply(this);if(typeof res==="string"){this.content=res;}else{if(typeof res==="object"&&typeof res.always==="function"){this.isAjax=true;this.isAjaxLoading=true;res.always(function(data,status,xhr){that.ajaxResponse={data:data,status:status,xhr:xhr};that._contentReady.resolve(data,status,xhr);if(typeof that.contentLoaded==="function"){that.contentLoaded(data,status,xhr);}});this.content=e;}else{this.content=e;}}}if(typeof this.content==="string"&&this.content.substr(0,4).toLowerCase()==="url:"){this.isAjax=true;this.isAjaxLoading=true;var u=this.content.substring(4,this.content.length);$.get(u).done(function(html){that.contentParsed.html(html);}).always(function(data,status,xhr){that.ajaxResponse={data:data,status:status,xhr:xhr};that._contentReady.resolve(data,status,xhr);if(typeof that.contentLoaded==="function"){that.contentLoaded(data,status,xhr);}});}if(!this.content){this.content=e;}if(!this.isAjax){this.contentParsed.html(this.content);this.setContent();that._contentReady.resolve();}},_stopCountDown:function(){clearInterval(this.autoCloseInterval);if(this.$cd){this.$cd.remove();}},_startCountDown:function(){var that=this;var opt=this.autoClose.split("|");if(opt.length!==2){console.error("Invalid option for autoClose. example 'close|10000'");return false;}var button_key=opt[0];var time=parseInt(opt[1]);if(typeof this.buttons[button_key]==="undefined"){console.error("Invalid button key '"+button_key+"' for autoClose");return false;}var seconds=Math.ceil(time/1000);this.$cd=$(' ('+seconds+")").appendTo(this["$_"+button_key]);this.autoCloseInterval=setInterval(function(){that.$cd.html(" ("+(seconds-=1)+") ");if(seconds<=0){that["$$"+button_key].trigger("click");that._stopCountDown();}},1000);},_getKey:function(key){switch(key){case 192:return"tilde";case 13:return"enter";case 16:return"shift";case 9:return"tab";case 20:return"capslock";case 17:return"ctrl";case 91:return"win";case 18:return"alt";case 27:return"esc";case 32:return"space";}var initial=String.fromCharCode(key);if(/^[A-z0-9]+$/.test(initial)){return initial.toLowerCase();}else{return false;}},reactOnKey:function(e){var that=this;var a=$(".jconfirm");if(a.eq(a.length-1)[0]!==this.$el[0]){return false;}var key=e.which;if(this.$content.find(":input").is(":focus")&&/13|32/.test(key)){return false;}var keyChar=this._getKey(key);if(keyChar==="esc"&&this.escapeKey){if(this.escapeKey===true){this.$scrollPane.trigger("click");}else{if(typeof this.escapeKey==="string"||typeof this.escapeKey==="function"){var buttonKey;if(typeof this.escapeKey==="function"){buttonKey=this.escapeKey();}else{buttonKey=this.escapeKey;}if(buttonKey){if(typeof this.buttons[buttonKey]==="undefined"){console.warn("Invalid escapeKey, no buttons found with key "+buttonKey);}else{this["$_"+buttonKey].trigger("click");}}}}}$.each(this.buttons,function(key,button){if(button.keys.indexOf(keyChar)!==-1){that["$_"+key].trigger("click");}});},setDialogCenter:function(){console.info("setDialogCenter is deprecated, dialogs are centered with CSS3 tables");},_unwatchContent:function(){clearInterval(this._timer);},close:function(onClosePayload){var that=this;if(typeof this.onClose==="function"){this.onClose(onClosePayload);}this._unwatchContent();$(window).unbind("resize."+this._id);$(window).unbind("keyup."+this._id);$(window).unbind("jcKeyDown."+this._id);if(this.draggable){$(window).unbind("mousemove."+this._id);$(window).unbind("mouseup."+this._id);this.$titleContainer.unbind("mousedown");}that.$el.removeClass(that.loadedClass);$("body").removeClass("jconfirm-no-scroll-"+that._id);that.$jconfirmBoxContainer.removeClass("jconfirm-no-transition");setTimeout(function(){that.$body.addClass(that.closeAnimationParsed);that.$jconfirmBg.addClass("jconfirm-bg-h");var closeTimer=(that.closeAnimation==="none")?1:that.animationSpeed;setTimeout(function(){that.$el.remove();var l=w.jconfirm.instances;var i=w.jconfirm.instances.length-1;for(i;i>=0;i--){if(w.jconfirm.instances[i]._id===that._id){w.jconfirm.instances.splice(i,1);}}if(!w.jconfirm.instances.length){if(that.scrollToPreviousElement&&w.jconfirm.lastFocused&&w.jconfirm.lastFocused.length&&$.contains(document,w.jconfirm.lastFocused[0])){var $lf=w.jconfirm.lastFocused;if(that.scrollToPreviousElementAnimate){var st=$(window).scrollTop();var ot=w.jconfirm.lastFocused.offset().top;var wh=$(window).height();if(!(ot>st&&ot<(st+wh))){var scrollTo=(ot-Math.round((wh/3)));$("html, body").animate({scrollTop:scrollTo},that.animationSpeed,"swing",function(){$lf.focus();});}else{$lf.focus();}}else{$lf.focus();}w.jconfirm.lastFocused=false;}}if(typeof that.onDestroy==="function"){that.onDestroy();}},closeTimer*0.4);},50);return true;},open:function(){if(this.isOpen()){return false;}this._buildHTML();this._bindEvents();this._open();return true;},setStartingPoint:function(){var el=false;if(this.animateFromElement!==true&&this.animateFromElement){el=this.animateFromElement;w.jconfirm.lastClicked=false;}else{if(w.jconfirm.lastClicked&&this.animateFromElement===true){el=w.jconfirm.lastClicked;w.jconfirm.lastClicked=false;}else{return false;}}if(!el){return false;}var offset=el.offset();var iTop=el.outerHeight()/2;var iLeft=el.outerWidth()/2;iTop-=this.$jconfirmBox.outerHeight()/2;iLeft-=this.$jconfirmBox.outerWidth()/2;var sourceTop=offset.top+iTop;sourceTop=sourceTop-this._scrollTop();var sourceLeft=offset.left+iLeft;var wh=$(window).height()/2;var ww=$(window).width()/2;var targetH=wh-this.$jconfirmBox.outerHeight()/2;var targetW=ww-this.$jconfirmBox.outerWidth()/2;sourceTop-=targetH;sourceLeft-=targetW;if(Math.abs(sourceTop)>wh||Math.abs(sourceLeft)>ww){return false;}this.$jconfirmBoxContainer.css("transform","translate("+sourceLeft+"px, "+sourceTop+"px)");},_open:function(){var that=this;if(typeof that.onOpenBefore==="function"){that.onOpenBefore();}this.$body.removeClass(this.animationParsed);this.$jconfirmBg.removeClass("jconfirm-bg-h");this.$body.focus();that.$jconfirmBoxContainer.css("transform","translate("+0+"px, "+0+"px)");setTimeout(function(){that.$body.css(that._getCSS(that.animationSpeed,1));that.$body.css({"transition-property":that.$body.css("transition-property")+", margin"});that.$jconfirmBoxContainer.addClass("jconfirm-no-transition");that._modalReady.resolve();if(typeof that.onOpen==="function"){that.onOpen();}that.$el.addClass(that.loadedClass);},this.animationSpeed);},loadedClass:"jconfirm-open",isClosed:function(){return !this.$el||this.$el.parent().length===0;},isOpen:function(){return !this.isClosed();},toggle:function(){if(!this.isOpen()){this.open();}else{this.close();}}};w.jconfirm.instances=[];w.jconfirm.lastFocused=false;w.jconfirm.pluginDefaults={template:'
    ',title:"Hello",titleClass:"",type:"default",typeAnimated:true,draggable:true,dragWindowGap:15,dragWindowBorder:true,animateFromElement:true,alignMiddle:true,smoothContent:true,content:"Are you sure to continue?",buttons:{},defaultButtons:{ok:{action:function(){}},close:{action:function(){}}},contentLoaded:function(){},icon:"",lazyOpen:false,bgOpacity:null,theme:"light",animation:"scale",closeAnimation:"scale",animationSpeed:400,animationBounce:1,escapeKey:true,rtl:false,container:"body",containerFluid:false,backgroundDismiss:false,backgroundDismissAnimation:"shake",autoClose:false,closeIcon:null,closeIconClass:false,watchInterval:100,columnClass:"col-md-4 col-md-offset-4 col-sm-6 col-sm-offset-3 col-xs-10 col-xs-offset-1",boxWidth:"50%",scrollToPreviousElement:true,scrollToPreviousElementAnimate:true,useBootstrap:true,offsetTop:40,offsetBottom:40,bootstrapClasses:{container:"container",containerFluid:"container-fluid",row:"row"},onContentReady:function(){},onOpenBefore:function(){},onOpen:function(){},onClose:function(){},onDestroy:function(){},onAction:function(){}};var keyDown=false;$(window).on("keydown",function(e){if(!keyDown){var $target=$(e.target);var pass=false;if($target.closest(".jconfirm-box").length){pass=true;}if(pass){$(window).trigger("jcKeyDown");}keyDown=true;}});$(window).on("keyup",function(){keyDown=false;});w.jconfirm.lastClicked=false;$(document).on("mousedown","button, a, [jc-source]",function(){w.jconfirm.lastClicked=$(this);});})); \ No newline at end of file diff --git a/web/public/js/socket.io.min.js b/web/public/js/socket.io.min.js new file mode 100644 index 0000000..40046a7 --- /dev/null +++ b/web/public/js/socket.io.min.js @@ -0,0 +1,7 @@ +/*! + * Socket.IO v4.7.3 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT 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).io=e()}(this,(function(){"use strict";function t(e){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t(e)}function e(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function n(t,e){for(var n=0;nt.length)&&(e=t.length);for(var n=0,r=new Array(e);n=t.length?{done:!0}:{done:!1,value:t[r++]}},e:function(t){throw t},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,s=!0,a=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return s=t.done,t},e:function(t){a=!0,o=t},f:function(){try{s||null==n.return||n.return()}finally{if(a)throw o}}}}var v=Object.create(null);v.open="0",v.close="1",v.ping="2",v.pong="3",v.message="4",v.upgrade="5",v.noop="6";var g=Object.create(null);Object.keys(v).forEach((function(t){g[v[t]]=t}));var m,b={type:"error",data:"parser error"},k="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===Object.prototype.toString.call(Blob),w="function"==typeof ArrayBuffer,_=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t&&t.buffer instanceof ArrayBuffer},A=function(t,e,n){var r=t.type,i=t.data;return k&&i instanceof Blob?e?n(i):O(i,n):w&&(i instanceof ArrayBuffer||_(i))?e?n(i):O(new Blob([i]),n):n(v[r]+(i||""))},O=function(t,e){var n=new FileReader;return n.onload=function(){var t=n.result.split(",")[1];e("b"+(t||""))},n.readAsDataURL(t)};function E(t){return t instanceof Uint8Array?t:t instanceof ArrayBuffer?new Uint8Array(t):new Uint8Array(t.buffer,t.byteOffset,t.byteLength)}for(var T="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",R="undefined"==typeof Uint8Array?[]:new Uint8Array(256),C=0;C<64;C++)R[T.charCodeAt(C)]=C;var B,S="function"==typeof ArrayBuffer,N=function(t,e){if("string"!=typeof t)return{type:"message",data:x(t,e)};var n=t.charAt(0);return"b"===n?{type:"message",data:L(t.substring(1),e)}:g[n]?t.length>1?{type:g[n],data:t.substring(1)}:{type:g[n]}:b},L=function(t,e){if(S){var n=function(t){var e,n,r,i,o,s=.75*t.length,a=t.length,u=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var c=new ArrayBuffer(s),h=new Uint8Array(c);for(e=0;e>4,h[u++]=(15&r)<<4|i>>2,h[u++]=(3&i)<<6|63&o;return c}(t);return x(n,e)}return{base64:!0,data:t}},x=function(t,e){return"blob"===e?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},P=String.fromCharCode(30);function q(){return new TransformStream({transform:function(t,e){!function(t,e){k&&t.data instanceof Blob?t.data.arrayBuffer().then(E).then(e):w&&(t.data instanceof ArrayBuffer||_(t.data))?e(E(t.data)):A(t,!1,(function(t){m||(m=new TextEncoder),e(m.encode(t))}))}(t,(function(n){var r,i=n.length;if(i<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,i);else if(i<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,i)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(i))}t.data&&"string"!=typeof t.data&&(r[0]|=128),e.enqueue(r),e.enqueue(n)}))}})}function j(t){return t.reduce((function(t,e){return t+e.length}),0)}function D(t,e){if(t[0].length===e)return t.shift();for(var n=new Uint8Array(e),r=0,i=0;i1?e-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this._hostname()+this._port()+this.opts.path+this._query(e)}},{key:"_hostname",value:function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"}},{key:"_port",value:function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""}},{key:"_query",value:function(t){var e=function(t){var e="";for(var n in t)t.hasOwnProperty(n)&&(e.length&&(e+="&"),e+=encodeURIComponent(n)+"="+encodeURIComponent(t[n]));return e}(t);return e.length?"?"+e:""}}]),i}(U),z="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_".split(""),J=64,$={},Q=0,X=0;function G(t){var e="";do{e=z[t%J]+e,t=Math.floor(t/J)}while(t>0);return e}function Z(){var t=G(+new Date);return t!==K?(Q=0,K=t):t+"."+G(Q++)}for(;X0&&void 0!==arguments[0]?arguments[0]:{};return i(t,{xd:this.xd,cookieJar:this.cookieJar},this.opts),new st(this.uri(),t)}},{key:"doWrite",value:function(t,e){var n=this,r=this.request({method:"POST",data:t});r.on("success",e),r.on("error",(function(t,e){n.onError("xhr post error",t,e)}))}},{key:"doPoll",value:function(){var t=this,e=this.request();e.on("data",this.onData.bind(this)),e.on("error",(function(e,n){t.onError("xhr poll error",e,n)})),this.pollXhr=e}}]),s}(W),st=function(t){o(i,t);var n=l(i);function i(t,r){var o;return e(this,i),H(f(o=n.call(this)),r),o.opts=r,o.method=r.method||"GET",o.uri=t,o.data=void 0!==r.data?r.data:null,o.create(),o}return r(i,[{key:"create",value:function(){var t,e=this,n=F(this.opts,"agent","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","autoUnref");n.xdomain=!!this.opts.xd;var r=this.xhr=new nt(n);try{r.open(this.method,this.uri,!0);try{if(this.opts.extraHeaders)for(var o in r.setDisableHeaderCheck&&r.setDisableHeaderCheck(!0),this.opts.extraHeaders)this.opts.extraHeaders.hasOwnProperty(o)&&r.setRequestHeader(o,this.opts.extraHeaders[o])}catch(t){}if("POST"===this.method)try{r.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch(t){}try{r.setRequestHeader("Accept","*/*")}catch(t){}null===(t=this.opts.cookieJar)||void 0===t||t.addCookies(r),"withCredentials"in r&&(r.withCredentials=this.opts.withCredentials),this.opts.requestTimeout&&(r.timeout=this.opts.requestTimeout),r.onreadystatechange=function(){var t;3===r.readyState&&(null===(t=e.opts.cookieJar)||void 0===t||t.parseCookies(r)),4===r.readyState&&(200===r.status||1223===r.status?e.onLoad():e.setTimeoutFn((function(){e.onError("number"==typeof r.status?r.status:0)}),0))},r.send(this.data)}catch(t){return void this.setTimeoutFn((function(){e.onError(t)}),0)}"undefined"!=typeof document&&(this.index=i.requestsCount++,i.requests[this.index]=this)}},{key:"onError",value:function(t){this.emitReserved("error",t,this.xhr),this.cleanup(!0)}},{key:"cleanup",value:function(t){if(void 0!==this.xhr&&null!==this.xhr){if(this.xhr.onreadystatechange=rt,t)try{this.xhr.abort()}catch(t){}"undefined"!=typeof document&&delete i.requests[this.index],this.xhr=null}}},{key:"onLoad",value:function(){var t=this.xhr.responseText;null!==t&&(this.emitReserved("data",t),this.emitReserved("success"),this.cleanup())}},{key:"abort",value:function(){this.cleanup()}}]),i}(U);if(st.requestsCount=0,st.requests={},"undefined"!=typeof document)if("function"==typeof attachEvent)attachEvent("onunload",at);else if("function"==typeof addEventListener){addEventListener("onpagehide"in I?"pagehide":"unload",at,!1)}function at(){for(var t in st.requests)st.requests.hasOwnProperty(t)&&st.requests[t].abort()}var ut="function"==typeof Promise&&"function"==typeof Promise.resolve?function(t){return Promise.resolve().then(t)}:function(t,e){return e(t,0)},ct=I.WebSocket||I.MozWebSocket,ht="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),ft=function(t){o(i,t);var n=l(i);function i(t){var r;return e(this,i),(r=n.call(this,t)).supportsBinary=!t.forceBase64,r}return r(i,[{key:"name",get:function(){return"websocket"}},{key:"doOpen",value:function(){if(this.check()){var t=this.uri(),e=this.opts.protocols,n=ht?{}:F(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(n.headers=this.opts.extraHeaders);try{this.ws=ht?new ct(t,e,n):e?new ct(t,e):new ct(t)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()}}},{key:"addEventListeners",value:function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws._socket.unref(),t.onOpen()},this.ws.onclose=function(e){return t.onClose({description:"websocket connection closed",context:e})},this.ws.onmessage=function(e){return t.onData(e.data)},this.ws.onerror=function(e){return t.onError("websocket error",e)}}},{key:"write",value:function(t){var e=this;this.writable=!1;for(var n=function(){var n=t[r],i=r===t.length-1;A(n,e.supportsBinary,(function(t){try{e.ws.send(t)}catch(t){}i&&ut((function(){e.writable=!0,e.emitReserved("drain")}),e.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){a.enqueue(b);break}i=l*Math.pow(2,32)+f.getUint32(4),r=3}else{if(j(n)t){a.enqueue(b);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=e.readable.pipeThrough(n).getReader(),i=q();i.readable.pipeTo(e.writable),t.writer=i.writable.getWriter();!function e(){r.read().then((function(n){var r=n.done,i=n.value;r||(t.onPacket(i),e())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.writer.write(o).then((function(){return t.onOpen()}))}))})))}},{key:"write",value:function(t){var e=this;this.writable=!1;for(var n=function(){var n=t[r],i=r===t.length-1;e.writer.write(n).then((function(){i&&ut((function(){e.writable=!0,e.emitReserved("drain")}),e.setTimeoutFn)}))},r=0;r1&&void 0!==arguments[1]?arguments[1]:{};return e(this,a),(r=s.call(this)).binaryType="arraybuffer",r.writeBuffer=[],n&&"object"===t(n)&&(o=n,n=null),n?(n=vt(n),o.hostname=n.host,o.secure="https"===n.protocol||"wss"===n.protocol,o.port=n.port,n.query&&(o.query=n.query)):o.host&&(o.hostname=vt(o.host).host),H(f(r),o),r.secure=null!=o.secure?o.secure:"undefined"!=typeof location&&"https:"===location.protocol,o.hostname&&!o.port&&(o.port=r.secure?"443":"80"),r.hostname=o.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=o.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=o.transports||["polling","websocket","webtransport"],r.writeBuffer=[],r.prevBufferLen=0,r.opts=i({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},o),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var e={},n=t.split("&"),r=0,i=n.length;r1))return this.writeBuffer;for(var t,e=1,n=0;n=57344?n+=3:(r++,n+=4);return n}(t):Math.ceil(1.33*(t.byteLength||t.size))),n>0&&e>this.maxPayload)return this.writeBuffer.slice(0,n);e+=2}return this.writeBuffer}},{key:"write",value:function(t,e,n){return this.sendPacket("message",t,e,n),this}},{key:"send",value:function(t,e,n){return this.sendPacket("message",t,e,n),this}},{key:"sendPacket",value:function(t,e,n,r){if("function"==typeof e&&(r=e,e=void 0),"function"==typeof n&&(r=n,n=null),"closing"!==this.readyState&&"closed"!==this.readyState){(n=n||{}).compress=!1!==n.compress;var i={type:t,data:e,options:n};this.emitReserved("packetCreate",i),this.writeBuffer.push(i),r&&this.once("flush",r),this.flush()}}},{key:"close",value:function(){var t=this,e=function(){t.onClose("forced close"),t.transport.close()},n=function n(){t.off("upgrade",n),t.off("upgradeError",n),e()},r=function(){t.once("upgrade",n),t.once("upgradeError",n)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():e()})):this.upgrading?r():e()),this}},{key:"onError",value:function(t){a.priorWebsocketSuccess=!1,this.emitReserved("error",t),this.onClose("transport error",t)}},{key:"onClose",value:function(t,e){"opening"!==this.readyState&&"open"!==this.readyState&&"closing"!==this.readyState||(this.clearTimeoutFn(this.pingTimeoutTimer),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),"function"==typeof removeEventListener&&(removeEventListener("beforeunload",this.beforeunloadEventListener,!1),removeEventListener("offline",this.offlineEventListener,!1)),this.readyState="closed",this.id=null,this.emitReserved("close",t,e),this.writeBuffer=[],this.prevBufferLen=0)}},{key:"filterUpgrades",value:function(t){for(var e=[],n=0,r=t.length;n=0&&e.num1?e-1:0),r=1;r1?n-1:0),i=1;in._opts.retries&&(n._queue.shift(),e&&e(t));else if(n._queue.shift(),e){for(var i=arguments.length,o=new Array(i>1?i-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this._queue.length){var e=this._queue[0];e.pending&&!t||(e.pending=!0,e.tryCount++,this.flags=e.flags,this.emit.apply(this,e.args))}}},{key:"packet",value:function(t){t.nsp=this.nsp,this.io._packet(t)}},{key:"onopen",value:function(){var t=this;"function"==typeof this.auth?this.auth((function(e){t._sendConnectPacket(e)})):this._sendConnectPacket(this.auth)}},{key:"_sendConnectPacket",value:function(t){this.packet({type:Bt.CONNECT,data:this._pid?i({pid:this._pid,offset:this._lastOffset},t):t})}},{key:"onerror",value:function(t){this.connected||this.emitReserved("connect_error",t)}},{key:"onclose",value:function(t,e){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,e)}},{key:"onpacket",value:function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var e=new Error(t.data.message);e.data=t.data.data,this.emitReserved("connect_error",e)}}},{key:"onevent",value:function(t){var e=t.data||[];null!=t.id&&e.push(this.ack(t.id)),this.connected?this.emitEvent(e):this.receiveBuffer.push(Object.freeze(e))}},{key:"emitEvent",value:function(t){if(this._anyListeners&&this._anyListeners.length){var e,n=y(this._anyListeners.slice());try{for(n.s();!(e=n.n()).done;){e.value.apply(this,t)}}catch(t){n.e(t)}finally{n.f()}}p(s(a.prototype),"emit",this).apply(this,t),this._pid&&t.length&&"string"==typeof t[t.length-1]&&(this._lastOffset=t[t.length-1])}},{key:"ack",value:function(t){var e=this,n=!1;return function(){if(!n){n=!0;for(var r=arguments.length,i=new Array(r),o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}It.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var e=Math.random(),n=Math.floor(e*this.jitter*t);t=0==(1&Math.floor(10*e))?t-n:t+n}return 0|Math.min(t,this.max)},It.prototype.reset=function(){this.attempts=0},It.prototype.setMin=function(t){this.ms=t},It.prototype.setMax=function(t){this.max=t},It.prototype.setJitter=function(t){this.jitter=t};var Ft=function(n){o(s,n);var i=l(s);function s(n,r){var o,a;e(this,s),(o=i.call(this)).nsps={},o.subs=[],n&&"object"===t(n)&&(r=n,n=void 0),(r=r||{}).path=r.path||"/socket.io",o.opts=r,H(f(o),r),o.reconnection(!1!==r.reconnection),o.reconnectionAttempts(r.reconnectionAttempts||1/0),o.reconnectionDelay(r.reconnectionDelay||1e3),o.reconnectionDelayMax(r.reconnectionDelayMax||5e3),o.randomizationFactor(null!==(a=r.randomizationFactor)&&void 0!==a?a:.5),o.backoff=new It({min:o.reconnectionDelay(),max:o.reconnectionDelayMax(),jitter:o.randomizationFactor()}),o.timeout(null==r.timeout?2e4:r.timeout),o._readyState="closed",o.uri=n;var u=r.parser||qt;return o.encoder=new u.Encoder,o.decoder=new u.Decoder,o._autoConnect=!1!==r.autoConnect,o._autoConnect&&o.open(),o}return r(s,[{key:"reconnection",value:function(t){return arguments.length?(this._reconnection=!!t,this):this._reconnection}},{key:"reconnectionAttempts",value:function(t){return void 0===t?this._reconnectionAttempts:(this._reconnectionAttempts=t,this)}},{key:"reconnectionDelay",value:function(t){var e;return void 0===t?this._reconnectionDelay:(this._reconnectionDelay=t,null===(e=this.backoff)||void 0===e||e.setMin(t),this)}},{key:"randomizationFactor",value:function(t){var e;return void 0===t?this._randomizationFactor:(this._randomizationFactor=t,null===(e=this.backoff)||void 0===e||e.setJitter(t),this)}},{key:"reconnectionDelayMax",value:function(t){var e;return void 0===t?this._reconnectionDelayMax:(this._reconnectionDelayMax=t,null===(e=this.backoff)||void 0===e||e.setMax(t),this)}},{key:"timeout",value:function(t){return arguments.length?(this._timeout=t,this):this._timeout}},{key:"maybeReconnectOnOpen",value:function(){!this._reconnecting&&this._reconnection&&0===this.backoff.attempts&&this.reconnect()}},{key:"open",value:function(t){var e=this;if(~this._readyState.indexOf("open"))return this;this.engine=new gt(this.uri,this.opts);var n=this.engine,r=this;this._readyState="opening",this.skipReconnect=!1;var i=jt(n,"open",(function(){r.onopen(),t&&t()})),o=function(n){e.cleanup(),e._readyState="closed",e.emitReserved("error",n),t?t(n):e.maybeReconnectOnOpen()},s=jt(n,"error",o);if(!1!==this._timeout){var a=this._timeout,u=this.setTimeoutFn((function(){i(),o(new Error("timeout")),n.close()}),a);this.opts.autoUnref&&u.unref(),this.subs.push((function(){e.clearTimeoutFn(u)}))}return this.subs.push(i),this.subs.push(s),this}},{key:"connect",value:function(t){return this.open(t)}},{key:"onopen",value:function(){this.cleanup(),this._readyState="open",this.emitReserved("open");var t=this.engine;this.subs.push(jt(t,"ping",this.onping.bind(this)),jt(t,"data",this.ondata.bind(this)),jt(t,"error",this.onerror.bind(this)),jt(t,"close",this.onclose.bind(this)),jt(this.decoder,"decoded",this.ondecoded.bind(this)))}},{key:"onping",value:function(){this.emitReserved("ping")}},{key:"ondata",value:function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}}},{key:"ondecoded",value:function(t){var e=this;ut((function(){e.emitReserved("packet",t)}),this.setTimeoutFn)}},{key:"onerror",value:function(t){this.emitReserved("error",t)}},{key:"socket",value:function(t,e){var n=this.nsps[t];return n?this._autoConnect&&!n.active&&n.connect():(n=new Ut(this,t,e),this.nsps[t]=n),n}},{key:"_destroy",value:function(t){for(var e=0,n=Object.keys(this.nsps);e=this._reconnectionAttempts)this.backoff.reset(),this.emitReserved("reconnect_failed"),this._reconnecting=!1;else{var n=this.backoff.duration();this._reconnecting=!0;var r=this.setTimeoutFn((function(){e.skipReconnect||(t.emitReserved("reconnect_attempt",e.backoff.attempts),e.skipReconnect||e.open((function(n){n?(e._reconnecting=!1,e.reconnect(),t.emitReserved("reconnect_error",n)):e.onreconnect()})))}),n);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}}},{key:"onreconnect",value:function(){var t=this.backoff.attempts;this._reconnecting=!1,this.backoff.reset(),this.emitReserved("reconnect",t)}}]),s}(U),Mt={};function Vt(e,n){"object"===t(e)&&(n=e,e=void 0);var r,i=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2?arguments[2]:void 0,r=t;n=n||"undefined"!=typeof location&&location,null==t&&(t=n.protocol+"//"+n.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?n.protocol+t:n.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==n?n.protocol+"//"+t:"https://"+t),r=vt(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var i=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+i+":"+r.port+e,r.href=r.protocol+"://"+i+(n&&n.port===r.port?"":":"+r.port),r}(e,(n=n||{}).path||"/socket.io"),o=i.source,s=i.id,a=i.path,u=Mt[s]&&a in Mt[s].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?r=new Ft(o,n):(Mt[s]||(Mt[s]=new Ft(o,n)),r=Mt[s]),i.query&&!n.query&&(n.query=i.queryKey),r.socket(i.path,n)}return i(Vt,{Manager:Ft,Socket:Ut,io:Vt,connect:Vt}),Vt})); +//# sourceMappingURL=socket.io.min.js.map diff --git a/web/public/js/txadmin/base.js b/web/public/js/txadmin/base.js new file mode 100644 index 0000000..f33aee1 --- /dev/null +++ b/web/public/js/txadmin/base.js @@ -0,0 +1,241 @@ +/* eslint-disable no-unused-vars */ +//================================================================ +//============================================= Settings & Helpers +//================================================================ +//Settings & constants +const REQ_TIMEOUT_SHORT = 1_500; +const REQ_TIMEOUT_MEDIUM = 5_000; +const REQ_TIMEOUT_LONG = 9_000; +const REQ_TIMEOUT_REALLY_LONG = 15_000; +const REQ_TIMEOUT_REALLY_REALLY_LONG = 30_000; +const SPINNER_HTML = '
    Loading...
    '; + +//Helpers +const anyUndefined = (...args) => { return [...args].some((x) => (typeof x === 'undefined')); }; +const xss = (x) => { + let tmp = document.createElement('div'); + tmp.innerText = x; + return tmp.innerHTML; +}; +const convertMarkdown = (input, inline = false) => { + const toConvert = xss(input) + .replaceAll(/\n/g, ' \n') + .replaceAll(/\t/g, ' '); + const markedOptions = { + breaks: true, + }; + const func = inline ? marked.parseInline : marked.parse; + return func(toConvert, markedOptions) + .replaceAll('&lt;', '<') + .replaceAll('&gt;', '>'); +}; + +//Navigates parent without refreshing the page +const navigateParentTo = (href) => { + return window.parent.postMessage({ type: 'navigateToPage', href}); +}; + +//================================================================ +//================================================= Event Handlers +//================================================================ +//Page load +document.addEventListener('DOMContentLoaded', function(event) { + if (typeof $.notifyDefaults !== 'undefined') { + $.notifyDefaults({ + z_index: 2000, + mouse_over: 'pause', + placement: { + align: 'center', + }, + offset: { + y: 8, + }, + }); + } + + if (typeof jconfirm !== 'undefined') { + jconfirm.defaults = { + title: 'Confirm:', + + draggable: false, + escapeKey: true, + closeIcon: true, + backgroundDismiss: true, + + typeAnimated: false, + animation: 'scale', + + type: 'red', + columnClass: 'medium', + theme: document.body.classList.contains('theme--dark') ? 'dark' : 'light', + }; + } +}); + + +//================================================================ +//================================================= Helper funcs +//================================================================ +const checkApiLogoutRefresh = (data) => { + if (data.logout === true) { + window.parent.postMessage({ type: 'logoutNotice' }); + return true; + } else if (data.refresh === true) { + window.location.reload(true); + return true; + } + return false; +}; +//usage: if (checkApiLogoutRefresh(data)) return; + + +/** + * To display the markdown errors. + * NOTE: likely deprecate when creating default api response handlers + * @param {object} data + * @param {object} notify + * @returns + */ +const updateMarkdownNotification = (data, notify) => { + if (data.markdown === true) { + let msgHtml = convertMarkdown(data.message, true); + if (data.type === 'danger') { + msgHtml += `
    + + For support, visit discord.gg/txAdmin. + +
    `; + } + + notify.update('progress', 0); + notify.update('type', data.type); + notify.update('message', msgHtml); + + //since we can't change the duration with an update + setTimeout(() => { + notify.update('progress', 0); + }, 5000); + } else { + notify.update('progress', 0); + notify.update('type', data.type); + notify.update('message', data.message); + } + return false; +}; + +//Must be as close to a JQuery $.ajax() as possible +//TODO: abstract a little bit more and use fetch +//TODO: use the function above for all calls +//NOTE: datatype is the expected return, we can probably remove it +//NOTE: still one $.ajax at setup.html > setFavTemplatesCards +//NOTE: to send json: +// data: JSON.stringify(data) +// contentType: 'application/json' +const txAdminAPI = ({type, url, data, dataType, timeout, success, error}) => { + if (anyUndefined(type, url)) return false; + + url = TX_BASE_PATH + url; + timeout = timeout ?? REQ_TIMEOUT_MEDIUM; + dataType = dataType || 'json'; + success = success || (() => {}); + error = error || (() => {}); + const headers = {'X-TxAdmin-CsrfToken': (csrfToken) ? csrfToken : 'not_set'} + // console.log(`txAdminAPI Req to: ${url}`); + return $.ajax({type, url, timeout, data, dataType, success, error, headers}); +}; + +const txAdminAlert = ({content, modalColor, title}) => { + $.confirm({ + title, + content: content, + type: modalColor || 'green', + buttons: { + close: { + text: 'Close', + keys: ['enter'], + } + }, + }); +}; + +const txAdminConfirm = ({content, confirmBtnClass, modalColor, title}) => { + return new Promise((resolve, reject) => { + $.confirm({ + title, + content: content, + type: modalColor || 'red', + buttons: { + cancel: () => {resolve(false);}, + confirm: { + btnClass: confirmBtnClass || 'btn-red', + keys: ['Enter', 'NumpadEnter'], + action: () => {resolve(true);}, + }, + }, + onClose: () => {resolve(false);}, + }); + }); +}; + +const txAdminPrompt = ({ + confirmBtnClass = 'btn-blue', + modalColor = 'blue', + title = '', + description = '', + placeholder = '', + required = true, +}) => { + return new Promise((resolve, reject) => { + $.confirm({ + title, + type: modalColor, + content: ` +
    +
    + + +
    +
    `, + buttons: { + cancel: () => {resolve(false);}, + formSubmit: { + text: 'Submit', + btnClass: confirmBtnClass, + action: function () { + resolve(this.$content.find('.inputField').val()); + }, + }, + }, + onClose: () => { + resolve(false); + }, + onContentReady: function () { + const jc = this; + this.$content.find('form').on('submit', function (e) { + e.preventDefault(); + jc.$$formSubmit.trigger('click'); + }); + this.$content.find('input').focus(); + }, + }); + }); +}; + +//Starts a notify which is expected to take long +//This notify will keep being updated by adding dots at the end +const startHoldingNotify = (awaitingMessage) => { + const holdingHtml = (secs) => { + const extraDots = '.'.repeat(secs); + return `

    ${awaitingMessage}${extraDots}

    `; + } + + const notify = $.notify({ message: holdingHtml(0) }, {}); + let waitingSeconds = 0; + const progressTimerId = setInterval(() => { + waitingSeconds++; + notify.update('message', holdingHtml(waitingSeconds)); + notify.update('progress', 0); + }, 1000); + + return {notify, progressTimerId}; +} diff --git a/web/standalone/deployer.ejs b/web/standalone/deployer.ejs new file mode 100644 index 0000000..5b1891b --- /dev/null +++ b/web/standalone/deployer.ejs @@ -0,0 +1,586 @@ + + + + + + + + + + + Server Deployer + + + + + + + + + + + + + + + + + +
    + + +
    +
    +
    + <% if (step === 'review') { %> + +
    Step 1: Review Recipe
    +
    +
    +
    + Please review the Recipe below and apply any changes you want,
    + then press the Run Recipe button below. + + <% if (!recipe.isTrustedSource) { %> +
    Warning: Only run Recipes from trusted sources! + <% } %> +
    +
    +

    + <%= recipe.name %> + <% if (recipe.author !== '') { %> + by <%= recipe.author %> + <% } %> + <% if (recipe.description !== '') { %> +
    + <%= recipe.description %> + <% } %> +

    + +
    + +     + +
    +
    + + + + + <% } else if (step === 'input') { %> +
    Step 1: Review Recipe ✔️
    +
    Step 2: Input Parameters
    +
    + <% if (defaults.autofilled) { %> +
    +
    + Note: The following configs were auto-filled by <%= hostConfigSource %>.
    + You may edit those, but it's strongly disencouraged. +
    +
    + <% } %> +
    +
    + +
    + + + Formely known as License Key, it can be obtained in the Cfx.re Portal.
    + For more info, check the guide: How to create a registration key. +
    +
    +
    + <% if (requireDBConfig) { %> + +
    +
    + +
    + + + The IP/Hostname for the database server (usually <%= defaults.mysqlHost %>). + +
    +
    +
    + +
    + + + The port for the database server (usually <%= defaults.mysqlPort %>). + +
    +
    +
    + +
    + + + The database username (usually root). + +
    +
    +
    + +
    + + + The database password (usually blank). + +
    +
    +
    + +
    + + + The name of the database to be used or created.
    + If left empty, the deployment ID (<%= deploymentID %>) will be used instead. +
    +
    +
    +
    + +
    + + + If already exists, automatically deletes the database with the name provided above.
    + Warning: all data will be lost. +
    +
    +
    +
    + <% } %> + <% if (inputVars.length) { %> +
    Custom Variables
    + <% for (const [key, inputVar] of inputVars.entries()) { %> +
    + +
    + + <% if (inputVar.description) { %> + + <%- inputVar.description %> + + <% } %> +
    +
    + <% } %> + <% } %> +
    +
    + +     + +
    +
    + + + + <% } else if (step === 'run') { %> +
    Step 1: Review Recipe ✔️
    +
    Step 2: Input Parameters ✔️
    +
    Step 3: Run Recipe
    +
    +
    +
    + Your recipe is being executed, the server will be deployed to:
    + <%= deployPath %> +
    +
    +
    +
    +

    🐌🐌🐌

    +
    +
    +
    +
    +
    0%
    +
    + +
    +
    + + +
    +
    + + + <% } else if (step === 'configure') { %> + +
    Step 1: Review Recipe ✔️
    +
    Step 2: Input Parameters ✔️
    +
    Step 3: Run Recipe ✔️
    +
    Step 4: Configure server.cfg
    +
    +
    +
    + Configure your server.cfg file to your liking,
    + then press the Save & Run Server button below. +
    +
    + + +
    + +     + +
    +
    + + <% } else { %> +
    + Something is wrong 🤔 +
    + <% } %> +
    +
    +
    + +
    + + + + + + + + + + + + + + + + + + diff --git a/web/standalone/setup.ejs b/web/standalone/setup.ejs new file mode 100644 index 0000000..19ffdbe --- /dev/null +++ b/web/standalone/setup.ejs @@ -0,0 +1,898 @@ + + + + + + + + + + + <%= headerTitle %> + + + + + + + + + + + + + + + + + +
    + + +
    +
    +
    +
    +
      + + +
    • +
      Server Name
      +
      + A short server name to be used in txAdmin interface and + Chat/Discord messages. + + +
      + + +
      +
      +
    • + + + +
    • +
      Deployment Type
      +
      + Select how you want to setup your server: +
      + ⭐ Popular Recipes RECOMMENDED +
      + + Select a template from a curated list of community favorites.
      + This includes QBCore, ESX and a Default template for you to customize. +
      +
      +
      + 📁 Existing Server Data +
      + + Select an existing server data folder in the host.
      + Only select this option if you already have a server.cfg and a resources folder.
      + If you are not running fxserver in your computer, you need to first upload those files to the server's host. +
      +
      +
      + 📥 Remote URL Template +
      + + Based on a Recipe URL in the YAML format.
      + You will have the option to edit the Recipe before running it. +
      +
      +
      + 📑 Custom Template +
      + + This is recommended if you have a custom recipe-based server or if you are writing your own recipe. + You will be asked for the recipe right after this page. + +
      +
      + +
      +
      +
    • + + + +
    • +
      please select deployment type
      +
      + + +
      +
      + +
      + +
      +
      + + +
      + The URL for the remote recipe, or the forum thread.
      + You can discover new recipes by visiting the CFX Forum. + + +
      + + +
      +
      + + +
      +

      + In the next page you will be prompted to insert your custom Recipe.
      + If you are developing your own recipes, we highly recommend you to check the following: +

      +

      + +
      + + +
      +
      + + +
      + The folder that contains the resources and + cache folders, usually it's here that you put your server.cfg.
      + Also known as Base Folder.
      + <% if (hasCustomDataPath) { %> + <%= hostConfigSource %>: this path should start with <%= dataPath %>. + <% } %> + + +
      + + + +
      +
      + +
      +
    • + + + +
    • +
      please select deployment type
      +
      + + +
      + The folder where the server will be deployed to.
      + This folder will contain all your resources and configuration files.
      + <% if (hasCustomDataPath) { %> + <%= hostConfigSource %>: this path must start with <%= dataPath %>.
      + <% } %> + We strongly recommend using the path suggested below. +
      + + + + +
      + +
      + + +
      +
      + + +
      + The path to your server config file, usually named server.cfg.
      + This can either be absolute, or relative to the Server Data folder. + +

      + Config file detected! If this file is correct, just click on the save button below. +

      + +
      +
      + + +
      +
      +
      + +
      +
    • + + + +
    • +
      Finish
      +
      +

      We are all set!

      + +
      + +
      +
      +
    • + +
    +
    +
    +
    +
    + +
    + + + + + + + + + + + +